{"node_modules":{"directory":{"@soleone":{"directory":{"showcase":{"directory":{"LICENSE":{"file":{"contents":"MIT License\n\nCopyright (c) 2026 Soleone\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}},"README.md":{"file":{"contents":"# showcase\n\nGrade how demoable a project is, from D to S, and get the exact next steps to raise it.\n\n**[Try it in your browser](https://showcase.soleone.xyz)**, no install.\n\n![showcase grading four sample projects in its browser demo](docs/media/demo.webp)\n\n## Quick start\n\n```sh\nnpx @soleone/showcase check   # grade the project in the current directory, no install\nnpm i -g @soleone/showcase    # or install it to get the `showcase` command\nshowcase check                # grade the project in the current directory\nshowcase check ~/code/a ~/code/b --verbose\nshowcase check ~/code/a ~/code/b --matrix   # every check against every project\nshowcase init                 # draft showcase.json from the README and package.json\nshowcase capture              # re-record the media with media.capture and check the loop\nshowcase terminal --serve     # build the in-browser terminal demo for a CLI\nshowcase manage               # browse, add and edit your projects; no setup\n```\n\nNo dependencies; needs Node 22 or later.\n\n## With an agent\n\nThe package includes a `showcase` skill for coding agents. It does the parts `check` cannot measure (the pitch, the demo strategy, sample data, the recording), runs `check` before and after each step, and asks before anything that publishes.\n\n```sh\npi install npm:@soleone/showcase                          # pi\n/plugin install showcase --marketplace Soleone/showcase   # Claude Code\nnpx skills add Soleone/showcase                           # other agents\n```\n\nThen ask your agent to make a project demoable, or to take it up a tier.\n\n## Tiers\n\nEach tier includes everything below it.\n\n| Tier | Means | Checked by |\n|---|---|---|\n| **D** | Nothing to show yet | |\n| **C** | The README says what it is and how to run it | README present, 80+ words, a usage section or code block |\n| **B** | The README sells it | Pitch right under the title, hero visual before the first section, 1,200 words or fewer, no broken local links |\n| **A** | It shows itself in motion | Valid `showcase.json`, a looping WebP, GIF or video, a one-command local demo |\n| **S** | One click to try it for real | `live` URL that responds |\n\nWhat \"live\" means depends on the kind of project: a hosted demo with sample data for a web app, an in-browser terminal for a CLI, a store listing for a browser extension, a replay for an agent. Set `ceiling` when a tier is out of reach, for example `\"A\"` for something that cannot be hosted, and `check` stops suggesting it.\n\n## showcase.json\n\nThe project's facts for any portfolio to use. How much a portfolio cares about the project (featured, order) lives in the portfolio, not here.\n\n```json\n{\n  \"$schema\": \"https://raw.githubusercontent.com/Soleone/showcase/main/schema/showcase.schema.json\",\n  \"title\": \"notecli\",\n  \"pitch\": \"Turns a folder of session logs into a daily journal you can read.\",\n  \"description\": \"Reads the session logs your coding agent already saves, groups them by day and writes a Markdown journal. Your handwritten notes are never touched.\",\n  \"kind\": \"cli\",\n  \"stage\": \"product\",\n  \"year\": 2026,\n  \"tech\": [\"Node.js\"],\n  \"repo\": \"https://github.com/you/notecli\",\n  \"live\": \"https://notecli.example.com\",\n  \"media\": {\n    \"hero\": \"docs/media/hero.png\",\n    \"motion\": \"docs/media/demo.webp\",\n    \"video\": \"docs/media/demo.mp4\",\n    \"capture\": \"npm run demo:capture\"\n  },\n  \"demo\": {\n    \"strategy\": \"browser-terminal\",\n    \"local\": \"npm run demo\",\n    \"externals\": [\n      { \"name\": \"LLM summaries\", \"type\": \"model\", \"seam\": \"modelCommand setting\", \"standIn\": \"canned\",\n        \"note\": \"A stand-in returns canned summaries, so nothing is sent to a model.\" }\n    ]\n  }\n}\n```\n\n`pitch` is the one-sentence subtitle. `description` goes further (how it works, what sets it apart) without repeating the pitch, so a portfolio can show both, for example a card with the pitch on its front and the description on its back.\n\n`demo.externals` makes explicit what the project needs that a demo cannot reach (a model, an account, hardware, a service, another app), where a stand-in plugs in without changing the code (`seam`), and which stand-in the demo uses: `canned`, `replay`, `simulated`, `bring-your-own`, `real` or `omitted`. `[]` says there are none. Demo pages use each `note` to tell visitors what is not real.\n\nThe full contract is [schema/showcase.schema.json](schema/showcase.schema.json). Fields starting with `x-` are allowed for your own extensions.\n\n## Feed a portfolio site\n\nA portfolio picks its projects in `portfolio.json`, in the order it shows them ([schema](schema/portfolio.schema.json)):\n\n```json\n{\n  \"projects\": [\n    { \"id\": \"local:notecli\", \"priority\": \"high\", \"path\": \"$HOME/code/notecli\" },\n    { \"id\": \"github:you/old-library\", \"title\": \"old-library\", \"priority\": \"low\", \"repo\": \"https://github.com/you/old-library\" }\n  ]\n}\n```\n\n```sh\nshowcase collect portfolio.json --out public/showcase\n```\n\nThis writes `feed.json` with one entry per project: its showcase.json facts (or a draft from the README when it has none), `tier`, `target` and `gap`, the required checks still failing up to the target. Priority sets the target: high S, mid A, low C, capped by `ceiling`. Local media is copied to `media/<slug>/` and the feed points at the copies. Projects without a local path are listed ungraded.\n\n## Browse and edit your projects\n\n```sh\nshowcase manage\n```\n\nNo setup. It opens, in this order: the path you pass (for this run only), `$SHOWCASE_CONFIG`, the nearest `portfolio.json` from the current folder upwards, then `~/.config/showcase/portfolio.json`. If there is none of those, it creates that last one from the project in the current folder (or the projects directly inside it) and says so. After that you add projects from the UI.\n\nIt is a full-screen list of every project with its tier, the goal its priority sets and what is still missing (`--offline` skips the live URL request). Keys:\n\n- `enter` opens a project, where `enter` edits a text field, `←` `→` change a choice, and `n` drafts a missing showcase.json from the README.\n- `s` cycles the sort: priority then tier (the start), tier, gap to the goal, or the order in portfolio.json. Sorting is only a view and the selection stays on the same project.\n- `a` adds a project by path, `d` removes one from the portfolio (its files stay), `J` `K` change the order in portfolio.json (switch to that sort first).\n- `r` refreshes, `q` quits.\n\nFields come from the two schemas, so the limits you see while typing (`n/160`) are the validator's. Every edit is written right away and changes only that value, so the diff is one line: the config for priority and path (plus title and repo, but only for a project without a showcase.json, whose own facts always win), the project's own showcase.json for its facts. Nested `media` and `demo` stay hand-edited.\n\nFor scripts and other tools, the same portfolio is available without the UI:\n\n```sh\nshowcase list --json                                  # the projects, in portfolio order\nshowcase add ~/code/notecli --priority high\nshowcase add --repo https://github.com/you/old --id github:you/old   # no local folder\nshowcase set local:notecli --priority mid\nshowcase set local:notecli --stage prototype          # saved in its showcase.json\nshowcase remove local:notecli                         # its files stay\nshowcase collect --out public/showcase                # no path needed either\n```\n\nEach takes `--config <file>` to use another portfolio.\n\n## Keep the media fresh\n\nSet `media.capture` to the command that re-records your screenshots and loops, and `showcase capture` runs it, then checks `media.motion`: animated, loops forever, at least 640 px wide, 3 to 30 s, under 3 MB. `--no-record` checks the file you have.\n\nFor a web app, `@soleone/showcase/capture` writes that command for you, minus the parts only your app knows. It records a fresh browser session with [agent-browser](https://github.com/vercel-labs/agent-browser) and encodes a looping WebP with FFmpeg (both on the PATH); you write how to start, drive and judge a take:\n\n```js\nimport { record } from \"@soleone/showcase/capture\";\n\nawait record({\n  url: \"https://your-app.example.com\",\n  out: \"docs/media/demo.webp\",\n  start: (page) => page.waitFor(`document.querySelector(\"main\")`),\n  drive: async (page) => { await page.click(\"Play\"); await page.sleep(10_000); },\n  verify: async (page) => (await page.eval(`document.title`)).includes(\"Done\") ? undefined : \"did not finish\",\n  attempts: 3,\n});\n```\n\n[docs/playbook.md](docs/playbook.md) has what to watch for when recording, and the rest of what it took to get projects to S.\n\n## Try a CLI in the browser\n\nFor a CLI, S means a visitor can run it. `showcase terminal` builds a static page that runs your real CLI in a Node.js sandbox in the browser ([WebContainers](https://webcontainers.io)), with sample data and stand-ins for anything it cannot reach:\n\n```sh\nshowcase terminal --serve   # builds demo/browser/terminal.json into dist/browser\n```\n\nDeploy `dist/browser` to any static host; it includes a `vercel.json` with the headers the sandbox needs. [recipes/browser-terminal](recipes/browser-terminal/README.md) explains `terminal.json` and the sandbox's quirks, and showcase's own demo in [demo/browser](demo/browser/terminal.json) is a working example.\n\n## Scripting\n\n`showcase check --json` prints every check with its status, detail and hint. `--min B` exits with status 1 when any project is below B, which is handy in CI. `--offline` skips requesting live URLs.\n\n## Development\n\n```sh\nnpm test\nnpm run demo            # grade the sample projects in demo/projects\nnpm run demo:browser    # the same in the browser terminal, on 127.0.0.1:4321\nnpm run media:capture   # re-record docs/media from the browser demo\n```\n\nReleases run from your machine with release-it. Commit with Conventional Commits (`feat:`, `fix:`); `npm run release` then tests, picks the next version, writes `CHANGELOG.md`, commits, tags and publishes to npm. `npm run release:dry-run` shows what it would do. The very first release publishes the version already in package.json: `npm run release -- --no-increment`.\n"}},"bin":{"directory":{"showcase.js":{"file":{"contents":"#!/usr/bin/env node\nimport { readFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { parseArgs } from \"node:util\";\nimport { evaluate, tiers } from \"../src/checks.js\";\nimport { collect } from \"../src/collect.js\";\nimport { init } from \"../src/init.js\";\nimport { addProject, findConfig, manifestFields, openRow, portfolioFields, removeProject, setManifestField, setPortfolioField, stageOf } from \"../src/manage.js\";\nimport { inspectMotion, runCapture } from \"../src/inspect.js\";\nimport { loadPortfolio } from \"../src/collect.js\";\nimport { loadProject } from \"../src/project.js\";\nimport { formatInspection, formatMatrix, formatReport, formatSummary } from \"../src/report.js\";\nimport { buildTerminal, serveTerminal } from \"../src/terminal.js\";\nimport { run as manage } from \"../src/tui.js\";\n\nconst { version } = JSON.parse(readFileSync(new URL(\"../package.json\", import.meta.url), \"utf8\"));\n\nconst usage = `showcase ${version}: grade how demoable a project is, from D to S.\n\nUsage:\n  showcase check [path...]   Show the tier and what it takes to reach the next one\n  showcase init [path]       Draft showcase.json from the README and package.json\n  showcase collect [config] --out <dir>\n                             Grade every project in the portfolio and write\n                             <dir>/feed.json plus copies of the local media\n  showcase manage [config]   Browse your projects and edit their metadata. Finds the\n                             portfolio by itself (see below); give a path once to switch\n  showcase add <path>        Put a project in the portfolio (--id, --priority);\n                             without a folder: showcase add --repo <url> --id <id>\n  showcase remove <id>       Take a project out of the portfolio; its files stay\n  showcase set <id>          Change a project's --priority (or none) or its showcase.json --stage\n  showcase list              The portfolio's projects (--json for scripts)\n  showcase capture [path]    Re-record the media with media.capture, then check media.motion\n  showcase terminal [recipe] Build the in-browser terminal demo for a CLI from a folder\n                             with terminal.json (default demo/browser)\n\nOptions:\n  --json         Machine-readable output (check)\n  --verbose, -v  List every check, not just the next steps (check)\n  --matrix       Every check against every project, one column each (check)\n  --config <f>   Use this portfolio.json (add, remove, list, collect)\n  --id <id>      Id for the new project (add), default local:<folder name>\n  --priority <p> high, mid or low (add, set)\n  --repo <url>   Add a project that has no local folder (add)\n  --stage <s>    product, prototype or proof of concept, saved in its showcase.json (set)\n  --offline      Do not request the live URL (check, collect, manage)\n  --out <dir>    Where to write feed.json and media/ (collect), or the page\n                 (terminal, default dist/browser)\n  --serve        Serve the page on 127.0.0.1 after building it (terminal)\n  --port <n>     Port for --serve (default 4321)\n  --min <tier>   Exit with status 1 if any project is below this tier (check)\n  --force        Overwrite an existing showcase.json (init)\n  --dry-run      Print the draft without writing it (init)\n  --no-record    Only check the existing media.motion (capture)\n  --help, -h     Show this help\n  --version      Show the version\n\nmanage opens, in order: the [config] argument, $SHOWCASE_CONFIG, the nearest portfolio.json\nfrom here upwards, then ~/.config/showcase/portfolio.json, which it creates from the projects in\nthis folder. You normally pass nothing.\n\nTiers:\n  D  nothing to show yet\n  C  README explains what it is and how to run it\n  B  polished README: pitch, hero visual, concise, no broken links\n  A  showcase.json, motion (looping WebP or video), one-command local demo\n  S  live: one click to try it for real`;\n\nasync function check(paths, values) {\n  if (values.min && !tiers.includes(values.min)) throw new Error(`--min must be one of ${tiers.join(\", \")}`);\n  const reports = await Promise.all((paths.length ? paths : [\".\"]).map(async (root) => evaluate(await loadProject(root), { offline: values.offline })));\n\n  if (values.json) console.log(JSON.stringify(reports, null, 2));\n  else if (values.matrix) console.log(formatMatrix(reports));\n  else if (reports.length === 1 || values.verbose) console.log(reports.map((report) => formatReport(report, values)).join(\"\\n\\n\"));\n  else console.log(formatSummary(reports));\n\n  if (values.min && reports.some((report) => tiers.indexOf(report.tier) < tiers.indexOf(values.min))) process.exitCode = 1;\n}\n\nasync function capture(paths, values) {\n  if (paths.length > 1) throw new Error(\"capture takes a single path\");\n  const project = await loadProject(paths[0] ?? \".\");\n  const media = project.manifest?.data?.media ?? {};\n  if (!values[\"no-record\"] && !media.capture) throw new Error(`Set media.capture in ${project.manifest?.file ?? \"showcase.json\"} to the command that records the media.`);\n  if (!media.motion) throw new Error(\"Set media.motion in showcase.json to the file the capture writes.\");\n  let since;\n  if (!values[\"no-record\"]) {\n    since = Date.now();\n    console.log(`Recording with ${media.capture}\\n`);\n    await runCapture(project, media.capture);\n    console.log(\"\");\n  }\n  const results = await inspectMotion(project, media.motion, { since });\n  console.log(`${media.motion}\\n${formatInspection(results)}`);\n  if (results.some((result) => result.status === \"fail\")) process.exitCode = 1;\n}\n\nasync function terminal(paths, values) {\n  if (paths.length > 1) throw new Error(\"terminal takes a single recipe folder\");\n  const { out, bytes } = await buildTerminal(paths[0] ?? \"demo/browser\", values.out ?? \"dist/browser\");\n  console.log(`Built ${out} (${Math.round(bytes / 1024)} KB of files)`);\n  if (!values.serve) return;\n  const port = Number(values.port ?? 4321);\n  if (!Number.isInteger(port)) throw new Error(\"--port must be a number\");\n  await serveTerminal(out, port);\n  console.log(`Serving at http://127.0.0.1:${port} (Ctrl-C to stop)`);\n}\n\n// The portfolio file for scripts: --config, else the same lookup manage uses.\nasync function configFor(explicit) {\n  if (explicit) return explicit;\n  const { file, exists } = await findConfig();\n  if (!exists) throw new Error(`No portfolio found. Run 'showcase manage' to start one, or pass --config <file>.`);\n  return file;\n}\n\nasync function portfolio(command, paths, values) {\n  const expected = command === \"list\" ? 0 : command === \"add\" && values.repo ? 0 : 1;\n  if (paths.length !== expected) throw new Error(command === \"list\" ? \"list takes no arguments\" : `Usage: showcase ${command} ${command === \"add\" ? \"<path>\" : \"<id>\"}`);\n  const file = await configFor(values.config);\n  if (command === \"list\") {\n    const { projects } = await loadPortfolio(file);\n    if (values.json) {\n      const opened = await Promise.all(projects.map((project) => openRow(project, path.dirname(file))));\n      return console.log(JSON.stringify(projects.map((project, i) => ({ ...project, stage: stageOf(opened[i]) ?? null })), null, 2));\n    }\n    const width = Math.max(...projects.map((project) => project.id.length), 2);\n    return console.log(projects.map((project) => `${project.id.padEnd(width)}  ${(project.priority ?? \"-\").padEnd(4)}  ${project.path ?? project.repo ?? \"\"}`).join(\"\\n\"));\n  }\n  if (command === \"add\") {\n    const { id, error } = await addProject(file, paths[0] ?? \"\", { id: values.id, priority: values.priority ?? null, repo: values.repo });\n    if (error) throw new Error(error);\n    return console.log(`Added ${id} to ${file}`);\n  }\n  const { projects } = await loadPortfolio(file);\n  const project = projects.find((candidate) => candidate.id === paths[0]);\n  if (!project) throw new Error(`${paths[0]} is not in ${file}`);\n  if (command === \"set\") {\n    const changes = [[\"priority\", values.priority], [\"stage\", values.stage]].filter(([, value]) => value !== undefined);\n    if (!changes.length) throw new Error(\"Nothing to set. Pass --priority and/or --stage.\");\n    for (const [key, raw] of changes) {\n      const value = raw === \"none\" ? null : raw;\n      let errors;\n      if (key === \"stage\") {\n        const row = await openRow(project, path.dirname(file));\n        if (!row.project?.manifest?.data) throw new Error(`${paths[0]} has no showcase.json to save the stage in. Draft one with showcase manage (n), or showcase init.`);\n        ({ errors } = await setManifestField(row.root, manifestFields.find((field) => field.key === \"stage\"), value));\n      } else {\n        ({ errors } = await setPortfolioField(file, paths[0], portfolioFields.find((field) => field.key === key), value));\n      }\n      if (errors.length) throw new Error(errors.join(\"; \"));\n    }\n    return console.log(`Updated ${paths[0]} in ${file}`);\n  }\n  await removeProject(file, paths[0]);\n  console.log(`Removed ${paths[0]} from ${file}`);\n}\n\nasync function main() {\n  const { values, positionals } = parseArgs({\n    allowPositionals: true,\n    options: {\n      json: { type: \"boolean\" },\n      verbose: { type: \"boolean\", short: \"v\" },\n      matrix: { type: \"boolean\" },\n      offline: { type: \"boolean\" },\n      min: { type: \"string\" },\n      force: { type: \"boolean\" },\n      \"dry-run\": { type: \"boolean\" },\n      \"no-record\": { type: \"boolean\" },\n      out: { type: \"string\" },\n      config: { type: \"string\" },\n      id: { type: \"string\" },\n      priority: { type: \"string\" },\n      stage: { type: \"string\" },\n      repo: { type: \"string\" },\n      serve: { type: \"boolean\" },\n      port: { type: \"string\" },\n      help: { type: \"boolean\", short: \"h\" },\n      version: { type: \"boolean\" },\n    },\n  });\n  const [command, ...paths] = positionals;\n\n  if (values.version) return console.log(version);\n  if (values.help || !command) return console.log(usage);\n  if (command === \"check\") return check(paths, values);\n  if (command === \"capture\") return capture(paths, values);\n  if (command === \"manage\") {\n    if (paths.length > 1) throw new Error(\"manage takes a single portfolio.json\");\n    return manage({ configFile: paths[0], offline: values.offline });\n  }\n  if (command === \"terminal\") return terminal(paths, values);\n  if (command === \"init\") {\n    if (paths.length > 1) throw new Error(\"init takes a single path\");\n    const { file, manifest, errors } = await init(paths[0] ?? \".\", { force: values.force, dryRun: values[\"dry-run\"] });\n    console.log(`${values[\"dry-run\"] ? \"Draft for\" : \"Wrote\"} ${file}\\n\\n${JSON.stringify(manifest, null, 2)}`);\n    if (errors.length) console.log(`\\nFill in before it validates:\\n${errors.map((error) => `  - ${error}`).join(\"\\n\")}`);\n    return;\n  }\n  if ([\"add\", \"remove\", \"set\", \"list\"].includes(command)) return portfolio(command, paths, values);\n  if (command === \"collect\") {\n    if (paths.length > 1 || !values.out) throw new Error(\"Usage: showcase collect [portfolio.json] --out <dir>\");\n    const { file, feed, reports, warnings } = await collect(paths[0] ?? await configFor(values.config), { out: values.out, offline: values.offline });\n    if (reports.length) console.log(formatSummary(reports));\n    if (warnings.length) console.log(`\\n${warnings.map((warning) => `! ${warning}`).join(\"\\n\")}`);\n    console.log(`\\nWrote ${file} (${feed.projects.length} projects)`);\n    return;\n  }\n  throw new Error(`Unknown command '${command}'. Run 'showcase --help'.`);\n}\n\nmain().catch((error) => {\n  console.error(error.message);\n  process.exitCode = 2;\n});\n"}}}},"package.json":{"file":{"contents":"{\n  \"name\": \"@soleone/showcase\",\n  \"version\": \"0.1.0\",\n  \"description\": \"Grade how demoable a project is, from D to S, and get the next steps to raise it.\",\n  \"license\": \"MIT\",\n  \"author\": \"Soleone\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/Soleone/showcase.git\"\n  },\n  \"homepage\": \"https://github.com/Soleone/showcase#readme\",\n  \"bugs\": \"https://github.com/Soleone/showcase/issues\",\n  \"keywords\": [\n    \"portfolio\",\n    \"readme\",\n    \"demo\",\n    \"cli\",\n    \"pi-package\",\n    \"agent-skill\"\n  ],\n  \"type\": \"module\",\n  \"engines\": {\n    \"node\": \">=22\"\n  },\n  \"publishConfig\": {\n    \"access\": \"public\"\n  },\n  \"exports\": {\n    \"./capture\": \"./src/capture.js\",\n    \"./package.json\": \"./package.json\"\n  },\n  \"bin\": {\n    \"showcase\": \"./bin/showcase.js\"\n  },\n  \"files\": [\n    \"bin/\",\n    \"src/\",\n    \"schema/\",\n    \"recipes/\",\n    \"skills/\",\n    \"docs/playbook.md\",\n    \"README.md\",\n    \"LICENSE\"\n  ],\n  \"scripts\": {\n    \"test\": \"node --test\",\n    \"demo\": \"node bin/showcase.js check demo/projects/*\",\n    \"demo:browser\": \"node bin/showcase.js terminal --serve\",\n    \"media:capture\": \"node scripts/capture-demo.mjs\",\n    \"release\": \"release-it\",\n    \"release:dry-run\": \"npm test && release-it --dry-run\"\n  },\n  \"pi\": {\n    \"skills\": [\n      \"./skills\"\n    ]\n  },\n  \"devDependencies\": {\n    \"@release-it/conventional-changelog\": \"^10.0.6\",\n    \"release-it\": \"^19.2.4\"\n  },\n  \"overrides\": {\n    \"release-it\": {\n      \"undici\": \"^7.30.0\"\n    }\n  }\n}\n"}},"schema":{"directory":{"portfolio.schema.json":{"file":{"contents":"{\n  \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n  \"$id\": \"https://raw.githubusercontent.com/Soleone/showcase/main/schema/portfolio.schema.json\",\n  \"title\": \"portfolio.json\",\n  \"description\": \"Which projects a portfolio shows and how much it cares about each, in portfolio order. Facts about each project live in its own showcase.json.\",\n  \"type\": \"object\",\n  \"required\": [\"projects\"],\n  \"additionalProperties\": false,\n  \"patternProperties\": {\n    \"^x-\": {}\n  },\n  \"properties\": {\n    \"$schema\": { \"type\": \"string\" },\n    \"projects\": {\n      \"type\": \"array\",\n      \"items\": {\n        \"type\": \"object\",\n        \"required\": [\"id\"],\n        \"additionalProperties\": false,\n        \"patternProperties\": {\n          \"^x-\": {}\n        },\n        \"properties\": {\n          \"id\": { \"type\": \"string\", \"minLength\": 1, \"description\": \"Stable identifier, e.g. local:notecli.\" },\n          \"title\": { \"type\": [\"string\", \"null\"], \"description\": \"Fallback when the project has no showcase.json.\" },\n          \"priority\": {\n            \"enum\": [\"high\", \"mid\", \"low\", null],\n            \"description\": \"Sets the target tier: high S, mid A, low C.\"\n          },\n          \"path\": {\n            \"type\": [\"string\", \"null\"],\n            \"minLength\": 1,\n            \"description\": \"Local checkout. ~ and $VARS are expanded; relative paths resolve from this file.\"\n          },\n          \"repo\": { \"type\": [\"string\", \"null\"], \"format\": \"uri\" }\n        }\n      }\n    }\n  }\n}\n"}},"showcase.schema.json":{"file":{"contents":"{\n  \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n  \"$id\": \"https://raw.githubusercontent.com/Soleone/showcase/main/schema/showcase.schema.json\",\n  \"title\": \"showcase.json\",\n  \"description\": \"Facts about one project that a portfolio can show. Importance and ordering belong to the portfolio, not here.\",\n  \"type\": \"object\",\n  \"required\": [\"title\", \"pitch\", \"description\", \"kind\"],\n  \"additionalProperties\": false,\n  \"patternProperties\": {\n    \"^x-\": {}\n  },\n  \"properties\": {\n    \"$schema\": { \"type\": \"string\" },\n    \"title\": { \"type\": \"string\", \"minLength\": 1, \"maxLength\": 60 },\n    \"pitch\": {\n      \"type\": \"string\",\n      \"minLength\": 1,\n      \"maxLength\": 160,\n      \"description\": \"One sentence that makes someone want to click. A subtitle: short enough to sit under a title.\"\n    },\n    \"description\": {\n      \"type\": \"string\",\n      \"minLength\": 1,\n      \"maxLength\": 240,\n      \"description\": \"A couple of sentences that go further than the pitch (how it works, what makes it different). Never restates the pitch; a portfolio shows the two together.\"\n    },\n    \"kind\": {\n      \"enum\": [\"web-app\", \"local-web-app\", \"cli\", \"library\", \"browser-extension\", \"desktop-app\", \"agent-extension\", \"game\", \"service\", \"other\"],\n      \"description\": \"local-web-app: a browser UI backed by a process on the user's machine (local models, hardware, files), so it cannot be hosted as-is.\"\n    },\n    \"stage\": {\n      \"enum\": [\"product\", \"prototype\", \"proof of concept\"],\n      \"description\": \"How far along it is. product: meant to be used; prototype: a working draft of an idea; proof of concept: shows it can be done.\"\n    },\n    \"year\": { \"type\": \"integer\", \"minimum\": 1990, \"maximum\": 2100 },\n    \"tech\": { \"type\": \"array\", \"items\": { \"type\": \"string\", \"minLength\": 1 } },\n    \"repo\": { \"type\": \"string\", \"format\": \"uri\" },\n    \"live\": {\n      \"type\": \"string\",\n      \"format\": \"uri\",\n      \"description\": \"One click to try it for real: a hosted demo, browser terminal, store listing, playground or replay.\"\n    },\n    \"media\": {\n      \"type\": \"object\",\n      \"additionalProperties\": false,\n      \"properties\": {\n        \"hero\": { \"type\": \"string\", \"minLength\": 1, \"description\": \"Still image, path relative to the project root or a URL.\" },\n        \"motion\": { \"type\": \"string\", \"minLength\": 1, \"description\": \"Short silent loop (animated WebP or GIF).\" },\n        \"video\": { \"type\": \"string\", \"minLength\": 1, \"description\": \"Longer video, may have sound (MP4 or WebM).\" },\n        \"screenshots\": { \"type\": \"array\", \"items\": { \"type\": \"string\", \"minLength\": 1 } },\n        \"capture\": { \"type\": \"string\", \"minLength\": 1, \"description\": \"Command that regenerates the media from scratch.\" }\n      }\n    },\n    \"demo\": {\n      \"type\": \"object\",\n      \"additionalProperties\": false,\n      \"properties\": {\n        \"strategy\": {\n          \"enum\": [\"hosted\", \"browser-terminal\", \"store-listing\", \"playground\", \"replay\", \"download\", \"none\"]\n        },\n        \"local\": { \"type\": \"string\", \"minLength\": 1, \"description\": \"One command that runs a seeded demo locally.\" },\n        \"externals\": {\n          \"type\": \"array\",\n          \"description\": \"What the project needs that a demo cannot reach (models, accounts, hardware, services, other apps), and the stand-in the demo uses instead. An empty list says there are none.\",\n          \"items\": {\n            \"type\": \"object\",\n            \"required\": [\"name\", \"type\", \"seam\", \"standIn\"],\n            \"additionalProperties\": false,\n            \"properties\": {\n              \"name\": { \"type\": \"string\", \"minLength\": 1, \"description\": \"What it is, e.g. \\\"LLM summaries\\\".\" },\n              \"type\": { \"enum\": [\"model\", \"service\", \"account\", \"hardware\", \"app\", \"data\"] },\n              \"seam\": { \"type\": \"string\", \"minLength\": 1, \"description\": \"Where the stand-in plugs in without changing the project's code: a setting, env var, flag or injected client.\" },\n              \"standIn\": {\n                \"enum\": [\"canned\", \"replay\", \"simulated\", \"bring-your-own\", \"real\", \"omitted\"],\n                \"description\": \"canned: fixed responses keyed by input. replay: recorded real responses. simulated: a fake implementation. bring-your-own: the visitor supplies a key or account. real: the demo uses the real thing. omitted: the feature is off in the demo.\"\n              },\n              \"note\": { \"type\": \"string\", \"minLength\": 1, \"description\": \"One sentence for visitors, e.g. \\\"Summaries are canned; nothing is sent to a model.\\\"\" }\n            }\n          }\n        }\n      }\n    },\n    \"ceiling\": {\n      \"enum\": [\"D\", \"C\", \"B\", \"A\", \"S\"],\n      \"description\": \"Highest tier this project can realistically reach, e.g. A when it cannot be hosted.\"\n    }\n  }\n}\n"}}}},"src":{"directory":{"capture.js":{"file":{"contents":"// Records a web app as a looping animated WebP with agent-browser and FFmpeg, both found on the PATH.\n// The project supplies what only it knows (how to start the demo, drive it and tell a good take); this module owns the rest.\nimport { execFile } from \"node:child_process\";\nimport { mkdir, mkdtemp, rm, writeFile } from \"node:fs/promises\";\nimport os from \"node:os\";\nimport path from \"node:path\";\nimport { promisify } from \"node:util\";\n\nconst exec = promisify(execFile);\n\nasync function requireTool(command, versionFlag, install) {\n  try {\n    await exec(command, [versionFlag]);\n  } catch {\n    throw new Error(`${command} is not on the PATH. ${install}`);\n  }\n}\n\n// A thin handle on one agent-browser session. Every call is a separate agent-browser process.\nfunction page(session, env) {\n  const run = (...args) => exec(\"agent-browser\", [\"--session\", session, ...args], { timeout: 120_000, env }).then(({ stdout }) => stdout.trim());\n  const escape = (text) => text.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n  return {\n    run,\n    // Runs a script in the page and returns its JSON result.\n    eval: async (script) => JSON.parse(await run(\"eval\", script)),\n    // Waits until a JavaScript expression is truthy in the page.\n    waitFor: (expression, { timeoutMs } = {}) => run(\"wait\", \"--fn\", expression, ...(timeoutMs ? [\"--timeout\", String(timeoutMs)] : [])),\n    sleep: (ms) => run(\"wait\", String(ms)),\n    // A real click through the accessibility tree, which counts as a user gesture (script clicks do not unlock audio).\n    click: async (name, role = \"button\") => {\n      const ref = (await run(\"snapshot\", \"-i\")).match(new RegExp(`${role} \"${escape(name)}\" \\\\[ref=(\\\\w+)\\\\]`))?.[1];\n      if (!ref) throw new Error(`No ${role} named \"${name}\" on the page`);\n      await run(\"click\", `@${ref}`);\n    },\n    // The page's clock, which is the clock frame timestamps use.\n    now: async () => JSON.parse(await run(\"eval\", \"Date.now()\")),\n  };\n}\n\n// agent-browser's own recorder writes a fixed 10 fps without timestamps, which speeds a busy page up.\n// The stream sends each frame with the time it was painted, so durations come out right.\nasync function streamFrames(url) {\n  const frames = [];\n  const socket = new WebSocket(`${url}/?maxFps=30`);\n  socket.onmessage = ({ data }) => {\n    const message = JSON.parse(data);\n    if (message.type === \"frame\" && message.metadata?.timestamp) frames.push({ at: message.metadata.timestamp, data: message.data });\n  };\n  await new Promise((resolve, reject) => {\n    socket.onopen = resolve;\n    socket.onerror = () => reject(new Error(`Cannot connect to the agent-browser stream at ${url}`));\n  });\n  return { frames, stop: () => socket.close() };\n}\n\n// The stream only sends a frame when the screen changes, so a clip opens on the last frame painted before it.\nexport function clipFrames(frames, { startAt, endAt }) {\n  const opening = frames.findLast((frame) => frame.at < startAt);\n  return [...(opening ? [{ ...opening, at: startAt }] : []), ...frames.filter((frame) => frame.at >= startAt && frame.at <= endAt)];\n}\n\nconst frameName = (index) => `frame-${String(index).padStart(5, \"0\")}.jpg`;\n\nexport async function encode(frames, endAt, work, { out, crop, width, fps, quality, grid }) {\n  const list = [];\n  for (const [index, frame] of frames.entries()) {\n    await writeFile(path.join(work, frameName(index)), Buffer.from(frame.data, \"base64\"));\n    list.push(`file '${frameName(index)}'`, `duration ${((frames[index + 1]?.at ?? endAt) - frame.at) / 1000}`);\n  }\n  // The concat demuxer drops the last duration unless the last file is listed again, and then the fps filter\n  // stretches the end unpredictably, so the output is also cut to the take's length.\n  await writeFile(path.join(work, \"frames.txt\"), `${list.join(\"\\n\")}\\nfile '${frameName(frames.length - 1)}'\\n`);\n  const seconds = String((endAt - frames[0].at) / 1000);\n  const filters = (scale) => [crop && `crop=${crop}`, `scale=${scale}:-2:flags=lanczos`].filter(Boolean).join(\",\");\n  await mkdir(path.dirname(out), { recursive: true });\n  await exec(\"ffmpeg\", [\"-hide_banner\", \"-loglevel\", \"error\", \"-y\", \"-f\", \"concat\", \"-safe\", \"0\", \"-i\", path.join(work, \"frames.txt\"),\n    \"-vf\", `fps=${fps},${filters(width)}`, \"-t\", seconds, \"-c:v\", \"libwebp_anim\", \"-quality\", String(quality), \"-compression_level\", \"6\", \"-loop\", \"0\", \"-an\", out], { timeout: 600_000 });\n  if (!grid) return;\n  // Twelve frames spread over the take, to look at before committing.\n  const picks = Array.from({ length: 12 }, (_, index) => frameName(Math.round((index * (frames.length - 1)) / 11)));\n  await writeFile(path.join(work, \"grid.txt\"), picks.map((file) => `file '${file}'`).join(\"\\n\"));\n  await mkdir(path.dirname(grid), { recursive: true });\n  await exec(\"ffmpeg\", [\"-hide_banner\", \"-loglevel\", \"error\", \"-y\", \"-f\", \"concat\", \"-safe\", \"0\", \"-i\", path.join(work, \"grid.txt\"),\n    \"-vf\", `${filters(400)},tile=3x4:padding=4`, \"-frames:v\", \"1\", \"-update\", \"1\", grid]);\n}\n\n/**\n * Records one take, or several until one passes `verify`.\n *\n * @param {object} options\n * @param {string} options.url        Page to open in a fresh browser session (empty storage, so a first visit).\n * @param {string} options.out        The .webp to write.\n * @param {(page) => Promise<void>} [options.start]   Gets the page ready. Recording starts when it returns.\n * @param {(page) => Promise<{startAt?: number, endAt?: number} | void>} options.drive\n *   Plays the clip and returns when it is over. Return page-clock times (`Date.now()` in the page) to trim the clip.\n * @param {(page) => Promise<string | void>} [options.verify]  Returns a reason to throw the take away and try again.\n * @param {number} [options.attempts=1]\n * @param {string} [options.crop]     FFmpeg crop, e.g. \"iw:ih/2:0:ih/4\", applied before scaling.\n * @param {number} [options.width=960]  Output width; the stream is captured at this width too.\n * @param {{width: number, height: number}} [options.viewport]\n * @param {number} [options.fps=20]\n * @param {number} [options.quality=75]\n * @param {string | false} [options.grid]  Where to write a sheet of twelve frames; defaults to the temp directory.\n * @param {(message: string) => void} [options.log]\n */\nexport async function record(options) {\n  const { url, drive, start = async () => {}, verify = async () => {}, attempts = 1, log = (message) => console.error(message) } = options;\n  if (!url || !options.out || typeof drive !== \"function\") throw new Error(\"record needs url, out and drive\");\n  const settings = {\n    out: path.resolve(options.out),\n    crop: options.crop,\n    width: options.width ?? 960,\n    fps: options.fps ?? 20,\n    quality: options.quality ?? 75,\n    grid: options.grid === false ? null : path.resolve(options.grid ?? path.join(os.tmpdir(), `${path.basename(options.out, path.extname(options.out))}-frames.png`)),\n  };\n  const viewport = options.viewport ?? { width: 1280, height: 800 };\n  await requireTool(\"agent-browser\", \"--version\", \"Install it with `npm i -g agent-browser && agent-browser install`.\");\n  await requireTool(\"ffmpeg\", \"-version\", \"Install FFmpeg with libwebp.\");\n\n  for (let attempt = 1; ; attempt += 1) {\n    const work = await mkdtemp(path.join(os.tmpdir(), \"showcase-capture-\"));\n    const session = `showcase-capture-${process.pid}-${attempt}`;\n    const env = { ...process.env, AGENT_BROWSER_STREAM_QUALITY: \"92\", AGENT_BROWSER_STREAM_MAX_WIDTH: String(settings.width) };\n    const browser = page(session, env);\n    let stream, window, rejected;\n    try {\n      await browser.run(\"open\", \"about:blank\");\n      await browser.run(\"set\", \"viewport\", String(viewport.width), String(viewport.height));\n      await browser.run(\"open\", url);\n      await start(browser);\n      stream = await streamFrames((await browser.run(\"stream\", \"status\")).match(/ws:\\/\\/[\\w.:]+/)?.[0] ?? \"ws://missing\");\n      const startAt = await browser.now();\n      window = { startAt, ...(await drive(browser)) };\n      window.endAt ??= await browser.now();\n      rejected = await verify(browser);\n    } finally {\n      stream?.stop();\n      await browser.run(\"close\").catch(() => {});\n    }\n    try {\n      if (rejected) {\n        if (attempt >= attempts) throw new Error(`The take was rejected: ${rejected}`);\n        log(`Take ${attempt} rejected: ${rejected}. Trying again.`);\n        continue;\n      }\n      const frames = clipFrames(stream.frames, window);\n      if (frames.length < 2) throw new Error(\"The stream sent fewer than two frames during the take\");\n      await encode(frames, window.endAt, work, settings);\n      return { out: settings.out, grid: settings.grid, frames: frames.length, durationMs: window.endAt - window.startAt, attempt };\n    } finally {\n      await rm(work, { recursive: true, force: true });\n    }\n  }\n}\n"}},"checks.js":{"file":{"contents":"import path from \"node:path\";\nimport { exists, localPath, mediaType } from \"./project.js\";\n\nexport const tiers = [\"D\", \"C\", \"B\", \"A\", \"S\"];\n\nconst usageHeading = /install|usage|quick ?start|getting started|setup|how to (use|run)|try it|running/i;\nconst maxReadmeWords = 1200;\n\nconst motionHints = {\n  cli: \"Record a terminal loop (e.g. a VHS .tape exported to WebP or MP4) and show it in the README.\",\n  \"web-app\": \"Record the main flow (e.g. Playwright video converted with ffmpeg to a looping WebP) and show it in the README.\",\n  \"local-web-app\": \"Record the main flow as a short looping WebP (plus an MP4 if sound matters) and show it in the README.\",\n  \"desktop-app\": \"Record the main flow as a short looping WebP and show it in the README.\",\n  \"browser-extension\": \"Record a short before/after clip of the extension in action and show it in the README.\",\n  \"agent-extension\": \"Record a real session as a short looping WebP and show it in the README.\",\n};\n\nconst liveHints = {\n  \"web-app\": \"Deploy a demo with seeded sample data and no login wall, then set live in showcase.json.\",\n  \"local-web-app\": \"Host the web UI against a recorded or simulated local backend (a replay), then set live in showcase.json.\",\n  cli: \"Host an in-browser terminal with sample files to play with, then set live in showcase.json.\",\n  \"browser-extension\": \"Publish to the Chrome Web Store (or Firefox Add-ons) and set live to the listing.\",\n  \"agent-extension\": \"Host a replay of a real recorded session, then set live in showcase.json.\",\n  library: \"Host a small playground, then set live in showcase.json.\",\n  \"desktop-app\": \"Ship a web build with sample data or a download page, then set live in showcase.json.\",\n  game: \"Host a playable web build, then set live in showcase.json.\",\n};\n\nfunction manifestData(project) {\n  return project.manifest?.errors.length === 0 ? project.manifest.data : null;\n}\n\nfunction mediaReferences(project) {\n  const base = project.readme ? path.dirname(project.readme.file) : project.root;\n  const fromReadme = (project.readme?.media ?? []).map((item) => ({ ...item, base }));\n  const media = project.manifest?.data?.media ?? {};\n  const fromManifest = [media.hero, media.motion, media.video, ...(media.screenshots ?? [])]\n    .filter((src) => typeof src === \"string\")\n    .map((src) => ({ src, base: project.root, manifest: true }));\n  return [...fromReadme, ...fromManifest];\n}\n\nasync function missingFiles(project, references) {\n  const missing = [];\n  for (const { src, base } of references) {\n    const file = localPath(project, src, base);\n    if (file && !(await exists(file))) missing.push(src);\n  }\n  return missing;\n}\n\nasync function isMotion(project, { src, base, tag }) {\n  if (tag === \"video\" || tag === \"source\") return true;\n  const type = await mediaType(project, src, base);\n  return type === \"unverified\" ? type : type === \"motion\" || type === \"video\";\n}\n\nconst pass = (detail) => ({ status: \"pass\", detail });\nconst fail = (detail) => ({ status: \"fail\", detail });\nconst noReadme = fail(\"no README\");\n\nexport const checks = [\n  {\n    id: \"readme\",\n    tier: \"C\",\n    label: \"README present\",\n    run: (project) => project.readme ? pass(path.relative(project.root, project.readme.file)) : fail(\"no README found\"),\n    hint: () => \"Add a README.md with a one-line pitch, a hero image and a quick start.\",\n  },\n  {\n    id: \"readme.substance\",\n    tier: \"C\",\n    label: \"README explains the project\",\n    run: ({ readme }) => !readme ? noReadme : readme.words >= 80 ? pass(`${readme.words} words`) : fail(`${readme.words} words, needs at least 80`),\n    hint: () => \"Say what it is, why it exists and how to run it.\",\n  },\n  {\n    id: \"readme.usage\",\n    tier: \"C\",\n    label: \"README shows how to run it\",\n    run: ({ readme }) => {\n      if (!readme) return noReadme;\n      const heading = readme.headings.find((item) => usageHeading.test(item.text));\n      if (heading) return pass(`\"${heading.text}\" section`);\n      return readme.codeBlocks ? pass(`${readme.codeBlocks} code block${readme.codeBlocks === 1 ? \"\" : \"s\"}`) : fail(\"no usage section or code block\");\n    },\n    hint: () => \"Add a Quick start section with the commands to install and run it.\",\n  },\n  {\n    id: \"readme.pitch\",\n    tier: \"B\",\n    label: \"Pitch right under the title\",\n    run: ({ readme }) => {\n      if (!readme) return noReadme;\n      if (!readme.title) return fail(\"no H1 title\");\n      if (!readme.lead) return fail(\"no paragraph after the title\");\n      const words = readme.lead.split(/\\s+/).length;\n      if (words < 8) return fail(`lead is ${words} words, too thin to sell it`);\n      if (words > 60) return fail(`lead is ${words} words, aim for 60 or fewer`);\n      return pass(`\"${readme.lead.length > 70 ? `${readme.lead.slice(0, 67)}...` : readme.lead}\"`);\n    },\n    hint: () => \"Open with an H1 and one or two sentences (8-60 words) that make someone want to try it.\",\n  },\n  {\n    id: \"readme.hero\",\n    tier: \"B\",\n    label: \"Hero visual before the first section\",\n    run: ({ readme }) => {\n      if (!readme) return noReadme;\n      const hero = readme.media.find((item) => item.line < readme.firstSectionLine);\n      if (hero) return pass(hero.src.length > 60 ? `${hero.src.slice(0, 57)}...` : hero.src);\n      return readme.media.length ? fail(\"visuals exist but sit below the first section\") : fail(\"no images or video\");\n    },\n    hint: () => \"Put a screenshot or looping WebP right after the pitch, above the first ## heading.\",\n  },\n  {\n    id: \"readme.concise\",\n    tier: \"B\",\n    label: `README is concise (at most ${maxReadmeWords} words of prose)`,\n    run: ({ readme }) => !readme ? noReadme : readme.words <= maxReadmeWords ? pass(`${readme.words} words`) : fail(`${readme.words} words`),\n    hint: () => `Cut the README to ${maxReadmeWords} words or fewer and move the detail into docs/.`,\n  },\n  {\n    id: \"readme.links\",\n    tier: \"B\",\n    label: \"Local links and images resolve\",\n    run: async (project) => {\n      if (!project.readme) return noReadme;\n      const base = path.dirname(project.readme.file);\n      const references = [...project.readme.links.map((link) => ({ src: link.href, base })), ...project.readme.media.map((item) => ({ src: item.src, base }))];\n      const missing = await missingFiles(project, references);\n      return missing.length ? fail(`broken: ${missing.slice(0, 3).join(\", \")}${missing.length > 3 ? ` and ${missing.length - 3} more` : \"\"}`) : pass(`${references.length} checked`);\n    },\n    hint: () => \"Fix or remove the broken links.\",\n  },\n  {\n    id: \"manifest\",\n    tier: \"A\",\n    label: \"showcase.json is valid\",\n    run: async (project) => {\n      const { manifest } = project;\n      if (!manifest) return fail(\"no showcase.json\");\n      if (manifest.errors.length) return fail(manifest.errors.slice(0, 3).join(\"; \"));\n      const missing = await missingFiles(project, mediaReferences(project).filter((item) => item.manifest));\n      return missing.length ? fail(`media not found: ${missing.join(\", \")}`) : pass(`${manifest.data.kind}: \"${manifest.data.title}\"`);\n    },\n    hint: (project) => project.manifest ? \"Fix the errors in showcase.json.\" : \"Run `showcase init` to create showcase.json.\",\n  },\n  {\n    id: \"media.motion\",\n    tier: \"A\",\n    label: \"Motion: looping WebP, GIF or video\",\n    run: async (project) => {\n      let unverified = null;\n      for (const reference of mediaReferences(project)) {\n        const motion = await isMotion(project, reference);\n        if (motion === true) return pass(path.basename(reference.src.split(/[?#]/)[0]));\n        if (motion === \"unverified\") unverified ??= reference.src;\n      }\n      return unverified ? pass(`${path.basename(unverified)} (remote WebP, assumed animated)`) : fail(\"only still images\");\n    },\n    hint: (project) => motionHints[project.kind] ?? \"Record a short looping WebP or video of it in action and show it in the README.\",\n  },\n  {\n    id: \"demo.local\",\n    tier: \"A\",\n    label: \"One-command local demo\",\n    run: (project) => {\n      const local = manifestData(project)?.demo?.local;\n      return local ? pass(local) : fail(\"demo.local not set in showcase.json\");\n    },\n    hint: () => \"Add a single command that runs it with sample data, and set it as demo.local in showcase.json.\",\n  },\n  {\n    id: \"demo.externals\",\n    tier: \"A\",\n    required: false,\n    label: \"Externals have stand-ins\",\n    run: (project) => {\n      const externals = manifestData(project)?.demo?.externals;\n      if (!externals) return fail(\"demo.externals not set\");\n      if (!externals.length) return pass(\"none\");\n      return pass(externals.map((external) => `${external.name}: ${external.standIn}`).join(\", \"));\n    },\n    hint: () => \"List what a demo cannot reach (models, accounts, hardware, services) in demo.externals, each with its seam and stand-in, or set it to [] if there is nothing.\",\n  },\n  {\n    id: \"media.capture\",\n    tier: \"A\",\n    required: false,\n    label: \"Media can be regenerated\",\n    run: (project) => {\n      const capture = manifestData(project)?.media?.capture;\n      return capture ? pass(capture) : fail(\"media.capture not set\");\n    },\n    hint: () => \"Script the screenshots and recordings, and set media.capture so they never go stale.\",\n  },\n  {\n    id: \"live\",\n    tier: \"S\",\n    label: \"Live: one click to try it for real\",\n    run: async (project, options) => {\n      const live = manifestData(project)?.live;\n      if (!live) return fail(\"live not set in showcase.json\");\n      if (options.offline) return { status: \"skip\", detail: `${live} (not verified offline)` };\n      try {\n        const response = await options.fetch(live, { redirect: \"follow\", signal: AbortSignal.timeout(options.timeoutMs ?? 8000) });\n        return response.ok ? pass(live) : fail(`${live} returned ${response.status}`);\n      } catch (error) {\n        return fail(`${live} unreachable: ${error.cause?.code ?? error.message}`);\n      }\n    },\n    hint: (project, result) => manifestData(project)?.live\n      ? (result.status === \"skip\" ? \"Run without --offline to verify the live URL.\" : \"Get the live URL responding again.\")\n      : liveHints[project.kind] ?? \"Host something people can try in one click, then set live in showcase.json.\",\n  },\n];\n\nexport async function evaluate(project, options = {}) {\n  options = { fetch: globalThis.fetch, ...options };\n  const results = [];\n  for (const check of checks) {\n    const result = await check.run(project, options);\n    results.push({ id: check.id, tier: check.tier, label: check.label, required: check.required !== false, ...result, hint: result.status === \"pass\" ? null : check.hint(project, result) });\n  }\n\n  let tier = \"D\";\n  for (const candidate of tiers.slice(1)) {\n    const blocked = results.some((result) => result.required && result.tier === candidate && result.status !== \"pass\");\n    if (blocked) break;\n    tier = candidate;\n  }\n\n  const ceiling = manifestData(project)?.ceiling ?? null;\n  const atCeiling = ceiling !== null && tiers.indexOf(tier) >= tiers.indexOf(ceiling);\n  const next = atCeiling || tier === \"S\" ? null : tiers[tiers.indexOf(tier) + 1];\n  return {\n    name: manifestData(project)?.title ?? project.name,\n    root: project.root,\n    kind: project.kind,\n    tier,\n    ceiling,\n    next,\n    nextSteps: next ? results.filter((result) => result.tier === next && result.status !== \"pass\") : [],\n    checks: results,\n  };\n}\n"}},"collect.js":{"file":{"contents":"import { copyFile, mkdir, readFile, realpath, rm, writeFile } from \"node:fs/promises\";\nimport os from \"node:os\";\nimport path from \"node:path\";\nimport { evaluate, tiers } from \"./checks.js\";\nimport { draftManifest } from \"./init.js\";\nimport { exists, localPath } from \"./project.js\";\nimport { portfolioSchema, validate } from \"./schema.js\";\n\nexport const priorityTargets = { high: \"S\", mid: \"A\", low: \"C\" };\n\nexport async function loadPortfolio(file) {\n  let data;\n  try {\n    data = JSON.parse(await readFile(file, \"utf8\"));\n  } catch (error) {\n    throw new Error(`Cannot read ${file}: ${error.message}`);\n  }\n  const errors = validate(data, portfolioSchema);\n  const ids = (Array.isArray(data?.projects) ? data.projects : []).map((project) => project?.id);\n  for (const id of new Set(ids.filter((id, index) => id && ids.indexOf(id) !== index))) errors.push(`projects: duplicate id ${id}`);\n  if (errors.length) throw new Error(`${file} is invalid:\\n${errors.map((error) => `  - ${error}`).join(\"\\n\")}`);\n  return data;\n}\n\n// Expands ~ and $VAR / ${VAR}; relative paths resolve from the config's folder.\nexport function resolvePath(raw, base, env = process.env) {\n  const missing = [];\n  const expanded = raw\n    .replace(/^~(?=$|\\/)/, os.homedir())\n    .replace(/\\$(?:\\{(\\w+)\\}|(\\w+))/g, (_, braced, bare) => {\n      const name = braced ?? bare;\n      if (env[name] === undefined) missing.push(name);\n      return env[name] ?? \"\";\n    });\n  if (missing.length) throw new Error(`${missing.map((name) => `$${name}`).join(\", \")} not set`);\n  return path.resolve(base, expanded);\n}\n\n// Entries that point at a folder another entry already covers, as {ignored id: kept id}. When one is reached\n// through a symlink (a project that moved and left a link behind) the real location is kept; otherwise the first.\nexport async function findAliases(projects, base, env = process.env) {\n  const groups = new Map();\n  for (const entry of projects) {\n    if (!entry.path) continue;\n    try {\n      const dir = resolvePath(entry.path, base, env);\n      const real = await realpath(dir);\n      groups.set(real, [...(groups.get(real) ?? []), { entry, linked: real !== dir }]);\n    } catch { /* a missing folder is reported where it is used */ }\n  }\n  const aliases = {};\n  for (const group of groups.values()) {\n    const kept = group.find((member) => !member.linked) ?? group[0];\n    for (const member of group) if (member !== kept) aliases[member.entry.id] = kept.entry.id;\n  }\n  return aliases;\n}\n\nexport function targetTier(priority, ceiling) {\n  const target = priorityTargets[priority];\n  if (!target) return null;\n  return ceiling && tiers.indexOf(ceiling) < tiers.indexOf(target) ? ceiling : target;\n}\n\n// The required checks still failing at or below the target tier.\nexport function gapTo(report, target) {\n  if (!target) return [];\n  return report.checks\n    .filter((check) => check.required && check.status !== \"pass\" && tiers.indexOf(check.tier) <= tiers.indexOf(target))\n    .map((check) => check.id);\n}\n\n// Only letters, digits, _ and -, so a slug can never climb out of media/.\nconst slugOf = (id) => id.replace(/^[a-z]+:/i, \"\").replace(/[^\\w-]+/g, \"-\").replace(/^-+|-+$/g, \"\").toLowerCase() || \"project\";\n\n// Copies local media into <out>/media/<slug>/ and points the manifest at the copies. Remote URLs stay as they are.\nasync function copyMedia(project, media, out, slug, warn) {\n  if (!media) return undefined;\n  const folder = path.join(out, \"media\", slug);\n  await rm(folder, { recursive: true, force: true });\n  const entries = [[\"hero\", media.hero], [\"motion\", media.motion], [\"video\", media.video], ...(media.screenshots ?? []).map((src, index) => [`screenshot-${index + 1}`, src])];\n  const copied = {};\n  for (const [name, src] of entries) {\n    if (typeof src !== \"string\") continue;\n    const file = localPath(project, src);\n    if (!file || !(await exists(file))) {\n      if (/^https?:/i.test(src)) copied[name] = src;\n      else warn(`media not found: ${src}`);\n      continue;\n    }\n    const target = `${name}${path.extname(file).toLowerCase()}`;\n    await mkdir(folder, { recursive: true });\n    await copyFile(file, path.join(folder, target));\n    copied[name] = `media/${slug}/${target}`;\n  }\n  const screenshots = Object.keys(copied).filter((key) => key.startsWith(\"screenshot-\")).map((key) => copied[key]);\n  const result = { hero: copied.hero, motion: copied.motion, video: copied.video, screenshots: screenshots.length ? screenshots : undefined, capture: media.capture };\n  return Object.values(result).some(Boolean) ? result : undefined;\n}\n\nasync function collectOne(entry, base, out, options) {\n  const warnings = [];\n  const warn = (message) => warnings.push(`${entry.id}: ${message}`);\n  const listing = { id: entry.id, priority: entry.priority ?? null };\n  const slug = slugOf(entry.id);\n  let root;\n  try {\n    root = entry.path ? resolvePath(entry.path, base, options.env) : null;\n  } catch (error) {\n    warn(error.message);\n  }\n  if (!root || !(await exists(root))) {\n    if (root) warn(`${root} not found`);\n    else if (!entry.path) warn(\"no local path, listed without a grade\");\n    return { warnings, entry: { ...listing, title: entry.title ?? slug, repo: entry.repo ?? undefined, tier: null, target: priorityTargets[listing.priority] ?? null, gap: [], source: null } };\n  }\n\n  const { project, manifest: draft } = await draftManifest(root);\n  const declared = project.manifest?.errors.length === 0 ? project.manifest.data : null;\n  if (project.manifest && !declared) warn(\"showcase.json is invalid, using a draft from the README\");\n  const { $schema, ceiling, media, ...facts } = declared ?? draft;\n  const report = await evaluate(project, options);\n  const target = targetTier(listing.priority, ceiling);\n  return {\n    warnings,\n    report: { ...report, target },\n    entry: {\n      ...listing,\n      ...facts,\n      repo: facts.repo ?? entry.repo ?? undefined,\n      media: await copyMedia(project, media, out, slug, warn),\n      tier: report.tier,\n      target,\n      gap: gapTo(report, target),\n      source: declared ? \"showcase.json\" : \"draft\",\n    },\n  };\n}\n\n// Reads a portfolio config, grades every project and writes <out>/feed.json plus copies of the local media.\nexport async function collect(configFile, { out, ...options }) {\n  options = { fetch: globalThis.fetch, ...options };\n  const config = await loadPortfolio(configFile);\n  const base = path.dirname(path.resolve(configFile));\n  const aliases = await findAliases(config.projects, base, options.env);\n  const results = await Promise.all(config.projects.filter((entry) => !aliases[entry.id]).map((entry) => collectOne(entry, base, out, options)));\n  const feed = { projects: results.map((result) => JSON.parse(JSON.stringify(result.entry))) };\n  await mkdir(out, { recursive: true });\n  const file = path.join(out, \"feed.json\");\n  await writeFile(file, `${JSON.stringify(feed, null, 2)}\\n`);\n  return { file, feed, reports: results.map((result) => result.report).filter(Boolean), warnings: [...Object.entries(aliases).map(([id, kept]) => `${id}: same folder as ${kept}, left out`), ...results.flatMap((result) => result.warnings)] };\n}\n"}},"init.js":{"file":{"contents":"import { writeFile } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { firstCommitYear, loadProject, localPath, manifestName, mediaType } from \"./project.js\";\nimport { schema, validate } from \"./schema.js\";\n\nfunction shortPitch(text) {\n  if (text.length <= 160) return text;\n  const sentence = text.match(/^.*?[.!?](?=\\s|$)/)?.[0] ?? text;\n  return sentence.length > 160 ? `${sentence.slice(0, 157).trimEnd()}...` : sentence;\n}\n\nfunction title(project) {\n  const raw = project.readme?.title ?? project.pkg?.name ?? project.name;\n  const cleaned = raw.replace(/^@[^/]+\\//, \"\").replace(/\\s*\\([^)]*\\)\\s*$/, \"\").split(/\\s*[:|–—-]\\s+/)[0].trim();\n  return (cleaned || project.name).slice(0, 60);\n}\n\nfunction manifestPath(project, reference, base) {\n  const file = localPath(project, reference, base);\n  return file ? path.relative(project.root, file).split(path.sep).join(\"/\") : reference;\n}\n\n// Sorts what the README shows or links to: the first loop is motion, the first video is video, and the first still above the first section is the hero.\nasync function draftMedia(project) {\n  const { readme } = project;\n  if (!readme) return {};\n  const base = path.dirname(readme.file);\n  const references = [...readme.media, ...readme.links.map((link) => ({ src: link.href, line: link.line, linked: true }))]\n    .sort((a, b) => a.line - b.line);\n  const media = {};\n  for (const { src, line, tag, linked } of references) {\n    const type = tag === \"video\" || tag === \"source\" ? \"video\" : await mediaType(project, src, base);\n    if (linked && !type) continue;\n    if (type === \"video\") media.video ??= manifestPath(project, src, base);\n    else if (type === \"motion\") media.motion ??= manifestPath(project, src, base);\n    else if (line < readme.firstSectionLine) media.hero ??= manifestPath(project, src, base);\n  }\n  return { hero: media.hero, motion: media.motion, video: media.video };\n}\n\n// Hosts that only serve deployed apps. A custom domain could be docs or a blog, so it is set by hand.\nconst appHosts = /(^|\\.)(vercel\\.app|netlify\\.app|github\\.io|pages\\.dev|workers\\.dev|web\\.app|firebaseapp\\.com|fly\\.dev|onrender\\.com|deno\\.dev|surge\\.sh|glitch\\.me|replit\\.app)$/i;\n\n// The first README link (or package.json homepage) to an app host is the live demo.\nasync function draftLive(project) {\n  const base = project.readme ? path.dirname(project.readme.file) : project.root;\n  for (const href of [...(project.readme?.links ?? []).map((link) => link.href), project.pkg?.homepage]) {\n    if (typeof href !== \"string\" || !/^https?:\\/\\//i.test(href)) continue;\n    if (appHosts.test(new URL(href).hostname) && !(await mediaType(project, href, base))) return href;\n  }\n  return undefined;\n}\n\nconst captureScripts = [\"capture\", \"demo:capture\", \"media:capture\"];\nconst runners = [\"npm\", \"pnpm\", \"yarn\", \"bun\"];\n\nfunction draftScripts(pkg) {\n  const scripts = pkg?.scripts ?? {};\n  const name = pkg?.packageManager?.split(\"@\")[0];\n  const run = (script) => `${runners.includes(name) ? name : \"npm\"} run ${script}`;\n  const capture = captureScripts.find((script) => scripts[script]);\n  return { local: scripts.demo ? run(\"demo\") : undefined, capture: capture ? run(capture) : undefined };\n}\n\n// Drafts a manifest from what the project already says about itself.\nexport async function draftManifest(root) {\n  const project = await loadProject(root);\n  const pitch = project.readme?.lead ?? project.pkg?.description ?? \"\";\n  const media = await draftMedia(project);\n  const { local, capture } = draftScripts(project.pkg);\n  const manifest = {\n    $schema: schema.$id,\n    title: title(project),\n    pitch: shortPitch(pitch),\n    kind: project.kind,\n    year: await firstCommitYear(project.root) ?? undefined,\n    repo: project.remote?.url,\n    live: await draftLive(project),\n    media: Object.values(media).some(Boolean) || capture ? { ...media, capture } : undefined,\n    demo: local ? { local } : undefined,\n  };\n  return { project, manifest: JSON.parse(JSON.stringify(manifest)) };\n}\n\nexport async function init(root, { force = false, dryRun = false } = {}) {\n  const { project, manifest } = await draftManifest(root);\n  const file = path.join(project.root, manifestName);\n  if (project.manifest && !force && !dryRun) throw new Error(`${file} already exists. Use --force to overwrite it.`);\n  if (!dryRun) await writeFile(file, `${JSON.stringify(manifest, null, 2)}\\n`);\n  return { file, manifest, errors: validate(manifest) };\n}\n"}},"inspect.js":{"file":{"contents":"import { execFile, spawn } from \"node:child_process\";\nimport { stat } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { promisify } from \"node:util\";\nimport { localPath } from \"./project.js\";\nimport { readAnimatedWebp } from \"./webp.js\";\n\nconst exec = promisify(execFile);\n\n// What a motion clip should be to work as a README hero and a portfolio card. Outside these it still counts, with a warning.\nexport const motionLimits = { maxBytes: 3_000_000, minWidth: 640, minSeconds: 3, maxSeconds: 30 };\n\nconst kilobytes = (bytes) => `${Math.round(bytes / 1000).toLocaleString(\"en-US\")} KB`;\n\n// Runs the project's media.capture command in its root, with its output going straight to the terminal.\nexport function runCapture(project, command) {\n  return new Promise((resolve, reject) => {\n    const child = spawn(command, { cwd: project.root, shell: true, stdio: \"inherit\" });\n    child.on(\"error\", reject);\n    child.on(\"exit\", (code) => (code === 0 ? resolve() : reject(new Error(`'${command}' exited with status ${code}`))));\n  });\n}\n\nasync function probe(file) {\n  try {\n    const { stdout } = await exec(\"ffprobe\", [\"-v\", \"error\", \"-select_streams\", \"v:0\", \"-count_packets\", \"-show_entries\", \"stream=width,height,nb_read_packets:format=duration\", \"-of\", \"json\", file]);\n    const { streams: [video] = [], format } = JSON.parse(stdout);\n    return video?.width ? { width: video.width, height: video.height, frames: Number(video.nb_read_packets), durationMs: Number(format.duration) * 1000 } : null;\n  } catch {\n    return null;\n  }\n}\n\n// Checks the motion file: present, written by this run, animated, looping, and a sensible size, width and length.\n// Each result is { status: \"pass\" | \"warn\" | \"fail\", detail }.\nexport async function inspectMotion(project, reference, { since } = {}) {\n  const results = [];\n  const add = (status, detail) => results.push({ status, detail });\n  const file = localPath(project, reference);\n  const info = file && await stat(file).catch(() => null);\n  if (!info?.isFile()) {\n    add(\"fail\", `${reference} not found`);\n    return results;\n  }\n  if (since && info.mtimeMs < since) add(\"fail\", `${reference} was not rewritten by the capture`);\n\n  const extension = path.extname(file).toLowerCase();\n  const clip = extension === \".webp\" ? await readAnimatedWebp(file) : await probe(file);\n  if (extension === \".webp\") {\n    if (!clip) add(\"fail\", \"not an animated WebP\");\n    else add(clip.loops === 0 ? \"pass\" : \"warn\", clip.loops === 0 ? \"animated WebP, loops forever\" : `animated WebP, plays ${clip.loops} time(s); encode with -loop 0`);\n  } else if (!clip) {\n    add(\"warn\", `cannot read ${extension} without ffprobe`);\n  }\n  if (clip) {\n    const seconds = clip.durationMs / 1000;\n    const shape = `${clip.width}x${clip.height}, ${seconds.toFixed(1)} s, ${clip.frames} frames`;\n    if (clip.width < motionLimits.minWidth) add(\"warn\", `${shape}: narrower than ${motionLimits.minWidth} px looks soft on a card`);\n    else if (seconds < motionLimits.minSeconds || seconds > motionLimits.maxSeconds) add(\"warn\", `${shape}: aim for ${motionLimits.minSeconds} to ${motionLimits.maxSeconds} s`);\n    else add(\"pass\", shape);\n  }\n  add(info.size > motionLimits.maxBytes ? \"warn\" : \"pass\", info.size > motionLimits.maxBytes\n    ? `${kilobytes(info.size)}: over ${kilobytes(motionLimits.maxBytes)}, try a lower quality, fps or a tighter crop`\n    : kilobytes(info.size));\n  return results;\n}\n"}},"manage.js":{"file":{"contents":"import { existsSync } from \"node:fs\";\nimport { mkdir, readdir, readFile, realpath, stat, writeFile } from \"node:fs/promises\";\nimport os from \"node:os\";\nimport path from \"node:path\";\nimport { isDeepStrictEqual } from \"node:util\";\nimport { evaluate, tiers } from \"./checks.js\";\nimport { findAliases, gapTo, loadPortfolio, resolvePath, targetTier } from \"./collect.js\";\nimport { exists, loadProject, manifestName } from \"./project.js\";\nimport { portfolioSchema, schema, validate } from \"./schema.js\";\n\nconst portfolioItem = portfolioSchema.properties.projects.items;\nconst textual = (node) => [node.type ?? []].flat().some((type) => type === \"string\" || type === \"integer\") || node.enum || (node.type === \"array\" && node.items?.type === \"string\");\n\n// What can be edited, derived from the schemas so a new field shows up without touching the UI.\nfunction fieldsOf(node, { skip = [] } = {}) {\n  return Object.entries(node.properties)\n    .filter(([key, child]) => !skip.includes(key) && key !== \"$schema\" && textual(child))\n    .map(([key, child]) => ({\n      key,\n      node: child,\n      required: (node.required ?? []).includes(key),\n      nullable: [child.type ?? []].flat().includes(\"null\"),\n    }));\n}\n\nexport const portfolioFields = fieldsOf(portfolioItem, { skip: [\"id\"] });\nexport const manifestFields = fieldsOf(schema);\n\n// The values an enum field cycles through. An optional field also cycles through \"unset\".\nexport function options(field) {\n  if (!field.node.enum) return null;\n  return field.required || field.node.enum.includes(null) ? field.node.enum : [...field.node.enum, undefined];\n}\n\nexport const isList = (field) => field.node.type === \"array\";\n\n// The text the editor starts from.\nexport function toText(field, value) {\n  if (value === undefined || value === null) return \"\";\n  return isList(field) ? value.join(\", \") : String(value);\n}\n\n// Turns what was typed into a value. Empty clears the field; the schema decides whether that is allowed.\nexport function fromText(field, text) {\n  const trimmed = text.trim();\n  if (!trimmed) return field.nullable ? null : undefined;\n  if (isList(field)) return trimmed.split(/\\s*,\\s*/).filter(Boolean);\n  if (field.node.type === \"integer\") return /^-?\\d+$/.test(trimmed) ? Number(trimmed) : trimmed;\n  return trimmed;\n}\n\nconst skipSpace = (source, at) => { while (/\\s/.test(source[at] ?? \"\")) at += 1; return at; };\n\n// Where the value that starts at `at` ends.\nfunction valueEnd(source, at) {\n  if (source[at] === '\"') {\n    let end = at + 1;\n    while (source[end] !== '\"') end += source[end] === \"\\\\\" ? 2 : 1;\n    return end + 1;\n  }\n  if (source[at] === \"{\" || source[at] === \"[\") {\n    let depth = 0;\n    for (let end = at; end < source.length; end += 1) {\n      if (source[end] === '\"') end = valueEnd(source, end) - 1;\n      else if (\"{[\".includes(source[end])) depth += 1;\n      else if (\"}]\".includes(source[end]) && --depth === 0) return end + 1;\n    }\n  }\n  const match = /^[^\\s,\\]}]+/.exec(source.slice(at));\n  return at + (match?.[0].length ?? 0);\n}\n\n// The [start, end) of the value at a path of keys and indexes in JSON text, or null.\nexport function locate(source, steps) {\n  let start = skipSpace(source, 0);\n  let end = valueEnd(source, start);\n  for (const step of steps) {\n    const object = source[start] === \"{\";\n    let at = skipSpace(source, start + 1);\n    let found = null;\n    for (let index = 0; at < end - 1 && !found; index += 1) {\n      let key = index;\n      if (object) {\n        const keyEnd = valueEnd(source, at);\n        key = JSON.parse(source.slice(at, keyEnd));\n        at = skipSpace(source, source.indexOf(\":\", keyEnd) + 1);\n      }\n      const stop = valueEnd(source, at);\n      if (key === step) found = [at, stop];\n      at = skipSpace(source, stop);\n      if (source[at] === \",\") at = skipSpace(source, at + 1);\n    }\n    if (!found) return null;\n    [start, end] = found;\n  }\n  return [start, end];\n}\n\nasync function readJson(file) {\n  return JSON.parse(await readFile(file, \"utf8\"));\n}\n\nconst writeJson = (file, data) => writeFile(file, `${JSON.stringify(data, null, 2)}\\n`);\n\n// Replaces one existing value in place so the rest of the file keeps its formatting and the diff stays one line.\n// Adding or removing a key rewrites the whole file instead.\nasync function save(file, candidate, steps, value) {\n  const source = await readFile(file, \"utf8\");\n  const span = value === undefined ? null : locate(source, steps);\n  if (span) {\n    const patched = source.slice(0, span[0]) + JSON.stringify(value) + source.slice(span[1]);\n    let parsed;\n    try { parsed = JSON.parse(patched); } catch { /* fall through to a rewrite */ }\n    if (isDeepStrictEqual(parsed, candidate)) return writeFile(file, patched);\n  }\n  return writeJson(file, candidate);\n}\n\nconst errorsFor = (errors, key) => errors.filter((error) => error.startsWith(`${key}:`) || error.startsWith(`${key}.`) || error.startsWith(`${key}[`));\n\n// Keeps the schema's field order when a key is added; keys the schema does not know (x-...) stay after it.\nfunction ordered(data) {\n  const known = Object.keys(schema.properties);\n  const rank = (key) => known.includes(key) ? known.indexOf(key) : known.length;\n  return Object.fromEntries(Object.entries(data).sort(([a], [b]) => rank(a) - rank(b)));\n}\n\nfunction assign(data, key, value, order) {\n  const next = { ...data };\n  if (value === undefined) delete next[key];\n  else next[key] = value;\n  return order && !(key in data) ? order(next) : next;\n}\n\n// Each edit re-reads the file, changes one field and writes it back, so edits made elsewhere in the meantime survive.\nexport async function setPortfolioField(configFile, id, field, value) {\n  const config = await readJson(configFile);\n  const index = config.projects.findIndex((entry) => entry.id === id);\n  if (index < 0) throw new Error(`${id} is no longer in ${configFile}`);\n  const candidate = { ...config, projects: config.projects.map((entry, i) => i === index ? assign(entry, field.key, value) : entry) };\n  const errors = errorsFor(validate(candidate, portfolioSchema), `projects[${index}].${field.key}`).map((error) => error.replace(/^projects\\[\\d+\\]\\./, \"\"));\n  if (errors.length) return { errors };\n  await save(configFile, candidate, [\"projects\", index, field.key], value);\n  return { errors: [] };\n}\n\nexport async function setManifestField(root, field, value) {\n  const file = path.join(root, manifestName);\n  const data = await readJson(file);\n  const candidate = assign(data, field.key, value, ordered);\n  const errors = errorsFor(validate(candidate), field.key);\n  if (errors.length) return { errors };\n  await save(file, candidate, [field.key], value);\n  return { errors: [] };\n}\n\n// Moves a project up (-1) or down (1) in portfolio order.\nexport async function moveProject(configFile, id, delta) {\n  const config = await readJson(configFile);\n  const from = config.projects.findIndex((entry) => entry.id === id);\n  const to = from + delta;\n  if (from < 0 || to < 0 || to >= config.projects.length) return false;\n  config.projects.splice(to, 0, config.projects.splice(from, 1)[0]);\n  await writeJson(configFile, config);\n  return true;\n}\n\n// Finds one project of the portfolio on disk. Quick: no network, no grading.\nexport async function openRow(entry, base, env = process.env) {\n  const row = { id: entry.id, entry, root: null, project: null, report: null, target: null, gap: [], note: null };\n  try {\n    row.root = entry.path ? resolvePath(entry.path, base, env) : null;\n  } catch (error) {\n    row.note = error.message;\n  }\n  if (!row.root) return { ...row, note: row.note ?? \"no local path, so nothing to grade\" };\n  if (!(await exists(row.root))) return { ...row, note: `${row.root} not found` };\n  row.project = await loadProject(row.root);\n  const data = row.project.manifest?.errors.length === 0 ? row.project.manifest.data : null;\n  row.target = targetTier(entry.priority, data?.ceiling);\n  return row;\n}\n\n// Grades an opened row. This is the slow part when the live URL is checked.\nexport async function gradeRow(row, options = {}) {\n  if (!row.project) return row;\n  const report = await evaluate(row.project, { offline: options.offline, fetch: options.fetch ?? globalThis.fetch });\n  return { ...row, report, gap: gapTo(report, row.target) };\n}\n\n// The portfolio's entries, minus any that only repeat another's folder (see findAliases), which come back as ignored.\nexport async function loadEntries(configFile, env = process.env) {\n  const config = await loadPortfolio(configFile);\n  const base = path.dirname(path.resolve(configFile));\n  const ignored = await findAliases(config.projects, base, env);\n  return { base, entries: config.projects.filter((entry) => !ignored[entry.id]), ignored };\n}\n\n// The ways the list can be ordered. \"order\" is portfolio.json itself, the only one J/K can change.\nexport const sorts = {\n  priority: \"priority, then tier\",\n  tier: \"tier, best first\",\n  gap: \"gap to target, biggest first\",\n  order: \"portfolio.json order\",\n};\n\n// Sorts rows for display without touching the portfolio. Ties keep portfolio order; ungraded rows come last.\nexport function sortRows(rows, mode, order = rows.map((row) => row.id)) {\n  const position = (row) => { const at = order.indexOf(row.id); return at < 0 ? order.length : at; };\n  const priority = (row) => [\"high\", \"mid\", \"low\"].indexOf(row.entry.priority) < 0 ? 3 : [\"high\", \"mid\", \"low\"].indexOf(row.entry.priority);\n  const tier = (row) => row.report ? tiers.indexOf(row.report.tier) : -1;\n  const gap = (row) => row.report && row.target ? tiers.indexOf(row.target) - tiers.indexOf(row.report.tier) : -1;\n  const keys = { priority: [priority, (row) => -tier(row)], tier: [(row) => -tier(row), priority], gap: [(row) => -gap(row), (row) => -tier(row)], order: [] }[mode] ?? [];\n  return [...rows].sort((a, b) => {\n    for (const key of keys) if (key(a) !== key(b)) return key(a) - key(b);\n    return position(a) - position(b);\n  });\n}\n\n// How far along a project is, as its showcase.json says.\nexport const stageOf = (row) => row.project?.manifest?.data?.stage;\n\n// The title shown for a project: showcase.json first, then the portfolio entry, then its id.\nexport function titleOf(row) {\n  const data = row.project?.manifest?.data;\n  return data?.title ?? row.entry.title ?? row.id.replace(/^[a-z]+:/i, \"\");\n}\n\nexport const configName = \"portfolio.json\";\n\n// Another project's portfolio.json (a data file of a site, say) is not ours unless it has this shape.\nasync function isPortfolio(file) {\n  try {\n    return validate(JSON.parse(await readFile(file, \"utf8\")), portfolioSchema).length === 0;\n  } catch {\n    return false;\n  }\n}\n\nconst userDir = (env) => path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), \".config\"), \"showcase\");\n// Where the portfolio lives when nobody says: $SHOWCASE_CONFIG, the nearest portfolio.json from here upwards,\n// then the user-level one.\nexport async function findConfig({ cwd = process.cwd(), env = process.env } = {}) {\n  if (env.SHOWCASE_CONFIG) {\n    const file = path.resolve(cwd, env.SHOWCASE_CONFIG);\n    return { file, exists: await exists(file) };\n  }\n  for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {\n    if (await isPortfolio(path.join(dir, configName))) return { file: path.join(dir, configName), exists: true };\n    if (path.dirname(dir) === dir) break;\n  }\n  const file = path.join(userDir(env), configName);\n  return { file, exists: await exists(file) };\n}\n\nconst projectMarkers = [\"showcase.json\", \"package.json\", \".git\", \"README.md\", \"readme.md\", \"Cargo.toml\", \"go.mod\"];\nconst isDirectory = async (dir) => (await stat(dir).catch(() => null))?.isDirectory() ?? false;\n\nasync function looksLikeProject(dir) {\n  for (const marker of projectMarkers) if (await exists(path.join(dir, marker))) return true;\n  return false;\n}\n\n// The project in this folder, or else the projects directly inside it.\nexport async function discoverProjects(dir) {\n  dir = path.resolve(dir);\n  if ([\"showcase.json\", \"package.json\", \".git\"].some((marker) => existsSync(path.join(dir, marker)))) return [dir];\n  const found = [];\n  for (const child of await readdir(dir, { withFileTypes: true }).catch(() => [])) {\n    if (!child.isDirectory() || child.name.startsWith(\".\") || child.name === \"node_modules\") continue;\n    if (await looksLikeProject(path.join(dir, child.name))) found.push(path.join(dir, child.name));\n  }\n  return found.sort().slice(0, 100);\n}\n\n// How a project folder is written in the config: relative inside the config's folder, else ~/..., else absolute.\nexport function storedPath(dir, configFile) {\n  const base = path.dirname(path.resolve(configFile));\n  const inside = (parent) => dir === parent || dir.startsWith(`${parent}${path.sep}`);\n  if (inside(base)) return path.relative(base, dir) || \".\";\n  if (inside(os.homedir())) return `~/${path.relative(os.homedir(), dir).split(path.sep).join(\"/\")}`;\n  return dir;\n}\n\nconst entryFor = (dir, configFile, id) => ({ id, priority: null, path: storedPath(dir, configFile) });\n\n// The config entry that points at this folder, if any. A symlink to the folder counts as the folder.\nasync function entryAt(config, configFile, dir, env) {\n  const base = path.dirname(path.resolve(configFile));\n  const real = await realpath(dir).catch(() => dir);\n  for (const entry of config.projects) {\n    try {\n      if (entry.path && await realpath(resolvePath(entry.path, base, env)).catch(() => resolvePath(entry.path, base, env)) === real) return entry;\n    } catch { /* an unset $VAR cannot match */ }\n  }\n  return undefined;\n}\n\n// The project the user is standing in: this folder or the nearest one above it with a project marker, never the home folder or above.\nexport function projectRootOf(cwd, home = os.homedir()) {\n  for (let dir = path.resolve(cwd); dir !== home && path.dirname(dir) !== dir; dir = path.dirname(dir)) {\n    if ([\"showcase.json\", \"package.json\", \".git\"].some((marker) => existsSync(path.join(dir, marker)))) return dir;\n  }\n  return null;\n}\n\n// The project to offer adding: where the user started, unless it is already registered or the portfolio lives inside it.\nexport async function unregisteredHere(configFile, { cwd = process.cwd(), env = process.env } = {}) {\n  const dir = projectRootOf(cwd);\n  if (!dir) return null;\n  const base = path.dirname(path.resolve(configFile));\n  if (base === dir || base.startsWith(`${dir}${path.sep}`)) return null;\n  return (await entryAt(await readJson(configFile), configFile, dir, env)) ? null : dir;\n}\n\n// Adds a project folder to the config, unless it is already there.\nexport async function addProject(configFile, input, { cwd = process.cwd(), env = process.env, id: wanted, priority = null, repo } = {}) {\n  if (!input.trim() && repo) return addRemote(configFile, { id: wanted, priority, repo });\n  if (!input.trim()) return { error: \"Type the path to a project folder\" };\n  let dir;\n  try {\n    dir = resolvePath(input.trim(), cwd, env);\n  } catch (error) {\n    return { error: error.message };\n  }\n  if (!(await isDirectory(dir))) return { error: `${dir} is not a folder` };\n  const config = await readJson(configFile);\n  const known = await entryAt(config, configFile, dir, env);\n  if (known) return { error: `${known.id} already points there` };\n  const name = path.basename(dir).toLowerCase();\n  let id = wanted ?? `local:${name}`;\n  if (wanted && config.projects.some((entry) => entry.id === wanted)) return { error: `${wanted} is already in the portfolio` };\n  for (let n = 2; !wanted && config.projects.some((entry) => entry.id === id); n += 1) id = `local:${name}-${n}`;\n  const entry = entryFor(dir, configFile, id);\n  // A path typed with ~ or $VAR stays that way, so the config does not hard-code this machine's folders.\n  if (/^[~$]/.test(input.trim())) entry.path = input.trim();\n  entry.priority = priority;\n  const errors = validate({ projects: [entry] }, portfolioSchema);\n  if (errors.length) return { error: errors.map((error) => error.replace(/^projects\\[0\\]\\./, \"\")).join(\"; \") };\n  config.projects.push(entry);\n  await writeJson(configFile, config);\n  return { id };\n}\n\n// A project with no local checkout, listed by its repo (graded as \"no local path\", shown by the portfolio).\nasync function addRemote(configFile, { id, priority, repo }) {\n  if (!id) return { error: \"A project without a folder needs an --id\" };\n  const config = await readJson(configFile);\n  if (config.projects.some((entry) => entry.id === id)) return { error: `${id} is already in the portfolio` };\n  const known = config.projects.find((entry) => entry.repo === repo);\n  if (known) return { error: `${known.id} already points there` };\n  const entry = { id, priority, path: null, repo };\n  const errors = validate({ projects: [entry] }, portfolioSchema);\n  if (errors.length) return { error: errors.map((error) => error.replace(/^projects\\[0\\]\\./, \"\")).join(\"; \") };\n  config.projects.push(entry);\n  await writeJson(configFile, config);\n  return { id };\n}\n\n// Takes a project out of the portfolio. The project's own files are not touched.\nexport async function removeProject(configFile, id) {\n  const config = await readJson(configFile);\n  config.projects = config.projects.filter((entry) => entry.id !== id);\n  await writeJson(configFile, config);\n}\n\n// A starter config from whatever is in this folder. An empty portfolio is valid too.\nexport async function createConfig(configFile, cwd = process.cwd()) {\n  const projects = (await discoverProjects(cwd)).map((dir) => entryFor(dir, configFile, `local:${path.basename(dir).toLowerCase()}`));\n  const seen = new Set();\n  await mkdir(path.dirname(configFile), { recursive: true });\n  await writeJson(configFile, { projects: projects.filter((entry) => !seen.has(entry.id) && seen.add(entry.id)) });\n  return seen.size;\n}\n"}},"project.js":{"file":{"contents":"import { execFile } from \"node:child_process\";\nimport { open, readFile, stat } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { promisify } from \"node:util\";\nimport { loadReadme } from \"./readme.js\";\nimport { schema, validate } from \"./schema.js\";\n\nconst exec = promisify(execFile);\n\nexport const manifestName = \"showcase.json\";\n\nasync function readJson(file) {\n  try {\n    return JSON.parse(await readFile(file, \"utf8\"));\n  } catch {\n    return null;\n  }\n}\n\nasync function exists(file) {\n  return stat(file).then(() => true, () => false);\n}\n\nexport async function gitRemote(root) {\n  try {\n    const { stdout } = await exec(\"git\", [\"-C\", root, \"remote\", \"get-url\", \"origin\"]);\n    const match = stdout.trim().match(/github\\.com[:/]([^/]+)\\/(.+?)(?:\\.git)?\\/?$/);\n    return match ? { owner: match[1], repo: match[2], url: `https://github.com/${match[1]}/${match[2]}` } : null;\n  } catch {\n    return null;\n  }\n}\n\nexport async function firstCommitYear(root) {\n  try {\n    const { stdout } = await exec(\"git\", [\"-C\", root, \"log\", \"--reverse\", \"--format=%ad\", \"--date=format:%Y\"]);\n    const year = Number(stdout.split(\"\\n\")[0]);\n    return Number.isInteger(year) && year > 1990 ? year : null;\n  } catch {\n    return null;\n  }\n}\n\nconst webFrameworks = [\"next\", \"vite\", \"astro\", \"@remix-run/react\", \"react-router\", \"@sveltejs/kit\", \"nuxt\", \"react-scripts\", \"gatsby\"];\n\n// A best guess; showcase.json's kind always wins.\nexport async function detectKind(root, pkg) {\n  const extension = await readJson(path.join(root, \"manifest.json\"));\n  if (extension?.manifest_version) return \"browser-extension\";\n  if (pkg) {\n    const deps = { ...pkg.dependencies, ...pkg.devDependencies };\n    if (deps.electron || deps[\"@tauri-apps/api\"] || await exists(path.join(root, \"src-tauri\"))) return \"desktop-app\";\n    if (pkg.bin) return \"cli\";\n    if (pkg.pi?.extensions) return \"agent-extension\";\n    if (webFrameworks.some((name) => deps[name])) return \"web-app\";\n    if (pkg.main || pkg.exports) return \"library\";\n  }\n  if (await exists(path.join(root, \"Cargo.toml\"))) return await exists(path.join(root, \"src/main.rs\")) ? \"cli\" : \"library\";\n  if (await exists(path.join(root, \"go.mod\"))) return await exists(path.join(root, \"main.go\")) ? \"cli\" : \"library\";\n  if (await exists(path.join(root, \"index.html\"))) return \"web-app\";\n  return \"other\";\n}\n\nexport async function loadManifest(root) {\n  const file = path.join(root, manifestName);\n  let source;\n  try {\n    source = await readFile(file, \"utf8\");\n  } catch {\n    return null;\n  }\n  try {\n    const data = JSON.parse(source);\n    return { file, data, errors: validate(data) };\n  } catch (error) {\n    return { file, data: null, errors: [`invalid JSON: ${error.message}`] };\n  }\n}\n\nexport async function loadProject(root) {\n  root = path.resolve(root);\n  if (!(await stat(root).catch(() => null))?.isDirectory()) throw new Error(`Not a directory: ${root}`);\n  const [pkg, readme, manifest, remote] = await Promise.all([\n    readJson(path.join(root, \"package.json\")),\n    loadReadme(root),\n    loadManifest(root),\n    gitRemote(root),\n  ]);\n  const declaredKind = manifest?.data?.kind;\n  return {\n    root,\n    name: path.basename(root),\n    pkg,\n    readme,\n    manifest,\n    remote,\n    kind: schema.properties.kind.enum.includes(declaredKind) ? declaredKind : await detectKind(root, pkg),\n  };\n}\n\n// Maps a reference from a README or manifest to a file on disk, including GitHub URLs that point into the same repo.\nexport function localPath(project, reference, base = project.root) {\n  if (/^(mailto:|tel:|data:|#)/i.test(reference)) return null;\n  const clean = decodeURIComponent(reference.split(/[?#]/)[0]);\n  if (!/^[a-z][a-z0-9+.-]*:|^\\/\\//i.test(clean)) return path.resolve(clean.startsWith(\"/\") ? project.root : base, clean.replace(/^\\//, \"\"));\n  if (!project.remote) return null;\n  const { owner, repo } = project.remote;\n  const escaped = `${owner}/${repo}`.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n  const match = clean.match(new RegExp(`^https://(?:raw\\\\.githubusercontent\\\\.com/${escaped}/[^/]+|github\\\\.com/${escaped}/(?:blob|raw|tree)/[^/]+)/(.+)$`, \"i\"));\n  return match ? path.join(project.root, match[1]) : null;\n}\n\nexport async function isAnimatedWebp(file) {\n  let handle;\n  try {\n    handle = await open(file);\n    const { buffer } = await handle.read(Buffer.alloc(21), 0, 21, 0);\n    return buffer.toString(\"ascii\", 0, 4) === \"RIFF\" && buffer.toString(\"ascii\", 8, 16) === \"WEBPVP8X\" && (buffer[20] & 0x02) !== 0;\n  } catch {\n    return false;\n  } finally {\n    await handle?.close();\n  }\n}\n\nconst stillExtensions = new Set([\".png\", \".jpg\", \".jpeg\", \".avif\", \".svg\"]);\nconst videoExtensions = new Set([\".mp4\", \".webm\", \".mov\", \".m4v\"]);\n\n// \"still\", \"motion\" (a silent loop), \"video\", or null when the reference is not media. A remote WebP cannot be read, so it is \"unverified\".\nexport async function mediaType(project, reference, base = project.root) {\n  const extension = path.extname(reference.split(/[?#]/)[0]).toLowerCase();\n  if (videoExtensions.has(extension)) return \"video\";\n  if (extension === \".gif\" || extension === \".apng\") return \"motion\";\n  if (stillExtensions.has(extension)) return \"still\";\n  if (extension !== \".webp\") return null;\n  const file = localPath(project, reference, base);\n  return file ? (await isAnimatedWebp(file) ? \"motion\" : \"still\") : \"unverified\";\n}\n\nexport { exists };\n"}},"readme.js":{"file":{"contents":"import { readdir, readFile } from \"node:fs/promises\";\nimport path from \"node:path\";\n\nexport async function findReadme(root) {\n  const names = await readdir(root).catch(() => []);\n  const candidates = names.filter((name) => /^readme(\\.(md|markdown|txt|rst))?$/i.test(name));\n  const name = candidates.find((candidate) => /\\.(md|markdown)$/i.test(candidate)) ?? candidates[0];\n  return name ? path.join(root, name) : null;\n}\n\nexport async function loadReadme(root) {\n  const file = await findReadme(root);\n  if (!file) return null;\n  return { file, ...parseReadme(await readFile(file, \"utf8\")) };\n}\n\nconst imagePattern = /!\\[[^\\]]*\\]\\(\\s*<?([^)\\s>]+)>?(?:\\s+\"[^\"]*\")?\\s*\\)/g;\nconst linkPattern = /(?<!!)\\[(?:[^[\\]]|!\\[[^\\]]*\\]\\([^)]*\\))*\\]\\(\\s*<?([^)\\s>]+)>?(?:\\s+\"[^\"]*\")?\\s*\\)/g;\nconst htmlMediaPattern = /<(img|video|source)\\b[^>]*?\\bsrc\\s*=\\s*[\"']([^\"']+)[\"']/gi;\nconst htmlLinkPattern = /<a\\b[^>]*?\\bhref\\s*=\\s*[\"']([^\"']+)[\"']/gi;\n// Status badges are images, but they show nothing about the project.\nconst badgePattern = /^https?:\\/\\/(?:img\\.shields\\.io|shields\\.io|badgen\\.net|badge\\.fury\\.io|github\\.com\\/[^/]+\\/[^/]+\\/(?:actions\\/)?workflows\\/[^?#]+\\/badge\\.svg|[^/]*codecov\\.io\\/.+\\/badge|coveralls\\.io\\/repos\\/.+badge)/i;\nconst bareAttachmentPattern = /^\\s*(https:\\/\\/github\\.com\\/user-attachments\\/assets\\/[\\w-]+)\\s*$/;\n\nfunction plain(text) {\n  return text\n    .replace(/<[^>]+>/g, \" \")\n    .replace(/!\\[[^\\]]*\\]\\([^)]*\\)/g, \" \")\n    .replace(/\\[([^\\]]*)\\]\\([^)]*\\)/g, \"$1\")\n    .replace(/[`*_~>#|]/g, \" \");\n}\n\nfunction countWords(text) {\n  return plain(text).split(/\\s+/).filter((word) => /[\\p{L}\\p{N}]/u.test(word)).length;\n}\n\nexport function parseReadme(source) {\n  const text = source.replace(/<!--[\\s\\S]*?-->/g, \"\");\n  const lines = text.split(/\\r?\\n/);\n  const prose = [];\n  const media = [];\n  const links = [];\n  const headings = [];\n  let codeBlocks = 0;\n  let fence = null;\n\n  lines.forEach((line, index) => {\n    const fenceMatch = line.match(/^\\s*(```+|~~~+)/);\n    if (fence) {\n      if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) fence = null;\n      return;\n    }\n    if (fenceMatch) {\n      fence = fenceMatch[1];\n      codeBlocks += 1;\n      return;\n    }\n    prose.push(line);\n\n    const markdownHeading = line.match(/^(#{1,6})\\s+(.*?)\\s*#*\\s*$/);\n    const htmlHeading = line.match(/^\\s*<h([1-6])\\b[^>]*>(.*?)<\\/h\\1>/i);\n    if (markdownHeading) headings.push({ level: markdownHeading[1].length, text: plain(markdownHeading[2]).trim(), line: index });\n    else if (htmlHeading) headings.push({ level: Number(htmlHeading[1]), text: plain(htmlHeading[2]).trim(), line: index });\n\n    for (const match of line.matchAll(imagePattern)) if (!badgePattern.test(match[1])) media.push({ src: match[1], line: index });\n    for (const match of line.matchAll(htmlMediaPattern)) if (!badgePattern.test(match[2])) media.push({ src: match[2], line: index, tag: match[1].toLowerCase() });\n    const attachment = line.match(bareAttachmentPattern);\n    if (attachment) media.push({ src: attachment[1], line: index });\n    for (const match of line.matchAll(linkPattern)) links.push({ href: match[1], line: index });\n    for (const match of line.matchAll(htmlLinkPattern)) links.push({ href: match[1], line: index });\n  });\n\n  const h1 = headings.find((heading) => heading.level === 1);\n  const firstSection = headings.find((heading) => heading.level >= 2 && heading.line > (h1?.line ?? -1));\n  return {\n    title: h1?.text ?? null,\n    lead: leadParagraph(lines, h1?.line ?? -1),\n    words: countWords(prose.join(\"\\n\")),\n    codeBlocks,\n    headings,\n    media,\n    links,\n    firstSectionLine: firstSection?.line ?? lines.length,\n  };\n}\n\n// The first prose paragraph after the H1, skipping badge rows, images and bare HTML.\nfunction leadParagraph(lines, start) {\n  const paragraph = [];\n  for (const line of lines.slice(start + 1)) {\n    if (/^\\s*(#|```|~~~|<h[1-6])/i.test(line)) break;\n    if (paragraph.length && /^\\s*([-*+]|\\d+[.)]|\\|)\\s/.test(line)) break;\n    const words = countWords(line.replace(/\\[!\\[[^\\]]*\\]\\([^)]*\\)\\]\\([^)]*\\)/g, \"\"));\n    const isDecoration = words === 0 || /^\\s*(!\\[|<(img|p|div|picture|a|video|br|sub)\\b|\\[!\\[|https?:\\/\\/\\S+\\s*$)/i.test(line);\n    if (!line.trim() || isDecoration) {\n      if (paragraph.length) break;\n      continue;\n    }\n    paragraph.push(line.trim());\n  }\n  return paragraph.length ? plain(paragraph.join(\" \")).replace(/\\s+/g, \" \").trim() : null;\n}\n\nexport { countWords };\n"}},"report.js":{"file":{"contents":"import os from \"node:os\";\n\nconst useColor = process.stdout.isTTY && !process.env.NO_COLOR;\nexport const paint = (code) => (text) => useColor ? `\\x1b[${code}m${text}\\x1b[0m` : String(text);\nexport const bold = paint(\"1\");\nexport const dim = paint(\"2\");\nexport const muted = paint(\"90\");\nexport const green = paint(\"32\");\nexport const red = paint(\"31\");\nexport const yellow = paint(\"33\");\nconst tierColors = { S: paint(\"1;35\"), A: paint(\"1;32\"), B: paint(\"1;36\"), C: paint(\"1;33\"), D: paint(\"1;31\") };\n\nexport const tierLabel = (tier) => tierColors[tier](tier);\nconst home = (file) => file.startsWith(os.homedir()) ? `~${file.slice(os.homedir().length)}` : file;\nconst mark = (result) => result.status === \"pass\" ? green(\"✓\") : result.status === \"skip\" ? yellow(\"?\") : result.required ? red(\"✗\") : yellow(\"·\");\n\nfunction nextLine(report) {\n  if (report.next) return `next ${tierLabel(report.next)}`;\n  return report.tier === \"S\" ? \"top tier\" : `at ceiling ${report.ceiling}`;\n}\n\nexport function formatReport(report, { verbose = false } = {}) {\n  const lines = [`${tierLabel(report.tier)}  ${bold(report.name)}  ${dim(`${report.kind} · ${home(report.root)}`)}  ${dim(\"·\")} ${nextLine(report)}`];\n  if (verbose) {\n    for (const result of report.checks) {\n      lines.push(`  ${dim(result.tier)} ${mark(result)} ${result.label}${result.required ? \"\" : dim(\" (advice)\")}  ${dim(result.detail ?? \"\")}`);\n    }\n  }\n  if (report.nextSteps.length) {\n    lines.push(\"\", `  To reach ${tierLabel(report.next)}:`);\n    for (const step of report.nextSteps) {\n      lines.push(`  ${mark(step)} ${step.label}${step.required ? \"\" : dim(\" (advice)\")}  ${dim(step.detail)}`, `    ${step.hint}`);\n    }\n  }\n  return lines.join(\"\\n\");\n}\n\nexport function formatSummary(reports) {\n  const width = Math.max(...reports.map((report) => report.name.length), 7);\n  const kindWidth = Math.max(...reports.map((report) => report.kind.length), 4);\n  const targets = reports.some((report) => \"target\" in report);\n  const lines = [dim(`TIER  ${targets ? \"TARGET  \" : \"\"}${\"PROJECT\".padEnd(width)}  ${\"KIND\".padEnd(kindWidth)}  NEXT`)];\n  for (const report of reports) {\n    const blockers = report.nextSteps.filter((step) => step.required).map((step) => step.id);\n    const next = report.next ? `${tierLabel(report.next)} ${dim(\"needs\")} ${blockers.join(\", \")}` : dim(nextLine(report));\n    const target = targets ? `${report.target ? tierLabel(report.target) : dim(\"-\")}       ` : \"\";\n    lines.push(`${tierLabel(report.tier)}     ${target}${report.name.padEnd(width)}  ${dim(report.kind.padEnd(kindWidth))}  ${next}`);\n  }\n  return lines.join(\"\\n\");\n}\n\n// One row per check, one column per project, so the same gap across projects lines up.\nexport function formatMatrix(reports) {\n  const names = reports.map((report) => report.name.length > 12 ? `${report.name.slice(0, 11)}…` : report.name);\n  const widths = names.map((name) => Math.max(name.length, 4));\n  const checks = reports[0].checks;\n  const labelWidth = Math.max(...checks.map((result) => result.id.length + (result.required ? 0 : 1)), 5);\n  const cell = (text, index, painted = text) => `${\" \".repeat(widths[index] - String(text).length)}${painted}`;\n  const lines = [\n    dim(`   ${\"CHECK\".padEnd(labelWidth)}  ${names.map((name, index) => cell(name, index)).join(\"  \")}`),\n    `   ${bold(\"tier\".padEnd(labelWidth))}  ${reports.map((report, index) => cell(report.tier, index, tierLabel(report.tier))).join(\"  \")}`,\n  ];\n  checks.forEach((check, row) => {\n    const label = `${check.id}${check.required ? \"\" : \"*\"}`;\n    if (row === 0 || checks[row - 1].tier !== check.tier) lines.push(\"\");\n    lines.push(`${dim(check.tier)}  ${label.padEnd(labelWidth)}  ${reports.map((report, index) => cell(\"x\", index, mark(report.checks[row]))).join(\"  \")}`);\n  });\n  lines.push(\"\", dim(\"✓ pass  ✗ missing  · advice missing (* advice only)  ? not verified\"));\n  return lines.join(\"\\n\");\n}\n\nconst inspectMarks = { pass: green(\"✓\"), warn: yellow(\"!\"), fail: red(\"✗\") };\n\nexport function formatInspection(results) {\n  return results.map((result) => `  ${inspectMarks[result.status]} ${result.detail}`).join(\"\\n\");\n}\n"}},"schema.js":{"file":{"contents":"import { readFileSync } from \"node:fs\";\n\nconst load = (name) => JSON.parse(readFileSync(new URL(`../schema/${name}`, import.meta.url), \"utf8\"));\nexport const schema = load(\"showcase.schema.json\");\nexport const portfolioSchema = load(\"portfolio.schema.json\");\n\nconst typeChecks = {\n  object: (value) => value !== null && typeof value === \"object\" && !Array.isArray(value),\n  array: Array.isArray,\n  string: (value) => typeof value === \"string\",\n  integer: Number.isInteger,\n  number: (value) => typeof value === \"number\" && Number.isFinite(value),\n  boolean: (value) => typeof value === \"boolean\",\n  null: (value) => value === null,\n};\n\nfunction isUri(value) {\n  try {\n    return [\"http:\", \"https:\"].includes(new URL(value).protocol);\n  } catch {\n    return false;\n  }\n}\n\n// Validates the JSON Schema subset that the schema files use, so they stay the single source of truth.\nexport function validate(value, node = schema, path = \"\") {\n  const at = path || \"(root)\";\n  const types = [node.type ?? []].flat();\n  if (types.length && !types.some((type) => typeChecks[type](value))) return [`${at}: expected ${types.join(\" or \")}`];\n  if (node.enum && !node.enum.includes(value)) return [`${at}: must be one of ${node.enum.join(\", \")}`];\n\n  const errors = [];\n  if (typeof value === \"string\") {\n    if (node.minLength !== undefined && value.length < node.minLength) errors.push(`${at}: must not be empty`);\n    if (node.maxLength !== undefined && value.length > node.maxLength) errors.push(`${at}: ${value.length} characters, max ${node.maxLength}`);\n    if (node.format === \"uri\" && !isUri(value)) errors.push(`${at}: must be an http(s) URL`);\n  }\n  if (typeof value === \"number\") {\n    if (node.minimum !== undefined && value < node.minimum) errors.push(`${at}: must be at least ${node.minimum}`);\n    if (node.maximum !== undefined && value > node.maximum) errors.push(`${at}: must be at most ${node.maximum}`);\n  }\n  if (Array.isArray(value) && node.items) {\n    value.forEach((item, index) => errors.push(...validate(item, node.items, `${path}[${index}]`)));\n  }\n  if (typeChecks.object(value)) {\n    for (const key of node.required ?? []) {\n      if (!(key in value)) errors.push(`${path ? `${path}.` : \"\"}${key}: required`);\n    }\n    for (const [key, child] of Object.entries(value)) {\n      const childPath = path ? `${path}.${key}` : key;\n      const property = node.properties?.[key];\n      const pattern = Object.entries(node.patternProperties ?? {}).find(([source]) => new RegExp(source).test(key));\n      if (property) errors.push(...validate(child, property, childPath));\n      else if (pattern) errors.push(...validate(child, pattern[1], childPath));\n      else if (node.additionalProperties === false) errors.push(`${childPath}: unknown field`);\n    }\n  }\n  return errors;\n}\n"}},"terminal.js":{"file":{"contents":"// Builds the browser terminal recipe: a static page that boots a Node.js sandbox (WebContainers) and opens a shell in it.\nimport { copyFile, cp, mkdir, readdir, readFile, writeFile } from \"node:fs/promises\";\nimport { createServer } from \"node:http\";\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { resolvePath } from \"./collect.js\";\nimport { loadManifest } from \"./project.js\";\n\nconst assets = fileURLToPath(new URL(\"../recipes/browser-terminal/\", import.meta.url));\nconst skip = new Set([\".git\", \"node_modules\"]);\n\nconst utf8 = new TextDecoder(\"utf-8\", { fatal: true });\n\n// Text stays readable in files.json; anything else (images, loops) travels as base64 and the page decodes it.\nfunction fileNode(buffer) {\n  try {\n    return { file: { contents: utf8.decode(buffer) } };\n  } catch {\n    return { file: { base64: buffer.toString(\"base64\") } };\n  }\n}\n\n// Reads a folder into a WebContainer FileSystemTree; `include` limits the top-level entries.\nasync function tree(folder, include) {\n  const result = {};\n  for (const entry of await readdir(folder, { withFileTypes: true })) {\n    if (include ? !include.includes(entry.name) : skip.has(entry.name)) continue;\n    const file = path.join(folder, entry.name);\n    if (entry.isDirectory()) result[entry.name] = { directory: await tree(file) };\n    else if (entry.isFile()) result[entry.name] = fileNode(await readFile(file));\n  }\n  return result;\n}\n\n// Places a subtree at a path like \"node_modules/my-cli\" inside the root tree.\nfunction place(root, target, subtree) {\n  const parts = target.split(\"/\").filter((part) => part && part !== \".\");\n  let node = root;\n  for (const part of parts) {\n    node[part] ??= { directory: {} };\n    node = node[part].directory;\n  }\n  Object.assign(node, subtree);\n}\n\nconst isolationHeaders = { \"Cross-Origin-Embedder-Policy\": \"require-corp\", \"Cross-Origin-Opener-Policy\": \"same-origin\" };\n\n// The page says what is not real, from the project's demo.externals.\nfunction disclosure(title, externals = []) {\n  const notes = externals.filter((external) => external.standIn !== \"real\").map((external) => external.note ?? `${external.name}: ${external.standIn} stand-in.`);\n  return [`The real ${title} runs in a Node.js sandbox in your browser (WebContainers).`, ...notes, \"Reload to start over.\"].join(\" \");\n}\n\nexport async function buildTerminal(recipeDir, out) {\n  recipeDir = path.resolve(recipeDir);\n  out = path.resolve(out);\n  const recipeFile = path.join(recipeDir, \"terminal.json\");\n  const { mount, project, ...recipe } = JSON.parse(await readFile(recipeFile, \"utf8\").catch(() => {\n    throw new Error(`No terminal.json in ${recipeDir}. See recipes/browser-terminal/README.md.`);\n  }));\n  if (!mount) throw new Error(`${recipeFile}: mount is required`);\n  // Facts come from the project's showcase.json; terminal.json only adds what is specific to this page.\n  const manifest = project ? await loadManifest(resolvePath(project, recipeDir)) : null;\n  if (manifest?.errors.length) throw new Error(`${manifest.file}: ${manifest.errors.join(\"; \")}`);\n  const facts = manifest?.data ?? {};\n  const config = { title: facts.title, pitch: facts.pitch, repo: facts.repo, ...recipe };\n  config.disclosure ??= disclosure(config.title, facts.demo?.externals);\n  const files = {};\n  for (const [target, { from, include }] of Object.entries(mount)) {\n    place(files, target, await tree(resolvePath(from, recipeDir), include));\n  }\n  await mkdir(out, { recursive: true });\n  await copyFile(path.join(assets, \"index.html\"), path.join(out, \"index.html\"));\n  await copyFile(path.join(assets, \"themes.json\"), path.join(out, \"themes.json\"));\n  await cp(path.join(assets, \"fonts\"), path.join(out, \"fonts\"), { recursive: true, filter: (source) => !source.endsWith(\".sh\") });\n  await writeFile(path.join(out, \"files.json\"), JSON.stringify(files));\n  if (config.seed) await copyFile(path.join(recipeDir, config.seed), path.join(out, config.seed));\n  await writeFile(path.join(out, \"terminal.json\"), `${JSON.stringify(config, null, 2)}\\n`);\n  await writeFile(path.join(out, \"vercel.json\"), `${JSON.stringify({\n    headers: [\n      { source: \"/(.*)\", headers: Object.entries(isolationHeaders).map(([key, value]) => ({ key, value })) },\n      { source: \"/fonts/(.*)\", headers: [{ key: \"Cache-Control\", value: \"public, max-age=31536000, immutable\" }] },\n    ],\n  }, null, 2)}\\n`);\n  return { out, bytes: JSON.stringify(files).length };\n}\n\nconst types = { \".html\": \"text/html; charset=utf-8\", \".json\": \"application/json\", \".js\": \"text/javascript\", \".woff2\": \"font/woff2\", \".txt\": \"text/plain\" };\n\n// Serves a built page on this machine only, with the headers the sandbox needs.\nexport function serveTerminal(out, port = 4321) {\n  out = path.resolve(out);\n  return new Promise((resolve, reject) => {\n    const server = createServer(async (request, response) => {\n      const name = new URL(request.url, \"http://localhost\").pathname.replace(/\\/$/, \"/index.html\");\n      const file = path.join(out, path.normalize(name));\n      if (!file.startsWith(out + path.sep)) return response.writeHead(403).end();\n      try {\n        const body = await readFile(file);\n        response.writeHead(200, { \"Content-Type\": types[path.extname(file)] ?? \"application/octet-stream\", ...isolationHeaders }).end(body);\n      } catch {\n        response.writeHead(404).end(\"Not found\");\n      }\n    });\n    server.once(\"error\", reject).listen(port, \"127.0.0.1\", () => resolve(server));\n  });\n}\n"}},"tui.js":{"file":{"contents":"import os from \"node:os\";\nimport path from \"node:path\";\nimport { init } from \"./init.js\";\nimport { addProject, createConfig, findConfig, fromText, gradeRow, loadEntries, manifestFields, moveProject, openRow, options, portfolioFields, removeProject, setManifestField, setPortfolioField, sortRows, sorts, stageOf, titleOf, toText, unregisteredHere } from \"./manage.js\";\nimport { manifestName } from \"./project.js\";\nimport { bold, dim, green, muted, paint, red, tierLabel, yellow } from \"./report.js\";\n\nconst inverse = paint(\"7\");\nconst home = (file) => file.startsWith(os.homedir()) ? `~${file.slice(os.homedir().length)}` : file;\n\n// Plain-text helpers: fit before painting so escape codes never count towards the width.\nconst fit = (text, width) => {\n  text = String(text);\n  return (text.length > width ? `${text.slice(0, Math.max(width - 1, 0))}…` : text).padEnd(width);\n};\nconst cell = (text, width, style = (value) => value) => style(fit(text, width));\n\n// Line breaks for a text box: [start, end) ranges that break after a space when they can.\nexport function wrap(text, width) {\n  const ranges = [];\n  let start = 0;\n  while (text.length - start > width) {\n    let end = start + width;\n    const space = text.lastIndexOf(\" \", end - 1);\n    if (space > start) end = space + 1;\n    ranges.push([start, end]);\n    start = end;\n  }\n  ranges.push([start, text.length]);\n  return ranges;\n}\n\nconst keyNames = new Map(Object.entries({\n  \"\\x1b[A\": \"up\", \"\\x1b[B\": \"down\", \"\\x1b[C\": \"right\", \"\\x1b[D\": \"left\",\n  \"\\x1b[H\": \"home\", \"\\x1b[1~\": \"home\", \"\\x1bOH\": \"home\", \"\\x1b[F\": \"end\", \"\\x1b[4~\": \"end\", \"\\x1bOF\": \"end\",\n  \"\\x1b[3~\": \"delete\", \"\\x1b[5~\": \"pageup\", \"\\x1b[6~\": \"pagedown\", \"\\x1b\": \"escape\",\n  \"\\r\": \"enter\", \"\\n\": \"enter\", \"\\x7f\": \"backspace\", \"\\b\": \"backspace\", \"\\t\": \"tab\",\n  \"\\x03\": \"ctrl-c\", \"\\x01\": \"ctrl-a\", \"\\x05\": \"ctrl-e\", \"\\x0b\": \"ctrl-k\", \"\\x15\": \"ctrl-u\", \"\\x17\": \"ctrl-w\",\n}));\n\n// Splits what the terminal sent into keys. Printable runs stay whole so a paste is one insert.\nexport function parseKeys(chunk) {\n  const tokens = String(chunk).match(/\\x1b\\[[0-9;]*[A-Za-z~]|\\x1bO[A-Za-z]|\\x1b|[\\x00-\\x1f\\x7f]|[^\\x00-\\x1f\\x7f\\x1b]+/g) ?? [];\n  return tokens.map((token) => keyNames.has(token) ? { name: keyNames.get(token) } : /^[^\\x00-\\x1f\\x7f\\x1b]+$/.test(token) ? { name: \"char\", text: token } : { name: \"ignored\" });\n}\n\n// The portfolio's title and repo only stand in for a project without a showcase.json; with one, its facts win.\nconst standIns = new Set([\"title\", \"repo\"]);\n\n// The editable lines of the detail screen.\nfunction items(row) {\n  const data = row.project?.manifest?.data;\n  const list = portfolioFields.filter((field) => !(data && standIns.has(field.key))).map((field) => ({ field, source: \"portfolio\", value: row.entry[field.key] }));\n  if (data) list.push(...manifestFields.map((field) => ({ field, source: \"manifest\", value: data[field.key] })));\n  return list;\n}\n\nexport function createApp({ configFile, cwd = process.cwd(), notice = null, offline = false, env = process.env, fetch = globalThis.fetch, onChange = () => {} } = {}) {\n  const state = { rows: [], screen: \"list\", cursor: 0, field: 0, edit: null, confirm: null, status: notice ? { text: notice, kind: \"ok\" } : null, base: null, sort: \"priority\", order: [] };\n  const pending = new Set();\n\n  const flash = (text, kind = \"info\") => { state.status = { text, kind }; };\n  const track = (promise) => {\n    const tracked = promise.catch((error) => flash(error.message, \"error\")).finally(() => { pending.delete(tracked); onChange(); });\n    pending.add(tracked);\n    return tracked;\n  };\n  // Puts the rows in the chosen order and keeps the cursor on the same project, wherever it lands.\n  const resort = (id = state.rows[state.cursor]?.id) => {\n    state.rows = sortRows(state.rows, state.sort, state.order);\n    state.cursor = Math.max(state.rows.findIndex((row) => row.id === id), 0);\n  };\n  const replaceRow = (row) => {\n    const index = state.rows.findIndex((existing) => existing.id === row.id);\n    if (index < 0) return;\n    state.rows[index] = row;\n    resort();\n  };\n\n  // Grades in the background; the list shows \"grading\" until the report arrives.\n  const grade = (row) => track(gradeRow(row, { offline, fetch }).then((graded) => replaceRow({ ...graded, grading: false }), (error) => replaceRow({ ...row, grading: false, note: error.message })));\n\n  // Lists the projects at once, then opens and grades each in the background.\n  async function load() {\n    const { base, entries, ignored } = await loadEntries(configFile, env);\n    const selected = state.rows[state.cursor]?.id;\n    state.base = base;\n    state.order = entries.map((entry) => entry.id);\n    state.rows = entries.map((entry) => ({ id: entry.id, entry, gap: [], target: null, grading: false, note: \"loading\" }));\n    resort(selected);\n    const repeats = Object.entries(ignored);\n    if (repeats.length) flash(`Ignoring ${repeats.map(([id, kept]) => `${id} (same folder as ${kept})`).join(\", \")}`);\n    onChange();\n    for (const entry of entries) {\n      track((async () => {\n        const row = await openRow(entry, base, env);\n        replaceRow({ ...row, grading: Boolean(row.project) });\n        onChange();\n        if (row.project) await grade(row);\n      })());\n    }\n  }\n\n  // Picks up what an edit changed: the entry from the config, the project from disk, then the grade.\n  async function reload(row) {\n    const { base, entries } = await loadEntries(configFile, env);\n    const entry = entries.find((candidate) => candidate.id === row.id);\n    if (!entry) return;\n    const opened = await openRow(entry, base, env);\n    replaceRow({ ...opened, grading: Boolean(opened.project) });\n    if (opened.project) grade(opened);\n  }\n\n  const current = () => state.rows[state.cursor];\n\n  async function commit(item, value) {\n    const row = current();\n    const write = item.source === \"portfolio\" ? () => setPortfolioField(configFile, row.id, item.field, value) : () => setManifestField(row.root, item.field, value);\n    const { errors } = await write();\n    if (errors.length) return errors[0];\n    await reload(row);\n    flash(`Saved ${item.field.key} to ${item.source === \"portfolio\" ? path.basename(configFile) : manifestName}`, \"ok\");\n    return null;\n  }\n\n  async function cycle(item, step) {\n    const choices = options(item.field);\n    const at = choices.indexOf(item.value);\n    const error = await commit(item, choices[(at + step + choices.length) % choices.length]);\n    if (error) flash(error, \"error\");\n  }\n\n  async function editKey(key) {\n    const edit = state.edit;\n    const { text, pos } = edit;\n    const set = (next, at) => { edit.text = next; edit.pos = at; edit.error = null; };\n    const wordStart = () => { let at = pos; while (at > 0 && text[at - 1] === \" \") at -= 1; while (at > 0 && text[at - 1] !== \" \") at -= 1; return at; };\n    switch (key.name) {\n      case \"char\": return set(text.slice(0, pos) + key.text + text.slice(pos), pos + key.text.length);\n      case \"backspace\": return pos > 0 && set(text.slice(0, pos - 1) + text.slice(pos), pos - 1);\n      case \"delete\": return set(text.slice(0, pos) + text.slice(pos + 1), pos);\n      case \"ctrl-w\": return set(text.slice(0, wordStart()) + text.slice(pos), wordStart());\n      case \"ctrl-u\": return set(text.slice(pos), 0);\n      case \"ctrl-k\": return set(text.slice(0, pos), pos);\n      case \"left\": edit.pos = Math.max(pos - 1, 0); return;\n      case \"right\": edit.pos = Math.min(pos + 1, text.length); return;\n      case \"home\": case \"ctrl-a\": edit.pos = 0; return;\n      case \"end\": case \"ctrl-e\": edit.pos = text.length; return;\n      case \"escape\": case \"ctrl-c\": state.edit = null; flash(\"Edit cancelled\"); return;\n      case \"enter\": {\n        const error = await edit.submit(text);\n        if (error) edit.error = error;\n        else state.edit = null;\n        return;\n      }\n      default:\n    }\n  }\n\n  async function detailKey(key) {\n    const list = items(current());\n    const item = list[state.field];\n    const move = (to) => { state.field = Math.min(Math.max(to, 0), list.length - 1); };\n    switch (key.name === \"char\" ? key.text : key.name) {\n      case \"up\": case \"k\": return move(state.field - 1);\n      case \"down\": case \"j\": return move(state.field + 1);\n      case \"home\": case \"g\": return move(0);\n      case \"end\": case \"G\": return move(list.length - 1);\n      case \"escape\": case \"q\": case \"backspace\": state.screen = \"list\"; state.status = null; return;\n      case \"left\": case \"h\": return options(item.field) ? cycle(item, -1) : undefined;\n      case \"right\": case \"l\": return options(item.field) ? cycle(item, 1) : undefined;\n      case \"enter\": case \" \":\n        if (options(item.field)) return cycle(item, 1);\n        {\n          const text = toText(item.field, item.value);\n          state.edit = { label: item.field.key, max: item.field.node.maxLength, help: item.field.node.description, text, pos: text.length, error: null, submit: (typed) => commit(item, fromText(item.field, typed)) };\n        }\n        return;\n      case \"n\": {\n        const row = current();\n        if (!row.root || row.project?.manifest) return flash(row.root ? `${manifestName} already exists` : \"This project has no local path\", \"error\");\n        const { file, errors } = await init(row.root);\n        await reload(row);\n        state.field = 0;\n        return flash(`Wrote ${home(file)}${errors.length ? `. Still to fill in: ${errors.join(\"; \")}` : \"\"}`, errors.length ? \"error\" : \"ok\");\n      }\n      case \"r\": await reload(current()); return flash(\"Refreshed\");\n      default:\n    }\n  }\n\n  async function addPrompt() {\n    state.edit = {\n      label: \"Add a project\", help: \"Path to the project folder. ~ and relative paths work.\", text: \"\", pos: 0, error: null,\n      submit: async (typed) => {\n        const { id, error } = await addProject(configFile, typed, { cwd, env });\n        if (error) return error;\n        await load();\n        state.cursor = Math.max(state.rows.findIndex((row) => row.id === id), 0);\n        flash(`Added ${id}`, \"ok\");\n        return null;\n      },\n    };\n  }\n\n  async function confirmKey(key) {\n    const { yes } = state.confirm;\n    state.confirm = null;\n    if (key.text === \"y\") await yes();\n    else flash(\"Okay, left as it was\");\n  }\n\n  // Asks to add the project the user started in, if it is not in the portfolio yet.\n  async function offerHere() {\n    const dir = await unregisteredHere(configFile, { cwd, env });\n    if (!dir) return;\n    state.confirm = {\n      question: `Add ${bold(path.basename(dir))} ${dim(`(${home(dir)})`)} to the portfolio?`,\n      yes: async () => {\n        const { id, error } = await addProject(configFile, dir, { cwd, env });\n        if (error) return flash(error, \"error\");\n        await load();\n        state.cursor = Math.max(state.rows.findIndex((row) => row.id === id), 0);\n        flash(`Added ${id}`, \"ok\");\n      },\n    };\n    onChange();\n  }\n\n  async function listKey(key) {\n    const move = (to) => { state.cursor = Math.max(Math.min(to, state.rows.length - 1), 0); };\n    switch (key.name === \"char\" ? key.text : key.name) {\n      case \"a\": return addPrompt();\n      case \"d\": case \"x\":\n        if (current()) {\n          const row = current();\n          state.confirm = {\n            question: `Remove ${bold(titleOf(row))} from the portfolio? ${dim(\"Its files stay as they are.\")}`,\n            yes: async () => {\n              await removeProject(configFile, row.id);\n              state.rows.splice(state.rows.findIndex((existing) => existing.id === row.id), 1);\n              state.cursor = Math.max(Math.min(state.cursor, state.rows.length - 1), 0);\n              flash(`Removed ${titleOf(row)} from the portfolio. Its files are untouched.`, \"ok\");\n            },\n          };\n        }\n        return;\n      case \"up\": case \"k\": return move(state.cursor - 1);\n      case \"down\": case \"j\": return move(state.cursor + 1);\n      case \"pageup\": return move(state.cursor - 10);\n      case \"pagedown\": return move(state.cursor + 10);\n      case \"home\": case \"g\": return move(0);\n      case \"end\": case \"G\": return move(state.rows.length - 1);\n      case \"enter\": case \"right\": case \"l\": case \" \":\n        if (!state.rows.length) return;\n        state.screen = \"detail\"; state.field = 0; state.status = null;\n        return;\n      case \"s\": {\n        const modes = Object.keys(sorts);\n        state.sort = modes[(modes.indexOf(state.sort) + 1) % modes.length];\n        resort();\n        return flash(`Sorted by ${sorts[state.sort]}`, \"ok\");\n      }\n      case \"J\": case \"K\": {\n        if (state.sort !== \"order\") return flash(\"Press s until it says portfolio.json order, then reorder\", \"error\");\n        const delta = key.text === \"J\" ? 1 : -1;\n        const row = current();\n        if (!(await moveProject(configFile, row.id, delta))) return;\n        state.rows.splice(state.cursor + delta, 0, state.rows.splice(state.cursor, 1)[0]);\n        state.order = state.rows.map((existing) => existing.id);\n        state.cursor += delta;\n        return flash(`Moved ${titleOf(row)} to position ${state.cursor + 1}`, \"ok\");\n      }\n      case \"r\": flash(\"Refreshing\"); track(load()); return;\n      case \"q\": state.quit = true; return;\n      default:\n    }\n  }\n\n  // Keys are handled one at a time, so a slow write never interleaves with the next key.\n  let queue = Promise.resolve();\n  function key(input) {\n    const keys = typeof input === \"string\" ? parseKeys(input) : [input];\n    queue = queue.then(async () => {\n      while (keys.length) {\n        const next = keys.shift();\n        // Outside the editor a run like \"jjj\" (key repeat) is several presses; a paste only matters while editing.\n        if (next.name === \"char\" && !state.edit && next.text.length > 1) {\n          const [first, ...rest] = [...next.text];\n          keys.unshift({ name: \"char\", text: first }, ...(rest.length ? [{ name: \"char\", text: rest.join(\"\") }] : []));\n          continue;\n        }\n        try {\n          if (!state.edit) state.status = null;\n          if (next.name === \"ctrl-c\" && !state.edit) state.quit = true;\n          else if (state.edit) await editKey(next);\n          else if (state.confirm) await confirmKey(next);\n          else if (state.screen === \"detail\") await detailKey(next);\n          else await listKey(next);\n        } catch (error) {\n          flash(error.message, \"error\");\n        }\n      }\n      onChange();\n    });\n    return queue;\n  }\n\n  const idle = async () => { await queue; while (pending.size) await Promise.all([...pending]); };\n\n  return { state, load, offerHere, key, idle, render: (size) => render(state, configFile, size) };\n}\n\n// The text box for the editor in state.edit: label with counter, wrapped text, error and help. cursor.row is relative to the first line returned.\nfunction editorLines(edit, width) {\n  const text = edit.text;\n  const ranges = wrap(text, Math.max(width - 4, 10));\n  const at = edit.pos;\n  const index = Math.max(ranges.findIndex(([start, end], i) => at >= start && (at < end || i === ranges.length - 1)), 0);\n  const lines = [` ${bold(edit.label)}${edit.max ? dim(`  ${text.length}/${edit.max}`) : \"\"}`, ...ranges.map(([start, end]) => `   ${text.slice(start, end)}`)];\n  if (edit.error) lines.push(` ${red(edit.error)}`);\n  if (edit.help) for (const [start, end] of wrap(edit.help, Math.max(width - 4, 10))) lines.push(muted(`   ${edit.help.slice(start, end)}`));\n  return { lines, cursor: { row: 1 + index, col: at - ranges[index][0] + 3 } };\n}\n\n// With no priority there is no goal, so show what the next tier needs, as check does.\nfunction nextNeeds(report) {\n  if (!report.next) return report.tier === \"S\" ? \"top tier\" : `at ceiling ${report.ceiling}`;\n  return `next ${report.next}: ${report.nextSteps.filter((step) => step.required).map((step) => step.id).join(\", \")}`;\n}\n\nfunction listScreen(state, configFile, width, height) {\n  const { rows } = state;\n  const widths = {\n    pri: Math.max(...rows.map((row) => (row.entry.priority ?? \"-\").length), 3),\n    stage: Math.max(...rows.map((row) => (stageOf(row) ?? \"-\").length), 5),\n    name: Math.min(Math.max(...rows.map((row) => titleOf(row).length), 7), 28),\n    kind: Math.max(...rows.map((row) => (row.report?.kind ?? \"\").length), 4),\n  };\n  const lines = [` ${bold(\"showcase\")}  ${dim(`${home(configFile)} · ${rows.length} project${rows.length === 1 ? \"\" : \"s\"} · by ${sorts[state.sort]}`)}`, \"\"];\n  lines.push(dim(` ${fit(\"TIER\", 5)}${fit(\"GOAL\", 5)}${fit(\"PRIO\", widths.pri + 2)}${fit(\"STAGE\", widths.stage + 2)}${fit(\"PROJECT\", widths.name + 2)}${fit(\"KIND\", widths.kind + 2)}GAP`));\n\n  const room = Math.max(height - lines.length - 3, 1);\n  const first = Math.min(Math.max(state.cursor - room + 1, 0), Math.max(rows.length - room, 0));\n  rows.slice(first, first + room).forEach((row, offset) => {\n    const selected = first + offset === state.cursor;\n    const { report } = row;\n    const tier = report ? tierLabel(report.tier) : dim(row.grading ? \"…\" : \"-\");\n    const gap = report ? (row.gap.length ? row.gap.join(\", \") : row.target ? \"on target\" : nextNeeds(report)) : (row.grading ? \"grading\" : row.note ?? \"\");\n    const plain = [\n      fit(report ? report.tier : row.grading ? \"…\" : \"-\", 5), fit(row.target ?? \"-\", 5), fit(row.entry.priority ?? \"-\", widths.pri + 2),\n      fit(stageOf(row) ?? \"-\", widths.stage + 2), fit(titleOf(row), widths.name + 2), fit(report?.kind ?? \"\", widths.kind + 2), gap,\n    ].join(\"\");\n    if (selected) return lines.push(inverse(fit(` ${plain}`, width)));\n    const gapStyle = report && !row.gap.length ? green : row.gap.length ? yellow : dim;\n    lines.push(` ${tier}${\" \".repeat(4)}${cell(row.target ?? \"-\", 5, row.target ? (value) => value : dim)}${cell(row.entry.priority ?? \"-\", widths.pri + 2, row.entry.priority ? (value) => value : dim)}${cell(stageOf(row) ?? \"-\", widths.stage + 2, stageOf(row) ? (value) => value : dim)}${cell(titleOf(row), widths.name + 2, bold)}${cell(report?.kind ?? \"\", widths.kind + 2, dim)}${gapStyle(gap)}`);\n  });\n  if (first + room < rows.length) lines.push(dim(` ↓ ${rows.length - first - room} more`));\n  return { lines, cursor: null, footer: \" ↑↓ select   enter open   s sort   a add   d remove   J/K reorder   r refresh   q quit\" };\n}\n\nfunction detailScreen(state, width, height) {\n  const row = state.rows[state.cursor];\n  const { report } = row;\n  const lines = [` ${bold(titleOf(row))}  ${report ? `${tierLabel(report.tier)}${row.target ? ` → ${tierLabel(row.target)}` : \"\"}  ${dim(report.kind)}` : dim(row.grading ? \"grading…\" : \"not graded\")}`];\n  lines.push(dim(` ${row.root ? home(row.root) : row.note ?? row.id}`));\n  if (report) lines.push(row.gap.length ? ` ${yellow(\"gap\")} ${row.gap.join(\", \")}` : row.target ? ` ${green(\"on target\")}` : dim(\" no priority set, so no target\"));\n  else if (row.note) lines.push(` ${yellow(row.note)}`);\n  const problems = row.project?.manifest?.errors ?? [];\n  for (const problem of problems.slice(0, 3)) lines.push(` ${red(\"✗\")} ${problem}`);\n  if (problems.length > 3) lines.push(dim(`   and ${problems.length - 3} more`));\n\n  const list = items(row);\n  const keyWidth = Math.max(...list.map((item) => item.field.key.length + 1), 6);\n  const section = (title) => lines.push(\"\", dim(` ${title}`));\n  const draw = (item, index) => {\n    const selected = index === state.field;\n    const choices = options(item.field);\n    const empty = item.value === undefined || item.value === null || (Array.isArray(item.value) && !item.value.length);\n    const shown = empty ? \"-\" : toText(item.field, item.value);\n    const label = fit(`${item.field.key}${item.field.required && item.source === \"manifest\" ? \"*\" : \"\"}`, keyWidth);\n    const body = selected && choices ? `◂ ${shown} ▸` : shown;\n    if (selected) return lines.push(inverse(fit(` ${label} ${body}`, width)));\n    lines.push(` ${dim(label)} ${empty ? dim(body) : fit(body, width - keyWidth - 3).trimEnd()}`);\n  };\n  section(\"portfolio.json\");\n  list.forEach((item, index) => { if (item.source === \"portfolio\") draw(item, index); });\n  if (row.project?.manifest && !row.project.manifest.data) {\n    section(manifestName);\n    lines.push(` ${red(\"invalid JSON\")}${dim(\", fix it by hand before editing here\")}`);\n  } else if (row.project?.manifest) {\n    section(manifestName);\n    list.forEach((item, index) => { if (item.source === \"manifest\") draw(item, index); });\n  } else if (row.root) {\n    section(manifestName);\n    lines.push(dim(\" none yet. Press n to draft one from the README and package.json.\"));\n  }\n\n  // Below the list: the selected field in full, or its editor.\n  const item = list[state.field];\n  lines.push(\"\", dim(` ${\"─\".repeat(Math.max(width - 2, 1))}`));\n  let cursor = null;\n  if (state.edit) {\n    const box = editorLines(state.edit, width);\n    cursor = { row: lines.length + box.cursor.row, col: box.cursor.col };\n    lines.push(...box.lines);\n  } else if (item) {\n    lines.push(` ${bold(item.field.key)}`);\n    for (const [start, end] of wrap(toText(item.field, item.value), Math.max(width - 4, 10))) lines.push(`   ${toText(item.field, item.value).slice(start, end)}`);\n    const help = item.field.node.description;\n    if (help) for (const [start, end] of wrap(help, Math.max(width - 4, 10))) lines.push(muted(`   ${help.slice(start, end)}`));\n  }\n  const footer = state.edit ? \" enter save   esc cancel   ctrl-u clear to start   ctrl-k clear to end\" : \" ↑↓ select   enter edit   ←→ change choice   n new showcase.json   esc back\";\n  return { lines, cursor, footer };\n}\n\n// Draws the whole screen as lines, plus where the text cursor goes while editing.\nexport function render(state, configFile, { width = 80, height = 24 } = {}) {\n  let screen;\n  if (state.rows.length === 0) {\n    screen = { lines: [` ${bold(\"showcase\")}  ${dim(home(configFile))}`, \"\", \" No projects in this portfolio yet.\", dim(\" Press a and type the path to a project folder.\")], cursor: null, footer: \" a add   q quit\" };\n  } else {\n    screen = state.screen === \"detail\" ? detailScreen(state, width, height) : listScreen(state, configFile, width, height);\n  }\n  if (state.screen === \"list\" || state.rows.length === 0) {\n    if (state.edit) {\n      const box = editorLines(state.edit, width);\n      screen.cursor = { row: screen.lines.length + 1 + box.cursor.row, col: box.cursor.col };\n      screen.lines.push(\"\", ...box.lines);\n      screen.footer = \" enter add   esc cancel\";\n    } else if (state.confirm) {\n      screen.lines.push(\"\", ` ${state.confirm.question} ${bold(\"y\")}/n`);\n      screen.footer = \" y yes   any other key no\";\n    }\n  }\n  const body = screen.lines.slice(0, height - 2);\n  while (body.length < height - 2) body.push(\"\");\n  const status = state.status ? ` ${state.status.kind === \"error\" ? red(state.status.text) : state.status.kind === \"ok\" ? green(state.status.text) : state.status.text}` : \"\";\n  const lines = [...body, status, dim(screen.footer)];\n  return { lines, cursor: screen.cursor && screen.cursor.row < height - 2 ? screen.cursor : null };\n}\n\n// Runs the interface on the real terminal until the user quits.\nexport async function run(options) {\n  const { stdin, stdout } = process;\n  if (!stdin.isTTY || !stdout.isTTY) throw new Error(\"showcase manage needs an interactive terminal.\");\n  const draw = () => {\n    const { lines, cursor } = app.render({ width: stdout.columns, height: stdout.rows });\n    stdout.write(`\\x1b[?25l\\x1b[H${lines.map((line) => `${line}\\x1b[K`).join(\"\\r\\n\")}\\x1b[J`);\n    if (cursor) stdout.write(`\\x1b[${cursor.row + 1};${cursor.col + 1}H\\x1b[?25h`);\n  };\n  const cwd = process.cwd();\n  let { configFile, notice } = options;\n  if (configFile) {\n    configFile = path.resolve(cwd, configFile);\n    await loadEntries(configFile);\n    notice = `Using ${home(configFile)}.`;\n  } else {\n    const found = await findConfig({ cwd, env: process.env });\n    configFile = found.file;\n    if (!found.exists) {\n      const count = await createConfig(configFile, cwd);\n      notice = `Started a portfolio at ${home(configFile)} with ${count} project${count === 1 ? \"\" : \"s\"} from here. Have one already? Run showcase manage <path> once.`;\n    }\n  }\n  await loadEntries(configFile);\n  const app = createApp({ ...options, configFile, cwd, notice, onChange: draw });\n\n  const restore = () => stdout.write(\"\\x1b[?25h\\x1b[?1049l\");\n  stdout.write(\"\\x1b[?1049h\");\n  process.on(\"exit\", restore);\n  stdin.setRawMode(true);\n  stdin.setEncoding(\"utf8\");\n  stdin.resume();\n  stdout.on(\"resize\", draw);\n\n  try {\n    await app.load();\n    await app.offerHere();\n  } catch (error) {\n    stdin.setRawMode(false);\n    stdin.pause();\n    throw error;\n  }\n  await new Promise((resolve) => {\n    stdin.on(\"data\", async (chunk) => {\n      await app.key(chunk);\n      if (app.state.quit) resolve();\n    });\n  });\n  stdin.setRawMode(false);\n  stdin.pause();\n  restore();\n}\n"}},"webp.js":{"file":{"contents":"import { readFile } from \"node:fs/promises\";\n\n// Reads an animated WebP's container without decoding it: canvas size, loop count and each frame's duration.\n// Returns null for anything that is not an animated WebP.\nexport function parseAnimatedWebp(buffer) {\n  if (buffer.length < 30 || buffer.toString(\"ascii\", 0, 4) !== \"RIFF\" || buffer.toString(\"ascii\", 8, 12) !== \"WEBP\") return null;\n  let width, height, animated = false, loops = null;\n  const durations = [];\n  for (let offset = 12; offset + 8 <= buffer.length;) {\n    const type = buffer.toString(\"ascii\", offset, offset + 4);\n    const size = buffer.readUInt32LE(offset + 4);\n    const data = offset + 8;\n    if (type === \"VP8X\") {\n      animated = (buffer[data] & 0x02) !== 0;\n      width = buffer.readUIntLE(data + 4, 3) + 1;\n      height = buffer.readUIntLE(data + 7, 3) + 1;\n    } else if (type === \"ANIM\") {\n      loops = buffer.readUInt16LE(data + 4);\n    } else if (type === \"ANMF\") {\n      durations.push(buffer.readUIntLE(data + 12, 3));\n    }\n    offset = data + size + (size % 2);\n  }\n  if (!animated || !durations.length) return null;\n  return { width, height, loops, frames: durations.length, durationMs: durations.reduce((sum, ms) => sum + ms, 0) };\n}\n\nexport async function readAnimatedWebp(file) {\n  return parseAnimatedWebp(await readFile(file));\n}\n"}}}}}}}}}},"projects":{"directory":{"beatbox":{"directory":{"README.md":{"file":{"contents":"# beatbox\n\nTap out a rhythm on your keyboard and beatbox turns it into a loop you can layer, tweak and share.\n\n![Layering three loops in beatbox](docs/loop.webp)\n\n## Quick start\n\n```sh\nnpm install\nnpm run dev\n```\n\nOpen http://localhost:5173, press a key to start recording, and press it again to close the loop. Each loop gets its own track, so you can mute, nudge or delete it without touching the rest.\n\n## How it works\n\nEvery key you press is quantised to the nearest sixteenth note, so a loop stays in time even when your tapping does not. Sounds come from a small built-in kit, and you can drop in your own samples as WAV files. Sharing a loop gives you a link that holds the whole pattern in the URL, so nothing is stored on a server.\n"}},"docs":{"directory":{"loop.webp":{"file":{"base64":"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"}}}},"package.json":{"file":{"contents":"{\n  \"name\": \"beatbox\",\n  \"private\": true,\n  \"scripts\": { \"dev\": \"vite\" },\n  \"devDependencies\": { \"vite\": \"^7.0.0\" }\n}\n"}},"showcase.json":{"file":{"contents":"{\n  \"title\": \"beatbox\",\n  \"pitch\": \"Tap out a rhythm and turn it into a loop you can layer and share.\",\n  \"description\": \"Pick a drum or synth for each pad, record taps against a metronome and stack up to eight loops. Share a link that replays your track.\",\n  \"kind\": \"web-app\",\n  \"year\": 2026,\n  \"tech\": [\"TypeScript\", \"Web Audio\"],\n  \"media\": { \"motion\": \"docs/loop.webp\" },\n  \"demo\": { \"local\": \"npm run dev\", \"externals\": [] }\n}\n"}}}},"notecli":{"directory":{"README.md":{"file":{"contents":"# notecli\n\nTurns a folder of session logs into a daily journal you can actually read.\n\n![A day of notes in the terminal](docs/hero.png)\n\n## Install\n\n```sh\nnpm install -g notecli\nnotecli ~/logs --since yesterday\n```\n\nnotecli reads every log in the folder, groups entries by project and writes one Markdown note per day. Run it again and it only adds what is new, so you can put it in a cron job and forget about it. Notes you edit by hand are left alone.\n\n## Options\n\n`--since` takes a date or a word like `yesterday`. `--out` picks where the notes go, and `--dry-run` prints them instead of writing anything.\n"}},"docs":{"directory":{"hero.png":{"file":{"base64":"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"}}}},"package.json":{"file":{"contents":"{\n  \"name\": \"notecli\",\n  \"version\": \"1.2.0\",\n  \"bin\": { \"notecli\": \"./bin/notecli.js\" }\n}\n"}}}},"old-library":{"directory":{"README.md":{"file":{"contents":"# old-library\n\nUtilities. See the source.\n"}},"package.json":{"file":{"contents":"{\n  \"name\": \"old-library\",\n  \"version\": \"0.3.1\",\n  \"main\": \"src/index.js\"\n}\n"}},"src":{"directory":{"index.js":{"file":{"contents":"export const clamp = (value, min, max) => Math.min(max, Math.max(min, value));\n"}}}}}},"voice-notes":{"directory":{"README.md":{"file":{"contents":"# voice-notes\n\n## Setup\n\nYou need Python 3.11 and a microphone. Clone the repo, then install the requirements and download the speech model the first time you run it, which takes a few minutes on a slow connection.\n\n```sh\npip install -r requirements.txt\npython app.py\n```\n\nOpen the page it prints and hold the space bar to talk. Everything runs on your own machine: the audio never leaves it, and the transcripts are saved as text files in a folder next to the app. You can search them from the page, or open the folder in any editor you like.\n"}},"package.json":{"file":{"contents":"{\n  \"name\": \"voice-notes-ui\",\n  \"private\": true,\n  \"devDependencies\": { \"vite\": \"^7.0.0\" }\n}\n"}}}}}},"bin":{"directory":{"showcase":{"file":{"contents":"#!/usr/bin/env node\n// CommonJS on purpose: extensionless ES modules do not run in the sandbox.\nimport(\"../node_modules/@soleone/showcase/bin/showcase.js\");\n"}}}},"portfolio.json":{"file":{"contents":"{\n  \"projects\": [\n    { \"id\": \"local:beatbox\", \"priority\": \"high\", \"path\": \"projects/beatbox\" },\n    { \"id\": \"local:notecli\", \"priority\": \"mid\", \"path\": \"projects/notecli\" },\n    { \"id\": \"local:voice-notes\", \"priority\": \"low\", \"path\": \"projects/voice-notes\" },\n    { \"id\": \"local:old-library\", \"priority\": null, \"path\": \"projects/old-library\" }\n  ]\n}\n"}}}