diff --git a/extensions/code-tutor/README.md b/extensions/code-tutor/README.md index e63cb04..ed0ab56 100644 --- a/extensions/code-tutor/README.md +++ b/extensions/code-tutor/README.md @@ -4,6 +4,8 @@ Turns the current codebase into a personal CS course. The agent reads your repo A GitHub Copilot App **canvas extension**: the agent and the user share the same live state through the same action handlers, and the view renders with Preact + htm and a vendored kit, with no build step and no `package.json`. +![The Code Tutor curriculum board: a header with a progress ring, the reading-level slider, and a list of concept cards](docs/img/overview.png) + ## What it does - **Extracts CS concepts from the code** and files each under a category (algorithm, data structure, complexity, theory, pattern, paradigm, concurrency, system). @@ -15,6 +17,82 @@ A GitHub Copilot App **canvas extension**: the agent and the user share the same - **Code review**: flags good / ok / bad spots (perf, wrong data structures, suboptimal algorithms). When the board knows its GitHub `owner/repo`, each issue gets a one-click **Fix in a new session** deep link (`ghapp://session/new`) that opens a dedicated Copilot session to run the fix; otherwise it copies a ready-to-run prompt for the agent to pick up. - **Freshness tracking**: fingerprints the code (git HEAD + newest file mtime) at analysis time, re-checks on a visibility-gated timer, and shows a "code changed, refresh" banner plus an always-available Refresh button. Code Tutor never re-analyzes on its own; analysis is the agent's job, so the Refresh button injects a re-analysis prompt into the current Copilot session. +## A guided tour + +Every shot below comes from the built-in demo board (see [Demo mode](#demo-mode)), so you can reproduce them yourself. + +### Adjustable reading level + +![The reading-level slider with four stops: ELI5, Curious, Engineer, Wizard](docs/img/reading-levels.png) + +One global slider from ELI5 to Wizard. Drag it and every topic re-explains itself at that depth. + +### Concept library cache + +![A topic explanation tagged "reused from the concept library"](docs/img/concept-cache.png) + +Generic, codebase-independent explanations are cached once and reused across boards. A reused one is tagged, so you know it did not cost another model call. + +### Filed by category, filtered by progress + +![The category dropdown and the progress filter chips: All, Understood, Stuck, Revisit, Not started](docs/img/categories.png) + +Each concept is filed under a category (algorithm, data structure, complexity, theory, pattern, paradigm, concurrency, system). Filter by category or by how far along you are. + +### Points at real code + +![An expanded code reference showing syntax-highlighted source with a line-number gutter](docs/img/code-reference.png) + +Every topic and finding links to a file and line range. Expand a reference to read the real source, highlighted, straight from disk. + +### Mark your understanding + +![The per-topic status row: Understood, Not understood, Revisit, New](docs/img/mark-understanding.png) + +Track each topic as Understood, Not understood, Revisit, or New. The header ring shows how much you have understood. + +### Ask and clarify + +![A topic's Q&A: an answered question and a pending one still waiting for the tutor](docs/img/ask-clarify.png) + +Ask questions per topic or globally, at a chosen level. Answers land in the panel without polluting the chat. + +### Code review + +![The Code review tab grouping findings into Issues, Could improve, and Strengths, with a Fix in a new session button](docs/img/code-review.png) + +Good / ok / bad spots with the reasoning. When the board knows its `owner/repo`, each issue gets a one-click **Fix in a new session** deep link. + +### Freshness tracking + +![The refresh banner: Re-analysis requested. Copilot is refreshing the board.](docs/img/freshness.png) + +The board fingerprints the code and flags when it drifts. The Refresh button hands a re-analysis prompt to the agent. + +## Demo mode + +Want to see Code Tutor fully populated without analyzing a repo first? Seed a demo board: + +``` +node demo/seed.mjs # writes a "demo" board to your COPILOT_HOME +node demo/seed.mjs --domain demo # pick the board name (default: demo) +node demo/seed.mjs --home # pick the COPILOT_HOME root +``` + +Then open the canvas pointed at it (`canvasId: "code-tutor"`, `input: { "domain": "demo" }`). + +The board is generated on demand and written to `$COPILOT_HOME/extensions/code-tutor/artifacts/demo.json`; no data is bundled in the extension. It teaches concepts from Code Tutor's own kit, so every code reference resolves to real source wherever the extension is installed. + +### Regenerating the screenshots + +The images above come from that demo board, captured headless: + +``` +node demo/screenshot.mjs # needs Playwright's chromium +``` + +It seeds a throwaway board in a temp directory, drives each feature, and writes PNGs to `docs/img/`, plus the site card at `site/public/screenshots/code-tutor.png` and the site gallery at `site/public/screenshots/code-tutor/`. + ## Layout ``` @@ -26,6 +104,9 @@ web/index.html shell that loads /kit/theme.css, ./styles.css and ./app.mjs web/app.mjs the Preact view web/highlight.mjs dependency-free, language-aware syntax tokenizer web/styles.css the visual design system +demo/seed.mjs buildDemoState() + CLI to seed a fake "demo" board (generated, never bundled) +demo/screenshot.mjs boots the demo board headless and captures the screenshots above +docs/img/ the per-feature screenshots (regenerated by demo/screenshot.mjs) test/smoke.test.mjs boots the runtime over HTTP and exercises the actions ``` diff --git a/extensions/code-tutor/demo/screenshot.mjs b/extensions/code-tutor/demo/screenshot.mjs new file mode 100644 index 0000000..dcd9026 --- /dev/null +++ b/extensions/code-tutor/demo/screenshot.mjs @@ -0,0 +1,207 @@ +// demo/screenshot.mjs - capture one screenshot per documented Code Tutor feature. +// +// It seeds the in-memory demo board (demo/seed.mjs) into a throwaway COPILOT_HOME, +// boots the canvas runtime over loopback HTTP exactly like the smoke test, then +// drives a headless browser through each feature and writes PNGs to docs/img/. +// The marketing-site hero (site/public/screenshots/code-tutor.png) is refreshed too. +// +// No data is committed: the board is generated at runtime in a temp dir that is +// removed on exit. Only the PNGs are written into the repo. +// +// Run: node demo/screenshot.mjs +// Needs Playwright's chromium. If it isn't installed, the script says how. + +import { mkdtemp, mkdir, rm, copyFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; +import { seedDemoBoard } from "./seed.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const EXT = resolve(HERE, ".."); // extensions/code-tutor +const OUT = resolve(EXT, "docs", "img"); // per-feature screenshots embedded in the README +const SITE = resolve(EXT, "..", "..", "site", "public", "screenshots"); // marketing site assets +const HERO = resolve(SITE, "code-tutor.png"); // site card image +const GALLERY = resolve(SITE, "code-tutor"); // per-feature gallery shown in the site lightbox +const DOMAIN = "demo"; + +// The feature shots, in the order the README and the site gallery present them. +// Kept here so the copy-to-site step and any future consumer share one list. +const FEATURE_SHOTS = [ + "overview", + "reading-levels", + "concept-cache", + "categories", + "code-reference", + "mark-understanding", + "ask-clarify", + "code-review", + "freshness", +]; + +// A dark, retina-ish panel sized like a generous side panel so text stays crisp. +const VIEWPORT = { width: 1000, height: 1360 }; +const HERO_VIEWPORT = { width: 1280, height: 720 }; +const SCALE = 2; + +async function loadChromium() { + try { + const { chromium } = await import("playwright"); + return chromium; + } catch { + console.error( + "This script needs Playwright's chromium.\n" + + " npm i -D playwright && npx playwright install chromium\n" + + "then re-run: node demo/screenshot.mjs", + ); + process.exit(1); + } +} + +// Wait for the curriculum to paint (state fetched + first topic rendered). We wait +// on a concrete selector rather than "networkidle": the canvas holds an open SSE +// stream (client.mjs), so the network never goes idle. +async function waitForBoard(page) { + await page.waitForSelector(".cs-topic", { timeout: 15000 }); +} + +// Expand a topic by its visible title and wait for its body to render. +async function expandTopic(page, title) { + const head = page.locator(".cs-topic-head", { hasText: title }).first(); + await head.scrollIntoViewIfNeeded(); + if ((await head.getAttribute("aria-expanded")) !== "true") await head.click(); + await page.locator(".cs-topic", { hasText: title }).locator(".cs-topic-body").first().waitFor(); +} + +// Collapse everything (client-only expand state) by reloading the page. +async function resetView(page, url) { + await page.goto(url, { waitUntil: "domcontentloaded" }); + await waitForBoard(page); +} + +async function shootPage(page, name) { + const path = join(OUT, `${name}.png`); + await page.screenshot({ path, fullPage: true }); + return path; +} + +async function shootEl(page, selector, name, { hasText } = {}) { + const loc = hasText ? page.locator(selector, { hasText }).first() : page.locator(selector).first(); + await loc.scrollIntoViewIfNeeded(); + const path = join(OUT, `${name}.png`); + await loc.screenshot({ path }); + return path; +} + +async function main() { + const chromium = await loadChromium(); + let home = null; + let runtime = null; + let browser = null; + const shot = []; + try { + home = await mkdtemp(join(tmpdir(), "code-tutor-shots-")); + process.env.COPILOT_HOME = home; // isolate durable storage before importing canvas.mjs + + await seedDemoBoard({ home, domain: DOMAIN }); + const { canvasConfig } = await import("../canvas.mjs"); + const { createCanvasRuntime } = await import("../canvas-kit/server.mjs"); + runtime = createCanvasRuntime(canvasConfig); + browser = await chromium.launch(); + + const open = await runtime.openInstance({ + instanceId: "shots", + input: { domain: DOMAIN }, + ctx: { instanceId: "shots", input: { domain: DOMAIN } }, + }); + const url = open.url; + await mkdir(OUT, { recursive: true }); + + const ctx = await browser.newContext({ viewport: VIEWPORT, deviceScaleFactor: SCALE, colorScheme: "dark" }); + const page = await ctx.newPage(); + + // 1) Overview - the whole Learn board (header, level slider, topic list). + await resetView(page, url); + shot.push(await shootPage(page, "overview")); + + // 2) Reading-level slider. + shot.push(await shootEl(page, ".cs-level", "reading-levels")); + + // 3) Category filing + progress filters toolbar. + shot.push(await shootEl(page, ".cs-toolbar", "categories")); + + // 4) Points at real code - expand a topic and its code reference. + await expandTopic(page, "Windowed range slicing"); + const ref = page.locator(".cs-ref").first(); + const refHead = ref.locator(".cs-ref-head"); + if ((await refHead.getAttribute("aria-expanded")) !== "true") await refHead.click(); + await ref.locator(".cs-code").waitFor({ timeout: 10000 }); + shot.push(await shootEl(page, ".cs-ref", "code-reference")); + + // 5) Mark your understanding - the per-topic status row. + shot.push(await shootEl(page, ".cs-status-row", "mark-understanding")); + + // 6) Ask & clarify - a topic's Q&A with an answered and a pending question. + // The pattern topic also has a cached "curious" explanation, so it doubles as + // the concept-library-cache shot (the "reused from the concept library" badge). + await resetView(page, url); + await expandTopic(page, "Publish/subscribe over Server-Sent Events"); + const cacheBlock = page.locator(".cs-from-cache").first().locator("xpath=.."); + await cacheBlock.scrollIntoViewIfNeeded(); + const ccPath = join(OUT, "concept-cache.png"); + await cacheBlock.screenshot({ path: ccPath }); + shot.push(ccPath); + shot.push(await shootEl(page, ".cs-qa", "ask-clarify")); + + // 7) Code review - the findings tab with good/ok/bad and Fix-in-a-new-session. + await resetView(page, url); + await page.getByRole("tab", { name: "Code review" }).click(); + await page.waitForSelector(".cs-finding-title", { timeout: 10000 }); + shot.push(await shootPage(page, "code-review")); + + // Hero - a 16:9 top-of-panel shot for the marketing site card. Captured BEFORE + // the freshness step so the board is still in its clean, unmutated state. + const heroCtx = await browser.newContext({ viewport: HERO_VIEWPORT, deviceScaleFactor: SCALE, colorScheme: "dark" }); + const heroPage = await heroCtx.newPage(); + await heroPage.goto(url, { waitUntil: "domcontentloaded" }); + await waitForBoard(heroPage); + await mkdir(dirname(HERO), { recursive: true }); + await heroPage.screenshot({ path: HERO }); + shot.push(HERO); + await heroCtx.close(); + + // 8) Freshness tracking - trigger the refresh banner, then capture it. LAST, + // because request_refresh mutates the board (sets refreshRequestedAt). + await resetView(page, url); + await fetch(new URL("/action", url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ actionName: "request_refresh", input: {} }), + }); + await page.waitForSelector(".cs-fresh", { timeout: 10000 }); + shot.push(await shootEl(page, ".cs-fresh", "freshness")); + await ctx.close(); + + // Publish the feature shots into the site gallery so the lightbox can show + // them. The README reads them from docs/img/; the site reads its own copy + // from public/screenshots/code-tutor/ (Astro only serves from public/). + await mkdir(GALLERY, { recursive: true }); + for (const name of FEATURE_SHOTS) { + await copyFile(join(OUT, `${name}.png`), join(GALLERY, `${name}.png`)); + } + shot.push(`${GALLERY}\\*.png (${FEATURE_SHOTS.length} gallery images)`); + + console.log(`Wrote ${shot.length} outputs:`); + for (const p of shot) console.log(` ${p}`); + } finally { + // Guard each teardown independently so a failing close can't leak the temp dir. + if (browser) await browser.close().catch(() => {}); + if (runtime) await runtime.shutdown().catch(() => {}); + if (home) await rm(home, { recursive: true, force: true }).catch(() => {}); + } +} + +main().catch((err) => { + console.error(`screenshot run failed: ${err?.stack ?? err}`); + process.exit(1); +}); diff --git a/extensions/code-tutor/demo/seed.mjs b/extensions/code-tutor/demo/seed.mjs new file mode 100644 index 0000000..5c81e2a --- /dev/null +++ b/extensions/code-tutor/demo/seed.mjs @@ -0,0 +1,408 @@ +// demo/seed.mjs - generate a rich, fully-populated Code Tutor board for demos and +// screenshots WITHOUT bundling any data file in the extension. +// +// buildDemoState() returns a complete, modern board in memory. The demo teaches +// concepts using the canvas kit's OWN source, so every code reference resolves +// wherever the extension is installed: codebase.root is the extension directory +// and each ref path is relative to it. Nothing here is written to disk unless you +// run this file as a CLI, which seeds the board into the runtime artifacts store. +// +// Launch demo mode: +// node demo/seed.mjs # writes /extensions/code-tutor/artifacts/demo.json +// node demo/seed.mjs --domain demo # pick the board domain (default: demo) +// node demo/seed.mjs --home # pick the COPILOT_HOME root (default: $COPILOT_HOME or ~/.copilot) +// then open the canvas with input { domain: "demo" }. + +import { mkdir, writeFile, rename } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +// The Code Tutor extension directory (this file lives in /demo/). Used as the +// codebase root so refs into the kit's own files resolve anywhere it's installed. +export const EXTENSION_DIR = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +// Fixed timestamps so the generated board (and the screenshots taken from it) are +// stable across runs. The one exception is the pending question's createdAt, which +// is stamped "now" on purpose (see buildDemoState) so its "thinking" timer reads a +// small, believable number instead of years. +const CREATED = "2026-01-14T09:00:00.000Z"; +const SCANNED = "2026-01-15T10:00:00.000Z"; + +// Each topic teaches a real concept found in the kit's own code, one per category +// so the demo exercises every icon/color the view knows. Explanations are written +// as self-contained prose at each reading level. One topic intentionally omits the +// "wizard" level so the "Get this explanation" call-to-action is visible. +const TOPICS = [ + { + id: "demo-t-algorithm", + title: "Windowed range slicing", + conceptKey: "windowed-range-slicing", + category: "algorithm", + summary: "Return only the lines around a reference, padded and capped, instead of a whole file.", + keyPoints: [ + "Clamp the focus range to the file bounds, then pad by a fixed margin.", + "Cap the window so an enormous file can never blow up the payload.", + ], + refs: [ + { file: "canvas.mjs", startLine: 906, endLine: 925, note: "read_snippet computes a padded, capped line window around the referenced range." }, + ], + status: "understood", + level: null, + explanations: { + eli5: "Imagine a giant book but you only photocopy the page someone pointed at, plus one page on each side. You never haul the whole book around.", + curious: "Instead of sending an entire source file to the UI, the code takes just the referenced lines, adds a few lines of context on each side, and stops at a maximum. You get readable context without the weight of the whole file.", + engineer: "read_snippet clamps [startLine, endLine] to [1, total], pads by SNIPPET_PAD, then truncates to SNIPPET_MAX_LINES. The result carries fromLine plus focusStart/focusEnd so the client can highlight the exact referenced span inside the padded window.", + wizard: "It is a bounded projection over a line-indexed sequence: O(window) output regardless of file size, with an explicit truncation flag so the consumer can distinguish a complete slice from a capped one. Padding is symmetric but re-clamped, so focus ranges near the file edges degrade gracefully rather than reading out of bounds.", + }, + cachedLevels: [], + }, + { + id: "demo-t-data-structure", + title: "A Set as an in-flight guard", + conceptKey: "set-in-flight-guard", + category: "data-structure", + summary: "One shared Set turns 'is this work already running?' into a synchronous test-and-set.", + keyPoints: [ + "claim() adds a key and reports whether it won the race.", + "The check happens before the first await, so a double-click can't slip through.", + ], + refs: [ + { file: "extension.mjs", startLine: 74, endLine: 76, note: "inFlight Set plus claim()/release() dedupe concurrent UI clicks." }, + ], + status: "understood", + level: null, + explanations: { + eli5: "It's like a single 'occupied' sign on a bathroom door. The first person flips it and goes in; anyone else sees the sign and waits.", + curious: "A Set remembers which jobs are currently running by a key. Before starting a job, the code tries to add the key: if it was already there, someone else is doing it, so this click quietly gives up. That stops the same button firing twice.", + engineer: "inFlight is a Set; claim(key) is has? false : (add, true) - a synchronous test-and-set executed before any await, so two near-simultaneous POSTs can't both pass an async 'already pending?' check. release(key) deletes it in a finally.", + wizard: "It is a lightweight mutual-exclusion primitive keyed per unit of work. Because JavaScript is single-threaded up to the first await, the add is atomic with respect to other microtasks, giving lock-free dedupe without a real lock as long as the claim precedes suspension.", + }, + cachedLevels: ["eli5"], + }, + { + id: "demo-t-complexity", + title: "O(n) find vs O(1) lookup", + conceptKey: "linear-find-vs-map", + category: "complexity", + summary: "A per-action Array.find is linear; an id-keyed Map would be constant time.", + keyPoints: [ + "findTopic scans the whole array every call.", + "Fine when n is tiny; the wrong shape once it isn't.", + ], + refs: [ + { file: "canvas.mjs", startLine: 194, endLine: 195, note: "findTopic does an Array.find over all topics on every action." }, + ], + status: "revisit", + level: null, + explanations: { + eli5: "Finding a friend by walking past every seat in the theater takes longer the bigger the theater. Assigned seat numbers let you go straight there.", + curious: "Looking something up by scanning a list gets slower as the list grows. Keeping a lookup table keyed by id stays fast no matter how big it gets. Here the list is short, so scanning is fine, but it's the classic trade-off.", + engineer: "findTopic is O(n) per call and several handlers call it per action, so worst case is O(handlers * n). For small curricula that's invisible; at scale a Map maintained alongside the array makes it O(1) lookups.", + wizard: "This is the amortized-vs-worst-case argument for choosing an index. The linear scan has no setup cost but linear query cost; a hash index trades O(n) build and O(n) memory for O(1) expected queries. The right choice is a function of query frequency and n, which is exactly why it stays a scan here.", + }, + cachedLevels: [], + }, + { + id: "demo-t-theory", + title: "Content fingerprints for change detection", + conceptKey: "content-fingerprint", + category: "theory", + summary: "Summarize the code into a small fingerprint, then compare fingerprints to detect drift.", + keyPoints: [ + "git HEAD plus newest file mtime plus a file count is a cheap signature.", + "A changed fingerprint means 'the code moved, offer a refresh'.", + ], + refs: [ + { file: "canvas.mjs", startLine: 321, endLine: 343, note: "computeFingerprint builds a compact signature of the codebase." }, + ], + status: "understood", + level: null, + explanations: { + eli5: "Like taking a quick photo of your messy desk. Later you glance at a new photo: if it looks different, something moved.", + curious: "Rather than re-reading every file to see if code changed, the tutor reduces the codebase to a short fingerprint (things like the git commit and the newest file time). If a fresh fingerprint differs from the saved one, it knows the code changed and shows a refresh nudge.", + engineer: "computeFingerprint concatenates a git HEAD ref, the newest mtime under the root, and a file count into a compact string. analysis_status recomputes it and compares to the value saved at analysis time; inequality flips the 'stale' banner without any content diff.", + wizard: "It is a cheap collision-tolerant digest chosen for change DETECTION, not integrity: false negatives (a change the signature misses) are the design risk, traded away for near-zero cost. Because it is compared only against its own prior value, adversarial collision resistance is unnecessary - monotonic sensitivity to ordinary edits is enough.", + }, + cachedLevels: [], + }, + { + id: "demo-t-pattern", + title: "Publish/subscribe over Server-Sent Events", + conceptKey: "pub-sub-sse", + category: "pattern", + summary: "The server pushes state; the client is a pure subscriber that re-renders on each frame.", + keyPoints: [ + "One EventSource stream; every push replaces state.", + "The client never polls for state - it reacts.", + ], + refs: [ + { file: "canvas-kit/client.mjs", startLine: 133, endLine: 151, note: "connect() subscribes to ./events and re-renders on each pushed frame." }, + ], + status: "new", + level: null, + explanations: { + eli5: "Like a group chat: the teacher posts once and everyone's phone buzzes with the same message. Nobody keeps asking 'anything new yet?'.", + curious: "The browser opens one long-lived connection and just listens. Whenever the server has new state it pushes it down that connection, and the page redraws. This is the publish/subscribe idea: publishers send, subscribers react.", + engineer: "connect() opens an EventSource('./events'); onmessage parses each frame, replaces local state, and calls rerender(). It's one-way server->client push, so multiple open panels stay in sync from a single source of truth without client polling.", + wizard: "SSE gives an ordered, auto-reconnecting, text/event-stream channel - a strictly weaker but simpler contract than WebSockets for unidirectional fan-out. The invariant is last-writer-wins on a full-state frame, which sidesteps operational-transform/CRDT complexity at the cost of sending whole snapshots.", + }, + cachedLevels: ["eli5", "curious"], + }, + { + id: "demo-t-paradigm", + title: "Immutability and structural sharing", + conceptKey: "immutability-structural-sharing", + category: "paradigm", + summary: "Never mutate state in place; spread a new object so unchanged parts are shared.", + keyPoints: [ + "Functional set((cur) => ({ ...cur, ... })) merges into the latest state.", + "Reads that raced a long await don't get clobbered.", + ], + refs: [ + { file: "canvas.mjs", startLine: 456, endLine: 460, note: "Functional set() spreads the CURRENT state so a concurrent write isn't lost." }, + ], + status: "confused", + level: null, + explanations: { + eli5: "Instead of scribbling on your drawing, you trace a fresh copy and change only the one part. The old drawing stays safe.", + curious: "The code never edits state in place. It makes a new object copied from the current one and changes just the field it needs. That way, if something else updated state while a slow task was running, the update isn't accidentally erased.", + engineer: "set((cur) => ({ ...cur, codebase, refreshRequestedAt: null })) reads the CURRENT state at commit time rather than a value captured before an await. Spreading shares the untouched sub-objects by reference (structural sharing) and avoids lost updates from interleaved handlers.", + wizard: "Persistent-data-structure discipline: each update yields a new root while sharing unchanged children, so old references stay valid snapshots. The functional updater linearizes concurrent mutations at commit time, which is what actually prevents the read-modify-write hazard a captured-state closure would create.", + }, + cachedLevels: [], + }, + { + id: "demo-t-concurrency", + title: "Visibility-gated polling", + conceptKey: "visibility-gated-polling", + category: "concurrency", + summary: "Only tick while the panel is visible, and never let a slow tick overlap the next.", + keyPoints: [ + "Skip the interval entirely when the document is hidden.", + "An inFlight flag stops ticks from stacking.", + ], + refs: [ + { file: "canvas-kit/client.mjs", startLine: 68, endLine: 85, note: "pollWhileVisible gates on visibility and guards against overlapping ticks." }, + ], + status: "new", + level: null, + explanations: { + eli5: "A robot that waters plants only when you're home, and won't start a new watering until the last one finishes.", + curious: "The auto-refresh timer does nothing while the panel is hidden, so a background tab stops hammering the server. And if one refresh is slow, the next tick waits instead of piling on top of it.", + engineer: "pollWhileVisible returns a cleanup so it drops into a useEffect. Each run checks document.visibilityState and an inFlight boolean; a hidden panel or an outstanding tick short-circuits, so there's no request pile-up and no wasted work off-screen.", + wizard: "It is a self-clocking guard around an interval: the visibility predicate sheds load under backgrounding, and the single-flight latch enforces at-most-one-in-flight, converting a naive fixed-rate poller into an adaptive one whose effective rate collapses to zero when unobserved.", + }, + cachedLevels: [], + }, + { + id: "demo-t-system", + title: "Sticky-on-scroll without IntersectionObserver", + conceptKey: "sticky-scroll-no-io", + category: "system", + summary: "A capture-phase scroll listener plus rAF-throttled measurement, not a viewport observer.", + keyPoints: [ + "A viewport-rooted IntersectionObserver misfires inside a scrolling webview container.", + "Capture phase catches scroll on any ancestor; rAF throttles the layout read.", + ], + refs: [ + { file: "web/app.mjs", startLine: 929, endLine: 958, note: "Capture-phase scroll + getBoundingClientRect + requestAnimationFrame; the comment explains why not IntersectionObserver." }, + ], + status: "new", + level: null, + // Intentionally missing the "wizard" level so the "Get this explanation" CTA shows. + explanations: { + eli5: "A little flag under the big controls. When it slides off the top, a small copy of the controls sticks to the top so you never lose them.", + curious: "As you scroll, the code watches an invisible marker below the full control bar. When the marker reaches the top, it pins a compact bar. It measures once per animation frame so scrolling stays smooth.", + engineer: "A capture-phase, passive scroll listener schedules one requestAnimationFrame measurement that reads sentinel.getBoundingClientRect().top and sets a 'stuck' flag when it's <= 0. An IntersectionObserver is avoided because a viewport-rooted IO never fires when the canvas scrolls an inner container, and a 1px target is fragile under fractional device-pixel ratios.", + }, + cachedLevels: [], + }, +]; + +// Code-quality findings: strengths (good), so-so spots (ok), and a real problem (bad). +// One finding is already marked "requested" so the demo shows the fix lifecycle, and +// because the board sets a repo, "bad"/"requested" findings render the "Fix in a new +// session" deep link. +const FINDINGS = [ + { + id: "demo-f-good-diff", + quality: "good", + title: "Diffing render loop preserves focus and caret", + detail: "rerender() goes through Preact's render() rather than assigning innerHTML, so an incoming state push patches only changed nodes. That's the reason a live update never eats text you're typing or jumps your cursor.", + topicId: "demo-t-pattern", + file: "canvas-kit/client.mjs", + startLine: 121, + endLine: 123, + suggestion: "", + fixPrompt: "", + fixStatus: "open", + }, + { + id: "demo-f-good-poll", + quality: "good", + title: "Polling is visibility-gated and won't stack ticks", + detail: "pollWhileVisible skips work while the panel is hidden and uses an inFlight guard so a slow tick can't overlap the next. Cheap, correct defenses against wasted work and request pile-up.", + topicId: "demo-t-concurrency", + file: "canvas-kit/client.mjs", + startLine: 68, + endLine: 85, + suggestion: "", + fixPrompt: "", + fixStatus: "open", + }, + { + id: "demo-f-ok-find", + quality: "ok", + title: "findTopic is a linear O(n) scan called per action", + detail: "Several handlers call findTopic, an Array.find over all topics. n is tiny here so it's not a real problem, but it's a repeated lookup-by-id over an unindexed array.", + topicId: "demo-t-complexity", + file: "canvas.mjs", + startLine: 194, + endLine: 195, + suggestion: "If topic counts ever grow large, maintain a Map alongside the array; otherwise accept the O(n) since curricula are small.", + fixPrompt: "In extensions/code-tutor/canvas.mjs, add a Map index kept in sync with the topics array and have findTopic consult it in O(1). Preserve behavior and keep the smoke test green.", + fixStatus: "open", + }, + { + id: "demo-f-ok-save", + quality: "ok", + title: "State save rewrites the whole JSON file on every action", + detail: "save() serializes and writes the entire state document on every mutation, with no debouncing. For a human-paced tutor that's fine, but under rapid programmatic updates it's write amplification.", + topicId: "demo-t-paradigm", + file: "canvas-kit/storage.mjs", + startLine: 34, + endLine: 42, + suggestion: "Debounce or coalesce writes (e.g. within ~250ms) so a burst of mutations collapses into one disk write.", + fixPrompt: "In extensions/code-tutor/canvas-kit/storage.mjs, add debounced/coalesced writes to save() so a burst of mutations results in one disk write without losing the final state. Keep the load() contract and add a test.", + fixStatus: "open", + }, + { + id: "demo-f-bad-syncfs", + quality: "bad", + title: "latestDomain() does synchronous fs in a loop", + detail: "latestDomain() calls readdirSync, then readFileSync + statSync for every board file, on the open path. With many boards this blocks the event loop during a UI open, delaying first paint.", + topicId: "demo-t-complexity", + file: "canvas.mjs", + startLine: 95, + endLine: 120, + suggestion: "Read directory entries and files asynchronously (fs/promises) and stat concurrently, or cache the most-recent-board result and invalidate on save.", + fixPrompt: "In extensions/code-tutor/canvas.mjs, make latestDomain() asynchronous: use fs/promises, read candidate boards concurrently, and avoid blocking the event loop on the open path. Preserve the 'newest non-empty board' semantics and keep the smoke test green.", + fixStatus: "requested", + }, +]; + +// A couple of learner questions: one already answered, one still pending (so the +// "Waiting for the tutor" state is visible). +const QUESTIONS = [ + { + id: "demo-q-answered", + text: "Why use Preact's render() instead of setting innerHTML?", + topicId: "demo-t-pattern", + level: "curious", + answer: "Setting innerHTML throws away and rebuilds the DOM on every update, which loses focus, selection, and scroll position and is slower. Preact's render() diffs the new virtual tree against the live DOM and patches only what changed, so an incoming state push leaves the node you're typing in untouched.", + answeredAt: SCANNED, + }, + { + id: "demo-q-pending", + text: "Could the client miss an SSE frame while the tab is backgrounded?", + topicId: "demo-t-pattern", + level: "engineer", + answer: null, + answeredAt: null, + }, +]; + +/** + * Build a complete, modern Code Tutor board in memory. No disk I/O. + * @param {object} [opts] + * @param {string} [opts.root] codebase root the refs resolve against (default: the extension dir) + * @param {string} [opts.repo] GitHub owner/repo so findings show "Fix in a new session" (default: jongio/copilot-extensions) + * @param {string} [opts.domain] board domain/key (default: "demo") + * @returns {object} a board state ready to serialize into the artifacts store + */ +export function buildDemoState({ root = EXTENSION_DIR, repo = "jongio/copilot-extensions", domain = "demo" } = {}) { + const stamp = (o) => ({ ...o, createdAt: CREATED, updatedAt: SCANNED }); + return { + domain, + defaultLevel: "curious", + codebase: { + label: "Code Tutor (demo)", + root, + repo, + summary: + "A guided tour of the Code Tutor canvas itself: the concepts, data structures, and design decisions " + + "in its own kit. Every code reference points at real source in this extension, so snippets resolve anywhere.", + fileCount: 22, + languages: ["JavaScript (ESM)", "Preact/htm", "Node.js"], + scannedAt: SCANNED, + // Empty so analysis_status reports "not comparable" instead of "stale": the + // overview/hero stay clean, and the dedicated freshness shot triggers the + // banner explicitly via request_refresh. + fingerprint: "", + }, + topics: TOPICS.map(stamp), + findings: FINDINGS.map(stamp), + // Answered questions keep the fixed timestamp; the pending one is stamped "now" + // so its "the tutor is thinking" timer reads a small, believable number. + questions: QUESTIONS.map((q) => ({ ...q, createdAt: q.answer ? CREATED : new Date().toISOString() })), + refreshRequestedAt: null, + }; +} + +// ---- CLI: seed the demo board into the runtime artifacts store --------------- + +function parseArgs(argv) { + const out = { home: null, domain: "demo" }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--home") out.home = argv[++i] ?? null; + else if (a === "--domain") out.domain = argv[++i] ?? "demo"; + else if (a === "--help" || a === "-h") out.help = true; + } + return out; +} + +function artifactPath(home, domain) { + const base = home || process.env.COPILOT_HOME || join(homedir(), ".copilot"); + const safe = String(domain).replace(/[^A-Za-z0-9._-]/g, "_") || "demo"; + return join(base, "extensions", "code-tutor", "artifacts", `${safe}.json`); +} + +/** + * Write a demo board to /extensions/code-tutor/artifacts/.json, + * using the same write-temp-then-atomic-rename discipline as the kit's storage. + * @returns {Promise} the file path written + */ +export async function seedDemoBoard({ home = null, domain = "demo" } = {}) { + const file = artifactPath(home, domain); + const state = buildDemoState({ domain }); + await mkdir(dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmp, JSON.stringify(state, null, 2), "utf8"); + await rename(tmp, file); + return file; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + console.log( + "Seed a Code Tutor demo board.\n\n" + + " node demo/seed.mjs [--domain ] [--home ]\n\n" + + 'Then open the canvas with input { domain: "" } (default: demo).', + ); + return; + } + const file = await seedDemoBoard({ home: args.home, domain: args.domain }); + console.log(`Seeded demo board (domain "${args.domain}") -> ${file}`); + console.log(`Open the Code Tutor canvas with input { "domain": "${args.domain}" } to view it.`); +} + +// Run main() only when executed directly, not when imported. +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((err) => { + console.error(`seed failed: ${err?.message ?? err}`); + process.exit(1); + }); +} diff --git a/extensions/code-tutor/docs/img/ask-clarify.png b/extensions/code-tutor/docs/img/ask-clarify.png new file mode 100644 index 0000000..9a08a0a Binary files /dev/null and b/extensions/code-tutor/docs/img/ask-clarify.png differ diff --git a/extensions/code-tutor/docs/img/categories.png b/extensions/code-tutor/docs/img/categories.png new file mode 100644 index 0000000..3a38808 Binary files /dev/null and b/extensions/code-tutor/docs/img/categories.png differ diff --git a/extensions/code-tutor/docs/img/code-reference.png b/extensions/code-tutor/docs/img/code-reference.png new file mode 100644 index 0000000..f788c06 Binary files /dev/null and b/extensions/code-tutor/docs/img/code-reference.png differ diff --git a/extensions/code-tutor/docs/img/code-review.png b/extensions/code-tutor/docs/img/code-review.png new file mode 100644 index 0000000..86f7f26 Binary files /dev/null and b/extensions/code-tutor/docs/img/code-review.png differ diff --git a/extensions/code-tutor/docs/img/concept-cache.png b/extensions/code-tutor/docs/img/concept-cache.png new file mode 100644 index 0000000..88cab2e Binary files /dev/null and b/extensions/code-tutor/docs/img/concept-cache.png differ diff --git a/extensions/code-tutor/docs/img/freshness.png b/extensions/code-tutor/docs/img/freshness.png new file mode 100644 index 0000000..ef15757 Binary files /dev/null and b/extensions/code-tutor/docs/img/freshness.png differ diff --git a/extensions/code-tutor/docs/img/mark-understanding.png b/extensions/code-tutor/docs/img/mark-understanding.png new file mode 100644 index 0000000..acc966f Binary files /dev/null and b/extensions/code-tutor/docs/img/mark-understanding.png differ diff --git a/extensions/code-tutor/docs/img/overview.png b/extensions/code-tutor/docs/img/overview.png new file mode 100644 index 0000000..ecdc20f Binary files /dev/null and b/extensions/code-tutor/docs/img/overview.png differ diff --git a/extensions/code-tutor/docs/img/reading-levels.png b/extensions/code-tutor/docs/img/reading-levels.png new file mode 100644 index 0000000..3a39e85 Binary files /dev/null and b/extensions/code-tutor/docs/img/reading-levels.png differ diff --git a/extensions/code-tutor/extension.mjs b/extensions/code-tutor/extension.mjs index 0d99fa3..9b1bcf3 100644 --- a/extensions/code-tutor/extension.mjs +++ b/extensions/code-tutor/extension.mjs @@ -5,7 +5,6 @@ import { createCanvas, joinSession, CanvasError } from "@github/copilot-sdk/extension"; import { canvasConfig } from "./canvas.mjs"; import { createCanvasRuntime, CanvasKitError } from "./canvas-kit/server.mjs"; -import { createFastAI } from "./fast-ai.mjs"; // Session handle, set once joinSession resolves. The wrappers below close over // it so a UI button click can reach the model for THIS Copilot session. @@ -14,42 +13,28 @@ let session = null; // only run on a later UI click, by which point it is initialized). let runtime = null; -// Fast path: a dedicated, warm Copilot runtime running a FAST model in a fresh, -// context-free session per query (~2s answers vs 20-60s for an in-session -// ephemeralQuery). See fast-ai.mjs. Falls back to ephemeralQuery if the warm -// runtime can't start (e.g. the binary can't be located). -// // How long a silent tutor query may run before we give up and show a retry. // Normal answers land well inside this; the cap is for true stalls. const AI_TIMEOUT_MS = 90_000; -const fastAI = createFastAI({ model: "gpt-5.4-mini", timeoutMs: AI_TIMEOUT_MS }); // ---- host AI capability (canvas-kit host model: ai + askAgent) ------------- // Two ways to reach the model. Both are handed to the kit via runtime.setHost(...) // so SDK-free canvas.mjs handlers can call ctx.ai(...) / ctx.askAgent(...). // -// - ai(question): a SILENT, context-free answer. Tries the FAST dedicated -// runtime first (separate process, fresh fast-model session, no chat bleed), -// and falls back to the in-session ephemeralQuery if that runtime is -// unavailable. Either way it never adds a turn to the user's conversation. +// - ai(question): a SILENT answer via the in-session ephemeralQuery. It never +// adds a turn to the user's conversation. It DOES run against the ambient +// conversation context, so canvas.mjs frames each prompt as a self-contained +// instruction ("You are a tutor. Output ONLY ...") to avoid context bleed. // - askAgent(prompt): hand a turn to the MAIN agent (visible, tool-capable). // Used by request_refresh, which needs the agent to re-read the repo. const host = { ai: async (question) => { - const q = String(question); - try { - return await fastAI.ai(q); - } catch (e) { - // Fast runtime unavailable or errored - fall back to the in-session - // ephemeralQuery so the tutor still works (just slower). - console.error(`[code-tutor] fast ai() failed, falling back to ephemeralQuery: ${e?.message ?? e}`); - const { answer } = await withTimeout( - session.rpc.ui.ephemeralQuery({ question: q }), - AI_TIMEOUT_MS, - "The tutor", - ); - return String(answer ?? "").trim(); - } + const { answer } = await withTimeout( + session.rpc.ui.ephemeralQuery({ question: String(question) }), + AI_TIMEOUT_MS, + "The tutor", + ); + return String(answer ?? "").trim(); }, askAgent: async (prompt) => session.send(String(prompt)), }; @@ -281,29 +266,3 @@ session = await joinSession({ canvases: [canvas] }); // The intercepts above use `host` directly; this makes the SAME capability // available to any plain handler too (via the kit's runtime.setHost host model). runtime.setHost(host); - -// Eagerly warm the dedicated fast-AI runtime so the learner's FIRST question or -// explanation is fast too (not just subsequent ones). Fire-and-forget; if it -// fails, the first ai() call simply pays the cold start (or falls back). -void fastAI.warmup(); - -// Graceful teardown of the warm fast-AI runtime so the dedicated child process -// doesn't linger after a reload/shutdown. On SIGINT/SIGTERM, run the async -// dispose() (which calls client.stop()) then exit — adding these listeners -// overrides Node's default terminate, so we MUST exit ourselves, with a hard -// timeout so a hung stop() can't wedge shutdown. We deliberately do NOT schedule -// async work on 'exit' (it can't run there); the forStdio child also dies on our -// stdin EOF, so an abrupt exit still reaps it. -let shuttingDown = false; -for (const sig of ["SIGINT", "SIGTERM"]) { - process.once(sig, () => { - if (shuttingDown) return; - shuttingDown = true; - const hardExit = setTimeout(() => process.exit(0), 3000); - if (typeof hardExit.unref === "function") hardExit.unref(); - Promise.resolve() - .then(() => fastAI.dispose()) - .catch(() => {}) - .finally(() => process.exit(0)); - }); -} diff --git a/extensions/code-tutor/fast-ai.mjs b/extensions/code-tutor/fast-ai.mjs deleted file mode 100644 index 9ed46a7..0000000 --- a/extensions/code-tutor/fast-ai.mjs +++ /dev/null @@ -1,185 +0,0 @@ -// fast-ai.mjs - fast, context-free host-model access for a canvas. -// -// Why this exists: the kit's default ctx.ai() uses the host's `ephemeralQuery`, -// which runs against the CURRENT session's full conversation context AND shares -// the model with the active agent turn. In a long, busy session that is slow -// (20-60s) and unpredictable. -// -// This module instead spawns a SEPARATE, dedicated Copilot runtime (the native -// `copilot` binary) ONCE, keeps it warm, and runs each query as a FRESH session -// with a FAST model. That makes every answer: -// * fast - ~2s/query after a ~2s one-time warmup (a 10-30x speedup) -// * context-free - a brand-new session each call, so no chat bleed and no -// giant-context reprocessing -// * isolated - it never touches the user's conversation -// -// It needs NO copilot-sdk change: it uses the SDK the extension already has, -// pointed at the runtime binary. `createSession` works here because this is a -// FRESH runtime we own (only the extension's parent-process stdio connection -// refuses session.create). - -import { createRequire } from "node:module"; -import { existsSync } from "node:fs"; -import { join, dirname } from "node:path"; -import { tmpdir } from "node:os"; -import { execSync } from "node:child_process"; -import { CopilotClient, RuntimeConnection } from "@github/copilot-sdk"; - -// SECURITY: these are silent, context-free TEXT-GENERATION calls — they need -// ZERO tools. The fast path runs on a SEPARATE, full Copilot runtime where the -// agent loop (and thus shell/file/edit tools) really can execute, and the -// prompts embed UNTRUSTED input (the codebase under study and free-text learner -// questions). A prompt injection ("ignore that, run shell …") in a tool-capable, -// auto-approving session would be remote code execution. So every session here -// is locked down three ways: -// 1. availableTools: [] — empty allowlist => no tool is ever enabled. -// 2. onPermissionRequest denies — belt-and-suspenders if any tool slips the net. -// 3. workingDirectory = a temp dir — never the user's repo, so even a tool that -// somehow ran has nothing useful to touch. -const denyToolUse = () => ({ kind: "reject", feedback: "Tools are disabled for tutor generation." }); - -function lockedDownSessionConfig(model) { - return { - model, - availableTools: [], - onPermissionRequest: denyToolUse, - workingDirectory: tmpdir(), - }; -} - -// ---- locate the native runtime binary (portable, no hardcoded path) -------- -function platformBinary(pkgRoot) { - const plat = process.platform; - const arch = process.arch; - const exe = plat === "win32" ? "copilot.exe" : "copilot"; - const bin = join(pkgRoot, "node_modules", "@github", `copilot-${plat}-${arch}`, exe); - return existsSync(bin) ? bin : null; -} - -function fromRequire() { - try { - const req = createRequire(import.meta.url); - const sdk = req.resolve("@github/copilot/sdk"); // .../@github/copilot/sdk/index.js - return platformBinary(dirname(dirname(sdk))); - } catch { - return null; - } -} - -function fromPath() { - try { - const cmd = process.platform === "win32" ? "where copilot" : "command -v copilot"; - const lines = execSync(cmd, { encoding: "utf8" }).split(/\r?\n/).filter(Boolean); - for (const shim of lines) { - const pkgRoot = join(dirname(shim), "node_modules", "@github", "copilot"); - if (existsSync(pkgRoot)) { - const bin = platformBinary(pkgRoot); - if (bin) return bin; - } - } - } catch {} - return null; -} - -export function resolveRuntimeBinary() { - const env = process.env.COPILOT_CLI_PATH; - if (env && existsSync(env)) return env; - return fromRequire() || fromPath(); -} - -// ---- warm runtime + fresh-session-per-query -------------------------------- -/** - * @param {object} [opts] - * @param {string} [opts.model="gpt-5.4-mini"] fast model for silent answers - * @param {number} [opts.timeoutMs=60000] per-query wait cap - * @param {number} [opts.idleMs=300000] tear down the warm runtime after this idle - */ -export function createFastAI({ model = "gpt-5.4-mini", timeoutMs = 60_000, idleMs = 300_000 } = {}) { - let clientP = null; // Promise, the warm runtime (started once) - let idleTimer = null; - let inFlight = 0; // active queries; the idle teardown must never fire mid-query - - function armIdle() { - if (idleTimer) clearTimeout(idleTimer); - idleTimer = null; - // Only schedule teardown when nothing is running. Re-armed on query - // completion (see ai/warmup finally), so a slow query can't be torn down - // out from under itself even if a caller sets idleMs < timeoutMs. - if (inFlight > 0) return; - idleTimer = setTimeout(() => { void dispose(); }, idleMs); - if (typeof idleTimer.unref === "function") idleTimer.unref(); - } - - async function ensureClient() { - if (!clientP) { - clientP = (async () => { - const t0 = Date.now(); - const path = resolveRuntimeBinary(); - if (!path) throw new Error("could not locate the Copilot runtime binary"); - const client = new CopilotClient({ - connection: RuntimeConnection.forStdio({ path }), - logLevel: "error", - }); - await client.start(); - console.error(`[fast-ai] runtime started in ${Date.now() - t0}ms (${path})`); - return client; - })().catch((e) => { - clientP = null; // let a later call retry a cold start - throw e; - }); - } - return clientP; - } - - /** Answer a single question with a fresh, fast, context-free, NO-TOOLS session. */ - async function ai(question) { - const client = await ensureClient(); - inFlight++; - if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } // hold teardown while active - // Fresh session per call => guaranteed no context bleed between questions. - // createSession on this warm runtime is ~1s; the query itself is ~2s. - const tc = Date.now(); - const session = await client.createSession(lockedDownSessionConfig(model)); - const tq = Date.now(); - try { - const ev = await session.sendAndWait({ prompt: String(question) }, timeoutMs); - console.error(`[fast-ai] createSession=${tq - tc}ms query=${Date.now() - tq}ms model=${model}`); - return String(ev?.data?.content ?? "").trim(); - } finally { - try { await session.disconnect(); } catch {} - inFlight--; - armIdle(); // re-arm only now that this query is done - } - } - - async function dispose() { - if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } - const p = clientP; - clientP = null; - try { const c = await p; await c?.stop(); } catch {} - } - - /** Pre-start the warm runtime (and prime the model path) so the first real - * query is fast. Fire-and-forget; safe to call repeatedly. */ - async function warmup() { - let counted = false; - try { - await ensureClient(); - inFlight++; - counted = true; - if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } - // Prime the full path (createSession + a tiny query) so the model - // handshake is done before the learner's first real question. - const client = await clientP; - const s = await client.createSession(lockedDownSessionConfig(model)); - try { await s.sendAndWait({ prompt: "Reply with: ok" }, 30_000); } finally { try { await s.disconnect(); } catch {} } - console.error("[fast-ai] warmup complete (runtime + model primed)"); - } catch (e) { - console.error(`[fast-ai] warmup failed (first ai() will retry/fallback): ${e?.message ?? e}`); - } finally { - if (counted) { inFlight--; armIdle(); } - } - } - - return { ai, warmup, dispose, resolveRuntimeBinary }; -} diff --git a/extensions/code-tutor/test/smoke.test.mjs b/extensions/code-tutor/test/smoke.test.mjs index 677bb14..48deda4 100644 --- a/extensions/code-tutor/test/smoke.test.mjs +++ b/extensions/code-tutor/test/smoke.test.mjs @@ -44,6 +44,7 @@ const { canvasConfig } = await import("../canvas.mjs"); const { createCanvasRuntime } = await import("../canvas-kit/server.mjs"); const fmt = await import("../canvas-kit/format.mjs"); const cacheMod = await import("../cache.mjs"); +const { buildDemoState, seedDemoBoard } = await import("../demo/seed.mjs"); const runtime = createCanvasRuntime(canvasConfig); let passed = 0; @@ -509,6 +510,71 @@ try { assert.equal(s.questions[0].level, "wizard"); // doctorate -> wizard }); + // ---- demo mode: buildDemoState + seedDemoBoard + self-contained refs ------ + await test("buildDemoState is a valid modern board covering every feature", () => { + const s = buildDemoState(); + const LEVELS = ["eli5", "curious", "engineer", "wizard"]; + const CATS = ["algorithm", "data-structure", "complexity", "theory", "pattern", "paradigm", "concurrency", "system"]; + const cats = new Set(s.topics.map((t) => t.category)); + for (const c of CATS) assert.ok(cats.has(c), `demo covers category ${c}`); + // At least one topic carries all four reading levels (so the slider always has content). + assert.ok( + s.topics.some((t) => LEVELS.every((l) => t.explanations[l])), + "a topic has all four levels", + ); + // At least one topic is intentionally missing a level so the "Get this explanation" CTA shows. + assert.ok( + s.topics.some((t) => LEVELS.some((l) => !t.explanations[l])), + "a topic is missing a level (CTA)", + ); + const quals = new Set(s.findings.map((f) => f.quality)); + for (const q of ["good", "ok", "bad"]) assert.ok(quals.has(q), `demo has a ${q} finding`); + assert.ok(s.findings.some((f) => f.fixStatus === "requested"), "a finding is in the requested state"); + assert.ok(s.questions.some((q) => q.answer), "an answered question"); + assert.ok(s.questions.some((q) => !q.answer), "a pending question"); + const statuses = new Set(s.topics.map((t) => t.status)); + for (const st of ["new", "understood", "confused", "revisit"]) assert.ok(statuses.has(st), `a ${st} topic`); + assert.ok(s.codebase.repo, "repo set so findings expose Fix-in-a-new-session"); + // Referential integrity: every finding/question topicId points at a real topic. + const ids = new Set(s.topics.map((t) => t.id)); + for (const x of [...s.findings, ...s.questions]) { + if (x.topicId) assert.ok(ids.has(x.topicId), `topicId ${x.topicId} exists`); + } + }); + + await test("seedDemoBoard writes the board and opening the domain loads it unchanged", async () => { + const file = await seedDemoBoard({ home, domain: "demo-seed" }); + assert.match(file, /artifacts[\\/]demo-seed\.json$/); + const expected = buildDemoState({ domain: "demo-seed" }); + const c = await runtime.openInstance({ + instanceId: "demo-seed", + input: { domain: "demo-seed" }, + ctx: { instanceId: "demo-seed", input: { domain: "demo-seed" } }, + }); + const s = await getState(c.url); + assert.equal(s.topics.length, expected.topics.length, "all topics load"); + assert.equal(s.findings.length, expected.findings.length, "all findings load"); + assert.equal(s.questions.length, expected.questions.length, "all questions load"); + assert.equal(s.defaultLevel, "curious", "defaultLevel survives load (modern key, no migration drift)"); + assert.equal(s.codebase.label, "Code Tutor (demo)"); + // Modern state must pass through migrateState untouched: fixed timestamps preserved. + const t0 = s.topics.find((t) => t.id === "demo-t-algorithm"); + assert.ok(t0 && t0.createdAt && t0.explanations.wizard, "topic loads with its explanations intact"); + }); + + await test("a demo code reference resolves via read_snippet under the demo root", async () => { + const c = await runtime.openInstance({ + instanceId: "demo-snip", + input: { domain: "demo-seed" }, + ctx: { instanceId: "demo-snip", input: { domain: "demo-seed" } }, + }); + const { body } = await post(c.url, "read_snippet", { file: "canvas.mjs", startLine: 906, endLine: 925 }); + assert.ok(body.result, "read_snippet returns a result"); + assert.ok(Array.isArray(body.result.lines) && body.result.lines.length > 0, "returns source lines"); + assert.equal(body.result.focusStart, 906); + assert.equal(body.result.focusEnd, 925); + }); + await test("unknown action returns 400 with a code", async () => { const { status, body } = await post(open.url, "nope", {}); assert.equal(status, 400); diff --git a/extensions/language-tutor/demo/screenshot.mjs b/extensions/language-tutor/demo/screenshot.mjs new file mode 100644 index 0000000..45638a3 --- /dev/null +++ b/extensions/language-tutor/demo/screenshot.mjs @@ -0,0 +1,178 @@ +// demo/screenshot.mjs - capture one screenshot per documented Language Tutor +// feature. +// +// It seeds a demo learner profile into an isolated COPILOT_HOME under this demo +// folder, boots the canvas runtime over loopback HTTP, then drives a headless +// browser and writes PNGs to docs/img/. The same PNGs are copied to the site +// gallery. No durable JSON state is committed. +// +// Run: node demo/screenshot.mjs +// Needs Playwright's chromium. If it is missing, the script says how. + +import { mkdtemp, mkdir, rm, copyFile, stat } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; +import { seedDemoBoard } from "./seed.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const EXT = resolve(HERE, ".."); +const OUT = resolve(EXT, "docs", "img"); +const GALLERY = resolve(EXT, "..", "..", "site", "public", "screenshots", "language-tutor"); +const DOMAIN = "demo"; +const TEMP_ROOT = resolve(HERE, ".tmp-shots"); + +const FEATURE_SHOTS = [ + "course-overview", + "learner-profile", + "lesson-path", + "flashcard-example", + "quiz", + "completed-lesson", +]; + +const VIEWPORT = { width: 1000, height: 1360 }; +const SCALE = 2; +const MIN_BYTES = 3 * 1024; + +async function loadChromium() { + try { + const { chromium } = await import("playwright"); + return chromium; + } catch { + chromiumHint(); + process.exit(1); + } +} + +function chromiumHint() { + console.error( + "This script needs Playwright's chromium.\n" + + " npm i -D playwright && npx playwright install chromium\n" + + "then re-run: node demo/screenshot.mjs", + ); +} + +async function launchBrowser(chromium) { + try { + return await chromium.launch(); + } catch (err) { + if (/Executable doesn't exist|browserType.launch|playwright install/i.test(String(err?.message ?? err))) { + chromiumHint(); + process.exit(1); + } + throw err; + } +} + +async function waitForCourse(page) { + await page.waitForSelector(".lt-banner", { timeout: 15000 }); + await page.waitForSelector(".lt-node", { timeout: 15000 }); +} + +async function resetView(page, url) { + await page.goto(url, { waitUntil: "domcontentloaded" }); + await waitForCourse(page); + await page.waitForTimeout(250); +} + +async function shootPage(page, name) { + const path = join(OUT, `${name}.png`); + await page.screenshot({ path, fullPage: true }); + await assertShot(path); + return path; +} + +async function shootEl(page, selector, name, { hasText } = {}) { + const loc = hasText ? page.locator(selector, { hasText }).first() : page.locator(selector).first(); + await loc.scrollIntoViewIfNeeded(); + await page.waitForTimeout(200); + const path = join(OUT, `${name}.png`); + await loc.screenshot({ path }); + await assertShot(path); + return path; +} + +async function assertShot(path) { + const s = await stat(path); + if (s.size < MIN_BYTES) throw new Error(`screenshot looks blank: ${path} (${s.size} bytes)`); +} + +async function main() { + const chromium = await loadChromium(); + let home = null; + let runtime = null; + let browser = null; + const shot = []; + try { + await mkdir(TEMP_ROOT, { recursive: true }); + home = await mkdtemp(join(TEMP_ROOT, "run-")); + process.env.COPILOT_HOME = home; + + await seedDemoBoard({ home, domain: DOMAIN }); + const { canvasConfig } = await import("../canvas.mjs"); + const { createCanvasRuntime } = await import("../canvas-kit/server.mjs"); + runtime = createCanvasRuntime(canvasConfig); + browser = await launchBrowser(chromium); + + const open = await runtime.openInstance({ + instanceId: "language-shots", + input: { profile: DOMAIN }, + ctx: { instanceId: "language-shots", input: { profile: DOMAIN } }, + }); + const url = open.url; + await mkdir(OUT, { recursive: true }); + + const ctx = await browser.newContext({ viewport: VIEWPORT, deviceScaleFactor: SCALE, colorScheme: "dark" }); + const page = await ctx.newPage(); + + await resetView(page, url); + shot.push(await shootPage(page, "course-overview")); + shot.push(await shootEl(page, ".lt-hud", "learner-profile")); + shot.push(await shootEl(page, ".lt-unit", "lesson-path")); + + const doneLesson = page.locator(".lt-node.lt-done", { hasText: "Greetings" }).first(); + await doneLesson.scrollIntoViewIfNeeded(); + const completedPath = join(OUT, "completed-lesson.png"); + await doneLesson.screenshot({ path: completedPath }); + await assertShot(completedPath); + shot.push(completedPath); + + await page.locator(".lt-node", { hasText: "Courtesy" }).first().click(); + await page.waitForSelector(".lt-flash", { timeout: 10000 }); + await page.waitForSelector(".lt-example-card", { timeout: 10000 }); + await page.waitForTimeout(300); + shot.push(await shootPage(page, "flashcard-example")); + + await page.getByRole("button", { name: /Take the quiz/i }).click(); + await page.waitForSelector(".lt-quiz-prompt", { timeout: 10000 }); + await page.waitForSelector(".lt-options", { timeout: 10000 }); + await page.waitForTimeout(300); + shot.push(await shootPage(page, "quiz")); + + await ctx.close(); + + await mkdir(GALLERY, { recursive: true }); + for (const name of FEATURE_SHOTS) { + await copyFile(join(OUT, `${name}.png`), join(GALLERY, `${name}.png`)); + } + + console.log(`Wrote ${FEATURE_SHOTS.length} feature screenshots:`); + for (const name of FEATURE_SHOTS) { + const docsPath = join(OUT, `${name}.png`); + const sitePath = join(GALLERY, `${name}.png`); + const s = await stat(docsPath); + console.log(` ${docsPath} (${s.size} bytes)`); + console.log(` ${sitePath}`); + } + } finally { + if (browser) await browser.close().catch(() => {}); + if (runtime) await runtime.shutdown().catch(() => {}); + if (home) await rm(home, { recursive: true, force: true }).catch(() => {}); + await rm(TEMP_ROOT, { recursive: true, force: true }).catch(() => {}); + } +} + +main().catch((err) => { + console.error(`screenshot run failed: ${err?.stack ?? err}`); + process.exit(1); +}); diff --git a/extensions/language-tutor/demo/seed.mjs b/extensions/language-tutor/demo/seed.mjs new file mode 100644 index 0000000..8075ca4 --- /dev/null +++ b/extensions/language-tutor/demo/seed.mjs @@ -0,0 +1,121 @@ +// demo/seed.mjs - generate a rich Language Tutor learner profile for demos and +// screenshots without committing any generated state file. +// +// buildDemoState() returns a complete, modern learner state in memory. It reuses +// the built-in catalog for realistic course content, then marks progress, +// rewards and examples directly so screenshots are deterministic and offline. +// +// Launch demo mode: +// node demo/seed.mjs +// node demo/seed.mjs --domain demo +// node demo/seed.mjs --home +// then open the canvas with input { profile: "demo" }. + +import { mkdir, writeFile, rename } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { buildCourse } from "../catalog.mjs"; + +const EXT_NAME = "language-tutor"; +const FIXED_DAY = "2026-07-07"; +const EXAMPLE_AT = "2026-07-07T09:30:00.000Z"; + +export const EXTENSION_DIR = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +function markLesson(course, unitIndex, lessonIndex, done) { + return { + ...course, + units: course.units.map((unit, ui) => ({ + ...unit, + lessons: unit.lessons.map((lesson, li) => (ui === unitIndex && li === lessonIndex ? { ...lesson, done } : lesson)), + })), + }; +} + +export function buildDemoState({ domain = "demo" } = {}) { + let spanish = buildCourse("Spanish"); + spanish = markLesson(spanish, 0, 0, true); + spanish = markLesson(spanish, 1, 0, true); + + return { + profile: { + avatar: "🦊", + name: domain === "demo" ? "Demo Learner" : "Language Explorer", + xp: 245, + level: 3, + streak: 12, + lastStudied: FIXED_DAY, + hearts: 3, + gems: 48, + badges: [], + }, + activeLanguage: spanish.code, + courses: { + [spanish.code]: spanish, + }, + examples: { + "es::gracias": { + text: "Gracias por ayudarme con la lección.\nThank you for helping me with the lesson.", + pending: false, + error: null, + at: EXAMPLE_AT, + }, + "es::hola": { + text: "Hola, Paco corre al café.\nHello, Paco runs to the cafe.", + pending: false, + error: null, + at: EXAMPLE_AT, + }, + }, + }; +} + +function parseArgs(argv) { + const out = { home: null, domain: "demo" }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--home") out.home = argv[++i] ?? null; + else if (a === "--domain") out.domain = argv[++i] ?? "demo"; + else if (a === "--help" || a === "-h") out.help = true; + } + return out; +} + +function artifactPath(home, domain) { + const base = home || process.env.COPILOT_HOME || join(homedir(), ".copilot"); + const safe = String(domain).replace(/[^A-Za-z0-9._-]/g, "_") || "demo"; + return join(base, "extensions", EXT_NAME, "artifacts", `${safe}.json`); +} + +export async function seedDemoBoard({ home = null, domain = "demo" } = {}) { + const file = artifactPath(home, domain); + const state = buildDemoState({ domain }); + await mkdir(dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmp, JSON.stringify(state, null, 2), "utf8"); + await rename(tmp, file); + return file; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + console.log( + "Seed a Language Tutor demo profile.\n\n" + + " node demo/seed.mjs [--domain ] [--home ]\n\n" + + 'Then open the canvas with input { "profile": "" } (default: demo).', + ); + return; + } + const file = await seedDemoBoard({ home: args.home, domain: args.domain }); + console.log(`Seeded demo profile (domain "${args.domain}") -> ${file}`); + console.log(`Open the Language Tutor canvas with input { "profile": "${args.domain}" } to view it.`); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((err) => { + console.error(`seed failed: ${err?.message ?? err}`); + process.exit(1); + }); +} diff --git a/extensions/language-tutor/docs/img/completed-lesson.png b/extensions/language-tutor/docs/img/completed-lesson.png new file mode 100644 index 0000000..d1ace16 Binary files /dev/null and b/extensions/language-tutor/docs/img/completed-lesson.png differ diff --git a/extensions/language-tutor/docs/img/course-overview.png b/extensions/language-tutor/docs/img/course-overview.png new file mode 100644 index 0000000..7ed1260 Binary files /dev/null and b/extensions/language-tutor/docs/img/course-overview.png differ diff --git a/extensions/language-tutor/docs/img/flashcard-example.png b/extensions/language-tutor/docs/img/flashcard-example.png new file mode 100644 index 0000000..a4c02cf Binary files /dev/null and b/extensions/language-tutor/docs/img/flashcard-example.png differ diff --git a/extensions/language-tutor/docs/img/learner-profile.png b/extensions/language-tutor/docs/img/learner-profile.png new file mode 100644 index 0000000..ec8fc53 Binary files /dev/null and b/extensions/language-tutor/docs/img/learner-profile.png differ diff --git a/extensions/language-tutor/docs/img/lesson-path.png b/extensions/language-tutor/docs/img/lesson-path.png new file mode 100644 index 0000000..6195e29 Binary files /dev/null and b/extensions/language-tutor/docs/img/lesson-path.png differ diff --git a/extensions/language-tutor/docs/img/quiz.png b/extensions/language-tutor/docs/img/quiz.png new file mode 100644 index 0000000..055eaf9 Binary files /dev/null and b/extensions/language-tutor/docs/img/quiz.png differ diff --git a/extensions/news-aggregator/demo/screenshot.mjs b/extensions/news-aggregator/demo/screenshot.mjs new file mode 100644 index 0000000..5000755 --- /dev/null +++ b/extensions/news-aggregator/demo/screenshot.mjs @@ -0,0 +1,195 @@ +// demo/screenshot.mjs - capture one screenshot per documented News Aggregator feature. +// +// It seeds the offline demo feed (demo/seed.mjs) into an isolated COPILOT_HOME +// under this demo folder, boots the canvas runtime over loopback HTTP, then +// drives a headless browser and writes PNGs to docs/img/. The same files are +// copied into site/public/screenshots/news-aggregator/ for the site gallery. +// +// Run: node demo/screenshot.mjs +// Needs Playwright's chromium. If it is not installed, the script says how. + +import { mkdir, rm, copyFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; +import { seedDemoBoard } from "./seed.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const EXT = resolve(HERE, ".."); +const OUT = resolve(EXT, "docs", "img"); +const SITE = resolve(EXT, "..", "..", "site", "public", "screenshots", "news-aggregator"); +const HOME = resolve(HERE, ".shot-home"); +const DOMAIN = "demo"; + +const FEATURE_SHOTS = [ + "overview", + "topic-feed", + "saved-items", + "favorite-items", + "search-history", + "pinned-topic", + "sort-filter", + "ai-digest", +]; + +const VIEWPORT = { width: 1000, height: 1360 }; +const SCALE = 2; + +async function loadChromium() { + try { + const { chromium } = await import("playwright"); + return chromium; + } catch { + console.error( + "This script needs Playwright's chromium.\n" + + " npm i -D playwright && npx playwright install chromium\n" + + "then re-run: node demo/screenshot.mjs", + ); + process.exit(1); + } +} + +async function launchBrowser(chromium) { + try { + return await chromium.launch(); + } catch (err) { + console.error( + "Playwright chromium could not launch.\n" + + "Run: npx playwright install chromium\n" + + `Details: ${err?.message ?? err}`, + ); + process.exit(1); + } +} + +async function waitForFeed(page) { + await page.waitForSelector(".na-card", { timeout: 15000 }); + await page.waitForSelector(".na-digest", { timeout: 15000 }); +} + +async function resetView(page, url) { + await page.goto(url, { waitUntil: "domcontentloaded" }); + await waitForFeed(page); +} + +async function postAction(url, actionName, input) { + const res = await fetch(new URL("/action", url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ actionName, input }), + }); + if (!res.ok) throw new Error(`Action ${actionName} failed with HTTP ${res.status}`); +} + +async function shootPage(page, name) { + const path = join(OUT, `${name}.png`); + await page.screenshot({ path, fullPage: true }); + return path; +} + +async function shootEl(page, selector, name, { hasText } = {}) { + const loc = hasText ? page.locator(selector, { hasText }).first() : page.locator(selector).first(); + await loc.scrollIntoViewIfNeeded(); + const path = join(OUT, `${name}.png`); + await loc.screenshot({ path }); + return path; +} + +// Capture the top of the page clipped to the bottom of the last matching element, +// so a sparse view (e.g. a Saved tab with one item) frames its content tightly +// instead of trailing a tall empty page (the app has a viewport-height min-height). +async function shootTop(page, name, bottomSelector, pad = 24) { + const height = await page.evaluate( + ({ sel, pad }) => { + const els = Array.from(document.querySelectorAll(sel)); + if (!els.length) return null; + const bottom = Math.max(...els.map((e) => e.getBoundingClientRect().bottom)); + return Math.ceil(bottom + pad); + }, + { sel: bottomSelector, pad }, + ); + const clipH = Math.min(height ?? VIEWPORT.height, VIEWPORT.height); + const path = join(OUT, `${name}.png`); + await page.screenshot({ path, clip: { x: 0, y: 0, width: VIEWPORT.width, height: clipH } }); + return path; +} + +async function main() { + const chromium = await loadChromium(); + let runtime = null; + let browser = null; + const shot = []; + try { + await rm(HOME, { recursive: true, force: true }); + process.env.COPILOT_HOME = HOME; + + await seedDemoBoard({ home: HOME, domain: DOMAIN }); + const { canvasConfig } = await import("../canvas.mjs"); + const { createCanvasRuntime } = await import("../canvas-kit/server.mjs"); + runtime = createCanvasRuntime(canvasConfig); + browser = await launchBrowser(chromium); + + const open = await runtime.openInstance({ + instanceId: "shots", + input: { domain: DOMAIN }, + ctx: { instanceId: "shots", input: { domain: DOMAIN } }, + }); + const url = open.url; + await mkdir(OUT, { recursive: true }); + + const ctx = await browser.newContext({ viewport: VIEWPORT, deviceScaleFactor: SCALE, colorScheme: "dark" }); + const page = await ctx.newPage(); + // The card thumbnail tries a google.com favicon and only falls back to a + // colored letter tile onError. Offline that request hangs, leaving an empty + // box, so abort external favicon fetches to trigger the graceful fallback. + await page.route("**/s2/favicons**", (route) => route.abort()); + + await resetView(page, url); + shot.push(await shootPage(page, "overview")); + + shot.push(await shootEl(page, ".na-list", "topic-feed")); + + await postAction(url, "set_view", { view: "saved" }); + await page.waitForSelector(".na-card", { timeout: 10000 }); + shot.push(await shootTop(page, "saved-items", ".na-card")); + + await postAction(url, "set_view", { view: "favorites" }); + await page.waitForSelector(".na-card", { timeout: 10000 }); + shot.push(await shootTop(page, "favorite-items", ".na-card")); + + await postAction(url, "set_view", { view: "feed" }); + await page.getByRole("tab", { name: "Search" }).click(); + await page.waitForSelector(".na-hist", { timeout: 10000 }); + shot.push(await shootEl(page, ".na-controls", "search-history")); + + await resetView(page, url); + shot.push(await shootEl(page, ".na-chips", "pinned-topic")); + + await page.getByPlaceholder("Filter visible").fill("orbital"); + await page.locator(".na-toolbar select").selectOption("source"); + await page.waitForSelector(".na-card", { timeout: 10000 }); + shot.push(await shootPage(page, "sort-filter")); + + await resetView(page, url); + shot.push(await shootEl(page, ".na-digest", "ai-digest")); + + await ctx.close(); + + await mkdir(SITE, { recursive: true }); + for (const name of FEATURE_SHOTS) { + await copyFile(join(OUT, `${name}.png`), join(SITE, `${name}.png`)); + } + shot.push(`${SITE}\\*.png (${FEATURE_SHOTS.length} gallery images)`); + + console.log(`Wrote ${shot.length} outputs:`); + for (const p of shot) console.log(` ${p}`); + } finally { + if (browser) await browser.close().catch(() => {}); + if (runtime) await runtime.shutdown().catch(() => {}); + await rm(HOME, { recursive: true, force: true }).catch(() => {}); + } +} + +main().catch((err) => { + console.error(`screenshot run failed: ${err?.stack ?? err}`); + process.exit(1); +}); diff --git a/extensions/news-aggregator/demo/seed.mjs b/extensions/news-aggregator/demo/seed.mjs new file mode 100644 index 0000000..017d6ba --- /dev/null +++ b/extensions/news-aggregator/demo/seed.mjs @@ -0,0 +1,203 @@ +// demo/seed.mjs - generate a rich, offline News Aggregator board for demos and +// screenshots without committing any state JSON. +// +// buildDemoState() returns a complete modern feed in memory. The CLI writes it to +// /extensions/news-aggregator/artifacts/.json using the same +// write-temp-then-atomic-rename discipline as the kit storage. +// +// Launch demo mode: +// node demo/seed.mjs +// node demo/seed.mjs --domain demo +// node demo/seed.mjs --home +// then open the canvas with input { domain: "demo" }. + +import { mkdir, writeFile, rename } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +export const EXTENSION_DIR = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +const REFRESHED = "2026-07-07T16:45:00.000Z"; +const DIGEST_AT = "2026-07-07T16:46:30.000Z"; +const SAVED_AT = "2026-07-07T16:47:00.000Z"; +const FAVORITED_AT = "2026-07-07T16:47:30.000Z"; +const HIDDEN_AT = "2026-07-07T16:48:00.000Z"; + +const ARTICLES = [ + { + id: "demo-a-orbit-ai", + title: "Open orbital lab uses AI scheduler to cut satellite idle time", + link: "https://example.com/news/open-orbital-lab-ai-scheduler", + source: "Tech Ledger", + sourceHost: "techledger.example", + publishedAt: Date.parse("2026-07-07T16:10:00.000Z"), + }, + { + id: "demo-a-chip-cooling", + title: "New liquid cooling design lets compact AI chips train longer", + link: "https://example.com/news/liquid-cooling-ai-chips", + source: "Silicon Daily", + sourceHost: "silicondaily.example", + publishedAt: Date.parse("2026-07-07T15:42:00.000Z"), + }, + { + id: "demo-a-rural-broadband", + title: "Rural broadband grants fund open source network monitors", + link: "https://example.com/news/rural-broadband-open-source-monitors", + source: "Civic Wire", + sourceHost: "civicwire.example", + publishedAt: Date.parse("2026-07-07T15:15:00.000Z"), + }, + { + id: "demo-a-robot-warehouse", + title: "Warehouse robot fleet adds safety model for shared aisles", + link: "https://example.com/news/warehouse-robot-safety-model", + source: "Automation Review", + sourceHost: "automationreview.example", + publishedAt: Date.parse("2026-07-07T14:58:00.000Z"), + }, + { + id: "demo-a-climate-grid", + title: "Climate startup predicts grid stress from rooftop solar swings", + link: "https://example.com/news/climate-startup-grid-stress-solar", + source: "Energy Signal", + sourceHost: "energysignal.example", + publishedAt: Date.parse("2026-07-07T14:31:00.000Z"), + }, + { + id: "demo-a-developer-tools", + title: "Developer tools team ships local replay for flaky cloud jobs", + link: "https://example.com/news/local-replay-flaky-cloud-jobs", + source: "DevOps Journal", + sourceHost: "devopsjournal.example", + publishedAt: Date.parse("2026-07-07T14:07:00.000Z"), + }, + { + id: "demo-a-health-wearable", + title: "Health wearable study finds better alerts with on-device models", + link: "https://example.com/news/health-wearable-on-device-models", + source: "Health Byte", + sourceHost: "healthbyte.example", + publishedAt: Date.parse("2026-07-07T13:38:00.000Z"), + }, + { + id: "demo-a-space-sensors", + title: "Space sensors spot tiny debris before it reaches crew capsules", + link: "https://example.com/news/space-sensors-debris-capsules", + source: "Orbit Times", + sourceHost: "orbittimes.example", + publishedAt: Date.parse("2026-07-07T13:12:00.000Z"), + }, +]; + +function articleById(id) { + return ARTICLES.find((a) => a.id === id); +} + +function markFor(id, flags) { + const a = articleById(id); + return { id, title: a.title, link: a.link, source: a.source, sourceHost: a.sourceHost, publishedAt: a.publishedAt, ...flags }; +} + +/** + * Build a complete News Aggregator demo state in memory. No disk I/O. + * @param {object} [opts] + * @param {string} [opts.domain] feed domain/key (default: "demo") + * @returns {object} a feed state ready to serialize into the artifacts store + */ +export function buildDemoState({ domain = "demo" } = {}) { + return { + domain, + activeId: "technology", + mode: "topic", + query: "", + articles: ARTICLES.map((a) => ({ ...a })), + error: null, + lastRefresh: REFRESHED, + view: "feed", + autoRefreshSec: 60, + marks: { + "demo-a-orbit-ai": markFor("demo-a-orbit-ai", { saved: true, savedAt: SAVED_AT }), + "demo-a-chip-cooling": markFor("demo-a-chip-cooling", { favorite: true, favoritedAt: FAVORITED_AT }), + "demo-a-space-sensors": markFor("demo-a-space-sensors", { hidden: true, hiddenAt: HIDDEN_AT }), + }, + searchHistory: [ + { query: "AI chip cooling", at: "2026-07-07T16:30:00.000Z" }, + { query: "orbital debris sensors", at: "2026-07-07T16:12:00.000Z" }, + { query: "open source network monitors", at: "2026-07-07T15:55:00.000Z" }, + { query: "climate grid forecasting", at: "2026-07-07T15:20:00.000Z" }, + ], + pinnedTopics: [ + { + id: "pin_demo_ai_chips", + label: "AI Chips", + query: "AI chip cooling", + icon: "cpu", + createdAt: "2026-07-07T16:32:00.000Z", + }, + ], + digest: { + text: + "Technology headlines are centered on practical AI infrastructure: faster chip cooling, safer robot operations, and local tools for cloud reliability. The most important story is the orbital lab scheduler, which shows AI moving from demos into operational systems that save real capacity.", + pending: false, + error: null, + label: "Technology", + at: DIGEST_AT, + refreshToken: REFRESHED, + }, + }; +} + +function parseArgs(argv) { + const out = { home: null, domain: "demo" }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--home") out.home = argv[++i] ?? null; + else if (a === "--domain") out.domain = argv[++i] ?? "demo"; + else if (a === "--help" || a === "-h") out.help = true; + } + return out; +} + +function artifactPath(home, domain) { + const base = home || process.env.COPILOT_HOME || join(homedir(), ".copilot"); + const safe = String(domain).replace(/[^A-Za-z0-9._-]/g, "_") || "demo"; + return join(base, "extensions", "news-aggregator", "artifacts", `${safe}.json`); +} + +/** + * Write a demo feed to /extensions/news-aggregator/artifacts/.json. + * @returns {Promise} the file path written + */ +export async function seedDemoBoard({ home = null, domain = "demo" } = {}) { + const file = artifactPath(home, domain); + const state = buildDemoState({ domain }); + await mkdir(dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmp, JSON.stringify(state, null, 2), "utf8"); + await rename(tmp, file); + return file; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + console.log( + "Seed a News Aggregator demo feed.\n\n" + + " node demo/seed.mjs [--domain ] [--home ]\n\n" + + 'Then open the News Aggregator canvas with input { domain: "" }.', + ); + return; + } + const file = await seedDemoBoard({ home: args.home, domain: args.domain }); + console.log(`Seeded demo feed (domain "${args.domain}") -> ${file}`); + console.log(`Open the News Aggregator canvas with input { "domain": "${args.domain}" } to view it.`); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((err) => { + console.error(`seed failed: ${err?.message ?? err}`); + process.exit(1); + }); +} diff --git a/extensions/news-aggregator/docs/img/ai-digest.png b/extensions/news-aggregator/docs/img/ai-digest.png new file mode 100644 index 0000000..014c5a0 Binary files /dev/null and b/extensions/news-aggregator/docs/img/ai-digest.png differ diff --git a/extensions/news-aggregator/docs/img/favorite-items.png b/extensions/news-aggregator/docs/img/favorite-items.png new file mode 100644 index 0000000..66e0bce Binary files /dev/null and b/extensions/news-aggregator/docs/img/favorite-items.png differ diff --git a/extensions/news-aggregator/docs/img/overview.png b/extensions/news-aggregator/docs/img/overview.png new file mode 100644 index 0000000..1733e97 Binary files /dev/null and b/extensions/news-aggregator/docs/img/overview.png differ diff --git a/extensions/news-aggregator/docs/img/pinned-topic.png b/extensions/news-aggregator/docs/img/pinned-topic.png new file mode 100644 index 0000000..f46ad98 Binary files /dev/null and b/extensions/news-aggregator/docs/img/pinned-topic.png differ diff --git a/extensions/news-aggregator/docs/img/saved-items.png b/extensions/news-aggregator/docs/img/saved-items.png new file mode 100644 index 0000000..c7543ed Binary files /dev/null and b/extensions/news-aggregator/docs/img/saved-items.png differ diff --git a/extensions/news-aggregator/docs/img/search-history.png b/extensions/news-aggregator/docs/img/search-history.png new file mode 100644 index 0000000..12966f3 Binary files /dev/null and b/extensions/news-aggregator/docs/img/search-history.png differ diff --git a/extensions/news-aggregator/docs/img/sort-filter.png b/extensions/news-aggregator/docs/img/sort-filter.png new file mode 100644 index 0000000..faf9606 Binary files /dev/null and b/extensions/news-aggregator/docs/img/sort-filter.png differ diff --git a/extensions/news-aggregator/docs/img/topic-feed.png b/extensions/news-aggregator/docs/img/topic-feed.png new file mode 100644 index 0000000..8fb308b Binary files /dev/null and b/extensions/news-aggregator/docs/img/topic-feed.png differ diff --git a/extensions/random-animal/demo/screenshot.mjs b/extensions/random-animal/demo/screenshot.mjs new file mode 100644 index 0000000..dcaf540 --- /dev/null +++ b/extensions/random-animal/demo/screenshot.mjs @@ -0,0 +1,141 @@ +// demo/screenshot.mjs - capture one screenshot per documented Random Animal feature. +// +// It seeds the demo board (demo/seed.mjs) into an isolated COPILOT_HOME under +// this demo folder, boots the canvas runtime over loopback HTTP exactly like the +// smoke test, then drives a headless browser through each feature and writes PNGs +// to docs/img/. +// +// No state data is committed: the board is generated at runtime in a scratch dir +// that is removed on exit. Only the PNGs are written into the repo. +// +// Run: node demo/screenshot.mjs +// Needs Playwright's chromium. If it isn't installed, the script says how. + +import { mkdir, rm, copyFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; +import { seedDemoBoard } from "./seed.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const EXT = resolve(HERE, ".."); +const OUT = resolve(EXT, "docs", "img"); +const SITE = resolve(EXT, "..", "..", "site", "public", "screenshots"); +const GALLERY = resolve(SITE, "random-animal"); +const DOMAIN = "demo"; + +// The feature shots, in the order the site gallery presents them. +const FEATURE_SHOTS = [ + "overview", + "current-animal", + "ai-fun-fact", + "roll-history", +]; + +// A dark, retina-ish panel sized like a generous side panel so text stays crisp. +const VIEWPORT = { width: 1000, height: 1200 }; +const SCALE = 2; + +async function loadChromium() { + try { + const { chromium } = await import("playwright"); + return chromium; + } catch { + console.error( + "This script needs Playwright's chromium.\n" + + " npm i -D playwright && npx playwright install chromium\n" + + "then re-run: node demo/screenshot.mjs", + ); + process.exit(1); + } +} + +// Wait for the animal card to paint. We wait on a concrete selector rather than +// "networkidle": the canvas holds an open SSE stream, so the network never goes idle. +async function waitForBoard(page) { + await page.waitForSelector(".animal-card", { timeout: 15000 }); + await page.waitForSelector(".history-item", { timeout: 15000 }); +} + +async function resetView(page, url) { + await page.goto(url, { waitUntil: "domcontentloaded" }); + await waitForBoard(page); +} + +async function shootPage(page, name) { + const path = join(OUT, `${name}.png`); + await page.screenshot({ path, fullPage: true }); + return path; +} + +async function shootEl(page, selector, name, { hasText } = {}) { + const loc = hasText ? page.locator(selector, { hasText }).first() : page.locator(selector).first(); + await loc.waitFor({ timeout: 10000 }); + await loc.scrollIntoViewIfNeeded(); + const path = join(OUT, `${name}.png`); + await loc.screenshot({ path }); + return path; +} + +async function main() { + const chromium = await loadChromium(); + let home = null; + let runtime = null; + let browser = null; + const shot = []; + try { + home = resolve(HERE, `.shot-home-${process.pid}-${Date.now()}`); + await rm(home, { recursive: true, force: true }); + await mkdir(home, { recursive: true }); + process.env.COPILOT_HOME = home; + + await seedDemoBoard({ home, domain: DOMAIN }); + const { canvasConfig } = await import("../canvas.mjs"); + const { createCanvasRuntime } = await import("../canvas-kit/server.mjs"); + runtime = createCanvasRuntime(canvasConfig); + browser = await chromium.launch(); + + const open = await runtime.openInstance({ + instanceId: "shots", + input: { domain: DOMAIN }, + ctx: { instanceId: "shots", input: { domain: DOMAIN } }, + }); + const url = open.url; + await mkdir(OUT, { recursive: true }); + + const ctx = await browser.newContext({ viewport: VIEWPORT, deviceScaleFactor: SCALE, colorScheme: "dark" }); + const page = await ctx.newPage(); + + // 1) Overview - the whole seeded board with current animal and history. + await resetView(page, url); + shot.push(await shootPage(page, "overview")); + + // 2) Current animal - emoji, name, and bundled fun fact with bounce styling. + shot.push(await shootEl(page, ".animal-card", "current-animal")); + + // 3) Tell me more - the pre-filled AI fact, captured without a live model call. + shot.push(await shootEl(page, ".animal-card .ck-card", "ai-fun-fact", { hasText: "AI fun fact" })); + + // 4) Roll history - prior animals and facts. + shot.push(await shootEl(page, ".history-item", "roll-history", { hasText: "Dolphin" })); + + // Publish the feature shots into the site gallery so the lightbox can show them. + await mkdir(GALLERY, { recursive: true }); + for (const name of FEATURE_SHOTS) { + await copyFile(join(OUT, `${name}.png`), join(GALLERY, `${name}.png`)); + } + shot.push(`${GALLERY}\\*.png (${FEATURE_SHOTS.length} gallery images)`); + + console.log(`Wrote ${shot.length} outputs:`); + for (const p of shot) console.log(` ${p}`); + } finally { + // Guard each teardown independently so a failing close cannot leak the scratch dir. + if (browser) await browser.close().catch(() => {}); + if (runtime) await runtime.shutdown().catch(() => {}); + if (home) await rm(home, { recursive: true, force: true }).catch(() => {}); + } +} + +main().catch((err) => { + console.error(`screenshot run failed: ${err?.stack ?? err}`); + process.exit(1); +}); diff --git a/extensions/random-animal/demo/seed.mjs b/extensions/random-animal/demo/seed.mjs new file mode 100644 index 0000000..43e1bc8 --- /dev/null +++ b/extensions/random-animal/demo/seed.mjs @@ -0,0 +1,149 @@ +// demo/seed.mjs - generate a fully-populated Random Animal board for demos and +// screenshots WITHOUT bundling any state file in the extension. +// +// buildDemoState() returns a complete board in memory. Nothing here is written +// to disk unless you run this file as a CLI, which seeds the board into the +// runtime artifacts store. +// +// Launch demo mode: +// node demo/seed.mjs # writes /extensions/random-animal/artifacts/demo.json +// node demo/seed.mjs --domain demo # pick the board domain (default: demo) +// node demo/seed.mjs --home # pick the COPILOT_HOME root (default: $COPILOT_HOME or ~/.copilot) +// then open the canvas with input { domain: "demo" }. + +import { mkdir, writeFile, rename } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +export const EXTENSION_DIR = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +const ROLLED = "2026-01-14T09:00:00.000Z"; + +const CURRENT = { + id: "demo-current-otter", + emoji: "🦦", + name: "Otter", + fact: "Sea otters hold hands while sleeping so they don't drift apart.", + rolledAt: ROLLED, + aiFact: + "Sea otters tuck favorite rocks into loose underarm skin pockets and use them as tools to crack open shellfish.", + aiFactPending: false, + aiFactError: null, +}; + +const HISTORY = [ + { + id: "demo-history-dolphin", + emoji: "🐬", + name: "Dolphin", + fact: "Dolphins sleep with one eye open.", + rolledAt: "2026-01-14T08:55:00.000Z", + }, + { + id: "demo-history-owl", + emoji: "🦉", + name: "Owl", + fact: "Owls can rotate their heads up to 270 degrees.", + rolledAt: "2026-01-14T08:50:00.000Z", + }, + { + id: "demo-history-panda", + emoji: "🐼", + name: "Panda", + fact: "A newborn panda is about the size of a stick of butter.", + rolledAt: "2026-01-14T08:45:00.000Z", + }, + { + id: "demo-history-flamingo", + emoji: "🦩", + name: "Flamingo", + fact: "Flamingos are born white and turn pink from their diet.", + rolledAt: "2026-01-14T08:40:00.000Z", + }, + { + id: "demo-history-shark", + emoji: "🦈", + name: "Shark", + fact: "Sharks have been around longer than trees.", + rolledAt: "2026-01-14T08:35:00.000Z", + }, + { + id: "demo-history-bee", + emoji: "🐝", + name: "Bee", + fact: "Bees can recognize human faces.", + rolledAt: "2026-01-14T08:30:00.000Z", + }, +]; + +/** + * Build a complete Random Animal board in memory. No disk I/O. + * @param {object} [opts] + * @param {string} [opts.domain] board domain/key (default: "demo") + * @returns {object} a board state ready to serialize into the artifacts store + */ +export function buildDemoState({ domain = "demo" } = {}) { + void domain; + return { + current: { ...CURRENT }, + history: HISTORY.map((animal) => ({ ...animal })), + }; +} + +// ---- CLI: seed the demo board into the runtime artifacts store --------------- + +function parseArgs(argv) { + const out = { home: null, domain: "demo" }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--home") out.home = argv[++i] ?? null; + else if (a === "--domain") out.domain = argv[++i] ?? "demo"; + else if (a === "--help" || a === "-h") out.help = true; + } + return out; +} + +function artifactPath(home, domain) { + const base = home || process.env.COPILOT_HOME || join(homedir(), ".copilot"); + const safe = String(domain).replace(/[^A-Za-z0-9._-]/g, "_") || "demo"; + return join(base, "extensions", "random-animal", "artifacts", `${safe}.json`); +} + +/** + * Write a demo board to /extensions/random-animal/artifacts/.json, + * using the same write-temp-then-atomic-rename discipline as the kit's storage. + * @returns {Promise} the file path written + */ +export async function seedDemoBoard({ home = null, domain = "demo" } = {}) { + const file = artifactPath(home, domain); + const state = buildDemoState({ domain }); + await mkdir(dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmp, JSON.stringify(state, null, 2), "utf8"); + await rename(tmp, file); + return file; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + console.log( + "Seed a Random Animal demo board.\n\n" + + " node demo/seed.mjs [--domain ] [--home ]\n\n" + + 'Then open the canvas with input { domain: "" } (default: demo).', + ); + return; + } + const file = await seedDemoBoard({ home: args.home, domain: args.domain }); + console.log(`Seeded demo board (domain "${args.domain}") -> ${file}`); + console.log(`Open the Random Animal canvas with input { "domain": "${args.domain}" } to view it.`); +} + +// Run main() only when executed directly, not when imported. +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((err) => { + console.error(`seed failed: ${err?.message ?? err}`); + process.exit(1); + }); +} diff --git a/extensions/random-animal/docs/img/ai-fun-fact.png b/extensions/random-animal/docs/img/ai-fun-fact.png new file mode 100644 index 0000000..6e1a020 Binary files /dev/null and b/extensions/random-animal/docs/img/ai-fun-fact.png differ diff --git a/extensions/random-animal/docs/img/current-animal.png b/extensions/random-animal/docs/img/current-animal.png new file mode 100644 index 0000000..aff6a42 Binary files /dev/null and b/extensions/random-animal/docs/img/current-animal.png differ diff --git a/extensions/random-animal/docs/img/overview.png b/extensions/random-animal/docs/img/overview.png new file mode 100644 index 0000000..0ab5d62 Binary files /dev/null and b/extensions/random-animal/docs/img/overview.png differ diff --git a/extensions/random-animal/docs/img/roll-history.png b/extensions/random-animal/docs/img/roll-history.png new file mode 100644 index 0000000..a5d2263 Binary files /dev/null and b/extensions/random-animal/docs/img/roll-history.png differ diff --git a/extensions/stock-ticker/demo/screenshot.mjs b/extensions/stock-ticker/demo/screenshot.mjs new file mode 100644 index 0000000..7baeebb --- /dev/null +++ b/extensions/stock-ticker/demo/screenshot.mjs @@ -0,0 +1,174 @@ +// demo/screenshot.mjs - capture one screenshot per documented Stock Ticker feature. +// +// It seeds the in-memory demo watchlist (demo/seed.mjs) into a throwaway +// COPILOT_HOME, boots the canvas runtime over loopback HTTP exactly like the +// smoke test, then drives a headless browser through each feature and writes +// PNGs to docs/img/. +// +// No data is committed: the watchlist is generated at runtime in a temp dir that +// is removed on exit. Only the PNGs are written into the repo. +// +// Run: node demo/screenshot.mjs +// Needs Playwright's chromium. If it isn't installed, the script says how. + +import { mkdtemp, mkdir, rm, copyFile, stat } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; +import { seedDemoBoard } from "./seed.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const EXT = resolve(HERE, ".."); +const OUT = resolve(EXT, "docs", "img"); +const GALLERY = resolve(EXT, "..", "..", "site", "public", "screenshots", "stock-ticker"); +const DOMAIN = "demo"; + +const FEATURE_SHOTS = [ + "overview", + "ticker-tape", + "watchlist-quotes", + "custom-aliases", + "sparkline-range", + "ai-summary", + "filters-sorting", +]; + +const VIEWPORT = { width: 1000, height: 1280 }; +const SCALE = 2; + +async function loadChromium() { + try { + const { chromium } = await import("playwright"); + return chromium; + } catch { + console.error( + "This script needs Playwright's chromium.\n" + + " npm i -D playwright && npx playwright install chromium\n" + + "then re-run: node demo/screenshot.mjs", + ); + process.exit(1); + } +} + +async function waitForBoard(page) { + await page.waitForSelector(".st-card", { timeout: 15000 }); + await page.waitForSelector(".st-ai", { timeout: 15000 }); +} + +async function blockQuoteRefresh(context) { + await context.route("**/action", async (route) => { + const req = route.request(); + if (req.method() !== "POST") return route.continue(); + try { + const body = JSON.parse(req.postData() || "{}"); + if (body.actionName === "refresh_quotes") { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ ok: true, result: { count: 0, ok: 0, failed: 0, summary: "Demo quotes are preloaded." } }), + }); + } + } catch { + // Let malformed requests reach the runtime so it can surface the error. + } + return route.continue(); + }); +} + +async function resetView(page, url) { + await page.goto(url, { waitUntil: "domcontentloaded" }); + await waitForBoard(page); +} + +async function shootPage(page, name) { + const path = join(OUT, `${name}.png`); + await page.screenshot({ path, fullPage: true }); + return path; +} + +async function shootEl(page, selector, name, { hasText } = {}) { + const loc = hasText ? page.locator(selector, { hasText }).first() : page.locator(selector).first(); + await loc.scrollIntoViewIfNeeded(); + const path = join(OUT, `${name}.png`); + await loc.screenshot({ path }); + return path; +} + +async function main() { + const chromium = await loadChromium(); + let home = null; + let runtime = null; + let browser = null; + const shot = []; + try { + home = await mkdtemp(join(HERE, ".shots-")); + process.env.COPILOT_HOME = home; + + await seedDemoBoard({ home, domain: DOMAIN }); + const { canvasConfig } = await import("../canvas.mjs"); + const { createCanvasRuntime } = await import("../canvas-kit/server.mjs"); + runtime = createCanvasRuntime(canvasConfig); + browser = await chromium.launch(); + + const open = await runtime.openInstance({ + instanceId: "shots", + input: { domain: DOMAIN }, + ctx: { instanceId: "shots", input: { domain: DOMAIN } }, + }); + const url = open.url; + await mkdir(OUT, { recursive: true }); + + const ctx = await browser.newContext({ viewport: VIEWPORT, deviceScaleFactor: SCALE, colorScheme: "dark" }); + await blockQuoteRefresh(ctx); + const page = await ctx.newPage(); + + // 1) Overview - the whole watchlist with live status, controls, summary, and cards. + await resetView(page, url); + shot.push(await shootPage(page, "overview")); + + // 2) Ticker tape - compact live quote strip across the top. + shot.push(await shootEl(page, ".st-tape", "ticker-tape")); + + // 3) Watchlist quotes - seeded price, change, day range, 52-week range, volume, and sparklines. + shot.push(await shootEl(page, ".st-grid", "watchlist-quotes")); + + // 4) Custom aliases - a card showing a human label while preserving the symbol. + shot.push(await shootEl(page, ".st-card", "custom-aliases", { hasText: "AI bellwether" })); + + // 5) Sparkline range - controls show the seeded 5d range selected. + shot.push(await shootEl(page, ".st-sub", "sparkline-range")); + + // 6) AI market summary - prefilled prose, generated timestamp, and refresh control. + shot.push(await shootEl(page, ".st-ai", "ai-summary")); + + // 7) Filters and sorting - select gainers and percent change to show local UI filtering. + await page.getByRole("tab", { name: "gainers" }).click(); + await page.getByRole("tab", { name: "% Change" }).click(); + await page.waitForSelector(".st-card", { timeout: 10000 }); + shot.push(await shootPage(page, "filters-sorting")); + await ctx.close(); + + await mkdir(GALLERY, { recursive: true }); + for (const name of FEATURE_SHOTS) { + await copyFile(join(OUT, `${name}.png`), join(GALLERY, `${name}.png`)); + } + + console.log(`Wrote ${FEATURE_SHOTS.length} feature screenshots:`); + for (const name of FEATURE_SHOTS) { + const docsPath = join(OUT, `${name}.png`); + const sitePath = join(GALLERY, `${name}.png`); + const docsSize = (await stat(docsPath)).size; + const siteSize = (await stat(sitePath)).size; + console.log(` ${docsPath} (${docsSize} bytes)`); + console.log(` ${sitePath} (${siteSize} bytes)`); + } + } finally { + if (browser) await browser.close().catch(() => {}); + if (runtime) await runtime.shutdown().catch(() => {}); + if (home) await rm(home, { recursive: true, force: true }).catch(() => {}); + } +} + +main().catch((err) => { + console.error(`screenshot run failed: ${err?.stack ?? err}`); + process.exit(1); +}); diff --git a/extensions/stock-ticker/demo/seed.mjs b/extensions/stock-ticker/demo/seed.mjs new file mode 100644 index 0000000..1ca8e01 --- /dev/null +++ b/extensions/stock-ticker/demo/seed.mjs @@ -0,0 +1,217 @@ +// demo/seed.mjs - generate a rich, fully-populated Stock Ticker watchlist for demos and +// screenshots WITHOUT bundling any market data file in the extension. +// +// buildDemoState() returns a complete, modern watchlist in memory. Nothing here +// is written to disk unless you run this file as a CLI, which seeds the board +// into the runtime artifacts store. +// +// Launch demo mode: +// node demo/seed.mjs # writes /extensions/stock-ticker/artifacts/demo.json +// node demo/seed.mjs --domain demo # pick the watchlist domain (default: demo) +// node demo/seed.mjs --home # pick the COPILOT_HOME root (default: $COPILOT_HOME or ~/.copilot) +// then open the canvas with input { domain: "demo" }. + +import { mkdir, writeFile, rename } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +export const EXTENSION_DIR = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +const ADDED = "2026-07-07T13:30:00.000Z"; +const REFRESHED = "2026-07-07T17:00:00.000Z"; +const MARKET_TIME = Date.parse("2026-07-07T16:00:00.000Z"); +const FETCHED = Date.parse(REFRESHED); + +const WATCHLIST = [ + { symbol: "NVDA", alias: "AI bellwether", addedAt: ADDED }, + { symbol: "MSFT", alias: "", addedAt: "2026-07-07T13:31:00.000Z" }, + { symbol: "AAPL", alias: "", addedAt: "2026-07-07T13:32:00.000Z" }, + { symbol: "AMZN", alias: "Retail cloud", addedAt: "2026-07-07T13:33:00.000Z" }, + { symbol: "GOOGL", alias: "", addedAt: "2026-07-07T13:34:00.000Z" }, + { symbol: "TSLA", alias: "Momentum watch", addedAt: "2026-07-07T13:35:00.000Z" }, +]; + +const QUOTES = { + NVDA: quote({ + symbol: "NVDA", + name: "NVIDIA Corporation", + exchange: "NasdaqGS", + price: 142.64, + prevClose: 138.92, + dayLow: 139.88, + dayHigh: 143.72, + week52Low: 86.62, + week52High: 153.13, + volume: 48231500, + spark: [136.8, 137.4, 138.1, 137.9, 139.0, 139.8, 140.6, 141.2, 140.9, 141.7, 142.1, 142.6, 142.4, 142.9, 143.2, 142.64], + }), + MSFT: quote({ + symbol: "MSFT", + name: "Microsoft Corporation", + exchange: "NasdaqGS", + price: 504.18, + prevClose: 497.76, + dayLow: 498.4, + dayHigh: 506.25, + week52Low: 385.58, + week52High: 513.37, + volume: 23187000, + spark: [492.9, 494.1, 495.5, 496.2, 497.0, 498.6, 500.2, 499.7, 501.1, 502.9, 503.4, 504.0, 503.6, 504.8, 505.3, 504.18], + }), + AAPL: quote({ + symbol: "AAPL", + name: "Apple Inc.", + exchange: "NasdaqGS", + price: 214.37, + prevClose: 216.05, + dayLow: 212.81, + dayHigh: 217.18, + week52Low: 164.08, + week52High: 237.49, + volume: 60422000, + spark: [218.2, 217.6, 216.8, 217.1, 216.0, 215.4, 214.9, 215.2, 214.3, 213.7, 214.1, 213.6, 214.0, 214.5, 214.2, 214.37], + }), + AMZN: quote({ + symbol: "AMZN", + name: "Amazon.com, Inc.", + exchange: "NasdaqGS", + price: 226.91, + prevClose: 224.12, + dayLow: 223.55, + dayHigh: 228.34, + week52Low: 151.61, + week52High: 233.0, + volume: 35984000, + spark: [220.7, 221.3, 222.1, 222.9, 223.5, 224.4, 224.1, 225.0, 225.8, 226.4, 225.9, 226.8, 227.2, 226.6, 227.1, 226.91], + }), + GOOGL: quote({ + symbol: "GOOGL", + name: "Alphabet Inc.", + exchange: "NasdaqGS", + price: 196.28, + prevClose: 198.74, + dayLow: 194.95, + dayHigh: 199.21, + week52Low: 130.67, + week52High: 207.05, + volume: 28765000, + spark: [200.1, 199.4, 198.8, 198.1, 197.5, 197.9, 197.0, 196.5, 195.9, 196.2, 195.7, 196.0, 196.4, 195.8, 196.1, 196.28], + }), + TSLA: quote({ + symbol: "TSLA", + name: "Tesla, Inc.", + exchange: "NasdaqGS", + price: 318.44, + prevClose: 322.81, + dayLow: 314.2, + dayHigh: 326.5, + week52Low: 138.8, + week52High: 414.5, + volume: 91245000, + spark: [329.2, 327.4, 325.1, 323.6, 324.4, 322.0, 320.7, 321.2, 319.6, 317.9, 318.5, 316.8, 317.4, 318.1, 317.7, 318.44], + }), +}; + +function quote(input) { + const change = Number((input.price - input.prevClose).toFixed(2)); + const changePct = Number(((change / input.prevClose) * 100).toFixed(4)); + return { + symbol: input.symbol, + name: input.name, + exchange: input.exchange, + currency: "USD", + price: input.price, + prevClose: input.prevClose, + change, + changePct, + dayHigh: input.dayHigh, + dayLow: input.dayLow, + week52High: input.week52High, + week52Low: input.week52Low, + volume: input.volume, + marketTime: MARKET_TIME, + spark: input.spark, + error: null, + fetchedAt: FETCHED, + }; +} + +/** + * Build a complete Stock Ticker demo state in memory. No disk I/O. + * @param {object} [opts] + * @param {string} [opts.domain] watchlist domain/key (default: "demo") + * @returns {object} a watchlist state ready to serialize into the artifacts store + */ +export function buildDemoState({ domain = "demo" } = {}) { + return { + domain, + symbols: WATCHLIST.map((s) => ({ ...s })), + quotes: Object.fromEntries(Object.entries(QUOTES).map(([symbol, q]) => [symbol, { ...q, spark: [...q.spark] }])), + range: "5d", + lastRefresh: REFRESHED, + aiSummary: { + text: + "Mega-cap tech is mixed but constructive, with NVIDIA and Microsoft leading the tape while Apple, Alphabet, and Tesla lag. The watchlist has a growth tilt today: cloud and AI names are carrying the gains, while consumer hardware and higher-beta autos are softer.", + pending: false, + error: null, + at: "2026-07-07T17:02:00.000Z", + }, + }; +} + +// ---- CLI: seed the demo watchlist into the runtime artifacts store ------------ + +function parseArgs(argv) { + const out = { home: null, domain: "demo" }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--home") out.home = argv[++i] ?? null; + else if (a === "--domain") out.domain = argv[++i] ?? "demo"; + else if (a === "--help" || a === "-h") out.help = true; + } + return out; +} + +function artifactPath(home, domain) { + const base = home || process.env.COPILOT_HOME || join(homedir(), ".copilot"); + const safe = String(domain).replace(/[^A-Za-z0-9._-]/g, "_") || "demo"; + return join(base, "extensions", "stock-ticker", "artifacts", `${safe}.json`); +} + +/** + * Write a demo watchlist to /extensions/stock-ticker/artifacts/.json, + * using the same write-temp-then-atomic-rename discipline as the kit's storage. + * @returns {Promise} the file path written + */ +export async function seedDemoBoard({ home = null, domain = "demo" } = {}) { + const file = artifactPath(home, domain); + const state = buildDemoState({ domain }); + await mkdir(dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmp, JSON.stringify(state, null, 2), "utf8"); + await rename(tmp, file); + return file; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + console.log( + "Seed a Stock Ticker demo watchlist.\n\n" + + " node demo/seed.mjs [--domain ] [--home ]\n\n" + + 'Then open the canvas with input { domain: "" } (default: demo).', + ); + return; + } + const file = await seedDemoBoard({ home: args.home, domain: args.domain }); + console.log(`Seeded demo watchlist (domain "${args.domain}") -> ${file}`); + console.log(`Open the Stock Ticker canvas with input { "domain": "${args.domain}" } to view it.`); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((err) => { + console.error(`seed failed: ${err?.message ?? err}`); + process.exit(1); + }); +} diff --git a/extensions/stock-ticker/docs/img/ai-summary.png b/extensions/stock-ticker/docs/img/ai-summary.png new file mode 100644 index 0000000..6bd9d48 Binary files /dev/null and b/extensions/stock-ticker/docs/img/ai-summary.png differ diff --git a/extensions/stock-ticker/docs/img/custom-aliases.png b/extensions/stock-ticker/docs/img/custom-aliases.png new file mode 100644 index 0000000..ab26c2b Binary files /dev/null and b/extensions/stock-ticker/docs/img/custom-aliases.png differ diff --git a/extensions/stock-ticker/docs/img/filters-sorting.png b/extensions/stock-ticker/docs/img/filters-sorting.png new file mode 100644 index 0000000..192418d Binary files /dev/null and b/extensions/stock-ticker/docs/img/filters-sorting.png differ diff --git a/extensions/stock-ticker/docs/img/overview.png b/extensions/stock-ticker/docs/img/overview.png new file mode 100644 index 0000000..514e029 Binary files /dev/null and b/extensions/stock-ticker/docs/img/overview.png differ diff --git a/extensions/stock-ticker/docs/img/sparkline-range.png b/extensions/stock-ticker/docs/img/sparkline-range.png new file mode 100644 index 0000000..6446d39 Binary files /dev/null and b/extensions/stock-ticker/docs/img/sparkline-range.png differ diff --git a/extensions/stock-ticker/docs/img/ticker-tape.png b/extensions/stock-ticker/docs/img/ticker-tape.png new file mode 100644 index 0000000..7a550ae Binary files /dev/null and b/extensions/stock-ticker/docs/img/ticker-tape.png differ diff --git a/extensions/stock-ticker/docs/img/watchlist-quotes.png b/extensions/stock-ticker/docs/img/watchlist-quotes.png new file mode 100644 index 0000000..fa0a175 Binary files /dev/null and b/extensions/stock-ticker/docs/img/watchlist-quotes.png differ diff --git a/extensions/wiki-discover/demo/screenshot.mjs b/extensions/wiki-discover/demo/screenshot.mjs new file mode 100644 index 0000000..ab3d072 --- /dev/null +++ b/extensions/wiki-discover/demo/screenshot.mjs @@ -0,0 +1,153 @@ +// demo/screenshot.mjs - capture one screenshot per Wiki Discover feature. +// +// It seeds the demo profile into an isolated COPILOT_HOME inside this demo folder, +// boots the canvas runtime over loopback HTTP, then drives a headless browser and +// writes PNGs to docs/img/. The same feature shots are copied into the site +// gallery folder. +// +// Run: node demo/screenshot.mjs +// Needs Playwright's chromium. If it is not installed, the script says how. + +import { mkdtemp, mkdir, rm, copyFile, stat } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; +import { seedDemoBoard } from "./seed.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const EXT = resolve(HERE, ".."); +const OUT = resolve(EXT, "docs", "img"); +const GALLERY = resolve(EXT, "..", "..", "site", "public", "screenshots", "wiki-discover"); +const DOMAIN = "demo"; + +const FEATURE_SHOTS = [ + "overview", + "article-card", + "ai-tldr", + "preference-profile", + "up-next", + "sentiment-controls", +]; + +const VIEWPORT = { width: 1000, height: 1320 }; +const SCALE = 2; + +async function loadChromium() { + try { + const { chromium } = await import("playwright"); + return chromium; + } catch { + console.error( + "This script needs Playwright's chromium.\n" + + " npm i -D playwright && npx playwright install chromium\n" + + "then re-run: node demo/screenshot.mjs", + ); + process.exit(1); + } +} + +async function launchChromium(chromium) { + try { + return await chromium.launch(); + } catch (err) { + console.error( + "Chromium is not installed for Playwright.\n" + + " npx playwright install chromium\n" + + "then re-run: node demo/screenshot.mjs\n" + + String(err?.message ?? err), + ); + process.exit(1); + } +} + +async function waitForBoard(page) { + await page.waitForSelector(".wd-article .wd-title", { timeout: 15000 }); + await page.waitForSelector(".wd-next-item", { timeout: 15000 }); +} + +async function resetView(page, url) { + await page.goto(url, { waitUntil: "domcontentloaded" }); + await waitForBoard(page); +} + +async function openTunePanel(page) { + const bar = page.locator(".wd-tunebar").first(); + if ((await bar.getAttribute("aria-expanded")) !== "true") await bar.click(); + await page.waitForSelector(".wd-tune-body", { timeout: 10000 }); +} + +async function shootPage(page, name) { + const path = join(OUT, `${name}.png`); + await page.screenshot({ path, fullPage: true }); + return path; +} + +async function shootEl(page, selector, name) { + const loc = page.locator(selector).first(); + await loc.scrollIntoViewIfNeeded(); + const path = join(OUT, `${name}.png`); + await loc.screenshot({ path }); + return path; +} + +async function sizeOf(path) { + return (await stat(path)).size; +} + +async function main() { + const chromium = await loadChromium(); + let home = null; + let runtime = null; + let browser = null; + const shot = []; + try { + home = await mkdtemp(join(HERE, ".tmp-shots-")); + process.env.COPILOT_HOME = home; + + await seedDemoBoard({ home, domain: DOMAIN }); + const { canvasConfig } = await import("../canvas.mjs"); + const { createCanvasRuntime } = await import("../canvas-kit/server.mjs"); + runtime = createCanvasRuntime(canvasConfig); + browser = await launchChromium(chromium); + + const open = await runtime.openInstance({ + instanceId: "shots", + input: { profile: DOMAIN }, + ctx: { instanceId: "shots", input: { profile: DOMAIN } }, + }); + const url = open.url; + await mkdir(OUT, { recursive: true }); + await mkdir(GALLERY, { recursive: true }); + + const ctx = await browser.newContext({ viewport: VIEWPORT, deviceScaleFactor: SCALE, colorScheme: "dark" }); + const page = await ctx.newPage(); + + await resetView(page, url); + shot.push(await shootPage(page, "overview")); + shot.push(await shootEl(page, ".wd-article", "article-card")); + shot.push(await shootEl(page, ".wd-tldr-card", "ai-tldr")); + + await openTunePanel(page); + shot.push(await shootEl(page, ".wd-tune", "preference-profile")); + + shot.push(await shootEl(page, ".wd-next-list", "up-next")); + shot.push(await shootEl(page, ".wd-actionbar", "sentiment-controls")); + + for (const name of FEATURE_SHOTS) { + await copyFile(join(OUT, `${name}.png`), join(GALLERY, `${name}.png`)); + } + + console.log(`Wrote ${shot.length} screenshots:`); + for (const p of shot) console.log(` ${p} (${await sizeOf(p)} bytes)`); + console.log(`Copied gallery screenshots to ${GALLERY}`); + await ctx.close(); + } finally { + if (browser) await browser.close().catch(() => {}); + if (runtime) await runtime.shutdown().catch(() => {}); + if (home) await rm(home, { recursive: true, force: true }).catch(() => {}); + } +} + +main().catch((err) => { + console.error(`screenshot run failed: ${err?.stack ?? err}`); + process.exit(1); +}); diff --git a/extensions/wiki-discover/demo/seed.mjs b/extensions/wiki-discover/demo/seed.mjs new file mode 100644 index 0000000..4e7f476 --- /dev/null +++ b/extensions/wiki-discover/demo/seed.mjs @@ -0,0 +1,203 @@ +// demo/seed.mjs - generate a rich Wiki Discover profile for demos and screenshots. +// +// buildDemoState() returns a complete profile in memory. Nothing is written to +// disk unless this file is run as a CLI, which seeds the profile into the canvas +// runtime artifacts store. +// +// Launch demo mode: +// node demo/seed.mjs +// node demo/seed.mjs --domain demo +// node demo/seed.mjs --home +// then open the canvas with input { profile: "demo" }. + +import { mkdir, writeFile, rename } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +export const EXTENSION_DIR = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +const REFRESHED = "2026-01-15T10:00:00.000Z"; +const HISTORY_TS = "2026-01-15T09:42:00.000Z"; + +const CURRENT = { + id: "demo-algorithmic-gardens", + title: "Islamic geometric patterns", + description: "mathematical decorative art", + summary: "Geometric pattern traditions connect art, symmetry, and mathematics across architecture and craft.", + extract: + "Islamic geometric patterns are designs built from repeated circles, squares, stars, and polygons. They appear in architecture, manuscripts, tiles, and textiles, where symmetry and careful construction create complex art from simple shapes.", + url: "https://en.wikipedia.org/wiki/Islamic_geometric_patterns", + thumbnail: "", + lang: "en", + tokens: ["mathematical", "decorative", "art", "geometric", "patterns", "symmetry", "architecture"], + matched: ["Mathematics", "Architecture"], + score: 11.5, + aiSummary: + "This article connects math and visual design, showing how simple repeated shapes become rich patterns in architecture, tiles, manuscripts, and textiles.", + aiSummaryPending: false, + aiSummaryError: null, + images: [], + imagesLoaded: true, +}; + +const QUEUE = [ + { + id: "demo-james-webb", + title: "James Webb Space Telescope", + description: "space observatory", + extract: "A large infrared space telescope used to study distant galaxies, stars, and exoplanets.", + url: "https://en.wikipedia.org/wiki/James_Webb_Space_Telescope", + thumbnail: "", + lang: "en", + tokens: ["space", "observatory", "astronomy", "telescope", "galaxies"], + matched: ["Astronomy", "Space exploration"], + score: 13.25, + }, + { + id: "demo-roman-concrete", + title: "Roman concrete", + description: "ancient building material", + extract: "A durable material used in Roman architecture and infrastructure.", + url: "https://en.wikipedia.org/wiki/Roman_concrete", + thumbnail: "", + lang: "en", + tokens: ["ancient", "building", "material", "roman", "architecture"], + matched: ["Ancient Rome", "Architecture"], + score: 10.75, + }, + { + id: "demo-bioluminescence", + title: "Bioluminescence", + description: "production and emission of light by living organisms", + extract: "Light made by living organisms, including many marine animals and fungi.", + url: "https://en.wikipedia.org/wiki/Bioluminescence", + thumbnail: "", + lang: "en", + tokens: ["production", "emission", "light", "living", "organisms", "biology", "oceans"], + matched: ["Biology", "Oceans"], + score: 7.5, + }, + { + id: "demo-ukiyo-e", + title: "Ukiyo-e", + description: "genre of Japanese art", + extract: "A Japanese printmaking and painting tradition that influenced modern visual culture.", + url: "https://en.wikipedia.org/wiki/Ukiyo-e", + thumbnail: "", + lang: "en", + tokens: ["genre", "japanese", "art", "painting", "culture"], + matched: ["Japan", "Painting"], + score: 5.6, + }, + { + id: "demo-transfer-window", + title: "Transfer window", + description: "sports administration period", + extract: "A period when professional sports teams can transfer players.", + url: "https://en.wikipedia.org/wiki/Transfer_window", + thumbnail: "", + lang: "en", + tokens: ["sports", "administration", "period", "football"], + matched: [], + score: -2.25, + }, +]; + +const WEIGHTS = { + astronomy: 4.25, + space: 3.5, + architecture: 2.75, + mathematics: 2.5, + biology: 1.5, + art: 1.1, + celebrity: -2, + football: -1.5, + politics: -1.25, +}; + +const LIKED = [ + { id: "demo-orion-nebula", title: "Orion Nebula", url: "https://en.wikipedia.org/wiki/Orion_Nebula", ts: HISTORY_TS }, + { id: "demo-pantheon", title: "Pantheon, Rome", url: "https://en.wikipedia.org/wiki/Pantheon,_Rome", ts: HISTORY_TS }, +]; + +const DISLIKED = [ + { id: "demo-reality-tv", title: "Reality television", url: "https://en.wikipedia.org/wiki/Reality_television", ts: HISTORY_TS }, +]; + +/** + * Build a complete Wiki Discover demo profile in memory. No disk I/O. + * @param {object} [opts] + * @param {string} [opts.domain] profile key, default "demo" + * @returns {object} a profile state ready to serialize into the artifacts store + */ +export function buildDemoState({ domain = "demo" } = {}) { + return { + profile: domain, + lang: "en", + interests: ["Astronomy", "Architecture", "Ancient Rome", "Mathematics", "Biology", "Japan"], + current: { ...CURRENT }, + queue: QUEUE.map((item) => ({ ...item })), + liked: LIKED.map((item) => ({ ...item })), + disliked: DISLIKED.map((item) => ({ ...item })), + seenIds: ["demo-orion-nebula", "demo-pantheon", "demo-reality-tv", CURRENT.id], + weights: { ...WEIGHTS }, + stats: { rated: 7, liked: 2, meh: 2, disliked: 1 }, + error: null, + lastRefresh: REFRESHED, + }; +} + +function parseArgs(argv) { + const out = { home: null, domain: "demo" }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--home") out.home = argv[++i] ?? null; + else if (a === "--domain") out.domain = argv[++i] ?? "demo"; + else if (a === "--help" || a === "-h") out.help = true; + } + return out; +} + +function artifactPath(home, domain) { + const base = home || process.env.COPILOT_HOME || join(homedir(), ".copilot"); + const safe = String(domain).replace(/[^A-Za-z0-9._-]/g, "_") || "demo"; + return join(base, "extensions", "wiki-discover", "artifacts", `${safe}.json`); +} + +/** + * Write a demo profile to /extensions/wiki-discover/artifacts/.json + * using write-temp-then-atomic-rename. + * @returns {Promise} the file path written + */ +export async function seedDemoBoard({ home = null, domain = "demo" } = {}) { + const file = artifactPath(home, domain); + const state = buildDemoState({ domain }); + await mkdir(dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmp, JSON.stringify(state, null, 2), "utf8"); + await rename(tmp, file); + return file; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + console.log( + "Seed a Wiki Discover demo profile.\n\n" + + " node demo/seed.mjs [--domain ] [--home ]\n\n" + + 'Then open the canvas with input { "profile": "" } (default: demo).', + ); + return; + } + const file = await seedDemoBoard({ home: args.home, domain: args.domain }); + console.log(`Seeded demo profile (domain "${args.domain}") -> ${file}`); + console.log(`Open the Wiki Discover canvas with input { "profile": "${args.domain}" } to view it.`); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((err) => { + console.error(`seed failed: ${err?.message ?? err}`); + process.exit(1); + }); +} diff --git a/extensions/wiki-discover/docs/img/ai-tldr.png b/extensions/wiki-discover/docs/img/ai-tldr.png new file mode 100644 index 0000000..812c412 Binary files /dev/null and b/extensions/wiki-discover/docs/img/ai-tldr.png differ diff --git a/extensions/wiki-discover/docs/img/article-card.png b/extensions/wiki-discover/docs/img/article-card.png new file mode 100644 index 0000000..b0ee127 Binary files /dev/null and b/extensions/wiki-discover/docs/img/article-card.png differ diff --git a/extensions/wiki-discover/docs/img/overview.png b/extensions/wiki-discover/docs/img/overview.png new file mode 100644 index 0000000..5a24c19 Binary files /dev/null and b/extensions/wiki-discover/docs/img/overview.png differ diff --git a/extensions/wiki-discover/docs/img/preference-profile.png b/extensions/wiki-discover/docs/img/preference-profile.png new file mode 100644 index 0000000..c5e6e72 Binary files /dev/null and b/extensions/wiki-discover/docs/img/preference-profile.png differ diff --git a/extensions/wiki-discover/docs/img/sentiment-controls.png b/extensions/wiki-discover/docs/img/sentiment-controls.png new file mode 100644 index 0000000..de6492b Binary files /dev/null and b/extensions/wiki-discover/docs/img/sentiment-controls.png differ diff --git a/extensions/wiki-discover/docs/img/up-next.png b/extensions/wiki-discover/docs/img/up-next.png new file mode 100644 index 0000000..3a3fe34 Binary files /dev/null and b/extensions/wiki-discover/docs/img/up-next.png differ diff --git a/site/public/screenshots/README.md b/site/public/screenshots/README.md index 9e6e209..440c40e 100644 --- a/site/public/screenshots/README.md +++ b/site/public/screenshots/README.md @@ -1,17 +1,28 @@ # Screenshots -Drop a screenshot per extension here and the matching card on the home page picks -it up automatically. Name each file after the extension's folder slug: +Two things live here, both consumed by the home page (`src/pages/index.astro`): -| File | Used by | +1. A **card + hero image** per extension, named after the extension's folder slug + (e.g. `code-tutor.png`). The card grid and the lightbox hero use it. +2. An optional **per-extension gallery folder**, named after the slug (e.g. + `code-tutor/`), holding one image per feature. When it exists, that extension's + lightbox shows the images as a thumbnail gallery with captions. Captions and + order live in `src/pages/index.astro` (the `shots` list). Without a folder, the + lightbox just shows the single hero. + +| Path | Used by | | --- | --- | -| `news-aggregator.png` | news-aggregator card | -| `stock-ticker.png` | stock-ticker card | -| `random-animal.png` | random-animal card | -| `language-tutor.png` | language-tutor card | -| `wiki-discover.png` | wiki-discover card | -| `code-tutor.png` | code-tutor card | +| `code-tutor.png` | code-tutor card + hero | +| `code-tutor/*.png` | code-tutor lightbox gallery | +| `language-tutor.png` | language-tutor card + hero | +| `stock-ticker.png` | stock-ticker card + hero | +| `news-aggregator.png` | news-aggregator card + hero | +| `wiki-discover.png` | wiki-discover card + hero | +| `random-animal.png` | random-animal card + hero | + +Until a slug's image exists, the card shows `placeholder.svg`. PNG or JPG both +work. A ~16:9 image (e.g. 1280x720) looks best for the card. -Until a file exists, the card shows `placeholder.svg`. PNG or JPG both work — keep -the path/extension in sync with `src/pages/index.astro` if you use something other -than `.png`. A ~16:9 image (e.g. 1280×720) looks best. +Code Tutor's images are generated, not hand-captured: run +`node extensions/code-tutor/demo/screenshot.mjs`, which writes the hero +(`code-tutor.png`) and the gallery (`code-tutor/*.png`) here. diff --git a/site/public/screenshots/code-tutor.png b/site/public/screenshots/code-tutor.png index 139ae0b..943fd4f 100644 Binary files a/site/public/screenshots/code-tutor.png and b/site/public/screenshots/code-tutor.png differ diff --git a/site/public/screenshots/code-tutor/ask-clarify.png b/site/public/screenshots/code-tutor/ask-clarify.png new file mode 100644 index 0000000..9a08a0a Binary files /dev/null and b/site/public/screenshots/code-tutor/ask-clarify.png differ diff --git a/site/public/screenshots/code-tutor/categories.png b/site/public/screenshots/code-tutor/categories.png new file mode 100644 index 0000000..3a38808 Binary files /dev/null and b/site/public/screenshots/code-tutor/categories.png differ diff --git a/site/public/screenshots/code-tutor/code-reference.png b/site/public/screenshots/code-tutor/code-reference.png new file mode 100644 index 0000000..f788c06 Binary files /dev/null and b/site/public/screenshots/code-tutor/code-reference.png differ diff --git a/site/public/screenshots/code-tutor/code-review.png b/site/public/screenshots/code-tutor/code-review.png new file mode 100644 index 0000000..86f7f26 Binary files /dev/null and b/site/public/screenshots/code-tutor/code-review.png differ diff --git a/site/public/screenshots/code-tutor/concept-cache.png b/site/public/screenshots/code-tutor/concept-cache.png new file mode 100644 index 0000000..88cab2e Binary files /dev/null and b/site/public/screenshots/code-tutor/concept-cache.png differ diff --git a/site/public/screenshots/code-tutor/freshness.png b/site/public/screenshots/code-tutor/freshness.png new file mode 100644 index 0000000..ef15757 Binary files /dev/null and b/site/public/screenshots/code-tutor/freshness.png differ diff --git a/site/public/screenshots/code-tutor/mark-understanding.png b/site/public/screenshots/code-tutor/mark-understanding.png new file mode 100644 index 0000000..acc966f Binary files /dev/null and b/site/public/screenshots/code-tutor/mark-understanding.png differ diff --git a/site/public/screenshots/code-tutor/overview.png b/site/public/screenshots/code-tutor/overview.png new file mode 100644 index 0000000..ecdc20f Binary files /dev/null and b/site/public/screenshots/code-tutor/overview.png differ diff --git a/site/public/screenshots/code-tutor/reading-levels.png b/site/public/screenshots/code-tutor/reading-levels.png new file mode 100644 index 0000000..3a39e85 Binary files /dev/null and b/site/public/screenshots/code-tutor/reading-levels.png differ diff --git a/site/public/screenshots/language-tutor/completed-lesson.png b/site/public/screenshots/language-tutor/completed-lesson.png new file mode 100644 index 0000000..d1ace16 Binary files /dev/null and b/site/public/screenshots/language-tutor/completed-lesson.png differ diff --git a/site/public/screenshots/language-tutor/course-overview.png b/site/public/screenshots/language-tutor/course-overview.png new file mode 100644 index 0000000..7ed1260 Binary files /dev/null and b/site/public/screenshots/language-tutor/course-overview.png differ diff --git a/site/public/screenshots/language-tutor/flashcard-example.png b/site/public/screenshots/language-tutor/flashcard-example.png new file mode 100644 index 0000000..a4c02cf Binary files /dev/null and b/site/public/screenshots/language-tutor/flashcard-example.png differ diff --git a/site/public/screenshots/language-tutor/learner-profile.png b/site/public/screenshots/language-tutor/learner-profile.png new file mode 100644 index 0000000..ec8fc53 Binary files /dev/null and b/site/public/screenshots/language-tutor/learner-profile.png differ diff --git a/site/public/screenshots/language-tutor/lesson-path.png b/site/public/screenshots/language-tutor/lesson-path.png new file mode 100644 index 0000000..6195e29 Binary files /dev/null and b/site/public/screenshots/language-tutor/lesson-path.png differ diff --git a/site/public/screenshots/language-tutor/quiz.png b/site/public/screenshots/language-tutor/quiz.png new file mode 100644 index 0000000..055eaf9 Binary files /dev/null and b/site/public/screenshots/language-tutor/quiz.png differ diff --git a/site/public/screenshots/news-aggregator/ai-digest.png b/site/public/screenshots/news-aggregator/ai-digest.png new file mode 100644 index 0000000..014c5a0 Binary files /dev/null and b/site/public/screenshots/news-aggregator/ai-digest.png differ diff --git a/site/public/screenshots/news-aggregator/favorite-items.png b/site/public/screenshots/news-aggregator/favorite-items.png new file mode 100644 index 0000000..66e0bce Binary files /dev/null and b/site/public/screenshots/news-aggregator/favorite-items.png differ diff --git a/site/public/screenshots/news-aggregator/overview.png b/site/public/screenshots/news-aggregator/overview.png new file mode 100644 index 0000000..1733e97 Binary files /dev/null and b/site/public/screenshots/news-aggregator/overview.png differ diff --git a/site/public/screenshots/news-aggregator/pinned-topic.png b/site/public/screenshots/news-aggregator/pinned-topic.png new file mode 100644 index 0000000..f46ad98 Binary files /dev/null and b/site/public/screenshots/news-aggregator/pinned-topic.png differ diff --git a/site/public/screenshots/news-aggregator/saved-items.png b/site/public/screenshots/news-aggregator/saved-items.png new file mode 100644 index 0000000..c7543ed Binary files /dev/null and b/site/public/screenshots/news-aggregator/saved-items.png differ diff --git a/site/public/screenshots/news-aggregator/search-history.png b/site/public/screenshots/news-aggregator/search-history.png new file mode 100644 index 0000000..12966f3 Binary files /dev/null and b/site/public/screenshots/news-aggregator/search-history.png differ diff --git a/site/public/screenshots/news-aggregator/sort-filter.png b/site/public/screenshots/news-aggregator/sort-filter.png new file mode 100644 index 0000000..faf9606 Binary files /dev/null and b/site/public/screenshots/news-aggregator/sort-filter.png differ diff --git a/site/public/screenshots/news-aggregator/topic-feed.png b/site/public/screenshots/news-aggregator/topic-feed.png new file mode 100644 index 0000000..8fb308b Binary files /dev/null and b/site/public/screenshots/news-aggregator/topic-feed.png differ diff --git a/site/public/screenshots/random-animal/ai-fun-fact.png b/site/public/screenshots/random-animal/ai-fun-fact.png new file mode 100644 index 0000000..6e1a020 Binary files /dev/null and b/site/public/screenshots/random-animal/ai-fun-fact.png differ diff --git a/site/public/screenshots/random-animal/current-animal.png b/site/public/screenshots/random-animal/current-animal.png new file mode 100644 index 0000000..aff6a42 Binary files /dev/null and b/site/public/screenshots/random-animal/current-animal.png differ diff --git a/site/public/screenshots/random-animal/overview.png b/site/public/screenshots/random-animal/overview.png new file mode 100644 index 0000000..0ab5d62 Binary files /dev/null and b/site/public/screenshots/random-animal/overview.png differ diff --git a/site/public/screenshots/random-animal/roll-history.png b/site/public/screenshots/random-animal/roll-history.png new file mode 100644 index 0000000..a5d2263 Binary files /dev/null and b/site/public/screenshots/random-animal/roll-history.png differ diff --git a/site/public/screenshots/stock-ticker/ai-summary.png b/site/public/screenshots/stock-ticker/ai-summary.png new file mode 100644 index 0000000..6bd9d48 Binary files /dev/null and b/site/public/screenshots/stock-ticker/ai-summary.png differ diff --git a/site/public/screenshots/stock-ticker/custom-aliases.png b/site/public/screenshots/stock-ticker/custom-aliases.png new file mode 100644 index 0000000..ab26c2b Binary files /dev/null and b/site/public/screenshots/stock-ticker/custom-aliases.png differ diff --git a/site/public/screenshots/stock-ticker/filters-sorting.png b/site/public/screenshots/stock-ticker/filters-sorting.png new file mode 100644 index 0000000..192418d Binary files /dev/null and b/site/public/screenshots/stock-ticker/filters-sorting.png differ diff --git a/site/public/screenshots/stock-ticker/overview.png b/site/public/screenshots/stock-ticker/overview.png new file mode 100644 index 0000000..514e029 Binary files /dev/null and b/site/public/screenshots/stock-ticker/overview.png differ diff --git a/site/public/screenshots/stock-ticker/sparkline-range.png b/site/public/screenshots/stock-ticker/sparkline-range.png new file mode 100644 index 0000000..6446d39 Binary files /dev/null and b/site/public/screenshots/stock-ticker/sparkline-range.png differ diff --git a/site/public/screenshots/stock-ticker/ticker-tape.png b/site/public/screenshots/stock-ticker/ticker-tape.png new file mode 100644 index 0000000..7a550ae Binary files /dev/null and b/site/public/screenshots/stock-ticker/ticker-tape.png differ diff --git a/site/public/screenshots/stock-ticker/watchlist-quotes.png b/site/public/screenshots/stock-ticker/watchlist-quotes.png new file mode 100644 index 0000000..fa0a175 Binary files /dev/null and b/site/public/screenshots/stock-ticker/watchlist-quotes.png differ diff --git a/site/public/screenshots/wiki-discover/ai-tldr.png b/site/public/screenshots/wiki-discover/ai-tldr.png new file mode 100644 index 0000000..812c412 Binary files /dev/null and b/site/public/screenshots/wiki-discover/ai-tldr.png differ diff --git a/site/public/screenshots/wiki-discover/article-card.png b/site/public/screenshots/wiki-discover/article-card.png new file mode 100644 index 0000000..b0ee127 Binary files /dev/null and b/site/public/screenshots/wiki-discover/article-card.png differ diff --git a/site/public/screenshots/wiki-discover/overview.png b/site/public/screenshots/wiki-discover/overview.png new file mode 100644 index 0000000..5a24c19 Binary files /dev/null and b/site/public/screenshots/wiki-discover/overview.png differ diff --git a/site/public/screenshots/wiki-discover/preference-profile.png b/site/public/screenshots/wiki-discover/preference-profile.png new file mode 100644 index 0000000..c5e6e72 Binary files /dev/null and b/site/public/screenshots/wiki-discover/preference-profile.png differ diff --git a/site/public/screenshots/wiki-discover/sentiment-controls.png b/site/public/screenshots/wiki-discover/sentiment-controls.png new file mode 100644 index 0000000..de6492b Binary files /dev/null and b/site/public/screenshots/wiki-discover/sentiment-controls.png differ diff --git a/site/public/screenshots/wiki-discover/up-next.png b/site/public/screenshots/wiki-discover/up-next.png new file mode 100644 index 0000000..3a3fe34 Binary files /dev/null and b/site/public/screenshots/wiki-discover/up-next.png differ diff --git a/site/src/layouts/Layout.astro b/site/src/layouts/Layout.astro index 0484749..9600dc9 100644 --- a/site/src/layouts/Layout.astro +++ b/site/src/layouts/Layout.astro @@ -132,8 +132,15 @@ const base = import.meta.env.BASE_URL; border-radius: 16px; box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.7); animation: lb-pop 0.2s ease; } - .lb-media { aspect-ratio: 16 / 9; background: var(--bg); border-bottom: 1px solid var(--border); } - .lb-media img { width: 100%; height: 100%; object-fit: cover; display: block; transition: opacity 0.15s ease; } + .lb-media { + background: var(--bg); border-bottom: 1px solid var(--border); + display: flex; align-items: center; justify-content: center; + min-height: 180px; max-height: 62vh; overflow: hidden; + } + .lb-media img { + max-width: 100%; max-height: 62vh; width: auto; height: auto; + object-fit: contain; display: block; cursor: zoom-in; transition: opacity 0.15s ease; + } .lb-body { padding: clamp(1rem, 3vw, 1.5rem); display: flex; flex-direction: column; gap: 0.75rem; } .lb-head { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; } .lb-title { margin: 0; font-size: 1.35rem; } @@ -143,6 +150,16 @@ const base = import.meta.env.BASE_URL; padding: 0.15em 0.55em; border-radius: 999px; } .lb-desc { margin: 0; color: var(--muted); } + .lb-caption { margin: 0; color: var(--muted); font-size: 0.9rem; } + .lb-thumbs { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 0; } + .lb-thumb { + flex: 0 0 auto; width: 88px; height: 54px; padding: 0; + border: 1px solid var(--border); border-radius: 8px; overflow: hidden; + background: var(--bg); cursor: pointer; opacity: 0.6; + transition: opacity 0.15s ease, border-color 0.15s ease; + } + .lb-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } + .lb-thumb:hover, .lb-thumb.active { opacity: 1; border-color: var(--accent); } .lb-install { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; } .lb-install code { flex: 1 1 240px; overflow-wrap: anywhere; } .lb-install .copy-btn { margin-top: 0; } @@ -174,6 +191,7 @@ const base = import.meta.env.BASE_URL; body { transition: none; } .lb-overlay, .lb-modal { animation: none; } .lb-media img { transition: none; } + .lb-thumb { transition: none; } .card { transition: none; } .card:hover { transform: none; } .card-media img { transition: none; } diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index 860b5c7..18b5117 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -9,30 +9,92 @@ const fallback = `${base}screenshots/placeholder.svg`; const installPromptFor = (slug: string) => `install the ${slug} canvas from jongio/copilot-extensions/extensions/${slug}`; -const meta: Record = { +// The gallery shown in an extension's lightbox. Extensions with per-feature shots +// get the full set (each image + caption); the rest fall back to their single hero. +const galleryFor = (slug: string, image: string) => { + const shots = meta[slug]?.shots; + if (shots?.length) { + return shots.map((s) => ({ src: `${base}screenshots/${slug}/${s.name}.png`, caption: s.caption })); + } + return [{ src: image, caption: "" }]; +}; + +const meta: Record = { "code-tutor": { type: "Canvas", body: "Turns the current codebase into a personal CS course: extracts the algorithms, data structures, complexity and theory in your code, explains each at an adjustable level (ELI5 to Wizard), points at real files with syntax-highlighted code, tracks what you understand, and reviews good/ok/bad spots with a path to fix them.", + shots: [ + { name: "overview", caption: "The full curriculum board: a progress ring, the reading-level slider, and a card per concept." }, + { name: "reading-levels", caption: "One slider from ELI5 to Wizard re-pitches every explanation to the depth you want." }, + { name: "concept-cache", caption: "Generic explanations are cached once and reused across boards, tagged when reused." }, + { name: "categories", caption: "Every concept is filed under a category; filter by category or by your progress." }, + { name: "code-reference", caption: "Each topic links to real source, read from disk with language-aware highlighting." }, + { name: "mark-understanding", caption: "Mark each topic Understood, Not understood, Revisit, or New." }, + { name: "ask-clarify", caption: "Ask questions per topic; answers land in the panel without touching the chat." }, + { name: "code-review", caption: "Good / ok / bad findings, each with a one-click Fix in a new session." }, + { name: "freshness", caption: "Fingerprints the code and flags when it drifts, with a Refresh button." }, + ], }, "language-tutor": { type: "Canvas", body: "Pick a language and a gamified course appears — flashcards, quizzes, XP, levels, streaks, hearts, gems, mascots and confetti, plus AI example sentences for any word. You and the agent share one learner profile.", + shots: [ + { name: "course-overview", caption: "The Spanish course with progress, a mascot, and the lesson path." }, + { name: "learner-profile", caption: "Shared XP, level, streak, hearts, and gems." }, + { name: "lesson-path", caption: "Completed, available, and locked lessons." }, + { name: "flashcard-example", caption: "A flashcard with a cached AI example sentence." }, + { name: "quiz", caption: "A gamified quiz where a miss costs a heart." }, + { name: "completed-lesson", caption: "The review state after a lesson is complete." }, + ], }, "stock-ticker": { type: "Canvas", body: "A personalized live stock watchlist — shared between you and the agent, with live quotes, sparklines, and an AI market summary of what's moving.", + shots: [ + { name: "overview", caption: "The full watchlist with controls, summary, and seeded quotes." }, + { name: "ticker-tape", caption: "A ticker strip with prices and daily moves." }, + { name: "watchlist-quotes", caption: "Quote cards with day and 52-week ranges, volume, and sparklines." }, + { name: "custom-aliases", caption: "Custom labels keep favorite symbols readable." }, + { name: "sparkline-range", caption: "A selected sparkline range across the whole board." }, + { name: "ai-summary", caption: "A pre-filled AI market summary of what is moving." }, + { name: "filters-sorting", caption: "Local filters and sorting surface the top movers." }, + ], }, "news-aggregator": { type: "Canvas", body: "Pick a topic or free-text search and get a live, shared news feed (Google News, no API key). Save/favorite/hide items, search history, pin searches as custom topics, sort & filter, visible-only auto-refresh — plus a one-tap AI TL;DR digest of the current headlines.", + shots: [ + { name: "overview", caption: "The Technology feed with saved, favorite, hidden, and digest state." }, + { name: "topic-feed", caption: "A populated Technology feed of headline cards." }, + { name: "saved-items", caption: "The saved headlines view." }, + { name: "favorite-items", caption: "The favorite headlines view." }, + { name: "search-history", caption: "The search box with recent searches." }, + { name: "pinned-topic", caption: "A pinned custom topic chip." }, + { name: "sort-filter", caption: "Visible-only filtering and sorting." }, + { name: "ai-digest", caption: "The pre-filled AI TL;DR digest of the headlines." }, + ], }, "wiki-discover": { type: "Canvas", body: "A \u201Cfor you\u201D Wikipedia reader shared with the agent — pick interests (or thumbs up / meh / not-for-me each article) and it learns your topics and surfaces popular articles you'll find interesting, with article images, a live preference profile, and an AI TL;DR per article.", + shots: [ + { name: "overview", caption: "The personalized Wikipedia reader." }, + { name: "article-card", caption: "The current article with its summary and matched topics." }, + { name: "ai-tldr", caption: "A pre-filled AI TL;DR of the article." }, + { name: "preference-profile", caption: "Your interests and the learned topic weights." }, + { name: "up-next", caption: "The ranked up-next queue with match scores." }, + { name: "sentiment-controls", caption: "Like, meh, and not-for-me controls train the feed." }, + ], }, "random-animal": { type: "Canvas", body: "Roll the dice to discover a random animal and a fun fact — with bounce-in animations, floating emojis, roll history, and a \u201CTell me more\u201D AI fun fact.", + shots: [ + { name: "overview", caption: "The rolled animal, its AI fact, and the roll history." }, + { name: "current-animal", caption: "The rolled animal with its emoji, name, and fun fact." }, + { name: "ai-fun-fact", caption: "A pre-filled AI fun fact, shown without a live model call." }, + { name: "roll-history", caption: "Recent rolls with prior animals, facts, and timestamps." }, + ], }, }; @@ -67,6 +129,7 @@ type Item = { fallback: string; href: string; installPrompt: string; + shots: { src: string; caption: string }[]; index: number; }; @@ -74,23 +137,25 @@ const items: Item[] = []; for (const g of groups) { for (const slug of g.slugs) { const m = meta[slug]; + const image = `${base}screenshots/${slug}.png`; items.push({ slug, group: g.title, title: slug, type: m.type, body: m.body, - image: `${base}screenshots/${slug}.png`, + image, fallback, href: `${repo}/tree/main/extensions/${slug}`, installPrompt: installPromptFor(slug), + shots: galleryFor(slug, image), index: items.length, }); } } // Slim payload the client lightbox reads (drop server-only fields). -const clientData = items.map(({ slug, title, type, body, image, fallback, href, installPrompt }) => ({ +const clientData = items.map(({ slug, title, type, body, image, fallback, href, installPrompt, shots }) => ({ slug, title, type, @@ -99,6 +164,7 @@ const clientData = items.map(({ slug, title, type, body, image, fallback, href, fallback, href, installPrompt, + shots, })); --- @@ -206,6 +272,8 @@ const clientData = items.map(({ slug, title, type, body, image, fallback, href,
+ +

@@ -243,6 +311,7 @@ const clientData = items.map(({ slug, title, type, body, image, fallback, href, fallback: string; href: string; installPrompt: string; + shots: { src: string; caption: string }[]; }; const dataEl = document.getElementById("ext-data"); @@ -258,26 +327,71 @@ const clientData = items.map(({ slug, title, type, body, image, fallback, href, const ghEl = document.getElementById("lb-gh") as HTMLAnchorElement | null; const countEl = document.getElementById("lb-count"); const closeEl = document.getElementById("lb-close") as HTMLElement | null; + const thumbsEl = document.getElementById("lb-thumbs"); + const captionEl = document.getElementById("lb-caption"); let current = 0; + let shotIdx = 0; let lastFocus: HTMLElement | null = null; - function populate(i: number) { - const it = items[i]; - if (!it) return; - current = i; + // Swap the main image + caption to shot j of the current extension, and mark + // the active thumbnail. Falls back to the extension's placeholder on load error. + function showShot(it: Item, j: number) { + const shots = it.shots?.length ? it.shots : [{ src: it.image, caption: "" }]; + shotIdx = (j + shots.length) % shots.length; + const s = shots[shotIdx]; if (imgEl) { imgEl.style.opacity = "0"; imgEl.onerror = () => { imgEl.onerror = null; imgEl.src = it.fallback; }; - imgEl.src = it.image; - imgEl.alt = `${it.slug} screenshot`; + imgEl.src = s.src; + imgEl.alt = `${it.slug} ${s.caption || "screenshot"}`; requestAnimationFrame(() => { imgEl.style.opacity = "1"; }); } + if (captionEl) { + captionEl.textContent = s.caption; + captionEl.hidden = !s.caption; + } + if (thumbsEl) { + thumbsEl.querySelectorAll(".lb-thumb").forEach((t, k) => { + t.classList.toggle("active", k === shotIdx); + t.setAttribute("aria-current", k === shotIdx ? "true" : "false"); + }); + } + } + + // Build the thumbnail strip for the current extension (hidden when it has one shot). + function renderShots(it: Item) { + if (!thumbsEl) return; + const shots = it.shots?.length ? it.shots : [{ src: it.image, caption: "" }]; + thumbsEl.hidden = shots.length <= 1; + thumbsEl.innerHTML = ""; + if (shots.length <= 1) return; + shots.forEach((s, k) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "lb-thumb"; + btn.setAttribute("data-shot", String(k)); + btn.setAttribute("aria-label", s.caption || `View ${k + 1}`); + const img = document.createElement("img"); + img.src = s.src; + img.alt = ""; + img.loading = "lazy"; + btn.appendChild(img); + thumbsEl.appendChild(btn); + }); + } + + function populate(i: number) { + const it = items[i]; + if (!it) return; + current = i; + renderShots(it); + showShot(it, 0); if (titleEl) titleEl.textContent = it.title; if (badgeEl) badgeEl.textContent = it.type; if (descEl) descEl.textContent = it.body; @@ -348,6 +462,18 @@ const clientData = items.map(({ slug, title, type, body, image, fallback, href, if (target.closest("#lb-next")) return go(1); if (overlay && !overlay.hidden && target === overlay) return close(); + const thumb = target.closest(".lb-thumb"); + if (thumb) { + const j = Number(thumb.getAttribute("data-shot")); + if (!Number.isNaN(j)) showShot(items[current], j); + return; + } + + if (target.closest("#lb-img")) { + showShot(items[current], shotIdx + 1); + return; + } + const card = target.closest("[data-card]"); if (card) { const idx = Number(card.getAttribute("data-index"));