From e9c06e6dfc93d290025275be9424f7af79311944 Mon Sep 17 00:00:00 2001 From: zpyoung Date: Tue, 13 Oct 2026 14:11:11 -0500 Subject: [PATCH 001/164] docs: add logic spec for writing-html-artifacts --- .../logic.md | 534 ++++++++++++++++++ 1 file changed, 534 insertions(+) create mode 100644 docs/quirk/specs/2026-10-13-writing-html-artifacts/logic.md diff --git a/docs/quirk/specs/2026-10-13-writing-html-artifacts/logic.md b/docs/quirk/specs/2026-10-13-writing-html-artifacts/logic.md new file mode 100644 index 00000000..f4dc5918 --- /dev/null +++ b/docs/quirk/specs/2026-10-13-writing-html-artifacts/logic.md @@ -0,0 +1,534 @@ +# Logic spec: `writing-html-artifacts` + +**Status**: Draft, awaiting user review + +A quirk skill that has agents in Claude Code, pi and Oh My Pi build HTML files that open anywhere, +look deliberate, and come with proof that they work. The same machinery gives brainstorming and +exploring-ideas a live visual companion, which replaces quirk's Agent Isles integration. Users and +projects can add their own templates. + +Derived from two research documents: *Cross-Agent Skills for Interactive Single-File HTML +Artifacts* (dual-path builder, bundle and QA contracts, host-neutral delivery) and *Deep research on +libraries like Astryx, TanStack* (library landscape, routing policy, two-tier architecture). + +This spec covers three pieces of work that the user chose to keep in one spec: the single-file +builder (tier 1), the React builder (tier 2), and the companion. + +## Conceptual model + +Everything the skill produces comes from a **template**. Built-in pages, a project's branded +reports, the companion's screens and the React scaffold all go through the same pipeline, so a +custom template gets every guarantee a built-in one has. + +The units: + +- **Template.** A manifest plus fixed chrome plus one to three freeform slots. The manifest holds: + - a name and a "use when" description the agent matches requests against + - the tier (1 or 2) + - the libraries it needs + - token overrides for the house theme + - a rules addendum layered on the design reference + - a filled-in example, which is also its verify fixture + + Templates are built-in, user-level or project-level. +- **Library.** A vendored file set, pinned to a version, checksummed, and carrying its license. + v1 ships Simple.css, Alpine.js, Web Awesome, one chart library and Mermaid. A template can bring + more. +- **House theme.** One token set with light and dark pairs, plus the design reference that says how + to use it. Project templates override its tokens. +- **Artifact.** A built `.html` file. It may have a sidecar data file and, for tier 2, a source + folder. A header inside the file records the template, tier, mode and library versions. +- **Companion session.** A local server plus a directory of screens. Each screen is an artifact + built from the `companion` template. +- **Verify report.** The result of running the checks on the final file, with screenshot paths. + +Slots are deliberately coarse. The chrome is fixed (theme, toggle, license notices, header) and the +agent writes freely inside the slots. Consistency comes from the shared chrome and tokens, and +quality comes from verifying the output rather than from a rigid slot grammar. + +## Data flow + +A request comes in and the agent matches it against template descriptions, looking in the project +first, then the user's templates, then the built-ins. It states which template it picked and which +tier, and why. + +The agent fills the slots by following the design reference and the template's rules addendum. It +also declares the artifact's interactions and its mode, so the later checks know what to expect. + +The build turns template plus slot content into the deliverable. Tier 1 assembles chrome, slots +and tokens, inlines the libraries, and adds license notices and the header. Tier 2 copies the +React scaffold into a source folder, installs from the template's frozen lockfile, and bundles to +one file. Either way the output lands in `.quirk/html/`. + +Static checks run on the built file, then browser verify runs on that same file if a browser is +reachable. Because the report must come from the final file, any edit after verify means verify +runs again. + +The agent then delivers a note with the path, the facts about the build and the verify result. It +offers to open or publish the file and does neither on its own. + +For the companion, brainstorming starts the local server, writes one screen per question into the +screen directory, and waits. The user's clicks travel back as an event stream. Proceed on the +current screen ends the wait and hands the agent the selection. + +## Key decisions and rationale + +- **One template engine for everything.** The user asked for custom templates that can be either + tier and can add libraries. A token-overlay design can't express those, and research showed + overlays break silently on upgrades: Shoelace's move to Web Awesome renamed every `--sl-*` token + to `--wa-*` and inverted the color scale. Making the companion and the React scaffold templates + too means project branding reaches them for free. +- **Coarse slots with freeform content.** A practitioner who shipped 21 rigid slot templates called + the slot grammar "the bottleneck" and moved to one freeform body inside shared chrome. +- **Verification over instruction.** Of 101 "tests pass" claims one developer audited, 35 were + false, and 34 of those were stale: the check ran before the last edit. Verify therefore runs on + the final file and the note points at its output. +- **Pinned, vendored, checksummed libraries.** An unpinned dependency in Anthropic's own artifact + builder resolved to a new major version. The build exited 0 and the page threw in the browser. + Vendoring also lets tier 1 build with no network. +- **Simple.css instead of Pico CSS.** Pico is archived; its last release was v2.1.1 in March 2025. + Simple.css is classless, MIT-licensed, maintained (last pushed July 2026), built on CSS variables + and has automatic dark mode, so house tokens can override it. Open Props was rejected because it + is a token library, not a classless stylesheet. +- **A stdlib Python server for the companion.** A page opened from `file://` can't send anything + back, so the companion needs a process that outlives the agent's tool call. Python stdlib keeps + Node out of the brainstorming path and matches quirk's existing `bin/` tooling. +- **The spec is the record.** Companion screens and events are transient. Decisions live in the + spec that brainstorming writes, as they did with Isles. +- **Quirk builds; publishing is separate.** The skill writes a local file. Claude's own artifact + skills and the Artifact tool apply only if the user later chooses to publish it. + +## Behavior + +### Triggering + +- The skill runs when the user asks for HTML, a page, a report as a page, or an interactive tool. +- When an answer "has shape" (a comparison, a dashboard, a flow, a table someone will explore), the + agent offers a page in one line and builds it only on a yes. + +### Picking a template and a tier + +1. The agent matches the request against template descriptions in lookup order: project + `.quirk/html-templates/`, then user `~/.quirk/html-templates/`, then built-in. A name that + exists at two levels resolves to the higher one. The user can name a template directly. +2. Tier 2 is chosen when any of these signals holds: + - the artifact has multiple views or routes + - it has an editable data grid + - state is shared across views + - the user asks for an app or a project + + The agent may also escalate on its own judgment, and must then say why. Otherwise the artifact + is tier 1. +3. The agent states the template, its source level and the tier before building. + +### Building + +- **Tier 1** writes `.quirk/html/.html`. The file contains the chrome, slot content, tokens, + inlined libraries, one license notice per library, and the header. +- **Tier 2** writes the source project to `.quirk/html//` and the bundle to + `.quirk/html/.html`. It installs from the template's lockfile with a frozen install. The + first build needs network for that install; the output never does. +- **Tier 2 failure.** If Node is missing or the install or build fails, the agent builds a tier-1 + version instead. The delivery note lists every feature that was cut. +- **Editing an existing artifact** rebuilds it with the library versions recorded in its header, + unless the user asks to upgrade. +- `.quirk/html/` is gitignored. + +### Modes + +- **Offline** (default): the file makes zero network requests. Fonts come from the system stack. +- **Connected**: chosen only when the artifact must call a real API, and labelled as connected in + the delivery note. Libraries are still inlined; only the page's own data calls go to the network. +- There is no CDN mode. + +### Static checks + +These run on every build, companion screens included: + +- in offline mode, no external references (scripts, stylesheets, fonts, images, fetches) +- size: a warning above 2 MB, a failure above 10 MB +- every inlined library matches its checksum, and its license notice is present +- the header is present and complete + +When embedded data would push the file over budget, the data moves to a sidecar `.json` or `.csv` +next to the page. The page loads it through a file picker and says so on screen. + +### Browser verify + +When a browser is reachable, one quirk verify script runs these checks on the final file: + +- no console errors +- screenshots at 375px wide and at desktop width +- light and dark themes both legible +- zero network requests (offline mode only; connected mode reports the requests it saw) + +When no browser is reachable, the delivery note says "not browser-verified" and lists the static +checks that did run. Verify never installs a browser without asking. + +### Delivery + +The agent's note lists: + +- the path and size, plus the sidecar and source folder if any +- the template and where it came from +- the tier and the signal (or judgment) that chose it +- the mode +- the libraries and their versions +- the verify result and screenshot paths + +It ends with a one-line offer to open or publish the file. Neither happens automatically. + +### Templates + +- **Built-ins:** + - `report`: Simple.css + Alpine, for reports, explainers, forms and small tools + - `components`: Web Awesome + Alpine, for component-heavy pages (dialogs, tabs, comboboxes) + - `companion`: the brainstorming screen shell + - `app`: tier 2, React with Vite single-file bundling, Astryx and TanStack, with its own lockfile + + Any template can request the chart library or Mermaid. +- **Custom templates** can do everything a built-in does: either tier, token overrides, a rules + addendum, extra libraries, and an example. A tier-2 custom template ships its own lockfile. +- **Project tokens apply to the companion.** A project template's token overrides reach the + companion shell too, so brainstorming screens look like the project. +- **Creating a template** is a guided flow. The user starts from scratch or from an existing + artifact. The flow writes the manifest, skeleton and example, then runs verify on the example. + The template is usable only once its example passes. +- **An invalid template** (bad manifest, failed checksum, missing example) is skipped with a + visible warning, and the next match in lookup order is used. + +### Companion + +- Brainstorming and exploring-ideas start the server. It prints its URL and directories as JSON, + runs in the background, and exits after an idle timeout. +- For each question, the agent writes the question text, the options, and any mockup or diagram + into the `companion` template. The server always serves the newest screen. Screens have 2 to 4 + options and never reuse a filename. +- The shell provides single- and multi-select choice sets, a Proceed button, the theme toggle, the + project's tokens, and a "superseded" banner on stale tabs. +- Every click is recorded in a full event stream, tagged with the nonce of the screen it came from. + Clicks from a screen that is no longer current are rejected. +- `wait` blocks until Proceed is clicked on the current screen, then returns the selection. If the + host kills the wait, the agent re-runs it and no click is lost. With no server running, `wait` + fails with a distinct exit code. +- The agent reads the stream and the Proceed payload. Anything the user types in the terminal takes + priority over clicks. +- Screens and events live under `.quirk/brainstorm/`, which is gitignored. + +### Isles removal + +- Removed from quirk: `bin/agent_isles.py`, `packs/quirk/`, `examples/agent-isles/`, + `docs/agent-isles-artifacts.md`, and the three Isles test files. +- Rewritten: both `visual-companion.md` guides and the companion offers in the brainstorming and + exploring-ideas skills, which currently name Agent Isles and Node. +- The separately installed agent-isles plugin is left alone. Quirk just stops referencing it. +- The README skill count goes from 25 to 26. + +### Hosts + +- v1 supports Claude Code, pi and Oh My Pi. +- Scripts are found relative to the skill's own directory, so the skill works wherever it is + installed or symlinked. +- Build, the server and static checks need only Python stdlib. Browser verify and tier 2 need Node. + +### Scenarios + +1. **A report from data.** The user asks for a page summarizing test-run timings. The agent picks + `report` (built-in), tier 1, offline, and adds the chart library. It builds + `.quirk/html/test-timings.html` at 410 KB, verify passes at both widths and both themes, and the + note offers to open it. +2. **A branded project report.** The project has `.quirk/html-templates/report/`. The same request + resolves to the project's `report`, the note says "project template", and the page uses the + project's tokens and rules addendum. +3. **An app.** The user asks for a tool to edit a list of feature flags and preview them in two + views. Two signals fire (editable grid, multiple views), so the agent picks `app`, tier 2. The + source lands in `.quirk/html/flag-editor/` and the bundle in `.quirk/html/flag-editor.html`. +4. **Tier 2 without Node.** Same request on a machine without Node. The agent builds a tier-1 + version with one view and a read-only table, and the note lists both cuts. +5. **Too much data.** The data would make a 14 MB file. It moves to `flag-data.json` next to the + page, the page shows a file picker, and the note names both files. +6. **No browser.** Verify can't find a browser. The note says "not browser-verified" and lists the + static checks that passed. +7. **A broken custom template.** A user template's library checksum doesn't match. The agent warns, + skips it, and uses the built-in of the same name, and the note says so. +8. **A companion session.** Brainstorming starts the server and shows a screen with three layout + options in the project's colors. The user clicks two, changes their mind, clicks Proceed. The + agent gets the full click stream and the final selection. A click on an older tab is rejected + and that tab shows the "superseded" banner. +9. **A killed wait.** The host times out the agent's `wait` mid-question. The agent re-runs it, the + user's earlier clicks are still in the stream, and Proceed returns normally. +10. **Editing an old artifact.** A file built against an older Web Awesome is edited. It rebuilds + with the versions in its header, not the current pins. + +## Scope and non-goals + +**Success criteria:** + +- every built-in template has a worked example that passes verify, and there is a tier-2 example +- trigger evals: about 10 prompts that should fire the skill and about 10 near-misses that should + not +- a brainstorming session runs end to end on the new companion in Claude Code and in pi or OMP +- the server, builder and static checks are covered by pytest, which runs in CI + +**Non-goals for v1:** + +- Codex, Gemini CLI and Copilot CLI +- handoffs into other skills (code review, finishing a branch, the pm dashboard) +- changes to the writing-specs interactive app +- automatic opening or publishing +- a CDN mode +- webfonts +- browser verify in CI +- automated pin-bump PRs +- keyboard, axe and per-interaction smoke checks in verify +- archiving companion screens as an audit trail + +## Decisions Locked + +**Scope and shape** + +- Two tiers: inlined single-file HTML, and a React project bundled to one file. +- Curated, inlined library stack rather than per-artifact choices. +- Replace Agent Isles as quirk's companion. +- Skill name `writing-html-artifacts`. +- All three pieces (tier 1, tier 2, companion) in one spec. + +**Tier escalation** + +- Named signal list plus agent judgment; the agent states the signal or the reason. +- Tier 2 leaves the source project and the bundle. +- Tier 2 installs from a shipped lockfile with a frozen install. +- Tier 2 failure falls back to tier 1 and lists what was cut. + +**Offline semantics** + +- Offline by default, opt-in connected mode, no CDN mode. +- System font stack only. +- Warn above 2 MB, fail above 10 MB. +- Over-budget data goes to a sidecar loaded through a file picker. + +**Companion protocol** + +- Quirk's own stdlib Python server. +- Content goes into a fixed shell with freeform regions. +- The agent reads the full click stream plus the Proceed payload. +- A killed wait is re-entered and loses no clicks. +- Stale clicks are rejected and old tabs show a "superseded" banner. +- Isles code is removed in the same change. +- The spec is the record; screens and events are transient. +- The external agent-isles plugin is left alone. + +**Verification** + +- Browser QA is required when a browser is reachable; otherwise the note says "not + browser-verified". +- One quirk verify script for every host. +- Checks: console, 375px and desktop viewports, light and dark, network. +- Done means verify ran on the final file, with report and screenshot paths in the note. +- The companion shell is tested in pytest; screens get static checks only. + +**Design quality** + +- Quirk ships its own full design reference, informed by web and Reddit research. +- One house theme shared by artifacts and the companion. +- Light and dark both required, following the OS, with a toggle. + +**Libraries and pins** + +- v1 libraries: a classless stylesheet with Alpine, Web Awesome with Alpine, a chart library, + Mermaid. +- Pico CSS is replaced by a maintained classless library; the design names Simple.css. +- Libraries are vendored with checksums. +- Pins are bumped by hand, and a check flags outdated pins or pins with advisories. +- Old artifacts keep their recorded versions unless the user asks to upgrade. +- Each inlined library carries a license notice. +- Python stdlib for build, server and static checks; Node for verify and tier 2. + +**Hosts and delivery** + +- Hosts: Claude Code, pi, Oh My Pi. +- Scripts resolve relative to the skill's directory. +- Output goes to `.quirk/html/.html`, gitignored. +- Report the path and the delivery note, then offer to open or publish; never automatic. +- Trigger when HTML, a page or a tool is asked for; offer in one line when an answer has shape. +- No handoffs beyond the companion in v1. +- The writing-specs app is left as is. +- Quirk builds the file; Claude's artifact skills apply only if the user publishes. + +**Templates** + +- Contents: skeleton with slots, token overrides, rules addendum, extra libraries, example. +- Lookup: project `.quirk/html-templates/` (committed), then user `~/.quirk/html-templates/`, then + built-in. +- Custom templates can be either tier. +- Project tokens apply to the companion. +- The agent matches the request to descriptions and states its pick; the user can name one. +- Templates are created through a guided flow, from scratch or from an existing artifact. +- An invalid template is skipped with a warning and the next match is used. + +**Approach** + +- Approach A: one template engine with coarse slots, borrowing freeform regions and + verify-over-rules from the agent-assembled approach. + +## Industry Insights + +Research ran in four phases: context research, adhd gray-area discovery, a deep dive on the design +reference, and validation of the three approaches. The Brave and reddit-research skills were +unavailable, so agents used WebSearch and WebFetch, and Reddit came from Arctic Shift. + +**Templates and slots** + +- Rigid slot grammars became "the bottleneck" for a 21-template HTML skill; one freeform body over + shared chrome kept pages consistent. + [Muninn](https://muninn.austegard.com/blog/the-unreasonable-effectiveness-of-html-and-the-skill-that-tries-not-to-ruin-it.html) +- dogum/html-artifacts v2.0.0 dropped HTML teaching in favor of judgment, headless-render checks + and evals against a no-skill baseline. [dogum/html-artifacts](https://github.com/dogum/html-artifacts) +- Template precedence needs one explicit rule, and collisions need qualified names. + [Hugo lookup order](https://gohugo.io/templates/lookup-order/), + [shadcn namespaces](https://ui.shadcn.com/docs/registry/namespace) +- Templates sprawl and rot unless versioned and tested; Backstage's official templates are + unversioned and Copier updates pass drift through unmarked. + [backstage/software-templates](https://github.com/backstage/software-templates), + [Red Hat](https://developers.redhat.com/articles/2025/03/17/10-tips-better-backstage-software-templates), + [fastmcp-server-template #653](https://github.com/pvliesdonk/fastmcp-server-template/issues/653) +- Manifests with descriptions and typed kinds keep growing template sets findable. + [shadcn registry-item.json](https://ui.shadcn.com/docs/registry/registry-item-json) + +**Theming** + +- Token overrides stop at component internals; Web Awesome needs component properties or + `::part()` for shadow DOM. [Web Awesome theming](https://webawesome.com/docs/theming-overview/) +- Upgrades break silently through renames: Shoelace to Web Awesome renamed `--sl-*` to `--wa-*` and + inverted the color scale. + [migration guide](https://webawesome.com/docs/resources/migrating-from-shoelace/) +- Light and dark pairs are usually hand-duplicated; Pico requires dark variables twice. The DTCG + 2025.10 resolver models themes as contexts. + [Pico CSS variables](https://picocss.com/docs/css-variables), + [DTCG resolver](https://www.designtokens.org/tr/2025.10/resolver/) +- Dark themes fail contrast near the threshold (4.43:1 against 4.5:1), so each theme is checked + separately. [gh-aw-cao #9249](https://github.com/githubnext/gh-aw-cao/issues/9249) + +**Building and dependencies** + +- An unpinned `react-resizable-panels` resolved to v4 in Anthropic's artifact builder; the build + exited 0 and the browser threw React #130. + [anthropics/skills #1893](https://github.com/anthropics/skills/issues/1893) +- vite-plugin-singlefile is the simplest single-file route; it doesn't inline `public/` files or + SVG. [vite-plugin-singlefile](https://github.com/richardtallent/vite-plugin-singlefile) +- Hidden CDN and font dependencies break offline pages. + [livepage #161](https://github.com/glennfaison/livepage/issues/161) +- Inlined widgets add up fast: ten self-contained widgets totaled 4.1 MB. + [bundle plan](https://glama.ai/mcp/servers/@mikechao/brave-search-mcp/blob/9db552a665dc2c1e7b81f16dd87301daddd1c88f/docs/reduce-bundle-plan.md) +- Anthropic's web-artifacts-builder is a heavy React/shadcn stack "not for simple single-file + artifacts", and defers testing. + [web-artifacts-builder](https://github.com/anthropics/skills/blob/main/skills/web-artifacts-builder/SKILL.md) + +**Verification** + +- In an audit of 101 "tests pass" claims, 35 were false and 34 of those were stale. + [dev.to audit](https://dev.to/vinzenz_eiberger/i-checked-101-tests-pass-claims-from-my-ai-coding-agents-35-werent-true-h6n) +- dogum verifies at 1280x800 in light and dark and at 400x800, checks the console, and doesn't + install a browser without asking. + [verify-before-delivering.md](https://raw.githubusercontent.com/dogum/html-artifacts/main/skills/html-artifacts/references/verify-before-delivering.md) +- Agent-written browser tests can pass by accident when they only confirm page load. + [Currents](https://currents.dev/posts/state-of-playwright-ai-ecosystem-in-2026) + +**Design reference** + +- Models converge on a default look ("distributional convergence": Inter, purple gradients); + Anthropic names Space Grotesk as the next default to avoid. + [Anthropic cookbook](https://platform.claude.com/cookbook/coding-prompting-for-frontend-aesthetics), + [Anthropic blog](https://claude.com/blog/improving-frontend-design-through-skills) +- dogum's banned list (gray card grids, gradient heroes, emoji headers, glassmorphism, centered + everything) with "rebuild at three or more" is the most usable artifact-specific list. + [matching-your-style.md](https://raw.githubusercontent.com/dogum/html-artifacts/main/skills/html-artifacts/references/matching-your-style.md) +- frontend-design gives the most concrete rules: a 4 to 6 color plan, one or two families, measure + under 80 characters, named device bans. + [frontend-design](https://raw.githubusercontent.com/anthropics/skills/main/skills/frontend-design/SKILL.md) +- Recurring defaults: body 16 to 18px, line-height 1.5 to 1.6, measure 60 to 75ch, one type ratio + (1.25 or 1.333), 4px spacing base, 4.5:1 text and 3:1 UI contrast, 2px focus outline with 2px + offset, a named radius scale, 5 to 8 categorical chart series (Okabe-Ito). + [accessibility-and-print.md](https://raw.githubusercontent.com/dogum/html-artifacts/main/skills/html-artifacts/references/accessibility-and-print.md), + [noqta](https://noqta.tn/en/blog/ai-design-slop-overused-ui-patterns-fix-2026) +- DESIGN.md (Google Labs, alpha) pairs YAML tokens with prose and lints contrast, but doesn't model + light and dark. [design.md](https://github.com/google-labs-code/design.md) +- The superpowers brainstorming companion has a ready vocabulary (options, cards, mockup, split, + pros-cons) and the rule of 2 to 4 options per screen. + [visual-companion.md](https://raw.githubusercontent.com/obra/superpowers/main/skills/brainstorming/visual-companion.md) +- Reddit: people spot "AI purple" and "Claude sad beige", but clarity and usability matter more + than avoiding the look. [r/webdev](https://www.reddit.com/r/webdev/comments/1trfxp1/), + [r/ClaudeAI](https://www.reddit.com/r/ClaudeAI/comments/1x22sap/), + [r/UI_Design](https://www.reddit.com/r/UI_Design/comments/1wnh9z5/) +- No source measures concrete rules against vague taste guidance; the reference ships numbers and + checkable rules, and evals test them. + [arXiv 2505.17037](https://arxiv.org/html/2505.17037v1) + +**Skill packaging** + +- Only `name` and `description` frontmatter is portable across hosts. + [Agent Skills spec](https://agentskills.io/specification) +- Descriptions are the only trigger signal; test about 20 queries, half should-fire and half + near-miss. [agentskills.io](https://agentskills.io/skill-creation/optimizing-descriptions) + +## Deferred Ideas + +Out of scope for v1: + +- handoffs from code review, finishing a branch and the pm dashboard into a page +- browser verify in CI +- Codex, Gemini CLI and Copilot CLI support +- automated pin-bump PRs +- keyboard, axe and per-interaction smoke checks in verify +- a pinned-CDN mode and inlined webfonts +- Preact + HTM and Bulma as libraries +- archiving companion screens alongside the spec + +Open for the tech spec: + +- `[tech-spec]` Which chart library ships. +- `[tech-spec]` How the companion keeps Mermaid (over 1 MB inlined) from bloating every screen. +- `[tech-spec]` The manifest schema, its version field, and how slots are marked in a skeleton. +- `[tech-spec]` The header's format and how an edit reads versions back from it. +- `[tech-spec]` How verify finds a browser, how it runs Playwright, and where screenshots go. +- `[tech-spec]` How the pin check finds outdated pins and advisories. +- `[tech-spec]` Companion details carried from Isles: idle timeout, `wait` chunk length, the + no-server exit code, and the event record shape. +- `[tech-spec]` How "create from an existing artifact" extracts a skeleton and tokens. +- `[tech-spec]` How trigger evals are run and scored. +- `[tech-spec]` The design reference's section list (the research proposed 14 sections). + +## Glossary + +- **Artifact**: a built `.html` file the skill delivers, with its header, plus any sidecar or + source folder. +- **Chrome**: the fixed part of a template (theme, toggle, notices, header) that the agent doesn't + write. +- **Companion**: the live page brainstorming and exploring-ideas show in a browser to ask visual + questions. +- **Connected mode**: an artifact allowed to call a real API at runtime. Libraries are still + inlined. +- **Header**: a comment block inside the artifact recording template, tier, mode and library + versions. +- **House theme**: quirk's default token set, with light and dark pairs. +- **Library**: a vendored, pinned, checksummed file set with its license, such as Alpine or + Simple.css. +- **Nonce**: a per-screen token that ties each click to the screen it came from. +- **Offline mode**: the default; the artifact makes zero network requests. +- **Proceed payload**: what `wait` returns when the user clicks Proceed: the current selection. +- **Sidecar**: a data file shipped next to an artifact and loaded through a file picker. +- **Slot**: a freeform region of a template the agent fills. +- **Template**: a manifest, chrome, slots, tokens, rules addendum, libraries and example. Built-in, + user or project. +- **Tier 1**: a single file assembled from a template with inlined libraries; no build tool. +- **Tier 2**: a React project bundled to one file, with the source kept. +- **Tokens**: CSS custom properties for color, type, spacing and radius. +- **Verify**: the browser checks run on the final file. + +## Status & amendments + +**Status**: Draft, awaiting user review. + +**Amendments** + +None yet. From 1e47ec26cc3ee64c6ee0121ac6e250c6908e0916 Mon Sep 17 00:00:00 2001 From: zpyoung Date: Tue, 13 Oct 2026 14:12:09 -0500 Subject: [PATCH 002/164] docs: mark writing-html-artifacts logic spec approved --- docs/quirk/specs/2026-10-13-writing-html-artifacts/logic.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/quirk/specs/2026-10-13-writing-html-artifacts/logic.md b/docs/quirk/specs/2026-10-13-writing-html-artifacts/logic.md index f4dc5918..1b1d49f0 100644 --- a/docs/quirk/specs/2026-10-13-writing-html-artifacts/logic.md +++ b/docs/quirk/specs/2026-10-13-writing-html-artifacts/logic.md @@ -1,6 +1,6 @@ # Logic spec: `writing-html-artifacts` -**Status**: Draft, awaiting user review +**Status**: Approved, 2026-10-13 A quirk skill that has agents in Claude Code, pi and Oh My Pi build HTML files that open anywhere, look deliberate, and come with proof that they work. The same machinery gives brainstorming and @@ -527,7 +527,7 @@ Open for the tech spec: ## Status & amendments -**Status**: Draft, awaiting user review. +**Status**: Approved by the user, 2026-10-13. Handed to subagent-driven-development. **Amendments** From ac902bd81622ae2a09d1b38c5f16e3210dcc82a7 Mon Sep 17 00:00:00 2001 From: zpyoung Date: Tue, 13 Oct 2026 14:17:01 -0500 Subject: [PATCH 003/164] chore: log pi-watch AuthStorage import crash --- BUGS.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/BUGS.md b/BUGS.md index edbf5a0d..8d46cb77 100644 --- a/BUGS.md +++ b/BUGS.md @@ -108,3 +108,10 @@ entries' IDs; manual edits to fix typos are fine. - **Severity**: medium - **Proposed fix**: Have fold carry notes into the requirement (e.g. reviewNote) or print them on stderr so Claude must act on them; and either let the page set certainty directly or document that notes are advisory. Omit stage-1 items from the stage-2 record, or mark them as pinned. +## BUG-12: pi-watch crashes at import: unpinned pi-coding-agent no longer exports AuthStorage +- **Observed**: 2026-10-13 +- **File**: skills/pi-dev/scripts/pi-watch/pi-watch.mjs:23 +- **Description**: skills/pi-dev/scripts/pi-watch/package.json declares @earendil-works/pi-coding-agent and pi-ai as "*". The installed version (pi 0.87.1) no longer exports AuthStorage, so pi-watch --check codex|gemini|opus exits 1 with: SyntaxError: The requested module '@earendil-works/pi-coding-agent' does not provide an export named 'AuthStorage'. Every pi-backed path (SDD pi implementers, adversarial-review reviewers) is dead; the SDD run on zpyoung/writing-artifacts fell back to Claude Agent reviewers with reduced independence. +- **Severity**: high +- **Proposed fix**: Pin both deps to a known-good version with a frozen lockfile install, and adapt to the new export if AuthStorage moved. + From 4eeb969990484f2c7c48578fa4a4a829ab16738b Mon Sep 17 00:00:00 2001 From: zpyoung Date: Tue, 13 Oct 2026 14:33:06 -0500 Subject: [PATCH 004/164] docs: add tech spec for writing-html-artifacts --- .../logic.md | 2 +- .../2026-10-13-writing-html-artifacts/tech.md | 603 ++++++++++++++++++ 2 files changed, 604 insertions(+), 1 deletion(-) create mode 100644 docs/quirk/specs/2026-10-13-writing-html-artifacts/tech.md diff --git a/docs/quirk/specs/2026-10-13-writing-html-artifacts/logic.md b/docs/quirk/specs/2026-10-13-writing-html-artifacts/logic.md index 1b1d49f0..5153f498 100644 --- a/docs/quirk/specs/2026-10-13-writing-html-artifacts/logic.md +++ b/docs/quirk/specs/2026-10-13-writing-html-artifacts/logic.md @@ -1,6 +1,6 @@ # Logic spec: `writing-html-artifacts` -**Status**: Approved, 2026-10-13 +**Status**: Approved, 2026-10-13 · Tech spec: [tech.md](tech.md) (gate fired: multi-session, several subsystems) A quirk skill that has agents in Claude Code, pi and Oh My Pi build HTML files that open anywhere, look deliberate, and come with proof that they work. The same machinery gives brainstorming and diff --git a/docs/quirk/specs/2026-10-13-writing-html-artifacts/tech.md b/docs/quirk/specs/2026-10-13-writing-html-artifacts/tech.md new file mode 100644 index 00000000..c9882075 --- /dev/null +++ b/docs/quirk/specs/2026-10-13-writing-html-artifacts/tech.md @@ -0,0 +1,603 @@ +# writing-html-artifacts: tech spec + +**Status**: Draft, awaiting user skim · Logic spec: [logic.md](logic.md) + +This document says where to work and what must hold. Paths are relative to the repo root. `$SKILL` +means `skills/writing-html-artifacts/`. Line anchors are against `ac902bd`. + +Every script is Python 3.9+ stdlib (`pyproject.toml:4`) except the Node pieces named in §1. Python +files open with `from __future__ import annotations`, like every in-skill script today. + +## 1. Files and ownership + +Back-link: [Scope and non-goals](logic.md#scope-and-non-goals). + +| Area | Files | Change | +|---|---|---| +| Skill entry | `$SKILL/SKILL.md` | new: workflow, delivery note, links to references | +| References | `$SKILL/references/design-reference.md`, `templates.md`, `companion.md` | new | +| Builder CLI | `$SKILL/scripts/quirk_html.py` | new: `build`, `build-app`, `check`, `verify`, `note`, `templates` subcommands | +| Builder library | `$SKILL/scripts/htmlkit/` (`__init__.py`, `manifest.py`, `resolve.py`, `tokens.py`, `assemble.py`, `checks.py`, `header.py`, `vendor.py`, `app.py`, `fsutil.py`) | new | +| Companion | `$SKILL/scripts/companion.py` | new: `start`, `show`, `wait`, `events`, `stop` | +| Pins | `$SKILL/scripts/pins.py` | new: `verify` (offline), `check` (network) | +| Verify (Node) | `$SKILL/scripts/verify/{package.json,pnpm-lock.yaml,verify.mjs}` | new | +| Vendor build (Node, maintainer only) | `$SKILL/scripts/vendor-build/{package.json,pnpm-lock.yaml,build-webawesome.mjs}` | new | +| Vendored libraries | `$SKILL/vendor/libraries.json`, `$SKILL/vendor///…` | new | +| House theme | `$SKILL/theme/{house.tokens.json,base.css,chrome.js}` | new | +| Built-in templates | `$SKILL/templates/{report,components,companion,app}/` | new | +| Evals | `$SKILL/evals/trigger-evals.json` | new | +| Brainstorming | `skills/brainstorming/visual-companion.md` (rewrite; it must say terminal input outranks clicks), `skills/brainstorming/SKILL.md:357-374` (offer text) | modify | +| Exploring ideas | `skills/exploring-ideas/visual-companion.md` (rewrite; same terminal-priority rule), `skills/exploring-ideas/SKILL.md:178,188-196`, `skills/exploring-ideas/references/interaction-model.md:110` | modify | +| Isles removal | `bin/agent_isles.py`, `packs/quirk/`, `examples/agent-isles/`, `docs/agent-isles-artifacts.md`, `tests/test_agent_isles_{bridge,wait,pack}.py` | delete | +| Repo docs | `README.md:9` (count 25→26), `README.md:62-71` (Isles section), `.gitignore:10,15` | modify | +| Tests | `tests/test_writing_html_artifacts_skill.py`, `tests/test_html_builder.py`, `tests/test_html_companion.py`, `tests/test_html_vendor.py`, `tests/test_html_verify.py`, `tests/test_isles_removed.py` | new | + +## 2. Architecture + +Back-link: [Data flow](logic.md#data-flow). + +- `quirk_html.py` and `companion.py` are thin CLIs. They put `Path(__file__).resolve().parent` on + `sys.path` (the filing-scripts pattern, `skills/filing-requests/scripts/template_resolve.py:18-23`) + and import `htmlkit`. Every subcommand writes exactly one JSON object to stdout, the same shape + convention as `skills/adversarial-review/SKILL.md:65-68`. Diagnostics go to stderr. +- `htmlkit` owns all logic so tests import it directly. Module responsibilities: + - `manifest.py`: parse and validate `template.json` and `libraries.json` + - `resolve.py`: template lookup across levels, invalid-template skipping + - `tokens.py`: merge token layers, emit the `:root` CSS + - `assemble.py`: tier-1 build (source file → HTML) + - `checks.py`: static checks over a built file + - `header.py`: write and read the artifact header and license block + - `vendor.py`: locate a library version, verify checksums, produce inline blocks + - `app.py`: tier-2 scaffold, install, build, post-process + - `fsutil.py`: atomic writes and self-ignoring directories +- `companion.py` builds screens with `htmlkit.assemble` against the `companion` template, then runs + an `http.server.ThreadingHTTPServer` in a detached process. +- Node runs only in `scripts/verify/` (Playwright), in tier-2 builds (`pnpm`), and in + `scripts/vendor-build/` (maintainer-time esbuild). Python never imports from them; it shells out. +- Agents locate scripts relative to the skill directory. SKILL.md and both companion guides say: + `SKILL_DIR` is the directory holding this skill's SKILL.md, which the host shows when the skill + loads. The brainstorming and exploring-ideas guides reach the companion as + `/../writing-html-artifacts/scripts/companion.py`. No `$CLAUDE_PLUGIN_ROOT` in + any new or rewritten text. + +## 3. Data models + +### 3.1 Template manifest: `template.json` + +Back-link: [Templates](logic.md#templates). + +SCHEMA: + +``` +{ + "schema": 1, // int; only 1 is valid + "name": "report", // must equal the template directory name; [a-z0-9-]{1,40} + "description": "Use when ...", // 30..400 chars, starts with "Use when" + "tier": 1, // 1 | 2 + "libraries": ["simple-css", "alpine"], // tier 1: names in libraries.json, or in this template's vendor/ + "optional_libraries": ["chartjs", "mermaid"], // tier 1: a source file may request these + "slots": { "main": {"required": true, "description": "..."} }, // tier 1: 1..3 entries + "skeleton": "skeleton.html", // tier 1, relative path + "tokens": "tokens.json", // optional, relative path (§3.3 shape) + "rules": "rules.md", // optional design-rules addendum + "example": "example.src.html", // tier 1: a source file (§3.4); tier 2: a directory overlay + "project": "project/" // tier 2 only: Vite project with package.json + pnpm-lock.yaml +} +``` + +- A library name is resolved first in the template's own `vendor/libraries.json`, then in + `$SKILL/vendor/libraries.json`. A template-level entry with the same name as a built-in shadows it + for that template only. +- Custom templates (project or user level) also need `verified.json` (§3.6). Built-ins don't. + +### 3.2 Vendored libraries: `vendor/libraries.json` + +Back-link: [Conceptual model](logic.md#conceptual-model). + +SCHEMA: + +``` +{ + "schema": 1, + "libraries": { + "": { + "current": "", // version new builds use + "versions": { + "": { + "package": "", // used by pins.py check + "license": "MIT", // SPDX id + "license_file": "LICENSE", // relative to vendor/// + "source": "", // where the files came from + "files": [ + {"path": "cdn.min.js", "kind": "js" | "css", + "sha256": "", + "inline_sha256": "", + "position": "head" | "body-end", + "inline": true} // false: served by the companion only, never inlined + ] + } + } + } + } +} +``` + +v1 contents (exact versions; the implementer records sha256 values from the downloaded files): + +| name | package@version | files | notes | +|---|---|---|---| +| `simple-css` | `simpledotcss@2.3.7` | `simple.min.css` | no manual dark toggle; `theme/base.css` adds one | +| `alpine` | `alpinejs@3.17.4` | `dist/cdn.min.js`, saved as `alpine.min.js`, position `body-end` | self-starts; must come after markup and after any Alpine plugin | +| `webawesome` | `@awesome.me/webawesome@3.14.0` | `webawesome.bundle.js` (IIFE), `webawesome.css` | produced by `scripts/vendor-build/build-webawesome.mjs`; free components only | +| `chartjs` | `chart.js@4.5.1` | `dist/chart.umd.js`, saved as `chart.umd.js` | already minified | +| `mermaid` | `mermaid@12.2.0` | `dist/mermaid.min.js` (about 5.2 MB IIFE) | never use the `.mjs` build; it loads chunks | + +**Old versions are never deleted.** A pin bump adds a `versions` entry and moves `current`; the +previous directory stays so frozen artifacts rebuild. `pins.py verify` enforces that every listed +version's files exist and match. + +### 3.3 Tokens: `house.tokens.json` and template `tokens.json` + +Back-link: [Conceptual model](logic.md#conceptual-model), [Templates](logic.md#templates). + +SCHEMA: + +``` +{ + "schema": 1, + "color": { "": ["", ""] }, // roles: bg, surface, text, text-muted, border, + // accent, accent-text, good, warn, critical, focus + "font": { "sans": "", "mono": "", "display": "" }, + "size": { "": "" }, // body, small, h1..h4 + "space": { "": "" }, // 1..8 on a 4px base + "radius": { "sm": "4px", "md": "6px", "pill": "999px" } +} +``` + +- Every color is a light/dark pair. A single string is invalid. +- `tokens.py` merges layers in this order, later wins per key: house → user + `~/.quirk/html-templates/tokens.json` → project `.quirk/html-templates/tokens.json` → the + template's own `tokens` file. The two shared files are how project tokens reach built-in + templates, the companion included. +- Emitted CSS uses `--q--` names. It defines light values on `:root`, dark values under + `@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {…} }`, and dark values + again under `:root[data-theme="dark"]`. +- `theme/base.css` maps `--q-*` onto Simple.css's variables (`--bg`, `--accent-bg`, `--text`, + `--text-light`, `--border`, `--accent`, `--accent-hover`, `--accent-text`, `--code`, + `--preformatted`, `--marked`, `--disabled`) and onto Web Awesome's brand and surface variables. + It must override Simple.css's light values explicitly too, because Simple.css's own dark media + block would otherwise win on a dark OS when the user picked light. +- System font stacks only. No `@font-face`, no `url()` fonts. + +### 3.4 Tier-1 source file: `.src.html` + +Back-link: [Picking a template and a tier](logic.md#picking-a-template-and-a-tier), [Building](logic.md#building). + +The agent writes this file; the builder reads it. Format: + +- Line 1 is a header comment containing one JSON object: + `` + - `template` (required), `title` (required), `mode` (`offline` default | `connected`), + `libraries` (subset of the template's `optional_libraries`), `interactions` (list of strings, + may be empty), `data` (optional path to a JSON or CSV file, relative to the source file), + `tier_reason` (optional; set when the agent overrode the signal list). +- The rest is slot sections. Each starts with a line that is exactly ``. + Content runs to the next such line or end of file. Text before the first slot line, other than + the header, is an error. +- REGEX: `^$` (one per line, matched with `re.M`) + +### 3.5 Artifact header and license block + +Back-link: [Building](logic.md#building), [Delivery](logic.md#delivery). + +- The built file starts `` and the next line is the header: + ``. + +SCHEMA: + +``` +{ + "schema": 1, + "template": "report", "template_level": "builtin" | "user" | "project", + "tier": 1 | 2, "tier_signal": "'>" | null, + "mode": "offline" | "connected", + "libraries": { "": "" }, + "sidecar": "" | null, + "built_at": "", + "quirk_version": "" +} +``` + +- `--` inside any JSON string is written as `--` so the comment can't close early. +- Directly after the header, one comment per inlined library: + ``. +- Each inlined library is wrapped as `b", "css") == "a<\\/style>b<\\/style>" + + +def test_escape_inline_leaves_other_text_untouched() -> None: + text = "x y q", "css") == " q" + + +def test_escape_inline_rejects_unknown_kind() -> None: + with pytest.raises(ValueError): + pins.escape_inline("x", "html") + + +WEBAWESOME_DIR = VENDOR / "webawesome" / "3.14.0" + + +def test_webawesome_manifest_entry_positions_and_hashes() -> None: + manifest = json.loads((VENDOR / "libraries.json").read_text(encoding="utf-8")) + entry = manifest["libraries"]["webawesome"]["versions"]["3.14.0"] + assert entry["package"] == "@awesome.me/webawesome" + assert entry["license"] == "MIT" + files = {f["path"]: f for f in entry["files"]} + assert set(files) == {"webawesome.bundle.js", "webawesome.css"} + assert files["webawesome.css"]["kind"] == "css" + assert files["webawesome.css"]["position"] == "head" + assert files["webawesome.bundle.js"]["kind"] == "js" + assert files["webawesome.bundle.js"]["position"] == "body-end" + assert all(f["inline"] is True for f in files.values()) + + +def test_webawesome_bundle_is_a_single_file_iife() -> None: + text = (WEBAWESOME_DIR / "webawesome.bundle.js").read_text(encoding="utf-8") + assert not re.search(r"^\s*(import|export)\s", text, re.MULTILINE) + assert not re.search(r"\bimport\s*\(", text) + + +def test_webawesome_bundle_never_reaches_the_font_awesome_cdn() -> None: + text = (WEBAWESOME_DIR / "webawesome.bundle.js").read_text(encoding="utf-8") + assert "ka-f.fontawesome.com" not in text + assert "data:image/svg+xml" in text + assert re.search(r"registerIconLibrary\(\s*[\"']default[\"']", text) + + +def test_webawesome_css_is_self_contained() -> None: + text = (WEBAWESOME_DIR / "webawesome.css").read_text(encoding="utf-8") + assert "@import" not in text + assert "url(http" not in text + assert 'url("http' not in text + assert "url('http" not in text + + +def test_webawesome_notes_record_the_dark_class() -> None: + lines = (WEBAWESOME_DIR / "NOTES.md").read_text(encoding="utf-8").splitlines() + assert any(re.fullmatch(r"dark-class: \S+", line) for line in lines) From 1feadb52b04b5b41c12c433ee44125b4af3e3f9e Mon Sep 17 00:00:00 2001 From: zpyoung Date: Tue, 13 Oct 2026 14:56:16 -0500 Subject: [PATCH 007/164] refactor: remove Agent Isles and point the companion guides at companion.py --- .gitignore | 3 +- README.md | 15 +- bin/agent_isles.py | 506 ------------------ docs/agent-isles-artifacts.md | 69 --- examples/agent-isles/integrated-workflow.md | 20 - examples/agent-isles/plan-review.md | 31 -- examples/agent-isles/tdd-cycle.md | 19 - .../agent-isles/typed-artifacts-summary.md | 17 - packs/quirk/agent-isles.pack.json | 30 -- packs/quirk/quirk-components.css | 43 -- packs/quirk/quirk-components.js | 110 ---- skills/brainstorming/SKILL.md | 2 +- skills/brainstorming/visual-companion.md | 323 +++++------ skills/exploring-ideas/SKILL.md | 6 +- .../references/interaction-model.md | 2 +- skills/exploring-ideas/visual-companion.md | 215 ++++---- tests/test_agent_isles_bridge.py | 211 -------- tests/test_agent_isles_pack.py | 88 --- tests/test_agent_isles_wait.py | 319 ----------- tests/test_isles_removed.py | 70 +++ 20 files changed, 317 insertions(+), 1782 deletions(-) delete mode 100755 bin/agent_isles.py delete mode 100644 docs/agent-isles-artifacts.md delete mode 100644 examples/agent-isles/integrated-workflow.md delete mode 100644 examples/agent-isles/plan-review.md delete mode 100644 examples/agent-isles/tdd-cycle.md delete mode 100644 examples/agent-isles/typed-artifacts-summary.md delete mode 100644 packs/quirk/agent-isles.pack.json delete mode 100644 packs/quirk/quirk-components.css delete mode 100644 packs/quirk/quirk-components.js delete mode 100644 tests/test_agent_isles_bridge.py delete mode 100644 tests/test_agent_isles_pack.py delete mode 100644 tests/test_agent_isles_wait.py create mode 100644 tests/test_isles_removed.py diff --git a/.gitignore b/.gitignore index e08be399..46ea91aa 100644 --- a/.gitignore +++ b/.gitignore @@ -7,10 +7,9 @@ htmlcov/ *.bak.* .lock .memsearch/ -.quirk/isles/ # git worktrees .worktrees/ -# local Agent Isles install (copied from npx cache, has `live`) +# node dependencies node_modules/ diff --git a/README.md b/README.md index 6f78aaba..77d0982f 100644 --- a/README.md +++ b/README.md @@ -59,16 +59,13 @@ generate `logic.md`, and use a local `review.html` for decisions and sign-off. The page and decision exports are not committed. See [the interactive rubric](skills/writing-specs/interactive-logic-spec.md). -## Agent Isles bridge +## HTML artifacts and the visual companion -Quirk can author Agent Isles-aware artifacts while keeping Markdown canonical and Agent Isles optional. The stdlib-only helper in `bin/agent_isles.py` detects a local `isles` binary, an `isles` on `PATH`, or an explicit `npx agent-isles@next` fallback; it never silently installs packages. - -```bash -python3 bin/agent_isles.py doctor -python3 bin/agent_isles.py command examples/agent-isles/typed-artifacts-summary.md -``` - -The experimental local component pack lives in `packs/quirk/` and covers typed artifact summaries, TDD cycle reports, plan readiness, and review findings. Examples live in `examples/agent-isles/`. See `docs/agent-isles-artifacts.md` for the authoring contract: Markdown source is canonical, generated HTML under `.quirk/isles/` is disposable by default, and the Quirk pack is trusted local code. +The `writing-html-artifacts` skill builds offline, single-file HTML pages from templates and +vendored libraries, and runs the brainstorming visual companion: a small local Python server +that shows option screens in a browser and sends clicks back to the agent. Brainstorming and +exploring-ideas both use it. Markdown stays the canonical source for specs; the companion needs +Python 3 only. ## Development diff --git a/bin/agent_isles.py b/bin/agent_isles.py deleted file mode 100755 index 28ccf61b..00000000 --- a/bin/agent_isles.py +++ /dev/null @@ -1,506 +0,0 @@ -#!/usr/bin/env python3 -"""Stdlib-only Agent Isles bridge for Quirk artifacts. - -The helper constructs deterministic Agent Isles commands without making Agent -Isles a hidden hard dependency. It prefers a repo-local binary, then PATH, then -an explicit npx fallback when allowed. -""" -from __future__ import annotations - -import argparse -import json -import os -import shlex -import shutil -import subprocess -import sys -import time -from dataclasses import dataclass -from pathlib import Path -from typing import Callable - -DEFAULT_OUTPUT_DIR = Path(".quirk") / "isles" -STATE_DIRNAME = "state" -EVENTS_FILENAME = "events" -DEFAULT_NPX_PACKAGE = "agent-isles@next" -# `isles live` is not on the published npm tag yet (it lives on github main). Until a -# release with `live` ships to npm, the live npx fallback targets the github spec. -LIVE_NPX_PACKAGE = "github:zpyoung/agent-isles" - - -class AgentIslesUnavailable(RuntimeError): - """Raised when no usable Agent Isles command path can be constructed.""" - - -@dataclass(frozen=True) -class CommandPlan: - argv: list[str] - output: Path | None - note: str | None = None - - -def _repo_local_isles(repo_root: Path) -> Path | None: - candidate = repo_root / "node_modules" / ".bin" / ("isles.cmd" if os.name == "nt" else "isles") - if candidate.exists() and os.access(candidate, os.X_OK): - return candidate - return None - - -def _which(name: str) -> str | None: - found = shutil.which(name) - return found if found else None - - -def detect_runner( - repo_root: Path, *, no_npx: bool = False, npx_package: str = DEFAULT_NPX_PACKAGE -) -> tuple[list[str], str | None]: - """Return the executable prefix and optional note for Agent Isles.""" - local = _repo_local_isles(repo_root) - if local is not None: - return [str(local)], None - path_isles = _which("isles") - if path_isles: - return [path_isles], None - if not no_npx: - npx = _which("npx") - if npx: - return [npx, npx_package], f"npx fallback is explicit; it may download {npx_package} when executed" - raise AgentIslesUnavailable( - "No Agent Isles executable found. Install/use a repo-local node_modules/.bin/isles, " - "put isles on PATH, or rerun without --no-npx to print/use the explicit npx agent-isles@next fallback." - ) - - -def default_output_for(artifact: Path, repo_root: Path) -> Path: - stem = artifact.stem or "artifact" - return repo_root / DEFAULT_OUTPUT_DIR / f"{stem}.html" - - -def build_command( - action: str, - artifact: Path, - *, - repo_root: Path | None = None, - output: Path | None = None, - no_npx: bool = False, - with_user_packs: bool = False, - no_quirk_pack: bool = False, - extra_packs: list[Path] | None = None, - execute: bool = False, -) -> CommandPlan: - """Build a render/preview command. Does not create files unless execute=True.""" - if action not in {"render", "preview"}: - raise ValueError(f"unsupported action: {action}") - repo = (repo_root or Path.cwd()).resolve() - artifact_path = artifact if artifact.is_absolute() else (Path.cwd() / artifact) - artifact_path = artifact_path.resolve() - if not artifact_path.exists(): - raise FileNotFoundError(f"artifact not found: {artifact_path}") - - runner, note = detect_runner(repo, no_npx=no_npx) - argv = [*runner, action, str(artifact_path)] - - output_path: Path | None = None - if action == "render": - output_path = (output if output is not None else default_output_for(artifact_path, repo)).resolve() - argv.extend(["--output", str(output_path)]) - - pack_paths: list[Path] = [] - quirk_pack = repo / "packs" / "quirk" - if not no_quirk_pack and quirk_pack.exists(): - pack_paths.append(quirk_pack) - pack_paths.extend(extra_packs or []) - for pack in pack_paths: - argv.extend(["--pack", str(pack.resolve())]) - if not with_user_packs: - argv.append("--no-user-packs") - - if execute and output_path is not None: - output_path.parent.mkdir(parents=True, exist_ok=True) - return CommandPlan(argv=argv, output=output_path, note=note) - - -def build_live_command( - screen_dir: Path, - *, - repo_root: Path | None = None, - stop: bool = False, - port: int | None = None, - host: str | None = None, - url_host: str | None = None, - idle_timeout: float | None = None, - owner_pid: int | None = None, - no_npx: bool = False, - execute: bool = False, -) -> CommandPlan: - """Build an `isles live ` command for the brainstorming visual companion. - - Unlike render/preview, `isles live` resolves packs internally (it renders with - project-dir discovery and no user packs) and REJECTS `--pack`/`--no-user-packs` - as unknown options. So this builder never adds them. - """ - repo = (repo_root or Path.cwd()).resolve() - screen = screen_dir if screen_dir.is_absolute() else (Path.cwd() / screen_dir) - screen = screen.resolve() - - runner, note = detect_runner(repo, no_npx=no_npx, npx_package=LIVE_NPX_PACKAGE) - argv = [*runner, "live", str(screen)] - - if stop: - argv.append("--stop") - return CommandPlan(argv=argv, output=None, note=note) - - if port is not None: - argv.extend(["--port", str(port)]) - if host: - argv.extend(["--host", host]) - if url_host: - argv.extend(["--url-host", url_host]) - if idle_timeout is not None: - argv.extend(["--idle-timeout", str(idle_timeout)]) - if owner_pid is not None: - argv.extend(["--owner-pid", str(owner_pid)]) - - if execute: - screen.mkdir(parents=True, exist_ok=True) - return CommandPlan(argv=argv, output=None, note=note) - - -def _read_events(events_path: Path) -> list[dict]: - """Parse the live server's JSONL events file; skip blank/malformed lines. - - Returns [] if the file is absent or vanishes mid-read (the live server may - clear/rotate it between our existence check and the read). - """ - try: - raw = events_path.read_text(encoding="utf-8", errors="replace") - except FileNotFoundError: - return [] - records: list[dict] = [] - for line in raw.splitlines(): - line = line.strip() - if not line: - continue - try: - obj = json.loads(line) - except json.JSONDecodeError: - continue - if isinstance(obj, dict): - records.append(obj) - return records - - -def _event_ts(event: dict) -> float: - """The record's epoch-seconds timestamp, or 0.0 if absent/non-numeric.""" - ts = event.get("timestamp") - return float(ts) if isinstance(ts, (int, float)) and not isinstance(ts, bool) else 0.0 - - -def _screen_matches(event: dict, screen: str | None) -> bool: - """Lenient screen match: no filter, or the record predates the screen nonce - (no ``screen`` field — older Agent Isles), or it matches. Stays compatible - with builds that don't stamp a screen, while rejecting stale-tab clicks that - carry a *different* screen.""" - if screen is None: - return True - recorded = event.get("screen") - return recorded is None or recorded == screen - - -def latest_proceed_event( - events_path: Path, *, since: float = 0.0, screen: str | None = None -) -> dict | None: - """Newest proceed with ``timestamp >= since`` and a matching screen, or None.""" - proceeds = [ - e for e in _read_events(events_path) - if e.get("type") == "proceed" and _event_ts(e) >= since and _screen_matches(e, screen) - ] - return proceeds[-1] if proceeds else None - - -def make_file_transport( - events_path: Path, *, since: float = 0.0, screen: str | None = None -) -> Callable[[], dict | None]: - """Build the file-poll transport: the newest proceed at/after ``since``. - - ``since`` is the current screen's start time (epoch seconds), so a stale - proceed left over from a PREVIOUS screen has an older timestamp and is - ignored — it can't false-advance even if the live server's best-effort - clearEvents never ran. Crucially ``since`` is SCREEN-scoped, not wait-scoped: - re-running ``wait`` after a timeout with the same ``since`` is a true - continuation, so a click that arrives between turns is never dropped (a - count/start-time baseline would lose it). - - ``screen`` adds a second, exact guard: only proceeds stamped with that screen - (the Agent Isles nonce) match, so a click from a stale, not-yet-reloaded tab - is rejected even within the same timestamp second. Lenient toward older - Agent Isles builds that don't stamp a screen (see :func:`_screen_matches`). - - This is the swappable seam. Phase 2's channel transport returns a pushed - proceed with the same "for this screen" semantics — the wait loop and event - shape stay identical. - """ - - def poll() -> dict | None: - return latest_proceed_event(events_path, since=since, screen=screen) - - return poll - - -def wait_for_proceed( - transport: Callable[[], dict | None], - *, - timeout: float, - poll_interval: float, - _clock: Callable[[], float] = time.monotonic, - _sleep: Callable[[float], None] = time.sleep, -) -> dict | None: - """Block until ``transport()`` yields a proceed event; return None on timeout. - - ``timeout=0`` checks the transport exactly once (no blocking). - """ - deadline = _clock() + timeout - while True: - event = transport() - if event is not None: - return event - if _clock() >= deadline: - return None - _sleep(poll_interval) - - -def resolve_state_dir(screen_dir: Path, state_dir: Path | None = None) -> Path: - """Where the live server writes events: ``/state`` unless overridden.""" - return state_dir if state_dir is not None else screen_dir / STATE_DIRNAME - - -def newest_screen_mtime(screen_dir: Path) -> int: - """Floor of the newest ``*.md`` screen mtime (epoch seconds), or 0 if none. - - Used as the default proceed cutoff so ``wait`` only honors clicks for the - current screen. Floored to match the server's integer-second timestamps. - """ - newest = 0.0 - try: - for path in screen_dir.glob("*.md"): - try: - mtime = path.stat().st_mtime - except OSError: - continue - if mtime > newest: - newest = mtime - except OSError: - pass - return int(newest) - - -def newest_screen_name(screen_dir: Path) -> str | None: - """Basename of the newest ``*.md`` screen, or None — the default screen nonce.""" - newest_name: str | None = None - newest_mtime = -1.0 - try: - for path in screen_dir.glob("*.md"): - try: - mtime = path.stat().st_mtime - except OSError: - continue - if mtime > newest_mtime: - newest_mtime = mtime - newest_name = path.name - except OSError: - pass - return newest_name - - -def server_running(state_dir: Path) -> bool: - """Best-effort: a live server is up for this dir (info present, not stopped, PID alive).""" - if (state_dir / "server-stopped").exists(): - return False - try: - data = json.loads((state_dir / "server-info").read_text(encoding="utf-8")) - except (FileNotFoundError, ValueError): - return False - pid = data.get("pid") - if not isinstance(pid, int): - return True # info present but no PID — assume running - try: - os.kill(pid, 0) - except ProcessLookupError: - return False - except (PermissionError, OSError): - return True # exists but not signalable by us → treat as alive - return True - - -def doctor(repo_root: Path, *, no_npx: bool = False) -> str: - repo = repo_root.resolve() - lines = ["Agent Isles bridge doctor", f"repo root: {repo}"] - local = _repo_local_isles(repo) - lines.append(f"repo-local isles: {local if local else 'not found'}") - path_isles = _which("isles") - lines.append(f"PATH isles: {path_isles if path_isles else 'not found'}") - npx = _which("npx") - lines.append(f"npx: {npx if npx else 'not found'}") - pack = repo / "packs" / "quirk" - lines.append(f"Quirk pack: {pack if pack.exists() else 'not found'}") - lines.append(f"default output: {repo / DEFAULT_OUTPUT_DIR}/") - try: - runner, note = detect_runner(repo, no_npx=no_npx) - lines.append("recommended runner: " + " ".join(shlex.quote(part) for part in runner)) - if note: - lines.append("note: " + note) - except AgentIslesUnavailable as exc: - lines.append("recommended runner: unavailable") - lines.append("reason: " + str(exc)) - lines.append("No packages were installed and no files were written.") - return "\n".join(lines) + "\n" - - -def _add_common_options(parser: argparse.ArgumentParser) -> None: - parser.add_argument("--repo-root", type=Path, default=Path.cwd(), help="Repository root; defaults to cwd.") - - -def _parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Quirk Agent Isles bridge helper.") - _add_common_options(parser) - sub = parser.add_subparsers(dest="command", required=True) - - doctor_p = sub.add_parser("doctor", help="Report detected Agent Isles command paths.") - _add_common_options(doctor_p) - doctor_p.add_argument("--no-npx", action="store_true", help="Do not consider npx fallback.") - - for name in ("render", "preview", "command"): - p = sub.add_parser(name, help=("Print command" if name == "command" else f"Build and run Agent Isles {name}.")) - _add_common_options(p) - p.add_argument("artifact", type=Path) - p.add_argument("--output", type=Path, help="Render output path; render only.") - p.add_argument("--no-npx", action="store_true", help="Disable explicit npx fallback.") - p.add_argument("--with-user-packs", action="store_true", help="Allow Agent Isles user packs.") - p.add_argument("--no-quirk-pack", action="store_true", help="Do not add packs/quirk automatically.") - p.add_argument("--pack", action="append", type=Path, default=[], help="Additional local trusted pack path.") - if name in {"render", "preview"}: - p.add_argument("--print-command", action="store_true", help="Print the command instead of executing it.") - - live_p = sub.add_parser("live", help="Start/stop the Agent Isles live brainstorming companion server.") - _add_common_options(live_p) - live_p.add_argument("dir", type=Path, help="Screen directory; agent writes *.md screens here, events land in /state/events.") - live_p.add_argument("--stop", action="store_true", help="Stop a running live server for .") - live_p.add_argument("--port", type=int, help="Bind to a fixed port (default: ephemeral).") - live_p.add_argument("--host", help="Bind host (default 127.0.0.1).") - live_p.add_argument("--url-host", help="Hostname to print in the served URL.") - live_p.add_argument("--idle-timeout", type=float, help="Idle minutes before auto-shutdown (default 30).") - live_p.add_argument("--owner-pid", type=int, help="Shut down when this PID exits.") - live_p.add_argument("--no-npx", action="store_true", help="Disable explicit npx fallback.") - live_p.add_argument("--print-command", action="store_true", help="Print the command instead of executing it.") - - wait_p = sub.add_parser( - "wait", - help="Block until a browser 'proceed' event for , then print the selection JSON.", - ) - _add_common_options(wait_p) - wait_p.add_argument("dir", type=Path, help="Screen directory passed to `live`; events are read from /state/events.") - wait_p.add_argument("--timeout", type=float, default=110.0, help="Max seconds to block (default 110, under the 120s Bash-tool default). 0 = check once.") - wait_p.add_argument("--poll-interval", type=float, default=0.5, help="Seconds between file polls (default 0.5; floored at 0.05).") - wait_p.add_argument("--state-dir", type=Path, help="Override the events state dir (default /state).") - wait_p.add_argument("--since", type=float, help="Only honor proceeds with timestamp >= this epoch (default: newest screen mtime). Pass the same value across re-runs to continue waiting on one screen.") - wait_p.add_argument("--screen", help="Only honor proceeds stamped with this screen basename (default: newest *.md). Rejects stale-tab clicks; lenient toward Agent Isles builds that don't stamp a screen.") - return parser - - -def main(argv: list[str] | None = None) -> int: - parser = _parser() - args = parser.parse_args(argv) - repo_root = args.repo_root.resolve() - if args.command == "doctor": - print(doctor(repo_root, no_npx=args.no_npx), end="") - return 0 - - if args.command == "wait": - screen = args.dir if args.dir.is_absolute() else (Path.cwd() / args.dir) - screen = screen.resolve() - state_dir = resolve_state_dir(screen, args.state_dir.resolve() if args.state_dir else None) - if not server_running(state_dir): - print( - f"agent_isles.py: no live Agent Isles server running for {screen}; " - "start one with `live` first (nothing can deliver a proceed).", - file=sys.stderr, - ) - return 2 - events_path = state_dir / EVENTS_FILENAME - since = args.since if args.since is not None else float(newest_screen_mtime(screen)) - screen_nonce = args.screen if args.screen is not None else newest_screen_name(screen) - transport = make_file_transport(events_path, since=since, screen=screen_nonce) - event = wait_for_proceed(transport, timeout=args.timeout, poll_interval=max(args.poll_interval, 0.05)) - if event is None: - print( - f"agent_isles.py: no proceed event within {args.timeout}s (timed out); " - f"re-run wait to keep waiting (pass --since {since:.0f} to resume the same " - "screen), or fall back to terminal-text advance.", - file=sys.stderr, - ) - return 1 - print(json.dumps(event)) - return 0 - - if args.command == "live": - execute = not args.print_command - try: - plan = build_live_command( - args.dir, - repo_root=repo_root, - stop=args.stop, - port=args.port, - host=args.host, - url_host=args.url_host, - idle_timeout=args.idle_timeout, - owner_pid=args.owner_pid, - no_npx=args.no_npx, - execute=execute, - ) - except AgentIslesUnavailable as exc: - print(f"agent_isles.py: {exc}", file=sys.stderr) - return 2 - quoted = " ".join(shlex.quote(part) for part in plan.argv) - if args.print_command: - print(quoted) - if plan.note: - print(f"# {plan.note}") - return 0 - if plan.note: - print(plan.note, file=sys.stderr) - completed = subprocess.run(plan.argv) - return completed.returncode - - action = "render" if args.command == "command" else args.command - execute = args.command in {"render", "preview"} and not getattr(args, "print_command", False) - try: - plan = build_command( - action, - args.artifact, - repo_root=repo_root, - output=args.output, - no_npx=args.no_npx, - with_user_packs=args.with_user_packs, - no_quirk_pack=args.no_quirk_pack, - extra_packs=args.pack, - execute=execute, - ) - except (AgentIslesUnavailable, FileNotFoundError, ValueError) as exc: - print(f"agent_isles.py: {exc}", file=sys.stderr) - return 2 - - quoted = " ".join(shlex.quote(part) for part in plan.argv) - if args.command == "command" or getattr(args, "print_command", False): - print(quoted) - if plan.note: - print(f"# {plan.note}") - return 0 - - if plan.note: - print(plan.note, file=sys.stderr) - completed = subprocess.run(plan.argv) - return completed.returncode - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/docs/agent-isles-artifacts.md b/docs/agent-isles-artifacts.md deleted file mode 100644 index 1a8f4d69..00000000 --- a/docs/agent-isles-artifacts.md +++ /dev/null @@ -1,69 +0,0 @@ -# Agent Isles-aware Quirk artifacts - -Quirk is the authoring and workflow discipline layer. Agent Isles is the optional rendering and presentation layer. This contract keeps normal Quirk skill use lightweight while making richer rendered reports available when Agent Isles is present. - -## Core contract - -- Markdown source is canonical. Review, edit, diff, and archive the `.md` files. -- generated HTML is disposable by default. The bridge writes to `.quirk/isles/`, which is ignored and can be deleted or regenerated at any time. -- Agent Isles is optional. Quirk must remain useful when Node, npm, npx, and Agent Isles are absent. -- Any built-in Agent Isles island may be used when it improves artifact structure, scanning, or readability. -- Quirk-specific component-pack tags represent Quirk-owned semantics such as typed artifacts, TDD cycles, plan readiness, and review findings. -- Prefer plain Markdown when a heading, list, or table communicates the artifact more clearly than a component. - -## Bridge helper - -Use the stdlib-only helper to inspect or run the optional renderer: - -```bash -python3 bin/agent_isles.py doctor -python3 bin/agent_isles.py command examples/agent-isles/typed-artifacts-summary.md -python3 bin/agent_isles.py render examples/agent-isles/typed-artifacts-summary.md -``` - -The helper chooses command paths in this order: - -1. repo-local `node_modules/.bin/isles`, -2. `isles` on `PATH`, -3. explicit `npx agent-isles@next ...` when `npx` exists and `--no-npx` is not set. - -It never silently installs packages. The npx fallback is printed or executed only as an explicit command path and may download `agent-isles@next` according to npx behavior. - -By default the helper adds `--pack packs/quirk --no-user-packs` when the local Quirk pack exists. Use `--with-user-packs`, `--no-quirk-pack`, and repeated `--pack ` only when a workflow intentionally changes that reproducibility boundary. - -## Authoring rules - -- Keep a useful Plain Markdown fallback near every island or custom element. A reader should understand the artifact in a terminal, code review, or raw GitHub view. -- Use no self-closing custom elements. Write `...`, not ``. -- Keep raw HTML blocks continuous when nesting custom elements; many Markdown parsers stop raw HTML parsing after blank lines or indentation changes. -- Put durable data in Markdown text and safe attributes. Do not rely on generated DOM state as the source of truth. -- Use sanitized mode for untrusted Markdown renders. The Quirk pack manifest declares sanitized-mode attributes and intentionally omits unsafe attributes such as `style`, `srcdoc`, and event handlers. -- Treat component packs as trusted local code. Even in sanitized mode, local pack JavaScript executes in the rendered page; review pack changes like code. -- Avoid gratuitous components. Built-in `` islands and Quirk `` tags are views, not a replacement for clear prose. - -## When to use each surface - -- Use built-in `` islands for generic presentation: status boards, decisions, risks, timelines, action lists, comparisons, and callouts. -- Use `` for Quirk typed artifact rollups across `BUGS.md`, `DEFERRED.md`, `TEST_BACKLOG.md`, `proposals.md`, and ADR files. -- Use `` for red/green/refactor state, verification evidence, and deferred/skipped test debt. -- Use `` for plan readiness gates: scope, clarity, files, tests, risks, and handoff readiness. -- Use `` for review findings that need severity, path, recommendation, and status. - -## Compatibility - -This integration targets the current prerelease Agent Isles command surface exposed through `agent-isles@next` and local `isles` binaries. Default Quirk tests do not require Agent Isles or Node. Developers who have Agent Isles available can smoke-test with: - -```bash -python3 bin/agent_isles.py doctor -python3 bin/agent_isles.py render examples/agent-isles/integrated-workflow.md --print-command -python3 bin/agent_isles.py render examples/agent-isles/integrated-workflow.md -``` - -Expected reproducibility flags for direct Agent Isles commands are: - -```bash -isles packs resolve examples/agent-isles/integrated-workflow.md --pack packs/quirk --no-user-packs -isles render examples/agent-isles/integrated-workflow.md --pack packs/quirk --no-user-packs --output .quirk/isles/integrated-workflow.html -``` - -Generated files under `.quirk/isles/` are non-canonical views and should not be committed unless a future publishing issue deliberately changes that policy. diff --git a/examples/agent-isles/integrated-workflow.md b/examples/agent-isles/integrated-workflow.md deleted file mode 100644 index 0c5c768d..00000000 --- a/examples/agent-isles/integrated-workflow.md +++ /dev/null @@ -1,20 +0,0 @@ -# Integrated Quirk + Agent Isles workflow - -Plain Markdown fallback: this example ties together the bridge helper, local pack, conventions, and disposable output policy. - -1. Author canonical Markdown in this file or another Quirk artifact. -2. Use `python3 bin/agent_isles.py doctor` to see whether Agent Isles is available. -3. Use `python3 bin/agent_isles.py command examples/agent-isles/integrated-workflow.md` to print the exact render command. -4. Render only when desired; output defaults to `.quirk/isles/` and is disposable. -5. The helper adds `--pack packs/quirk --no-user-packs` by default when the Quirk pack exists. - - -- Author Markdown source -- Review fallback text in GitHub or terminal -- Render with Agent Isles when present -- Delete/regenerate generated HTML as needed - - - -Plain Markdown fallback: bin/agent_isles.py, packs/quirk, docs/agent-isles-artifacts.md, examples/agent-isles, and .quirk/isles form the integration story. - diff --git a/examples/agent-isles/plan-review.md b/examples/agent-isles/plan-review.md deleted file mode 100644 index 13d3f3ca..00000000 --- a/examples/agent-isles/plan-review.md +++ /dev/null @@ -1,31 +0,0 @@ -# Plan readiness and review findings - -Plain Markdown fallback: the plan is ready when scope, clarity, files, tests, risks, and handoff are explicit. Review findings remain actionable in Markdown with severity, path, recommendation, and status. - -## Plan readiness - -- Scope: bridge helper, pack, examples, docs, and tests only. -- Clarity: child issue acceptance criteria map to files and checks. -- Files: `bin/`, `packs/quirk/`, `docs/`, `examples/agent-isles/`, `tests/`. -- Tests: focused tests plus `python3 -m pytest -q`. -- Risks: Agent Isles CLI surface may shift while targeting `agent-isles@next`. -- Handoff: PR body records verification and smoke availability. - - -- Agent Isles prerelease command names may change. -- Local component packs are trusted code and need code review. - - - -Plain Markdown fallback: Plan readiness bullets above explain scope, clarity, files, tests, risks, and handoff. - - -## Review findings - - -Plain Markdown fallback: medium finding for bin/agent_isles.py; document explicit npx behavior; status addressed. - - - -Plain Markdown fallback: low finding for pack manifest; sanitized attributes are narrow; status addressed. - diff --git a/examples/agent-isles/tdd-cycle.md b/examples/agent-isles/tdd-cycle.md deleted file mode 100644 index 7ed6f258..00000000 --- a/examples/agent-isles/tdd-cycle.md +++ /dev/null @@ -1,19 +0,0 @@ -# TDD cycle report - -Plain Markdown fallback: this artifact records one complete Quirk TDD loop. RED failed first, GREEN passed with minimal code, REFACTOR cleaned names without changing behavior, and Verification records the exact command. - -- RED: `test_bridge_uses_path_isles` failed because only repo-local detection existed. -- GREEN: command detection now falls back to `isles` on `PATH`. -- REFACTOR: runner detection is isolated in a pure function. -- Verification: `python3 -m pytest -q tests/test_agent_isles_bridge.py` passed. -- Deferred debt: no skipped tests. - - -- RED: captured failing expectation -- GREEN: behavior implemented -- REFACTOR: pure helper extracted - - - -Plain Markdown fallback: RED, GREEN, REFACTOR, and Verification evidence are listed above. - diff --git a/examples/agent-isles/typed-artifacts-summary.md b/examples/agent-isles/typed-artifacts-summary.md deleted file mode 100644 index 717adc23..00000000 --- a/examples/agent-isles/typed-artifacts-summary.md +++ /dev/null @@ -1,17 +0,0 @@ -# Typed artifacts summary - -Plain Markdown fallback: this report summarizes Quirk's canonical typed artifact files. If Agent Isles is unavailable, the counts and bullets below remain the source of truth. - -- BUGS: 2 open defects need triage. -- DEFERRED: 1 deferred refactor is tracked. -- TEST_BACKLOG: 3 skipped or missing coverage items exist. -- proposals: 1 proposal is ready for review. -- ADR: 2 decisions are accepted in `docs/adr/`. - - -Markdown source is canonical. The rendered Agent Isles page is a disposable view under `.quirk/isles/`. - - - -Plain Markdown fallback: BUGS, DEFERRED, TEST_BACKLOG, proposals, and ADR summaries are listed above for raw readers. - diff --git a/packs/quirk/agent-isles.pack.json b/packs/quirk/agent-isles.pack.json deleted file mode 100644 index a34f064f..00000000 --- a/packs/quirk/agent-isles.pack.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "schemaVersion": 1, - "name": "quirk", - "version": "0.1.0-experimental", - "description": "Experimental Quirk Agent Isles component pack for workflow artifacts.", - "module": "./quirk-components.js", - "styles": ["./quirk-components.css"], - "components": [ - { - "tag": "quirk-artifact-summary", - "description": "Summarize Quirk typed artifact surfaces.", - "sanitizedModeAttributes": ["title", "bugs", "deferred", "test-backlog", "proposals", "adrs", "status"] - }, - { - "tag": "quirk-tdd-cycle", - "description": "Present red/green/refactor status and verification evidence.", - "sanitizedModeAttributes": ["title", "red", "green", "refactor", "verification", "debt", "status"] - }, - { - "tag": "quirk-plan-review", - "description": "Present plan readiness gates and handoff state.", - "sanitizedModeAttributes": ["title", "scope", "clarity", "files", "tests", "risks", "handoff", "status"] - }, - { - "tag": "quirk-review-finding", - "description": "Present a review finding with severity, location, recommendation, and status.", - "sanitizedModeAttributes": ["severity", "path", "recommendation", "status"] - } - ] -} diff --git a/packs/quirk/quirk-components.css b/packs/quirk/quirk-components.css deleted file mode 100644 index 0d17265f..00000000 --- a/packs/quirk/quirk-components.css +++ /dev/null @@ -1,43 +0,0 @@ -.quirk-card { - border: 1px solid color-mix(in srgb, currentColor 18%, transparent); - border-radius: 0.75rem; - margin: 1rem 0; - padding: 1rem; - background: color-mix(in srgb, Canvas 96%, currentColor 4%); -} - -.quirk-card h3 { - margin: 0 0 0.5rem; -} - -.quirk-badge { - display: inline-block; - border-radius: 999px; - padding: 0.15rem 0.55rem; - font-size: 0.85em; - font-weight: 700; - background: color-mix(in srgb, currentColor 12%, transparent); -} - -.quirk-kv { - display: grid; - grid-template-columns: max-content 1fr; - gap: 0.4rem 0.8rem; - margin: 0.75rem 0 0; -} - -.quirk-kv dt { - font-weight: 700; -} - -.quirk-kv dd { - margin: 0; -} - -.quirk-card details { - margin-top: 0.75rem; -} - -.quirk-card pre { - white-space: pre-wrap; -} diff --git a/packs/quirk/quirk-components.js b/packs/quirk/quirk-components.js deleted file mode 100644 index 3b57bed4..00000000 --- a/packs/quirk/quirk-components.js +++ /dev/null @@ -1,110 +0,0 @@ -class QuirkArtifactSummary extends HTMLElement { - connectedCallback() { - this.render([ - ["BUGS", this.getAttribute("bugs")], - ["DEFERRED", this.getAttribute("deferred")], - ["TEST_BACKLOG", this.getAttribute("test-backlog")], - ["proposals", this.getAttribute("proposals")], - ["ADRs", this.getAttribute("adrs")], - ]); - } - - render(rows) { - const card = buildCard(this.getAttribute("title") || "Typed artifact summary", this.getAttribute("status")); - const list = document.createElement("dl"); - list.className = "quirk-kv"; - for (const [label, value] of rows) { - appendKeyValue(list, label, value || "not reported"); - } - appendSlottedFallback(card, this); - card.append(list); - this.replaceChildren(card); - } -} - -class QuirkTddCycle extends HTMLElement { - connectedCallback() { - const card = buildCard(this.getAttribute("title") || "TDD cycle", this.getAttribute("status")); - const list = document.createElement("dl"); - list.className = "quirk-kv"; - appendKeyValue(list, "RED", this.getAttribute("red") || "not reported"); - appendKeyValue(list, "GREEN", this.getAttribute("green") || "not reported"); - appendKeyValue(list, "REFACTOR", this.getAttribute("refactor") || "not reported"); - appendKeyValue(list, "Verification", this.getAttribute("verification") || "not reported"); - appendKeyValue(list, "Deferred/skipped debt", this.getAttribute("debt") || "none reported"); - appendSlottedFallback(card, this); - card.append(list); - this.replaceChildren(card); - } -} - -class QuirkPlanReview extends HTMLElement { - connectedCallback() { - const card = buildCard(this.getAttribute("title") || "Plan readiness", this.getAttribute("status")); - const list = document.createElement("dl"); - list.className = "quirk-kv"; - for (const key of ["scope", "clarity", "files", "tests", "risks", "handoff"]) { - appendKeyValue(list, titleCase(key), this.getAttribute(key) || "not reviewed"); - } - appendSlottedFallback(card, this); - card.append(list); - this.replaceChildren(card); - } -} - -class QuirkReviewFinding extends HTMLElement { - connectedCallback() { - const card = buildCard(`Review finding: ${this.getAttribute("severity") || "unspecified"}`, this.getAttribute("status")); - const list = document.createElement("dl"); - list.className = "quirk-kv"; - appendKeyValue(list, "Path", this.getAttribute("path") || "not specified"); - appendKeyValue(list, "Recommendation", this.getAttribute("recommendation") || "not specified"); - appendSlottedFallback(card, this); - card.append(list); - this.replaceChildren(card); - } -} - -function buildCard(title, status) { - const section = document.createElement("section"); - section.className = "quirk-card"; - const heading = document.createElement("h3"); - heading.textContent = title; - section.append(heading); - if (status) { - const badge = document.createElement("span"); - badge.className = "quirk-badge"; - badge.textContent = status; - section.append(badge); - } - return section; -} - -function appendKeyValue(list, key, value) { - const dt = document.createElement("dt"); - dt.textContent = key; - const dd = document.createElement("dd"); - dd.textContent = value; - list.append(dt, dd); -} - -function appendSlottedFallback(card, source) { - const fallback = source.textContent.trim(); - if (!fallback) return; - const details = document.createElement("details"); - const summary = document.createElement("summary"); - summary.textContent = "Plain Markdown fallback"; - const pre = document.createElement("pre"); - pre.textContent = fallback; - details.append(summary, pre); - card.append(details); -} - -function titleCase(value) { - return value.charAt(0).toUpperCase() + value.slice(1); -} - -customElements.define("quirk-artifact-summary", QuirkArtifactSummary); -customElements.define("quirk-tdd-cycle", QuirkTddCycle); -customElements.define("quirk-plan-review", QuirkPlanReview); -customElements.define("quirk-review-finding", QuirkReviewFinding); diff --git a/skills/brainstorming/SKILL.md b/skills/brainstorming/SKILL.md index 063d1eff..029b4488 100644 --- a/skills/brainstorming/SKILL.md +++ b/skills/brainstorming/SKILL.md @@ -359,7 +359,7 @@ Do not proceed past the rubric's user review gate until the user approves. A browser-based companion for showing mockups, diagrams, and visual options during brainstorming. Available as a tool — not a mode. Accepting the companion means it's available for questions that benefit from visual treatment; it does NOT mean every question goes through the browser. **Offering the companion:** When you anticipate that upcoming questions will involve visual content (mockups, layouts, diagrams), offer it once for consent: -> "Some of what we're working on might be easier to explain if I can show it to you in a web browser. I can put together mockups, diagrams, comparisons, and other visuals as we go. This feature is still new and runs on Agent Isles (needs Node available). Want to try it? (Requires opening a local URL)" +> "Some of what we're working on might be easier to explain if I can show it to you in a web browser. I can put together mockups, diagrams, comparisons, and other visuals as we go. This feature is still new and runs a small local Python server. Want to try it? (Requires opening a local URL)" **This offer MUST be its own message.** Do not combine it with clarifying questions, context summaries, or any other content. The message should contain ONLY the offer above and nothing else. Wait for the user's response before continuing. If they decline, proceed with text-only brainstorming. diff --git a/skills/brainstorming/visual-companion.md b/skills/brainstorming/visual-companion.md index 307d1301..a0605706 100644 --- a/skills/brainstorming/visual-companion.md +++ b/skills/brainstorming/visual-companion.md @@ -2,9 +2,11 @@ Browser-based visual brainstorming companion for showing mockups, diagrams, and options. -It runs on **Agent Isles** (`isles live`): you author **Markdown screens** with small interactive -islands instead of hand-writing HTML. Agent Isles renders the newest screen, shows it in the -browser, and captures clicks back to you as JSONL events. +You describe each screen in a small `screen.json`; `companion.py` builds it into a single offline +HTML page, serves the newest one from a local Python server, and records the user's clicks as +JSONL events you read back. You never hand-write HTML, CSS, or JavaScript. Full protocol details +(routes, exit codes, state files, check results) are in +`../writing-html-artifacts/references/companion.md`, relative to this file. ## When to Use @@ -13,16 +15,14 @@ Decide per-question, not per-session. The test: **would the user understand this **Use the browser** when the content itself is visual: - **UI mockups** — wireframes, layouts, navigation structures, component designs -- **Architecture diagrams** — system components, data flow, relationship maps (Mermaid/D2 supported) -- **Side-by-side visual comparisons** — comparing two layouts, two color schemes, two design directions -- **Design polish** — when the question is about look and feel, spacing, visual hierarchy -- **Spatial relationships** — state machines, flowcharts, entity relationships rendered as diagrams +- **Architecture diagrams** — system components, data flow, state machines, entity relationships (Mermaid) +- **Side-by-side visual comparisons** — two layouts, two color schemes, two design directions +- **Design polish** — look and feel, spacing, visual hierarchy **Use the terminal** when the content is text or tabular: - **Requirements and scope questions** — "what does X mean?", "which features are in scope?" -- **Conceptual A/B/C choices** — picking between approaches described in words -- **Tradeoff lists** — pros/cons, comparison tables +- **Conceptual A/B/C choices** and **tradeoff lists** — approaches described in words, pros/cons - **Technical decisions** — API design, data modeling, architectural approach selection - **Clarifying questions** — anything where the answer is words, not a visual preference @@ -30,243 +30,190 @@ A question *about* a UI topic is not automatically a visual question. "What kind ## How It Works -`isles live ` watches a directory for **Markdown** files and serves the **newest one** (by -modification time) to the browser, auto-switching when you write a newer screen. You write `.md` -screens to the screen directory; the user sees the rendered result and clicks to select options; -selections are appended to `/state/events` (JSONL) that you read on your next turn. +`companion.py` has five subcommands, each printing exactly one JSON object to stdout (diagnostics +go to stderr): `start` launches a detached local server for a session, `show` builds a screen from +your spec and makes it current, `wait` blocks until the user clicks Proceed, `events` prints the +recorded events, and `stop` stops the server. -You author **standard Markdown plus Agent Isles islands**. The renderer adds the page chrome, -theme, dark mode, a live header/footer, and all interactive infrastructure — you never hand-write -``, CSS, or ``` or the +``b", "css") == r"a<\/style>b<\/style>" + assert vendor.escape_inline("a", "js") == "a" + assert vendor.escape_inline("a", "css") == "a" + with pytest.raises(ValueError): + vendor.escape_inline("x", "html") + + +def test_resolve_current_and_explicit(store: vendor.VendorStore) -> None: + assert store.resolve("fakejs", None).version == "1.1.0" + lv = store.resolve("fakejs", "1.0.0") + assert lv.version == "1.0.0" and lv.license == "MIT" + assert lv.files[0].path == "fake.js" + + +def test_resolve_absent_version_names_upgrade(store: vendor.VendorStore) -> None: + with pytest.raises(vendor.VendorError) as exc: + store.resolve("fakejs", "0.9.0") + assert "--upgrade" in str(exc.value) + + +def test_resolve_unknown_library(store: vendor.VendorStore) -> None: + with pytest.raises(vendor.VendorError): + store.resolve("nonesuch", None) + + +def test_template_entry_shadows_builtin(tmp_path: Path, skill: Path) -> None: + tvendor = tmp_path / "tvendor" + write_json( + tvendor / "libraries.json", + { + "schema": 1, + "libraries": { + "fakecss": { + "current": "9.0.0", + "versions": {"9.0.0": lib_entry(tvendor, "fakecss", "9.0.0", [("f.css", "css", "x{}\n", "head", True)])}, + } + }, + }, + ) + st = vendor.VendorStore([tvendor / "libraries.json", skill / "vendor" / "libraries.json"]) + assert st.resolve("fakecss", None).version == "9.0.0" + assert st.resolve("fakejs", None).version == "1.1.0" + absent = vendor.VendorStore([tmp_path / "nope.json", skill / "vendor" / "libraries.json"]) + assert absent.resolve("fakecss", None).version == "1.0.0" + + +def test_inline_blocks_wrap_and_escape(store: vendor.VendorStore) -> None: + (block,) = store.inline_blocks(store.resolve("fakejs", "1.0.0")) + assert block.kind == "js" and block.position == "body-end" + assert block.text.startswith('") + inner = block.text[len('")] + assert r"<\/script" in inner and "' + FAKE_CSS + "" + assert css.kind == "css" and css.position == "head" + + +def test_inline_blocks_skip_non_inline_files(store: vendor.VendorStore) -> None: + assert store.inline_blocks(store.resolve("servedonly", None)) == [] + + +def test_inline_blocks_reject_checksum_mismatch(skill: Path, store: vendor.VendorStore) -> None: + (skill / "vendor" / "fakecss" / "1.0.0" / "fake.css").write_text("body{color:red}\n", encoding="utf-8") + with pytest.raises(vendor.VendorError) as exc: + store.inline_blocks(store.resolve("fakecss", None)) + assert "sha256" in str(exc.value) or "checksum" in str(exc.value) + + +def test_inline_blocks_missing_file(skill: Path, store: vendor.VendorStore) -> None: + (skill / "vendor" / "fakecss" / "1.0.0" / "fake.css").unlink() + with pytest.raises(vendor.VendorError): + store.inline_blocks(store.resolve("fakecss", None)) + + +def test_license_comment_format(store: vendor.VendorStore) -> None: + lv = store.resolve("fakecss", None) + assert vendor.license_comment(lv) == "" + + +def test_license_comment_never_contains_double_dash(skill: Path) -> None: + lic = skill / "vendor" / "fakecss" / "1.0.0" / "LICENSE" + lic.write_text("Copyright (c) 2024 A -- B --> C\n", encoding="utf-8") + st = vendor.VendorStore([skill / "vendor" / "libraries.json"]) + comment = vendor.license_comment(st.resolve("fakecss", None)) + body = comment[len("")] + assert "--" not in body + + +# tokens + + +def test_merge_later_wins_per_key_per_group() -> None: + a = {"schema": 1, "color": {"bg": ["#fff", "#000"], "text": ["#111", "#eee"]}, "radius": {"sm": "4px"}} + b = {"schema": 1, "color": {"bg": ["#f0f0f0", "#101010"]}, "space": {"1": "2px"}} + merged = tokens.merge_tokens([a, b]) + assert merged["color"] == {"bg": ["#f0f0f0", "#101010"], "text": ["#111", "#eee"]} + assert merged["radius"] == {"sm": "4px"} + assert merged["space"] == {"1": "2px"} + + +def test_merge_does_not_mutate_inputs() -> None: + a = {"schema": 1, "color": {"bg": ["#fff", "#000"]}} + tokens.merge_tokens([a, {"schema": 1, "color": {"bg": ["#eee", "#111"]}}]) + assert a["color"]["bg"] == ["#fff", "#000"] + + +@pytest.mark.parametrize("bad", ["#fff", ["#fff"], ["#fff", "#000", "#111"], [1, 2], None]) +def test_color_must_be_a_light_dark_pair(bad: object) -> None: + with pytest.raises(ValueError): + tokens.merge_tokens([{"schema": 1, "color": {"bg": bad}}]) + with pytest.raises(ValueError): + tokens.tokens_css({"color": {"bg": bad}}) + + +def test_tokens_css_shape() -> None: + css = tokens.tokens_css( + { + "color": {"bg": ["#fff", "#000"], "text-muted": ["#555", "#aaa"]}, + "font": {"sans": "system-ui, sans-serif"}, + "size": {"body": "17px", "h1": "2rem"}, + "space": {"1": "4px"}, + "radius": {"pill": "999px"}, + } + ) + assert ":root {" in css or ":root{" in css + assert "--q-color-bg: #fff;" in css and "--q-color-text-muted: #555;" in css + assert "--q-font-sans: system-ui, sans-serif;" in css + assert "--q-size-h1: 2rem;" in css and "--q-space-1: 4px;" in css and "--q-radius-pill: 999px;" in css + media = css.index("@media (prefers-color-scheme: dark)") + attr = css.index(':root[data-theme="dark"]') + guard = css.index(':root:not([data-theme="light"])') + assert media < guard < attr + light, dark_media, dark_attr = css[:media], css[media:attr], css[attr:] + assert "--q-color-bg: #fff;" in light and "#000" not in light + assert "--q-color-bg: #000;" in dark_media and "--q-color-text-muted: #aaa;" in dark_media + assert "--q-color-bg: #000;" in dark_attr + assert "--q-font-sans" not in dark_media and "--q-font-sans" not in dark_attr + + +@pytest.mark.parametrize( + "bad", + [ + {"font": {"sans": "x; } body { display: none"}}, + {"font": {"sans": "url(https://x/f.woff2)"}}, + {"size": {"body": "@import 'x'"}}, + {"mystery": {"a": "b"}}, + {"space": {"Bad Key": "4px"}}, + ], +) +def test_tokens_reject_unsafe_values(bad: dict) -> None: + with pytest.raises(ValueError): + tokens.tokens_css(bad) + + +def test_token_layers_order_and_missing_files(tmp_path: Path, skill: Path) -> None: + (skill / "theme").mkdir(exist_ok=True) + write_json(skill / "theme" / "house.tokens.json", {"schema": 1, "radius": {"sm": "1px"}}) + user = tmp_path / "user" + project = tmp_path / "project" + assert [layer["radius"]["sm"] for layer in tokens.token_layers(skill, user, project, None)] == ["1px"] + + write_json(user / "tokens.json", {"schema": 1, "radius": {"sm": "2px"}}) + write_json(project / ".quirk" / "html-templates" / "tokens.json", {"schema": 1, "radius": {"sm": "3px"}}) + tdir = make_template(tmp_path / "troot", "basic", tokens="tokens.json") + write_json(tdir / "tokens.json", {"schema": 1, "radius": {"sm": "4px"}}) + tpl = load(tdir) + layers = tokens.token_layers(skill, user, project, tpl) + assert [layer["radius"]["sm"] for layer in layers] == ["1px", "2px", "3px", "4px"] + assert tokens.merge_tokens(layers)["radius"]["sm"] == "4px" + + +# header + + +def test_header_round_trip_with_double_dash() -> None: + meta = { + "schema": 1, + "template": "report", + "template_level": "builtin", + "tier": 1, + "tier_signal": "judgment: --- needs a chart --> and ") + assert "\n" not in line + assert "--" not in line[len("")] + assert "\\u002d" in line + assert header.read_header("\n" + line + "\n") == meta + + +def test_read_header_missing_or_garbage() -> None: + assert header.read_header("\n") is None + assert header.read_header("\n\n") is None + assert header.read_header("\n\n") is None + assert header.read_header("") is None + + +# fsutil + + +def test_atomic_write_str_and_bytes(tmp_path: Path) -> None: + target = tmp_path / "out.html" + fsutil.atomic_write(target, "hello") + assert target.read_text(encoding="utf-8") == "hello" + fsutil.atomic_write(target, b"\x00\x01") + assert target.read_bytes() == b"\x00\x01" + assert [p.name for p in tmp_path.iterdir()] == ["out.html"] + + +def test_atomic_write_failure_leaves_original_and_no_temp(tmp_path: Path) -> None: + target = tmp_path / "out.html" + target.write_text("keep", encoding="utf-8") + with pytest.raises(TypeError): + fsutil.atomic_write(target, 123) # type: ignore[arg-type] + assert target.read_text(encoding="utf-8") == "keep" + assert [p.name for p in tmp_path.iterdir()] == ["out.html"] + + +def test_ensure_self_ignoring(tmp_path: Path) -> None: + d = tmp_path / ".quirk" / "html" + fsutil.ensure_self_ignoring(d) + assert (d / ".gitignore").read_text(encoding="utf-8") == "*\n" + (d / ".gitignore").write_text("custom\n", encoding="utf-8") + fsutil.ensure_self_ignoring(d) + assert (d / ".gitignore").read_text(encoding="utf-8") == "custom\n" + + +# resolve + + +@pytest.fixture +def levels(tmp_path: Path, skill: Path, monkeypatch: pytest.MonkeyPatch) -> dict: + user = tmp_path / "userroot" + project = tmp_path / "proj" + monkeypatch.setenv("QUIRK_HTML_USER_DIR", str(user)) + return {"skill": skill, "user": user, "project": project, "proj_root": project / ".quirk" / "html-templates"} + + +def test_template_roots_order_and_env_default(levels: dict) -> None: + roots = resolve.template_roots(levels["skill"], levels["project"], None) + assert [lvl for lvl, _ in roots] == ["project", "user", "builtin"] + assert roots[0][1] == levels["proj_root"] + assert roots[1][1] == levels["user"] + assert roots[2][1] == levels["skill"] / "templates" + + +def test_user_dir_default_without_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.delenv("QUIRK_HTML_USER_DIR", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + assert resolve.default_user_dir() == Path.home() / ".quirk" / "html-templates" + + +def test_default_project_dir_uses_git_toplevel(tmp_path: Path) -> None: + if shutil.which("git") is None: + pytest.skip("git not available") + repo = tmp_path / "repo" + (repo / "a" / "b").mkdir(parents=True) + subprocess.run(["git", "init", "-q", str(repo)], check=True) + assert resolve.default_project_dir(repo / "a" / "b").resolve() == repo.resolve() + plain = tmp_path / "plain" + plain.mkdir() + assert resolve.default_project_dir(plain).resolve() == plain.resolve() + + +def test_precedence_project_over_user_over_builtin(levels: dict) -> None: + make_template(levels["skill"] / "templates", "basic") + make_template(levels["user"], "basic", verified=True) + make_template(levels["proj_root"], "basic", verified=True) + roots = resolve.template_roots(levels["skill"], levels["project"], None) + tpl, warnings = resolve.find_template("basic", roots) + assert warnings == [] + assert tpl.dir == levels["proj_root"] / "basic" and tpl.level == "project" + + shutil.rmtree(levels["proj_root"] / "basic") + tpl, _ = resolve.find_template("basic", roots) + assert tpl.level == "user" + shutil.rmtree(levels["user"] / "basic") + tpl, _ = resolve.find_template("basic", roots) + assert tpl.level == "builtin" and tpl.dir == levels["skill"] / "templates" / "basic" + + +def test_builtin_does_not_need_verified_but_custom_does(levels: dict) -> None: + make_template(levels["skill"] / "templates", "basic") + make_template(levels["proj_root"], "basic") + roots = resolve.template_roots(levels["skill"], levels["project"], None) + tpl, warnings = resolve.find_template("basic", roots) + assert tpl.level == "builtin" + assert len(warnings) == 1 + w = warnings[0] + assert set(w) == {"name", "level", "path", "reason"} + assert w["name"] == "basic" and w["level"] == "project" + assert w["path"] == str(levels["proj_root"] / "basic") + assert "unverified" in w["reason"] + + +def test_invalid_match_is_skipped_with_warning(levels: dict) -> None: + make_template(levels["skill"] / "templates", "basic") + bad = make_template(levels["user"], "basic", verified=True) + meta = json.loads((bad / "template.json").read_text(encoding="utf-8")) + meta["tier"] = 9 + write_json(bad / "template.json", meta) + roots = resolve.template_roots(levels["skill"], levels["project"], None) + tpl, warnings = resolve.find_template("basic", roots) + assert tpl.level == "builtin" + assert [w["level"] for w in warnings] == ["user"] + + +def test_hash_tampering_after_verification_is_skipped(levels: dict) -> None: + make_template(levels["skill"] / "templates", "basic") + tdir = make_template(levels["proj_root"], "basic", verified=True) + (tdir / "skeleton.html").write_text(SKELETON + "", encoding="utf-8") + roots = resolve.template_roots(levels["skill"], levels["project"], None) + tpl, warnings = resolve.find_template("basic", roots) + assert tpl.level == "builtin" + assert "unverified" in warnings[0]["reason"] + + +def test_template_not_found(levels: dict) -> None: + roots = resolve.template_roots(levels["skill"], levels["project"], None) + with pytest.raises(resolve.TemplateNotFound): + resolve.find_template("nope", roots) + bad = make_template(levels["proj_root"], "basic") + with pytest.raises(resolve.TemplateNotFound) as exc: + resolve.find_template("basic", roots) + assert "unverified" in str(exc.value) + assert bad.exists() + + +@pytest.mark.parametrize("name", ["../escape", "a/b", "", "Upper"]) +def test_find_template_rejects_path_like_names(levels: dict, name: str) -> None: + roots = resolve.template_roots(levels["skill"], levels["project"], None) + with pytest.raises(resolve.TemplateNotFound): + resolve.find_template(name, roots) + + +def test_list_templates_lists_shadowed_names_once(levels: dict) -> None: + make_template(levels["skill"] / "templates", "basic") + make_template(levels["skill"] / "templates", "other") + make_template(levels["proj_root"], "basic", verified=True) + make_template(levels["user"], "mine", verified=True) + make_template(levels["user"], "unverified-one") + roots = resolve.template_roots(levels["skill"], levels["project"], None) + templates, warnings = resolve.list_templates(roots) + by_name = {t.name: t for t in templates} + assert sorted(by_name) == ["basic", "mine", "other"] + assert len(templates) == 3 + assert by_name["basic"].level == "project" + assert by_name["other"].level == "builtin" + assert [(w["name"], w["level"]) for w in warnings] == [("unverified-one", "user")] + + +def test_list_templates_missing_roots(tmp_path: Path) -> None: + roots = [("project", tmp_path / "a"), ("user", tmp_path / "b"), ("builtin", tmp_path / "skill" / "templates")] + assert resolve.list_templates(roots) == ([], []) + + +# checks + + +def make_page( + skill: Path, + store: vendor.VendorStore, + *, + mode: str = "offline", + tier: int = 1, + libs: tuple = (("fakecss", "1.0.0"), ("fakejs", "1.0.0")), + body: str = "

hello

", + head: str = "", + shells: tuple = ("chrome",), + license_comments: bool = True, + meta: dict | None = None, +) -> str: + base_meta = { + "schema": 1, + "template": "basic", + "template_level": "builtin", + "tier": tier, + "tier_signal": None, + "mode": mode if mode != "companion" else "offline", + "libraries": dict(libs), + "sidecar": None, + "built_at": "2026-10-13T00:00:00Z", + "quirk_version": "unknown", + } + parts = ["", header.render_header(meta if meta is not None else base_meta)] + head_blocks, end_blocks = [], [] + for name, ver in libs: + lv = store.resolve(name, ver) + if license_comments: + parts.append(vendor.license_comment(lv)) + for blk in store.inline_blocks(lv): + (head_blocks if blk.position == "head" else end_blocks).append(blk.text) + parts.append("t" + "".join(head_blocks) + head + "") + parts.append(body) + parts.extend(end_blocks) + for name in shells: + parts.append(vendor.shell_block(name, skill / "theme" / "chrome.js").text) + parts.append("\n") + return "\n".join(parts) + + +def run(skill: Path, store: vendor.VendorStore, html: str, *, mode: str = "offline", tier: int = 1, shells: dict | None = None) -> dict: + return checks.run_checks( + html, + mode=mode, + tier=tier, + vendor=store, + shells=shells if shells is not None else {"chrome": skill / "theme" / "chrome.js"}, + ) + + +def failed(result: dict) -> list[str]: + return sorted({f["check"] for f in result["failures"]}) + + +def test_clean_page_passes_and_result_shape(skill: Path, store: vendor.VendorStore) -> None: + result = run(skill, store, make_page(skill, store)) + assert result == {"passed": True, "failures": [], "warnings": []} + + +def test_library_and_shell_blocks_ignored_by_reference_scan(skill: Path, store: vendor.VendorStore) -> None: + html = make_page(skill, store) + assert "https://cdn.example.com/x.js" in html and "fetch(" in html + assert run(skill, store, html)["passed"] + + +def test_header_check(skill: Path, store: vendor.VendorStore) -> None: + good = make_page(skill, store) + no_header = "\n".join(line for line in good.split("\n") if "quirk-html-artifact" not in line) + assert failed(run(skill, store, no_header)) == ["header"] + schemaless = make_page(skill, store, meta={"template": "basic"}) + assert failed(run(skill, store, schemaless)) == ["header"] + garbage = good.replace("", + "

Visit https://x.example for docs.

", + "", + "", + '', +] + + +@pytest.mark.parametrize("snippet", OFFLINE_FAILS) +def test_offline_external_refs_fail(skill: Path, store: vendor.VendorStore, snippet: str) -> None: + result = run(skill, store, make_page(skill, store, body=snippet)) + assert failed(result) == ["external-refs"], result + assert result["passed"] is False + + +@pytest.mark.parametrize("snippet", OFFLINE_PASSES) +def test_offline_allowed_content_passes(skill: Path, store: vendor.VendorStore, snippet: str) -> None: + result = run(skill, store, make_page(skill, store, body=snippet)) + assert result["passed"], result + + +CONNECTED_FAILS = [ + '', + '', + '', + '', + '', + "", +] + +CONNECTED_PASSES = [ + '', + '', + '', + "", + 'x', + "", +] + + +@pytest.mark.parametrize("snippet", CONNECTED_FAILS) +def test_connected_fails(skill: Path, store: vendor.VendorStore, snippet: str) -> None: + result = run(skill, store, make_page(skill, store, mode="connected", body=snippet), mode="connected") + assert failed(result) == ["external-refs"], result + + +@pytest.mark.parametrize("snippet", CONNECTED_PASSES) +def test_connected_allows_data_calls(skill: Path, store: vendor.VendorStore, snippet: str) -> None: + result = run(skill, store, make_page(skill, store, mode="connected", body=snippet), mode="connected") + assert result["passed"], result + + +def test_connected_same_snippets_fail_offline(skill: Path, store: vendor.VendorStore) -> None: + html = make_page(skill, store, body='') + assert failed(run(skill, store, html, mode="offline")) == ["external-refs"] + + +def test_companion_allows_lib_paths_and_shell_fetch(skill: Path, store: vendor.VendorStore, tmp_path: Path) -> None: + companion_js = tmp_path / "companion.js" + companion_js.write_text('fetch("/_quirk/event", {method: "POST"});\n', encoding="utf-8") + shells = {"chrome": skill / "theme" / "chrome.js", "companion": companion_js} + html = make_page( + skill, + store, + body='', + libs=(), + shells=(), + ) + html = html.replace("", vendor.shell_block("companion", companion_js).text + "") + result = run(skill, store, html, mode="companion", shells=shells) + assert result["passed"], result + + +def test_companion_still_blocks_external_and_page_fetch(skill: Path, store: vendor.VendorStore) -> None: + ext = make_page(skill, store, body='') + assert failed(run(skill, store, ext, mode="companion")) == ["external-refs"] + page_fetch = make_page(skill, store, body='') + assert failed(run(skill, store, page_fetch, mode="companion")) == ["external-refs"] + + +def test_tier2_offline_scans_markup_only(skill: Path, store: vendor.VendorStore) -> None: + def bundle(body: str) -> str: + return make_page(skill, store, tier=2, libs=(), shells=(), body=body) + + for bad in ( + '', + '', + "", + "", + ): + assert failed(run(skill, store, bundle(bad), tier=2)) == ["external-refs"], bad + for ok in ( + '', + '', + 'x', + '', + ): + assert run(skill, store, bundle(ok), tier=2)["passed"], ok + + +def test_tier2_connected(skill: Path, store: vendor.VendorStore) -> None: + def bundle(body: str) -> str: + return make_page(skill, store, tier=2, mode="connected", libs=(), shells=(), body=body) + + bad = bundle('') + assert failed(run(skill, store, bad, mode="connected", tier=2)) == ["external-refs"] + ok = bundle('') + assert run(skill, store, ok, mode="connected", tier=2)["passed"] + + +def test_tier2_has_no_library_blocks_to_license_check(skill: Path, store: vendor.VendorStore) -> None: + html = make_page(skill, store, tier=2, libs=(), shells=()) + assert run(skill, store, html, tier=2) == {"passed": True, "failures": [], "warnings": []} + + +def test_failure_detail_names_the_offending_reference(skill: Path, store: vendor.VendorStore) -> None: + html = make_page(skill, store, body='') + detail = run(skill, store, html)["failures"][0]["detail"] + assert "https://tracker.example/pixel.gif" in detail + + +def test_unknown_mode_is_rejected(skill: Path, store: vendor.VendorStore) -> None: + with pytest.raises(ValueError): + run(skill, store, make_page(skill, store), mode="online") + + +MB = 1024 * 1024 + + +def test_size_warns_over_2mb(skill: Path, store: vendor.VendorStore) -> None: + html = make_page(skill, store, body="

" + "x" * (2 * MB + 10) + "

") + result = run(skill, store, html) + assert result["passed"] is True + assert [w["check"] for w in result["warnings"]] == ["size"] + + +def test_size_boundary_is_exclusive(skill: Path, store: vendor.VendorStore) -> None: + base = make_page(skill, store, body="") + pad = 2 * MB - len(base.encode("utf-8")) - len("

") + html = make_page(skill, store, body="

" + "x" * pad + "

") + assert len(html.encode("utf-8")) == 2 * MB + assert run(skill, store, html)["warnings"] == [] + + +def test_size_fails_over_10mb(skill: Path, store: vendor.VendorStore) -> None: + html = make_page(skill, store, body="

" + "x" * (10 * MB + 10) + "

") + result = run(skill, store, html) + assert result["passed"] is False + assert failed(result) == ["size"] + + +def test_size_counts_bytes_not_characters(skill: Path, store: vendor.VendorStore) -> None: + html = make_page(skill, store, body="

" + "é" * (int(1.5 * MB)) + "

") + assert [w["check"] for w in run(skill, store, html)["warnings"]] == ["size"] + + +# house theme + + +def _lum(hex_color: str) -> float: + h = hex_color.lstrip("#") + channels = [int(h[i : i + 2], 16) / 255 for i in (0, 2, 4)] + lin = [c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 for c in channels] + return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2] + + +def contrast(a: str, b: str) -> float: + hi, lo = sorted((_lum(a), _lum(b)), reverse=True) + return (hi + 0.05) / (lo + 0.05) + + +@pytest.fixture(scope="module") +def house() -> dict: + return json.loads(HOUSE_TOKENS.read_text(encoding="utf-8")) + + +def test_house_tokens_are_valid_and_complete(house: dict) -> None: + assert tokens.validate_tokens(house) == [] + assert tokens.merge_tokens([house]) == house + assert {"bg", "surface", "text", "text-muted", "border", "accent", "accent-text", "good", "warn", "critical", "focus"} <= set(house["color"]) + assert {"sans", "mono", "display"} <= set(house["font"]) + assert {"body", "small", "h1", "h2", "h3", "h4"} <= set(house["size"]) + assert house["size"]["body"] == "17px" + assert [house["space"][str(i)] for i in range(1, 9)] == ["4px", "8px", "12px", "16px", "24px", "32px", "48px", "64px"] + assert house["radius"] == {"sm": "4px", "md": "6px", "pill": "999px"} + for stack in house["font"].values(): + assert "url(" not in stack and "Inter" not in stack + + +@pytest.mark.parametrize("scheme", [0, 1], ids=["light", "dark"]) +@pytest.mark.parametrize("role", ["text", "text-muted", "good", "warn", "critical"]) +@pytest.mark.parametrize("ground", ["bg", "surface"]) +def test_house_text_colors_reach_4_5(house: dict, scheme: int, role: str, ground: str) -> None: + c = house["color"] + assert contrast(c[role][scheme], c[ground][scheme]) >= 4.5 + + +@pytest.mark.parametrize("scheme", [0, 1], ids=["light", "dark"]) +def test_house_accent_pairs(house: dict, scheme: int) -> None: + c = house["color"] + assert contrast(c["accent"][scheme], c["bg"][scheme]) >= 3.0 + assert contrast(c["accent-text"][scheme], c["accent"][scheme]) >= 4.5 + assert contrast(c["focus"][scheme], c["bg"][scheme]) >= 3.0 + + +def test_house_palette_avoids_purple(house: dict) -> None: + for pair in house["color"].values(): + for value in pair: + h = value.lstrip("#") + r, g, b = (int(h[i : i + 2], 16) for i in (0, 2, 4)) + purple = b > g + 40 and r > g + 40 + assert not purple, value + + +def test_base_css_maps_simple_and_web_awesome_variables() -> None: + css = BASE_CSS.read_text(encoding="utf-8") + for var in ("--bg", "--accent-bg", "--text", "--text-light", "--border", "--accent", "--accent-hover", "--accent-text", "--code", "--preformatted", "--marked", "--disabled"): + assert f"{var}:" in css, var + assert "--wa-color-brand-" in css and "--wa-color-surface-" in css + assert "var(--q-color-bg)" in css and "var(--q-color-accent)" in css + assert "color-scheme: light" in css and "color-scheme: dark" in css + assert '@media (prefers-color-scheme: dark)' in css and ':root:not([data-theme="light"])' in css + assert ':root[data-theme="dark"]' in css + assert ":focus-visible" in css and "outline: 2px solid" in css and "outline-offset: 2px" in css + assert "[x-cloak]" in css and "display: none !important" in css + assert ".q-theme-toggle" in css and ".q-data-picker" in css + assert "url(" not in css.lower() and "@import" not in css.lower() and "@font-face" not in css.lower() + + +def test_chrome_js_makes_no_network_calls() -> None: + js = CHROME_JS.read_text(encoding="utf-8") + for banned in ("fetch(", "XMLHttpRequest", "WebSocket", "EventSource", "http://", "https://", "import(", "= 0 ? opts.preToggle : null; }, + querySelectorAll() { return []; }, + createElement: el, + addEventListener(t, f) { (this.listeners[t] = this.listeners[t] || []).push(f); }, + }; + if (opts.data !== undefined) { const d = el("script"); d.textContent = opts.data; byId["quirk-data"] = d; } + const win = { localStorage: opts.noStorage ? { getItem() { throw new Error("blocked"); }, setItem() { throw new Error("blocked"); }, removeItem() { throw new Error("blocked"); } } : { getItem: k => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } } }; + new Function("window", "document", src)(win, doc); + return { root, body, doc, win, store, byId }; +} +module.exports = { run, el }; +const scenario = process.argv[3]; +const out = {}; +(async () => { + if (scenario === "toggle") { + const t = run({}); + const btn = t.body.children[0]; + out.created = !!btn; + out.initial = t.root.getAttribute("data-theme"); + const seen = []; + for (let i = 0; i < 4; i++) { btn.listeners.click[0](); seen.push(t.root.getAttribute("data-theme")); } + out.cycle = seen; + out.stored = t.store["quirk-theme"] === undefined ? null : t.store["quirk-theme"]; + const t2 = run({ storage: { "quirk-theme": "dark" } }); + out.restored = t2.root.getAttribute("data-theme"); + const t3 = run({ noStorage: true }); + t3.body.children[0].listeners.click[0](); + out.noStorage = t3.root.getAttribute("data-theme"); + } else if (scenario === "embedded") { + const t = run({ data: '{"a": [1, 2], "u": "\\u003c/script>"}' }); + out.value = await t.win.quirkData(); + out.same = t.win.quirkData() === t.win.quirkData(); + } else if (scenario === "picker") { + const t = run({}); + const p = t.win.quirkData(); + const picker = el("input"); picker.id = "quirk-data-picker"; + picker.files = [{ name: "rows.csv", text: async () => '\ufeffname,qty\r\n' + '"Smith, J",3\r\n' + '"say ' + '""' + 'hi' + '""' + '",4\r\n\r\n' }]; + t.doc.listeners.change[0]({ target: picker }); + out.csv = await p; + const t2 = run({}); + const p2 = t2.win.quirkData(); + const jp = el("input"); jp.id = "quirk-data-picker"; + jp.files = [{ name: "d.json", text: async () => '{"k": 1}' }]; + t2.doc.listeners.change[0]({ target: jp }); + out.json = await p2; + out.parse = t.win.quirkData.parseCSV("a,b\n1,2\n3\n"); + } + console.log(JSON.stringify(out)); +})(); +""" + + +def _node(tmp_path: Path, scenario: str) -> dict: + harness = tmp_path / "harness.js" + harness.write_text(CHROME_HARNESS, encoding="utf-8") + proc = subprocess.run( + ["node", str(harness), str(CHROME_JS), scenario], capture_output=True, text=True, timeout=30 + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.mark.skipif(NODE is None, reason="node not available") +def test_chrome_js_theme_toggle_cycles_and_persists(tmp_path: Path) -> None: + out = _node(tmp_path, "toggle") + assert out["created"] is True + assert out["initial"] is None + assert out["cycle"] == ["light", "dark", None, "light"] + assert out["restored"] == "dark" + assert out["noStorage"] == "light" + + +@pytest.mark.skipif(NODE is None, reason="node not available") +def test_chrome_js_quirk_data_embedded(tmp_path: Path) -> None: + out = _node(tmp_path, "embedded") + assert out["value"] == {"a": [1, 2], "u": ""} + assert out["same"] is True + + +@pytest.mark.skipif(NODE is None, reason="node not available") +def test_chrome_js_quirk_data_from_picker(tmp_path: Path) -> None: + out = _node(tmp_path, "picker") + assert out["csv"] == [{"name": "Smith, J", "qty": "3"}, {"name": 'say "hi"', "qty": "4"}] + assert out["json"] == {"k": 1} + assert out["parse"] == [{"a": "1", "b": "2"}, {"a": "3", "b": ""}] + + +@pytest.mark.skipif(NODE is None, reason="node not available") +def test_chrome_js_is_valid_script(tmp_path: Path) -> None: + proc = subprocess.run(["node", "--check", str(CHROME_JS)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + assert os.path.getsize(CHROME_JS) < 20_000 From c890634ca969559b6d77f98fcd46591878295400 Mon Sep 17 00:00:00 2001 From: zpyoung Date: Tue, 13 Oct 2026 14:56:16 -0500 Subject: [PATCH 009/164] feat(writing-html-artifacts): add Playwright browser verify and its runner --- .../scripts/htmlkit/verify_runner.py | 128 +++++++++ .../scripts/verify/package.json | 8 + .../scripts/verify/pnpm-lock.yaml | 24 ++ .../scripts/verify/verify.mjs | 260 ++++++++++++++++++ tests/fixtures/html-verify/fetches.html | 27 ++ .../html-verify/lowcontrast-dark.html | 26 ++ tests/fixtures/html-verify/ok.html | 26 ++ tests/fixtures/html-verify/overflow.html | 27 ++ tests/test_html_verify.py | 137 +++++++++ 9 files changed, 663 insertions(+) create mode 100644 skills/writing-html-artifacts/scripts/htmlkit/verify_runner.py create mode 100644 skills/writing-html-artifacts/scripts/verify/package.json create mode 100644 skills/writing-html-artifacts/scripts/verify/pnpm-lock.yaml create mode 100644 skills/writing-html-artifacts/scripts/verify/verify.mjs create mode 100644 tests/fixtures/html-verify/fetches.html create mode 100644 tests/fixtures/html-verify/lowcontrast-dark.html create mode 100644 tests/fixtures/html-verify/ok.html create mode 100644 tests/fixtures/html-verify/overflow.html create mode 100644 tests/test_html_verify.py diff --git a/skills/writing-html-artifacts/scripts/htmlkit/verify_runner.py b/skills/writing-html-artifacts/scripts/htmlkit/verify_runner.py new file mode 100644 index 00000000..75cc84f4 --- /dev/null +++ b/skills/writing-html-artifacts/scripts/htmlkit/verify_runner.py @@ -0,0 +1,128 @@ +"""Runs the Node/Playwright browser verification (`scripts/verify/verify.mjs`) on an HTML file. + +Guarantees a `VerifyResult` dict for every call: when Node, the dependency install or the browser +is unavailable the result has `status: "not-browser-verified"` and a `reason`, never an exception. +The report is also written to `/report.json` so `report_is_stale` can later tell whether +verification ran on the file as it is now. Never downloads a browser and never opens the network +beyond the one-time `pnpm install` of `playwright-core`. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import subprocess +from pathlib import Path + +VERIFY_DIR = Path(__file__).resolve().parent.parent / "verify" +INSTALL_TIMEOUT_S = 300 +VERIFY_TIMEOUT_S = 180 + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def default_out_dir(file: Path) -> Path: + """`.verify/` beside the file.""" + return file.parent / f"{file.stem}.verify" + + +def _not_verified(file: Path, out_dir: Path, reason: str, detail: str = "") -> dict: + result: dict = { + "status": "not-browser-verified", + "file_sha256": _sha256(file), + "browser": None, + "checks": {}, + "screenshots": [], + "requests": [], + "reason": reason, + } + if detail: + result["detail"] = detail + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / "report.json").write_text(json.dumps(result, indent=2) + "\n") + return result + + +def _install_command() -> list[str] | None: + if shutil.which("pnpm"): + return ["pnpm", "install", "--frozen-lockfile"] + if shutil.which("corepack"): + return ["corepack", "pnpm", "install", "--frozen-lockfile"] + return None + + +def _ensure_installed() -> str | None: + """Installs the Node dependency when absent; returns an error detail on failure, else None.""" + if (VERIFY_DIR / "node_modules").is_dir(): + return None + command = _install_command() + if command is None: + return "neither pnpm nor corepack is available" + env = {**os.environ, "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD": "1"} + try: + proc = subprocess.run( + command, + cwd=VERIFY_DIR, + env=env, + capture_output=True, + text=True, + timeout=INSTALL_TIMEOUT_S, + ) + except (subprocess.TimeoutExpired, OSError) as exc: + return f"{type(exc).__name__}: {exc}" + if proc.returncode != 0: + return (proc.stderr or proc.stdout).strip()[-500:] + return None + + +def run_verify(file: Path, out_dir: Path | None = None, mode: str = "offline") -> dict: + """Verifies `file` in a real browser and returns the `VerifyResult`. + + `mode` is "offline" (any non-local request fails the network check) or "connected" (requests + are listed but do not fail it). + """ + if mode not in ("offline", "connected"): + raise ValueError(f"mode must be 'offline' or 'connected', got {mode!r}") + file = Path(file).resolve() + out_dir = Path(out_dir) if out_dir is not None else default_out_dir(file) + + node = shutil.which("node") + if node is None: + return _not_verified(file, out_dir, "no-node") + error = _ensure_installed() + if error is not None: + return _not_verified(file, out_dir, "install-failed", error) + + report = out_dir / "report.json" + report.unlink(missing_ok=True) + try: + proc = subprocess.run( + [node, str(VERIFY_DIR / "verify.mjs"), str(file), str(out_dir), mode], + capture_output=True, + text=True, + timeout=VERIFY_TIMEOUT_S, + ) + except subprocess.TimeoutExpired: + return _not_verified(file, out_dir, "verify-timeout") + + try: + result = json.loads(report.read_text()) + except (OSError, ValueError): + return _not_verified(file, out_dir, "verify-crashed", proc.stderr.strip()[-500:]) + if result.get("status") == "not-browser-verified": + result["reason"] = "no-browser" + report.write_text(json.dumps(result, indent=2) + "\n") + return result + + +def report_is_stale(file: Path, report: Path) -> bool: + """True when `report` is missing, unreadable, or was produced for different file contents.""" + try: + recorded = json.loads(Path(report).read_text()).get("file_sha256") + return recorded != _sha256(Path(file)) + except (OSError, ValueError, AttributeError): + return True diff --git a/skills/writing-html-artifacts/scripts/verify/package.json b/skills/writing-html-artifacts/scripts/verify/package.json new file mode 100644 index 00000000..f641f8a6 --- /dev/null +++ b/skills/writing-html-artifacts/scripts/verify/package.json @@ -0,0 +1,8 @@ +{ + "name": "quirk-html-verify", + "private": true, + "type": "module", + "dependencies": { + "playwright-core": "1.64.0" + } +} diff --git a/skills/writing-html-artifacts/scripts/verify/pnpm-lock.yaml b/skills/writing-html-artifacts/scripts/verify/pnpm-lock.yaml new file mode 100644 index 00000000..be2ce7dc --- /dev/null +++ b/skills/writing-html-artifacts/scripts/verify/pnpm-lock.yaml @@ -0,0 +1,24 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + playwright-core: + specifier: 1.64.0 + version: 1.64.0 + +packages: + + playwright-core@1.64.0: + resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==} + engines: {node: '>=20'} + hasBin: true + +snapshots: + + playwright-core@1.64.0: {} diff --git a/skills/writing-html-artifacts/scripts/verify/verify.mjs b/skills/writing-html-artifacts/scripts/verify/verify.mjs new file mode 100644 index 00000000..3ef365c2 --- /dev/null +++ b/skills/writing-html-artifacts/scripts/verify/verify.mjs @@ -0,0 +1,260 @@ +#!/usr/bin/env node +/** + * Browser verification for a single HTML file: console cleanliness, responsive layout, + * light/dark text contrast and network isolation. + * + * Usage: node verify.mjs FILE OUT_DIR MODE (MODE is "offline" or "connected") + * + * Writes OUT_DIR/report.json and four full-page screenshots, prints the report to stdout. + * Exit codes: 0 passed, 1 failed, 2 usage error, 3 no browser could be launched. + * Never downloads a browser; it only drives one that is already installed. + */ +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { chromium } from "playwright-core"; + +const WIDTHS = [375, 1280]; +const SCHEMES = ["light", "dark"]; +const HEIGHT = 800; +const MAX_DETAILS = 20; +const LOCAL_SCHEMES = ["file:", "data:", "blob:", "about:"]; + +const [, , fileArg, outArg, mode] = process.argv; +if (!fileArg || !outArg || !["offline", "connected"].includes(mode)) { + console.error("usage: verify.mjs FILE OUT_DIR offline|connected"); + process.exit(2); +} + +const file = path.resolve(fileArg); +const outDir = path.resolve(outArg); +const url = pathToFileURL(file).href; + +/** Launches the first available browser; returns `{browser, label}` or null. */ +async function launchBrowser() { + const candidates = [ + ["chrome", { channel: "chrome" }], + ["msedge", { channel: "msedge" }], + ]; + if (process.env.CHROME_PATH) { + candidates.push(["CHROME_PATH", { executablePath: process.env.CHROME_PATH }]); + } + let bundled = null; + try { + bundled = chromium.executablePath(); + } catch { + bundled = null; + } + if (bundled && existsSync(bundled)) { + candidates.push(["chromium", { executablePath: bundled }]); + } + for (const [label, options] of candidates) { + try { + return { browser: await chromium.launch({ headless: true, ...options }), label }; + } catch (err) { + console.error(`browser ${label} did not launch: ${String(err.message).split("\n")[0]}`); + } + } + return null; +} + +/** + * Runs inside the page. Returns one finding string per sampled element whose text contrast + * is below WCAG AA (4.5:1, or 3:1 from 24px up), plus a count of elements it could not judge. + */ +function sampleContrast() { + const SELECTOR = "body, h1, h2, h3, h4, h5, h6, p, li, td, th, label, button, a"; + const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true }); + ctx.canvas.width = ctx.canvas.height = 1; + + const parse = (css) => { + ctx.clearRect(0, 0, 1, 1); + ctx.fillStyle = "#000"; + ctx.fillStyle = css; + ctx.fillRect(0, 0, 1, 1); + const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data; + return { r, g, b, a: a / 255 }; + }; + const over = (top, under) => ({ + r: top.r * top.a + under.r * (1 - top.a), + g: top.g * top.a + under.g * (1 - top.a), + b: top.b * top.a + under.b * (1 - top.a), + a: 1, + }); + const lum = ({ r, g, b }) => { + const lin = (v) => { + const s = v / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); + }; + const hex = ({ r, g, b }) => + "#" + [r, g, b].map((v) => Math.round(v).toString(16).padStart(2, "0")).join(""); + + const dark = matchMedia("(prefers-color-scheme: dark)").matches; + const declaresDark = getComputedStyle(document.documentElement).colorScheme.includes("dark"); + const canvas = dark && declaresDark ? { r: 18, g: 18, b: 18, a: 1 } : { r: 255, g: 255, b: 255, a: 1 }; + + const background = (el) => { + const layers = []; + for (let node = el; node; node = node.parentElement) { + const cs = getComputedStyle(node); + if (cs.backgroundImage !== "none") return null; + const bg = parse(cs.backgroundColor); + if (bg.a > 0) layers.push(bg); + if (bg.a === 1) break; + } + return layers.reduceRight((under, layer) => over(layer, under), canvas); + }; + + const findings = []; + let unjudged = 0; + for (const el of document.querySelectorAll(SELECTOR)) { + const text = [...el.childNodes] + .filter((n) => n.nodeType === Node.TEXT_NODE) + .map((n) => n.textContent) + .join(" ") + .trim(); + if (!text) continue; + const cs = getComputedStyle(el); + const rect = el.getBoundingClientRect(); + if (cs.display === "none" || cs.visibility !== "visible" || rect.width === 0 || rect.height === 0) continue; + if (Number(cs.opacity) === 0) continue; + const bg = background(el); + if (!bg) { + unjudged += 1; + continue; + } + const fg = over(parse(cs.color), bg); + const [hi, lo] = [lum(fg), lum(bg)].sort((a, b) => b - a); + const ratio = (hi + 0.05) / (lo + 0.05); + const size = parseFloat(cs.fontSize); + const needed = size >= 24 ? 3 : 4.5; + if (ratio < needed) { + const label = text.length > 30 ? text.slice(0, 30) + "..." : text; + findings.push( + `<${el.tagName.toLowerCase()}> "${label}" contrast ${ratio.toFixed(2)}:1 < ${needed}:1 (${hex(fg)} on ${hex(bg)})`, + ); + } + } + return { findings, unjudged }; +} + +function check(details) { + return { passed: details.length === 0, details }; +} + +async function verify(browser, label, sha) { + const consoleDetails = []; + const viewportDetails = []; + const themeDetails = []; + const requests = new Set(); + const screenshots = []; + let skippedBackgrounds = 0; + + for (const scheme of SCHEMES) { + for (const width of WIDTHS) { + const run = `${width}px ${scheme}`; + const context = await browser.newContext({ + viewport: { width, height: HEIGHT }, + colorScheme: scheme, + serviceWorkers: "block", + }); + const blocked = new Set(); + await context.route(/^https?:/, (route) => { + const target = route.request().url(); + requests.add(target); + blocked.add(target); + return route.abort(); + }); + context.on("request", (req) => { + const target = req.url(); + if (!LOCAL_SCHEMES.some((s) => target.startsWith(s))) requests.add(target); + }); + + const page = await context.newPage(); + page.on("console", (msg) => { + if (msg.type() !== "error") return; + // a blocked request logs "Failed to load resource"; the network check owns that failure + if (blocked.has(msg.location().url)) return; + consoleDetails.push(`${run}: console.error: ${msg.text()}`); + }); + page.on("pageerror", (err) => consoleDetails.push(`${run}: pageerror: ${err.message}`)); + + await page.goto(url, { waitUntil: "load" }); + await page.waitForTimeout(150); + + const shot = path.join(outDir, `${width}-${scheme}.png`); + await page.screenshot({ path: shot, fullPage: true }); + screenshots.push(shot); + + if (width === WIDTHS[0]) { + const { scrollWidth, clientWidth } = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + })); + if (scrollWidth > clientWidth) { + viewportDetails.push(`${run}: scrollWidth ${scrollWidth} exceeds clientWidth ${clientWidth}`); + } + } + if (width === WIDTHS[WIDTHS.length - 1]) { + const { findings, unjudged } = await page.evaluate(sampleContrast); + findings.forEach((f) => themeDetails.push(`${scheme}: ${f}`)); + skippedBackgrounds += unjudged; + } + await context.close(); + } + } + + const requestList = [...requests].sort(); + const networkDetails = requestList.map((r) => `${mode === "offline" ? "blocked" : "reported"} request: ${r}`); + const network = mode === "offline" ? check(networkDetails) : { passed: true, details: networkDetails }; + const themes = check(themeDetails); + if (skippedBackgrounds > 0) { + themes.details.push(`note: ${skippedBackgrounds} sampled elements sit on a background image and were not judged`); + } + + const checks = { + console: check(consoleDetails.slice(0, MAX_DETAILS)), + viewports: check(viewportDetails), + themes: { passed: themes.passed, details: themes.details.slice(0, MAX_DETAILS) }, + network, + }; + const status = Object.values(checks).every((c) => c.passed) ? "passed" : "failed"; + return { status, file_sha256: sha, browser: label, checks, screenshots, requests: requestList }; +} + +async function main() { + const sha = createHash("sha256").update(await readFile(file)).digest("hex"); + await mkdir(outDir, { recursive: true }); + + const launched = await launchBrowser(); + let report; + if (!launched) { + report = { + status: "not-browser-verified", + file_sha256: sha, + browser: null, + checks: {}, + screenshots: [], + requests: [], + }; + } else { + try { + report = await verify(launched.browser, launched.label, sha); + } finally { + await launched.browser.close(); + } + } + + await writeFile(path.join(outDir, "report.json"), JSON.stringify(report, null, 2) + "\n"); + console.log(JSON.stringify(report)); + process.exit(report.status === "passed" ? 0 : report.status === "failed" ? 1 : 3); +} + +main().catch((err) => { + console.error(err); + process.exit(4); +}); diff --git a/tests/fixtures/html-verify/fetches.html b/tests/fixtures/html-verify/fetches.html new file mode 100644 index 00000000..4874a8e1 --- /dev/null +++ b/tests/fixtures/html-verify/fetches.html @@ -0,0 +1,27 @@ + + + + + +Fetches fixture + + + +

Quarterly summary

+

Everything here is self-contained and legible in both themes.

+
  • First item
  • Second item
+
NameValue
Alpha1
+ + +

A link

+ + + diff --git a/tests/fixtures/html-verify/lowcontrast-dark.html b/tests/fixtures/html-verify/lowcontrast-dark.html new file mode 100644 index 00000000..297fc722 --- /dev/null +++ b/tests/fixtures/html-verify/lowcontrast-dark.html @@ -0,0 +1,26 @@ + + + + + +Low contrast dark fixture + + + +

Quarterly summary

+

Everything here is self-contained and legible in both themes.

+
  • First item
  • Second item
+
NameValue
Alpha1
+ + +

A link

+ + diff --git a/tests/fixtures/html-verify/ok.html b/tests/fixtures/html-verify/ok.html new file mode 100644 index 00000000..05023515 --- /dev/null +++ b/tests/fixtures/html-verify/ok.html @@ -0,0 +1,26 @@ + + + + + +OK fixture + + + +

Quarterly summary

+

Everything here is self-contained and legible in both themes.

+
  • First item
  • Second item
+
NameValue
Alpha1
+ + +

A link

+ + diff --git a/tests/fixtures/html-verify/overflow.html b/tests/fixtures/html-verify/overflow.html new file mode 100644 index 00000000..3e0354f0 --- /dev/null +++ b/tests/fixtures/html-verify/overflow.html @@ -0,0 +1,27 @@ + + + + + +Overflow fixture + + + +

Quarterly summary

+

Everything here is self-contained and legible in both themes.

+
  • First item
  • Second item
+
NameValue
Alpha1
+ + +

A link

+
+ + diff --git a/tests/test_html_verify.py b/tests/test_html_verify.py new file mode 100644 index 00000000..bb81e9aa --- /dev/null +++ b/tests/test_html_verify.py @@ -0,0 +1,137 @@ +"""Tests for the browser verify runner (`htmlkit.verify_runner`) and `verify.mjs`. + +Staleness tests always run. Browser tests need `scripts/verify/node_modules` and a launchable +browser; they skip otherwise, and never touch the network. +""" + +from __future__ import annotations + +import hashlib +import json +import shutil +import sys +from pathlib import Path + +import pytest + +SKILL = Path(__file__).resolve().parent.parent / "skills" / "writing-html-artifacts" +SCRIPTS = SKILL / "scripts" +FIXTURES = Path(__file__).resolve().parent / "fixtures" / "html-verify" +sys.path.insert(0, str(SCRIPTS)) + +from htmlkit import verify_runner # noqa: E402 + +SCREENSHOTS = ["375-light.png", "375-dark.png", "1280-light.png", "1280-dark.png"] + + +def _sha(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def test_report_is_stale_when_report_missing(tmp_path: Path) -> None: + page = tmp_path / "a.html" + page.write_text("

x

") + assert verify_runner.report_is_stale(page, tmp_path / "report.json") is True + + +def test_report_is_fresh_when_hash_matches(tmp_path: Path) -> None: + page = tmp_path / "a.html" + page.write_text("

x

") + report = tmp_path / "report.json" + report.write_text(json.dumps({"file_sha256": _sha(page)})) + assert verify_runner.report_is_stale(page, report) is False + + +def test_report_is_stale_after_file_changes(tmp_path: Path) -> None: + page = tmp_path / "a.html" + page.write_text("

x

") + report = tmp_path / "report.json" + report.write_text(json.dumps({"file_sha256": _sha(page)})) + page.write_text("

y

") + assert verify_runner.report_is_stale(page, report) is True + + +def test_report_is_stale_when_report_unreadable(tmp_path: Path) -> None: + page = tmp_path / "a.html" + page.write_text("

x

") + report = tmp_path / "report.json" + report.write_text("not json") + assert verify_runner.report_is_stale(page, report) is True + + +def test_no_node_gives_not_browser_verified(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + page = tmp_path / "a.html" + page.write_text("

x

") + monkeypatch.setattr(verify_runner.shutil, "which", lambda name: None) + result = verify_runner.run_verify(page, tmp_path / "out") + assert result["status"] == "not-browser-verified" + assert result["reason"] == "no-node" + assert result["file_sha256"] == _sha(page) + + +def test_default_out_dir_is_beside_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + page = tmp_path / "report.html" + page.write_text("

x

") + monkeypatch.setattr(verify_runner.shutil, "which", lambda name: None) + verify_runner.run_verify(page) + assert (tmp_path / "report.verify" / "report.json").is_file() + + +@pytest.fixture(scope="module") +def browser_ready(tmp_path_factory: pytest.TempPathFactory) -> None: + if not (SCRIPTS / "verify" / "node_modules").is_dir(): + pytest.skip("scripts/verify/node_modules is absent; run pnpm install there") + if shutil.which("node") is None: + pytest.skip("node is not installed") + probe = verify_runner.run_verify(FIXTURES / "ok.html", tmp_path_factory.mktemp("probe")) + if probe["status"] == "not-browser-verified": + pytest.skip(f"no browser could be launched ({probe.get('reason')})") + + +def _run(name: str, tmp_path: Path, mode: str = "offline") -> dict: + return verify_runner.run_verify(FIXTURES / name, tmp_path / "out", mode=mode) + + +@pytest.mark.usefixtures("browser_ready") +def test_ok_fixture_passes_with_four_screenshots(tmp_path: Path) -> None: + result = _run("ok.html", tmp_path) + assert result["status"] == "passed", result + assert result["file_sha256"] == _sha(FIXTURES / "ok.html") + assert all(result["checks"][k]["passed"] for k in ("console", "viewports", "themes", "network")) + assert sorted(Path(p).name for p in result["screenshots"]) == sorted(SCREENSHOTS) + for name in SCREENSHOTS: + assert (tmp_path / "out" / name).stat().st_size > 0 + assert result["requests"] == [] + assert verify_runner.report_is_stale(FIXTURES / "ok.html", tmp_path / "out" / "report.json") is False + + +@pytest.mark.usefixtures("browser_ready") +def test_fetching_fixture_fails_network_and_records_url(tmp_path: Path) -> None: + result = _run("fetches.html", tmp_path) + assert result["status"] == "failed" + assert result["checks"]["network"]["passed"] is False + assert any("https://example.com/x" in url for url in result["requests"]) + + +@pytest.mark.usefixtures("browser_ready") +def test_overflow_fixture_fails_viewports(tmp_path: Path) -> None: + result = _run("overflow.html", tmp_path) + assert result["status"] == "failed" + assert result["checks"]["viewports"]["passed"] is False + assert result["checks"]["themes"]["passed"] is True + + +@pytest.mark.usefixtures("browser_ready") +def test_low_contrast_dark_fixture_fails_themes(tmp_path: Path) -> None: + result = _run("lowcontrast-dark.html", tmp_path) + assert result["status"] == "failed" + assert result["checks"]["themes"]["passed"] is False + assert any("dark" in d for d in result["checks"]["themes"]["details"]) + + +@pytest.mark.usefixtures("browser_ready") +def test_connected_mode_reports_requests_without_failing(tmp_path: Path) -> None: + result = _run("fetches.html", tmp_path, mode="connected") + assert result["checks"]["network"]["passed"] is True + assert any("https://example.com/x" in url for url in result["requests"]) + assert result["status"] == "passed", result From de254a683c6be0d31ff5c5994fb1679380812266 Mon Sep 17 00:00:00 2001 From: zpyoung Date: Tue, 13 Oct 2026 14:56:16 -0500 Subject: [PATCH 010/164] docs(writing-html-artifacts): add design, templates and companion references --- .../references/companion.md | 111 +++++++ .../references/design-reference.md | 276 ++++++++++++++++++ .../references/templates.md | 175 +++++++++++ 3 files changed, 562 insertions(+) create mode 100644 skills/writing-html-artifacts/references/companion.md create mode 100644 skills/writing-html-artifacts/references/design-reference.md create mode 100644 skills/writing-html-artifacts/references/templates.md diff --git a/skills/writing-html-artifacts/references/companion.md b/skills/writing-html-artifacts/references/companion.md new file mode 100644 index 00000000..d4f39ab4 --- /dev/null +++ b/skills/writing-html-artifacts/references/companion.md @@ -0,0 +1,111 @@ +# Companion + +The companion is a local page that shows the user one question at a time in a browser and sends their clicks back. Brainstorming and exploring-ideas use it for visual questions. Read this before starting a session. For screen design, see section 12 of the [design reference](design-reference.md); for custom branding of screens, see [templates](templates.md). + +Script paths below are relative to the skill directory, the one holding this skill's `SKILL.md`. Every command prints exactly one JSON object to stdout; diagnostics go to stderr. + +## The loop + +1. **Start once** per session: `scripts/companion.py start SESSION_DIR`. Keep the printed `url`; give it to the user. A second `start` for a live session reuses the server. +2. **Show per question**: write a `screen.json` and run `scripts/companion.py show SESSION_DIR SCREEN.json`. One question per screen. +3. **Wait**: `scripts/companion.py wait SESSION_DIR`. It blocks until the user clicks Proceed on the current screen. +4. **On exit 1 (timeout), re-run `wait`.** The host's tool limit is 120 s and the default timeout is 110 s, so a timeout is normal. No click is lost; the events file is append-only. +5. **Read the result**: `proceed` holds the final selection and note; `clicks` holds every click on this screen in order, so you can see what the user considered and changed. +6. **Terminal input outranks clicks.** If the user types something in the terminal, act on that, not on the page's selection. Clicks are a convenience, not an instruction that overrides the user's words. +7. Record decisions in the spec or plan. Screens and events are transient and are not the record. +8. **Stop** with `scripts/companion.py stop SESSION_DIR` when the session ends. The server also stops itself when idle. + +`SESSION_DIR` lives under `.quirk/brainstorm/`, for example `.quirk/brainstorm/2026-10-13-dashboard/`. That directory is self-ignored by git, so nothing in it is ever committed. + +## Commands + +``` +companion.py start SESSION_DIR [--host 127.0.0.1] [--port 0] [--idle-minutes 30] [--foreground] +companion.py show SESSION_DIR SCREEN.json [--project-dir DIR] +companion.py wait SESSION_DIR [--timeout 110] +companion.py events SESSION_DIR [--screen ID] +companion.py stop SESSION_DIR +``` + +| Command | Prints | Exit codes | +|---|---|---| +| `start` | `{"type": "server-started", "pid", "port", "host", "url", "screen_dir", "state_dir"}` | `0` started or reused; `2` the server wrote no `server-info` within 10 s | +| `show` | `{"screen", "nonce", "url", "checks"}` | `0` shown; `1` a static check failed (the screen is written but not made current); `2` malformed `screen.json` or missing session | +| `wait` | `{"proceed": , "clicks": [, ...]}` | `0` proceed received; `1` timeout, stdout `{"status": "timeout", "screen"}`, stderr says to re-run; `2` no live server, stdout `{"status": "no-server"}` | +| `events` | `{"events": [...]}` | `0` | +| `stop` | `{"stopped": bool}` | `0`, with `stopped: false` when no server was running | + +Flags: + +- `start --host`: bind address, default `127.0.0.1`. `--port`: `0` picks a free port. `--idle-minutes`: shutdown after this much inactivity, default 30. `--foreground`: run in the current process instead of detaching, mainly for tests. +- `show --project-dir`: where to find project tokens and templates; defaults to the git toplevel of the current directory. +- `wait --timeout`: seconds to block, default 110. +- `events --screen ID`: only events for that screen, for example `0003-layout`. + +`wait` exit 2 means no live server: `server-info` is missing or unparseable, `server-stopped` exists, or the pid is dead. Run `start` again, then `show` the current question again. + +## `screen.json` + +```json +{ + "title": "Layout", + "question": "Which layout fits the dashboard?", + "select": "single", + "options": [ + { "id": "two-col", "label": "Two column", "description": "Filters left, results right", "html": "" }, + { "id": "stacked", "label": "Stacked", "description": "Filters above results" } + ], + "content": "", + "diagram": "" +} +``` + +- `title` is short and becomes the page ``. `question` is the header the user reads. +- `select` is `single` or `multi`. +- `options` has 2 to 4 entries. Each `id` is unique and matches `[a-z0-9-]{1,40}`. `label` is what the user clicks, `description` says the consequence, and `html` is an optional per-option mockup. +- `content` is freeform HTML shown with the question. `diagram` is Mermaid source; the screen loads the server-hosted Mermaid build only when `diagram` is set. +- A screen that only shows something still offers 2 to 4 options about it. +- `content` and option `html` are served exactly as written. Do not paste untrusted text into them. + +## Event record + +`state/events` is JSONL, one record per accepted event. `wait` and `events` return these records. + +```json +{"type": "click", "screen": "0003-layout", "nonce": "<token>", "choice": "two-col", "text": "Two column", "selected": ["two-col"], "note": null, "timestamp": 1760356800.5} +``` + +- `type` is `click` (an option toggled) or `proceed` (the Proceed button). +- `choice` is the option id for a click, and `null` for proceed. `selected` is the whole selection at that moment, and `note` is the optional text field. +- `wait` returns the first `proceed` for the current screen at or after the time that screen was shown, plus that screen's clicks. + +## Stale screens + +- Every `show` mints a new nonce and makes a new file. Filenames are `screens/NNNN-<slug>.html` with a zero-padded counter and are never reused. +- A click from an older screen is rejected with 409. The old tab shows a "superseded" banner and disables its controls within about 2 seconds, so the user knows to switch to the new tab. +- A stale click never appears in `events`, so you cannot mistake it for an answer to the current question. + +## Idle shutdown + +- With no HTTP request and no `show` for `--idle-minutes` (default 30), the server writes `state/server-stopped` with reason `idle` and exits. +- `stop` and a termination signal write reasons `stop` and `signal`. +- After a stop, `wait` exits 2. Start a new server with `start`. + +## Session directory + +| Path | Contents | +|---|---| +| `screens/NNNN-<slug>.html` | each built screen | +| `state/current.json` | `{"screen", "nonce", "shown_at"}` for the current screen | +| `state/server-info` | the `server-started` JSON | +| `state/server-stopped` | `{"reason": "idle" or "stop" or "signal", "at"}`, present once the server has exited | +| `state/events` | the event stream | +| `state/server.log` | server output, for debugging | + +## Security + +- The server binds `127.0.0.1` by default. Only the local machine can reach it. +- **`--host 0.0.0.0` exposes the session to the network.** Use it only for a remote container the user asked for, and tell the user the URL is open to anyone who can reach that address. +- Events need the current screen's nonce, and the server rejects a foreign `Origin` or `Host` with 403, malformed JSON or a body over 64 KB with 400. +- The server serves only the current screen, its state, and the vendored library files named in `libraries.json`. Nothing else on disk is reachable. +- Screen HTML is not sanitized. Write it yourself, and do not include secrets: it is visible to anyone with the URL. diff --git a/skills/writing-html-artifacts/references/design-reference.md b/skills/writing-html-artifacts/references/design-reference.md new file mode 100644 index 00000000..9461a61c --- /dev/null +++ b/skills/writing-html-artifacts/references/design-reference.md @@ -0,0 +1,276 @@ +# Design reference + +Read this before filling a template's slots. It is the house design standard for every page this skill builds. A template's `rules.md` adds to it; see [templates](templates.md). Companion screens follow section 12 on top of the rest; see [companion](companion.md). + +## 1. Scope and precedence + +When two instructions disagree, the higher one wins: + +1. The user's own words in this conversation. +2. The project's tokens and the chosen template's `rules.md`. +3. This reference. + +Rules in this file are defaults, not laws. Break one only when a higher source asks for it, and say so in the delivery note. + +Stay in Markdown, and do not build a page, when: + +- the answer is under about 30 lines of prose with no table, chart, or interaction +- the reader will paste it into a ticket, a chat message, or a commit +- the user asked for a document or a file format (`.md`, `.docx`, `.pdf`), not a page + +When an answer has shape (a comparison, a dashboard, a flow, a table someone will sort) and the user did not ask for a page, offer one in a single line and build only on a yes. + +## 2. Direction brief + +Write these three lines before opening a slot. Put them in your reply, not in the page. + +``` +Subject and audience: <what the page is about> for <who reads it and what they already know> +One job: <the single thing the reader should be able to do or decide after 60 seconds> +Specific detail: <one thing only this subject has: a real number, a unit, a name, a shape> +``` + +- The one job decides what goes above the fold. Everything that does not serve it moves lower, into `<details>`, or out. +- The specific detail becomes the page's one distinctive element: a unit-aware axis, a domain term as the heading, a real example in the lede. It replaces decoration. +- If you cannot fill the third line, you have not read the source material enough. Go back to it. + +## 3. Banned tells + +These are the marks of a default model-generated page. Each has a replacement. **Three or more tells on one page means rebuild it, not patch it.** + +| Tell | Replace with | +|---|---| +| Inter as the face | the `--q-font-sans` system stack; for character, use weight and size contrast, not a webfont | +| Space Grotesk as the "fix" for Inter | the same system stack; a second default face is still a default | +| Purple or indigo gradients | one flat accent from `--q-color-accent`; no gradient on any surface | +| Gradient text | solid `--q-color-text`; emphasis through weight | +| Gray rounded card grids with shadows | a list, a table, or sections separated by a 1px `--q-color-border` rule | +| Full-bleed gradient hero | a plain header: h1, one-sentence lede, the summary | +| Emoji section headers | text headings; an inline SVG with `<title>` only if the icon carries meaning | +| Glassmorphism (blur, translucent panels) | opaque `--q-color-surface` | +| Everything centered | left-aligned text at a 60-75ch measure; center only a single short title or an empty state | +| Four-stat hero row on a page that is not a dashboard | state the one number that matters in a sentence; put the rest in a table | +| Decorative icon dividers | a `<hr>` or whitespace | +| One radius on every element | the radius scale in section 7: `sm` for controls, `md` for panels, `pill` for tags | +| Wide letter-spacing on uppercase eyebrows | sentence-case label, normal tracking, `--q-color-text-muted` | +| Fake "Live" or "New" pulse badges | omit; a real status uses text and a semantic color, with no animation | +| Thick colored left border on a rounded card | a callout with a full 1px border and an icon or label, or no box | +| `01 / 02 / 03` markers without a real sequence | an `<ol>` when the order matters; headings when it does not | + +How to apply this: + +- Check the finished page against the whole table, not only the rows you were thinking about. +- A tell is allowed when the user or a project template asks for it. Section 1 precedence applies. +- Do not swap a banned tell for a neighbor. A beige background with a serif display face is the same default one step over. + +## 4. Tokens + +Use the `--q-*` variables. Never write a hex, `rgb()`, or `hsl()` color, a pixel font size, or a font stack in slot content when a token exists. The theme switches light and dark by redefining the variables, so a hard-coded color breaks one theme. + +The house theme defines: + +| Group | Variables | +|---|---| +| Color | `--q-color-bg`, `--q-color-surface`, `--q-color-text`, `--q-color-text-muted`, `--q-color-border`, `--q-color-accent`, `--q-color-accent-text`, `--q-color-good`, `--q-color-warn`, `--q-color-critical`, `--q-color-focus` | +| Font | `--q-font-sans`, `--q-font-mono`, `--q-font-display` | +| Size | `--q-size-body`, `--q-size-small`, `--q-size-h1`, `--q-size-h2`, `--q-size-h3`, `--q-size-h4` | +| Space | `--q-space-1` through `--q-space-8`, on a 4px base | +| Radius | `--q-radius-sm` (4px), `--q-radius-md` (6px), `--q-radius-pill` (999px) | + +Rules for using them: + +- Every color is a light/dark pair, so `var(--q-color-text)` is correct in both themes with no extra CSS. +- `--q-color-accent-text` is the text color to put on an accent-filled surface. Do not use `--q-color-text` there. +- Spacing comes from `--q-space-*`. Do not invent 13px or 22px gaps. +- Plain elements (`h1`, `p`, `table`, `button`, `input`, `details`) are already styled through Simple.css, mapped to these tokens by the theme. Write semantic HTML and add CSS only for what it lacks. +- A project or user token file can change any value. Because you reference the variables, the page follows without edits. +- The font tokens are system stacks. Do not add `@font-face`, a `url()` font, or a font `<link>`; external font references fail the offline static check, and system stacks cover every theme. +- If you need a color the roles do not cover (a chart series, say), define it once as a local custom property in the page's own `<style>` as a light/dark pair, using the same media-query structure the theme uses. Do not scatter literals. + +## 5. Type + +- Body text is 16 to 18px (`--q-size-body`), line-height 1.5 to 1.6. +- Measure is 60 to 75ch. Cap prose at `max-width: 70ch`; tables and charts may run wider. +- Use one scale ratio, 1.25 or 1.333, for headings. Use the `--q-size-h1` to `--q-size-h4` tokens and do not add a fifth heading size. +- `--q-size-small` is for captions, table footers, and metadata only. It is not a way to fit more text. +- Numbers in tables, stat readouts, and anything that updates: `font-variant-numeric: tabular-nums`. +- Headings are sentence case, weight 600 to 700, and carry the hierarchy. Do not make hierarchy with color alone. +- System font stacks only. `--q-font-display` is a system stack too; use it for the h1 only if it differs from `--q-font-sans`. +- Code and identifiers use `--q-font-mono`. Do not set whole paragraphs in mono. +- Left-align prose. Justified text and centered paragraphs both hurt at narrow widths. + +## 6. Color and themes + +- Define 4 to 6 named roles and use only those. The house roles are bg, surface, text (with text-muted), border, and accent. That is the whole palette for chrome. +- Use one accent. It marks the primary action and the current selection. A second accent hue is a sign a semantic color is being misused. +- Semantic colors (`good`, `warn`, `critical`) are separate from the accent. Use them only for status. Never use `critical` for a button that is merely important. +- Contrast: 4.5:1 for text, 3:1 for large text (24px and up, or 18.66px bold), UI boundaries, icons, and focus rings. +- Check both themes separately. A pair that passes in light can fail in dark near the threshold (4.43:1 against 4.5:1 is a failure). +- Never carry meaning by color alone. Pair it with text, an icon with a `<title>`, a pattern, or position. +- Do not stack text on an image or gradient. If you must, put it on an opaque surface. +- Muted text (`--q-color-text-muted`) still needs 4.5:1 on its background. Do not lower its opacity further. +- The theme follows the OS and has a toggle in the chrome. Do not build a second toggle, and do not read `matchMedia` to restyle in JS; the CSS variables already switch. + +## 7. Shape and depth + +- Radius scale: `sm` 4px for inputs, buttons, and code spans; `md` 6px for panels and callouts; `pill` for tags and badges only. Elements nested inside a rounded container get a smaller radius than the container. +- A card is a box with a border or surface fill. Use one only to separate content that would otherwise run together, or to group a repeatable item the reader scans (a flag, a run, an incident). A page of cards is a tell; see section 3. +- Do not put a card inside a card. +- At most one shadow level on the whole page, used only for things that float over content (a dialog, a menu, a tooltip). Flat surfaces get a 1px `--q-color-border`, not a shadow. +- Separate sections with spacing first (`--q-space-6` or larger), a 1px rule second, and a background change last. +- Borders are 1px. A 2px or thicker border is reserved for the focus ring. + +## 8. Layout recipes + +Every layout is a single column on a 375px screen. At desktop widths, put the summary where each recipe says. Use CSS grid or flex with `gap` from `--q-space-*`; use `clamp()` for fluid padding, not breakpoints per element. + +| Artifact | Max width | Grid | Summary sits | +|---|---|---|---| +| Explainer | 70ch prose column, up to 960px with figures | one column; figures may span wider; optional sticky table of contents at 1100px and up | a one-paragraph answer directly under the h1, before any section | +| Report | 960px | one column of sections; tables and charts fill the width; optional two-column key facts block | a "Findings" block under the h1: the 3 to 5 facts a reader acts on, each one sentence | +| Calculator or tool | 720px for the tool, 960px if it has a results table | inputs first, results beside them at 900px and up, below them on narrow screens | the live result, in an `aria-live` region, next to the inputs it depends on | +| Comparison | 1100px | a table with the options as columns and criteria as rows; at 375px each option becomes a stacked block | a "Recommendation" line above the table naming the pick and the one reason | +| Dashboard | 1280px | 12-column grid, at most 4 panels across, each panel with a title and a table view; stack at 700px and below | the one headline metric top-left, with its unit and its comparison period | + +Notes that apply to all five: + +- The h1 names the subject, not the format. "Checkout latency, week 41", not "Report". +- Put the date or data source under the h1 in `--q-size-small`. +- Do not center the whole layout. Center the container with `margin-inline: auto` and left-align its contents. +- Tables that are wider than the screen scroll inside their own `overflow-x: auto` wrapper, never the page. The page must not scroll horizontally at 375px. +- Sticky headers and sidebars only when the page is longer than three screens. + +## 9. Tables and data visualization + +### Tables + +- Every table has a `<caption>` that says what it is, `<th scope="col">` or `<th scope="row">` on every header, and a `<thead>`. +- Right-align numeric columns, left-align text, and use `tabular-nums` on both. Put the unit in the column header, not in each cell. +- Sortable columns use a `<button>` inside the `<th>` and set `aria-sort` on the `<th>`. +- Zebra striping is optional. Never use it with row borders as well. +- Cap a table at 12 visible rows before offering search, a filter, or `<details>` for the rest. + +### Choosing a chart + +| Question | Chart | +|---|---| +| How do values compare across categories | horizontal bar, sorted by value | +| How does a value change over time | line, with at most 5 lines | +| What share of a whole | a single stacked bar, or a table; a pie only for 2 to 3 slices | +| How are two measures related | scatter | +| How is a measure distributed | histogram | +| Exact values matter more than shape | a table, no chart | + +### Drawing it + +- Label directly: put the series name at the end of its line or on its bar. Use a legend only when direct labels collide. +- Bars start at zero. Lines may start elsewhere but the axis must say so. +- No dual axes. Two measures with different units get two charts stacked on a shared x axis. +- No 3D, no gradient fills, no drop shadows, no animated entry that delays reading the value. +- Categorical series: 5 to 8 at most, using the Okabe-Ito palette (`#E69F00`, `#56B4E9`, `#009E73`, `#F0E442`, `#0072B2`, `#D55E00`, `#CC79A7`, plus the theme text color for the eighth). Define them as local light/dark pairs per section 4, and check each against the chart background for 3:1. +- Sequential data (a heatmap, a density): one hue, light to dark, with the scale shown. Diverging data needs a meaningful midpoint. +- More than 8 series means the chart is wrong. Group the tail into "Other", filter, or use a table. +- Every chart has a table view: a `<details>` after the chart holding the same data as a real table. This is what makes the chart accessible and the numbers copyable. + +### Chart.js + +- Put the canvas in a wrapper with a fixed height and set `maintainAspectRatio: false`. +- The `<canvas>` carries `role="img"` and an `aria-label` that states the finding ("Median build time fell from 14 to 9 minutes between June and September"), not the chart type. +- Read colors from the page's custom properties with `getComputedStyle` when creating the chart, and rebuild the chart when the theme toggles. +- Set `animation: false`, or keep it under 400ms, so verify screenshots show the finished chart. + +## 10. Interaction and motion + +- Every interaction you build is declared in the source file's `interactions` list, because verify and the delivery note refer to it. +- Prefer native behavior: `<details>`, `<dialog>`, form controls, `:target`, CSS `:has()`. Reach for Alpine only for state those cannot hold. +- A readout that changes because of an input updates in place and is announced; see section 11. +- At most one orchestrated moment per page: a single entrance for the summary, or one transition when a view changes. No scroll-jacking, parallax, staggered card reveals, or looping animation. +- Motion lasts 150 to 250ms, uses `ease-out`, and animates only `opacity` and `transform`. +- Wrap all motion in `@media (prefers-reduced-motion: no-preference)`, so the default is none. Do not animate on reduced motion. +- Hover is never the only way to reach something. Touch and keyboard users get the same content. +- Controls show a state for hover, focus, active, and disabled. A disabled control says why nearby. +- State that matters (a filter, a selected tab) is reflected in the URL hash only if the user will share the page; otherwise keep it in memory. + +## 11. Accessibility floor + +A page that misses any item here is not finished. + +- [ ] Exactly one `<h1>`; headings descend without skipping a level +- [ ] Landmarks: one `<main>`, a `<header>`, and `<nav>` if there is navigation; the template chrome supplies what it can, and you check the rest +- [ ] Every control has an accessible name (visible `<label>`, `aria-label`, or its text) +- [ ] Buttons are `<button>`, navigation is `<a href>`; no `div` or `span` with a click handler +- [ ] Visible focus on every interactive element: 2px outline, 2px offset, `--q-color-focus`, 3:1 against the background. Never `outline: none` without a replacement +- [ ] Meaning never depends on color alone +- [ ] Inline SVG that conveys information has `role="img"` and a `<title>`; decorative SVG has `aria-hidden="true"` +- [ ] Charts follow section 9; every chart has a table view +- [ ] Readouts that change from user input sit in an element with `aria-live="polite"` +- [ ] Collapsible content uses `<details>` and `<summary>`, not a custom toggle +- [ ] Touch targets are at least 44px tall at 375px +- [ ] `<html lang>` is set and link text makes sense out of context ("Read the run log", not "here") +- [ ] The page reads in the order it is written: tab order and DOM order match, with no positive `tabindex` +- [ ] Zoom to 200% does not clip or overlap content + +## 12. Companion page kit + +Companion screens are questions, not documents. Build them from the `companion` template through `screen.json`; the protocol is in [companion](companion.md). + +- **State the question as the header.** The `question` field is a complete question in sentence case: "Which layout fits the dashboard?". The `title` is two or three words. +- **Offer 2 to 4 options.** One option is not a question and five or more is a survey. A screen that only shows something still offers choices about it ("Looks right", "Change the spacing", "Start over"). +- **Each option has a short label and a one-line description.** The description says the consequence, not the feature. +- **Pick the fidelity the question needs.** Use a wireframe (boxes, labels, `--q-color-border`, no color) for layout, flow, and structure questions. Use a polished mockup (real tokens, real type, real copy) only for questions about look: color, density, typography. Never polish a question about structure. +- **One new screen per question.** Do not reuse a screen for a second question and do not stack two questions on one screen. Every `show` makes a new file and supersedes the last. +- Put mockups in an option's `html` or in `content`. Keep them under about 60 lines; if a mockup needs more, the question is two questions. +- Set `select` to `single` for a choice and `multi` for a "which of these apply". Never use `multi` when you will pick one yourself afterward. +- Use `diagram` (Mermaid) for flows and relationships, and a wireframe for anything spatial. +- Everything in sections 3 to 7 and 11 still applies to the screen. + +## 13. Copy rules + +- Sentence case everywhere: headings, buttons, table headers, tabs. Capitalize proper nouns only. +- Name buttons by their action: "Save changes", "Export CSV", "Run comparison". Not "Submit", "OK", or "Click here". +- Specific over clever. "Median checkout latency fell 22% after the cache change" beats "Faster than ever". +- Numbers carry units and a comparison: "412 ms, down from 530 ms in week 40". +- Write the lede as the conclusion, then the evidence. +- No filler openers ("In today's fast-paced world"), no marketing adjectives (seamless, robust, powerful), no exclamation marks. +- Empty states say what is missing and what to do next. Error messages say what happened and how to fix it, next to the field. +- Use the reader's terms from the source material. Do not rename the user's domain. +- Dates are absolute ("13 Oct 2026"), not "yesterday"; numbers over 9,999 get thousands separators. + +## 14. Verify checklist + +Run these on the final file, in this order. Any edit after verify means run verify again. Script paths are relative to the skill directory. + +### What the tools check + +`scripts/quirk_html.py check FILE` (static, runs on every build): + +- [ ] `header`: the `quirk-html-artifact` header is present and complete +- [ ] `license`: every inlined library has a license comment +- [ ] `checksum`: every library block matches its recorded hash +- [ ] `external-refs`: no external scripts, styles, fonts, images or fetches in offline mode +- [ ] `size`: under 2 MB (warning) and 10 MB (failure) + +`scripts/quirk_html.py verify FILE` (browser, when one is reachable): + +- [ ] `console`: no `console.error` and no uncaught page error in any run +- [ ] `viewports`: screenshots at 375x800 and 1280x800, in light and dark; at 375px the page does not scroll horizontally +- [ ] `themes`: sampled text reaches 4.5:1 (3:1 at 24px and up) in both themes +- [ ] `network`: zero requests in offline mode + +`scripts/quirk_html.py note FILE` builds the delivery note and reports `verify_stale: true` when the file changed after verify. If it is true, run verify again. + +### What the tools cannot check + +Open each screenshot yourself. Verify proves the page loads and is legible; it does not prove it is good. + +- [ ] The direction brief's one job is clear in the first screen at 1280px and at 375px +- [ ] No three tells from section 3 are present +- [ ] Hierarchy reads without color: squint and the h1, the summary, and the sections still stand out +- [ ] Charts show the finding, labels are not clipped, and every chart has a table view +- [ ] Every control in the `interactions` list works when you use it, not only when the page loads +- [ ] Copy follows section 13; no placeholder text, no lorem ipsum, no invented numbers +- [ ] Focus is visible and the tab order matches the reading order +- [ ] Both themes look intentional, not merely legible +- [ ] The delivery note's claims (size, libraries, mode, verify result) match what you saw + +If verify returns `not-browser-verified`, say so in the note and list the static checks that passed. Do not install a browser to get around it. diff --git a/skills/writing-html-artifacts/references/templates.md b/skills/writing-html-artifacts/references/templates.md new file mode 100644 index 00000000..392ec20f --- /dev/null +++ b/skills/writing-html-artifacts/references/templates.md @@ -0,0 +1,175 @@ +# Templates + +Everything this skill builds comes from a template, so a project's own template gets every guarantee a built-in one has. Read this when matching a request to a template, writing a `.src.html` source file, or creating or repairing a custom template. For the design rules a template's slots follow, see [design reference](design-reference.md). For companion screens, which are built from the `companion` template, see [companion](companion.md). + +## What a template is + +A template is a directory holding a manifest (`template.json`), fixed chrome, one to three freeform slots, and a filled-in example that doubles as its verify fixture. It can also hold token overrides, a rules addendum, and extra vendored libraries. + +- **Chrome** is fixed: theme, theme toggle, license notices, header. You do not write it. +- **Slots** are the freeform regions you fill. Consistency comes from the shared chrome and tokens. +- Built-ins are `report`, `components`, `companion` and `app` (tier 2). Custom templates live at project or user level and can be either tier. + +## Lookup order and name clashes + +The lookup order is project, then user, then built-in. All paths are relative to the root named: + +1. Project: `<project>/.quirk/html-templates/<name>/` (committed with the repo) +2. User: `~/.quirk/html-templates/<name>/` +3. Built-in: `templates/<name>/` under the skill directory + +- A name that exists at two levels resolves to the higher level. The project `report` hides the built-in `report`. +- The first valid match wins. An invalid match is skipped with a warning and the search continues down the order. No valid match at any level is an error (exit 2). +- Run `scripts/quirk_html.py templates list [--project-dir DIR]` to see every template that resolves (`name`, `level`, `tier`, `description`, `path`) and every one that was skipped (`warnings`). +- Match the request against each `description`; every description starts "Use when". Then say which template you picked, its level, and the tier before you build. The user can name a template directly. + +## `template.json` + +```json +{ + "schema": 1, + "name": "report", + "description": "Use when the user wants a branded report, explainer, or small tool as a single page.", + "tier": 1, + "libraries": ["simple-css", "alpine"], + "optional_libraries": ["chartjs", "mermaid"], + "slots": { "main": { "required": true, "description": "The report body" } }, + "skeleton": "skeleton.html", + "tokens": "tokens.json", + "rules": "rules.md", + "example": "example.src.html" +} +``` + +| Field | Rule | +|---|---| +| `schema` | integer, only `1` is valid | +| `name` | must equal the directory name; `[a-z0-9-]{1,40}` | +| `description` | 30 to 400 characters, starts with "Use when"; this is what requests are matched against | +| `tier` | `1` (assembled single file) or `2` (React project bundled to one file) | +| `libraries` | tier 1: libraries always inlined, in order; names from the skill's `vendor/libraries.json` or from this template's own `vendor/libraries.json` | +| `optional_libraries` | tier 1: libraries a source file may add through its header's `libraries` field | +| `slots` | tier 1: 1 to 3 entries, each `{"required": bool, "description": str}` | +| `skeleton` | tier 1: relative path to the skeleton HTML | +| `tokens` | optional: relative path to a token file layered last (see below) | +| `rules` | optional: relative path to a design-rules addendum, read with the design reference | +| `example` | tier 1: a `.src.html` source file; tier 2: a directory overlay copied over the scaffold | +| `project` | tier 2 only: relative path to a Vite project containing `package.json` and `pnpm-lock.yaml` | + +- A library name resolves first in the template's `vendor/libraries.json`, then in the skill's. A template-level entry with a built-in's name shadows it for that template only. +- Skeletons contain literal placeholders, each replaced once: `{{header}}`, `{{title}}`, `{{head}}`, `{{chrome}}`, `{{slot:<name>}}` for each declared slot, `{{data}}` (optional), `{{body_end}}`. A skeleton missing any of them except `{{data}}` is invalid. An unknown `{{...}}` left after substitution is exit 2. +- Libraries load in manifest order, with Alpine last. + +## Source file format + +For a tier-1 template you write `<slug>.src.html`, and `scripts/quirk_html.py build SRC.html` builds it to `.quirk/html/<slug>.html`. The format: + +- Line 1 is a header comment holding one JSON object. +- Each slot starts with a line that is exactly `<!-- slot: <name> -->` and runs to the next slot line or end of file. +- Text before the first slot line, other than the header, is an error. + +```html +<!-- quirk-src {"template": "report", "title": "Test timings, week 41", "mode": "offline", "libraries": ["chartjs"], "interactions": ["sort the table by column"], "data": "timings.json"} --> +<!-- slot: main --> +<h1>Test timings, week 41</h1> +<p>Median suite time fell from 14 to 9 minutes.</p> +``` + +Header fields: + +- `template` and `title` are required. +- `mode` is `offline` (the default) or `connected`. +- `libraries` must be a subset of the template's `optional_libraries`. +- `interactions` lists what the page lets the reader do; it may be empty. +- `data` is an optional JSON or CSV path relative to the source file. Data too large to embed moves to a sidecar file next to the page. +- `tier_reason` is optional; set it when you overrode the tier signals. + +Slot names match `^<!-- slot: ([a-z][a-z0-9-]{0,30}) -->$`. A rebuild of an existing artifact reuses the library versions recorded in its header; pass `--upgrade` to use the current pins. + +## Tokens and the four-layer merge + +A token file has `schema: 1` and the groups `color`, `font`, `size`, `space`, `radius`. Every color is a `["<light>", "<dark>"]` pair; a single string is invalid. Fonts are system stacks only, with no `@font-face` and no `url()` fonts. + +```json +{ + "schema": 1, + "color": { "accent": ["#0b5cad", "#7ab8ff"], "accent-text": ["#ffffff", "#0b1b2e"] }, + "radius": { "md": "8px" } +} +``` + +Layers merge in this order, and a later layer wins per key: + +1. House tokens (`theme/house.tokens.json` in the skill) +2. User `~/.quirk/html-templates/tokens.json` +3. Project `.quirk/html-templates/tokens.json` +4. The template's own `tokens` file + +- Layers 2 and 3 are how project branding reaches the built-in templates, the companion included. Put brand tokens there when a whole project should be branded, and in a template's own file only when that template differs. +- A token file only needs the keys it changes. +- Output uses `--q-<group>-<key>` names, with light values on `:root` and dark values under the `prefers-color-scheme` media query and under `[data-theme="dark"]`. Slot content references the variables, per [design reference](design-reference.md). + +## `verified.json` + +Custom templates (project and user level) need a `verified.json` beside `template.json`. Built-ins do not. + +```json +{ "schema": 1, "verified_at": "2026-10-13T12:00:00Z", "browser": true, "hashes": { "template.json": "<sha256>", "skeleton.html": "<sha256>" } } +``` + +- `hashes` covers `template.json`, the skeleton, tokens, rules, every file under `vendor/`, and the example. +- For tier 2 it covers `template.json`, `project/package.json` and `project/pnpm-lock.yaml`. +- `browser` is `false` when the example passed static checks but no browser was reachable. +- Do not write it by hand. `templates verify` writes it, and only when the example passes. Editing any covered file afterwards invalidates it, so re-run verify. + +## Create flow + +Creating a template is guided and ends when the example passes. + +1. Choose the level: `project` for a template the repo shares, `user` for one that follows the person. +2. Scaffold it: + + ``` + scripts/quirk_html.py templates new NAME --level project|user [--base TEMPLATE] [--from ARTIFACT.html] [--project-dir DIR] + ``` + + It prints `{"path", "next": [...]}`; do what `next` lists. + - `--base` copies an existing template as the starting point. + - `--from` starts from a built artifact. A quirk-built artifact copies its source template and uses its `.src.html` as the example. Any other HTML becomes a one-slot skeleton (`main` wraps the body) with house tokens, which you then refine. +3. Edit `template.json` (write a real "Use when" description), the skeleton, tokens, rules and the example. Keep the example a real, filled-in page, because verify runs on it. +4. Optionally check the shape: `scripts/quirk_html.py templates validate PATH` prints `{"valid", "errors"}`. +5. Verify it: + + ``` + scripts/quirk_html.py templates verify NAME [--project-dir DIR] + ``` + + It builds the example, runs the static checks and browser verify, and on a pass writes `verified.json`. The result is `{"passed", "browser", "report"}`. Fix what `report` lists and run it again. + +The template is usable only once `templates verify` passes. A project template belongs in version control, including `verified.json`. + +## Tier-2 custom templates + +A tier-2 template sets `"tier": 2` and `"project": "project/"`, and ships its own `project/package.json` and `project/pnpm-lock.yaml`. + +- The lockfile is required, and dependencies are exact-pinned. Builds run `pnpm install --frozen-lockfile`, so a missing or stale lockfile fails the build. +- Build with `scripts/quirk_html.py build-app SLUG --title TITLE`. If Node or pnpm is missing, or install or build fails, it exits 3 and you fall back to a tier-1 build, listing every cut in the delivery note. +- Never commit `node_modules/`. + +## What makes a template invalid + +A template is skipped, with a warning, when: + +- `template.json` is missing, unparseable, or fails the schema table above (this includes `name` not matching the directory) +- the skeleton is missing or lacks a required placeholder +- a library does not resolve, or a vendored file's checksum does not match +- the example is missing +- `verified.json` is missing, or any covered file's hash differs (reason `unverified`) + +What you see: `templates list` reports it under `warnings` as `{name, level, path, reason}`, and the build result carries the same warning. The build then uses the next valid match in lookup order, often the built-in of the same name. + +What you do: + +- Tell the user in the delivery note which template was skipped, where it was, and why, and name the template you used instead. +- Offer to repair it. For `unverified`, edit nothing and run `templates verify NAME`; for any other reason, fix the named file first. +- Do not silently build with the fallback and say nothing, and do not edit `verified.json` to clear the warning. From aedcda156ec21a7a980254ec481152d0546e4d3b Mon Sep 17 00:00:00 2001 From: zpyoung <zach@fabledfreedom.com> Date: Tue, 13 Oct 2026 15:19:08 -0500 Subject: [PATCH 011/164] fix(writing-html-artifacts): tokenize pages for static checks Library and shell blocks are hash-verified only when their opening tag carries nothing but the data attribute, comments follow HTML rules instead of a regex, and network-call patterns are matched only in script bodies and on* handlers. --- .../scripts/htmlkit/checks.py | 262 ++++++++++++++---- tests/test_htmlkit_core.py | 99 +++++++ 2 files changed, 305 insertions(+), 56 deletions(-) diff --git a/skills/writing-html-artifacts/scripts/htmlkit/checks.py b/skills/writing-html-artifacts/scripts/htmlkit/checks.py index edafb788..0bcf6589 100644 --- a/skills/writing-html-artifacts/scripts/htmlkit/checks.py +++ b/skills/writing-html-artifacts/scripts/htmlkit/checks.py @@ -1,9 +1,15 @@ """Static checks over a built artifact: header, licenses, checksums, external references, size. -Library blocks (``data-quirk-lib``) and shell blocks (``data-quirk-shell``) are cut out before -the reference scan and verified by hash instead, so the vendored libraries and the two shipped -scripts can mention URLs and call ``fetch`` without tripping the scan. The scan also skips HTML -comments and ``application/json`` script blocks, which are data and never load anything. +The page is tokenized once with ``html.parser``, following HTML raw-text and comment rules, so +text that merely looks like markup inside script, style or comments cannot hide or fake a reference. + +Library blocks (``data-quirk-lib``) and shell blocks (``data-quirk-shell``) are verified by hash +instead of scanned, so the vendored libraries and the two shipped scripts can mention URLs and call +``fetch`` without tripping the scan. A block counts as verified only when its opening tag carries +exactly that one attribute; any other attribute (``src`` above all) makes it an ordinary element +that is scanned like the rest of the page. Comments and ``application/json`` script blocks are +data and never load anything. ``fetch(`` and similar calls are looked for only in inline script +bodies and ``on*`` handler attributes, never in prose. ``mode`` is ``offline``, ``connected`` or ``companion``; companion pages follow the offline rules (``/_quirk/lib/`` paths are not ``http:``, ``https:`` or ``//`` targets, so they pass). @@ -12,6 +18,8 @@ import html as html_lib import re +from dataclasses import dataclass, field +from html.parser import HTMLParser from pathlib import Path from typing import Iterable, Optional @@ -23,20 +31,6 @@ SIZE_FAIL = 10 * 1024 * 1024 SIZE_WARN = 2 * 1024 * 1024 -_LIB_BLOCK = re.compile( - r'<(script|style)\b[^>]*?\bdata-quirk-lib="([^"]*)"[^>]*>(.*?)</\1\s*>', re.IGNORECASE | re.DOTALL -) -_SHELL_BLOCK = re.compile( - r'<script\b[^>]*?\bdata-quirk-shell="([^"]*)"[^>]*>(.*?)</script\s*>', re.IGNORECASE | re.DOTALL -) -_JSON_BLOCK = re.compile( - r'<script\b[^>]*\btype\s*=\s*["\']?application/(?:ld\+)?json["\']?[^>]*>.*?</script\s*>', - re.IGNORECASE | re.DOTALL, -) -_COMMENT = re.compile(r"<!--.*?-->", re.DOTALL) -_TAG = re.compile(r'<([a-zA-Z][a-zA-Z0-9:-]*)((?:"[^"]*"|\'[^\']*\'|[^\'">])*)>') -_ATTR = re.compile(r'([^\s"\'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s"\'=<>`]+)))?') -_STYLE_BLOCK = re.compile(r"<style\b[^>]*>(.*?)</style\s*>", re.IGNORECASE | re.DOTALL) _FONT_FACE = re.compile(r"@font-face\s*\{[^}]*\}", re.IGNORECASE) _CSS_URL = re.compile(r"url\(\s*(?:\"([^\"]*)\"|'([^']*)'|([^)\"'\s]*))\s*\)", re.IGNORECASE) _CSS_IMPORT = re.compile( @@ -52,6 +46,175 @@ _NARROW_ATTRS = (("script", "src"), ("link", "href")) +_RAW_TEXT = frozenset({"script", "style", "textarea", "title", "xmp", "iframe", "noembed", "noframes", "noscript"}) +_FOREIGN = frozenset({"svg", "math"}) +_JSON_TYPES = ("application/json", "application/ld+json") +_COMMENT_END = re.compile(r"--!?>") +_SCRIPT_ESCAPES = re.compile(r"<!---?>|<!--|-->|<script(?=[\t\n\r\f />])|</script(?=[\t\n\r\f />])", re.IGNORECASE) + + +def _ends_double_escaped(script_text: str) -> bool: + """True when ``script_text`` leaves an HTML script in the double-escaped state. + + There a ``</script>`` does not end the element, so the text after it is still script. + """ + state = 0 + for m in _SCRIPT_ESCAPES.finditer(script_text): + token = m.group(0).lower() + if token in ("<!-->", "<!--->") or token == "-->": + state = 0 + elif token == "<!--": + state = state or 1 + elif token == "<script": + state = 2 if state == 1 else state + elif state == 2: + state = 1 + return state == 2 + + +@dataclass +class _Page: + """What the tokenizer found that can load or run something.""" + + attrs: list[tuple[str, str, str]] = field(default_factory=list) + scripts: list[str] = field(default_factory=list) + handlers: list[str] = field(default_factory=list) + css: list[str] = field(default_factory=list) + libs: list[tuple[str, str, str]] = field(default_factory=list) + shells: list[tuple[str, str]] = field(default_factory=list) + + +@dataclass +class _Capture: + tag: str + verified: Optional[tuple[str, str]] + json_data: bool + foreign: bool + parts: list[str] = field(default_factory=list) + + +class _Scanner(HTMLParser): + """Tokenizer that mirrors how browsers split markup from raw text and comments. + + The stdlib parser diverges from the HTML spec in ways that would let text hide real + markup: it enters raw-text mode only for script and style, ends a comment at the next + ``-->`` whatever follows ``<!--``, and ignores attributes on end tags. Those hooks are + overridden so the scan is never narrower than what a browser would load. + """ + + CDATA_CONTENT_ELEMENTS = () + RCDATA_CONTENT_ELEMENTS = () + + def __init__(self, page: _Page) -> None: + super().__init__(convert_charrefs=True) + self.page = page + self._foreign = 0 + self._capture: Optional[_Capture] = None + + def set_cdata_mode(self, elem, *, escapable=False): + self.cdata_elem = elem.lower() + self.interesting = re.compile(r"</%s(?=[\t\n\r\f />])" % re.escape(self.cdata_elem), re.IGNORECASE) + + def parse_comment(self, i, report=True): + raw = self.rawdata + start = i + 4 + if raw.startswith(">", start): + return start + 1 + if raw.startswith("->", start): + return start + 2 + match = _COMMENT_END.search(raw, start) + return match.end() if match else -1 + + def parse_marked_section(self, i, report=1): + end = self.rawdata.find(">", i) + return end + 1 if end >= 0 else -1 + + def parse_endtag(self, i): + if self.cdata_elem: + raw = self.rawdata + body = "".join(self._capture.parts) if self._capture else "" + if self.cdata_elem == "script" and not self._foreign and _ends_double_escaped(body): + self.handle_data(raw[i : i + 8]) + return i + 8 + end = raw.find(">", i) + if end < 0: + return -1 + self.handle_endtag(self.cdata_elem) + self.clear_cdata_mode() + return end + 1 + return super().parse_endtag(i) + + def handle_starttag(self, tag, attrs): + self._open(tag, attrs, selfclosing=False) + + def handle_startendtag(self, tag, attrs): + self._open(tag, attrs, selfclosing=True) + + def handle_data(self, data): + if self._capture is not None: + self._capture.parts.append(data) + + def handle_endtag(self, tag): + if self._capture is not None and tag == self._capture.tag: + self._close_capture() + if tag in _FOREIGN and self._foreign: + self._foreign -= 1 + + def _open(self, tag: str, attrs: list, selfclosing: bool) -> None: + values = [(name, value if value is not None else "") for name, value in attrs] + lone = attrs[0] if len(attrs) == 1 and attrs[0][1] is not None else None + verified = None + if lone is not None and not self._foreign: + if lone[0] == "data-quirk-lib" and tag in ("script", "style"): + verified = ("lib", lone[1]) + elif lone[0] == "data-quirk-shell" and tag == "script": + verified = ("shell", lone[1]) + if verified is None: + for name, value in values: + self.page.attrs.append((tag, name, value)) + if name.startswith("on"): + self.page.handlers.append(value) + if tag in _FOREIGN and not selfclosing: + self._foreign += 1 + if tag in _RAW_TEXT and not (selfclosing and self._foreign): + declared = next((v.strip().lower() for n, v in values if n == "type"), "") + self.set_cdata_mode(tag) + self._capture = _Capture(tag, verified, declared in _JSON_TYPES, bool(self._foreign)) + + def _close_capture(self, terminated: bool = True) -> None: + cap, self._capture = self._capture, None + text = "".join(cap.parts) + if cap.tag == "noscript" or cap.foreign: + nested = _Scanner(self.page) + nested.feed(text) + nested.finish() + if cap.verified and terminated: + kind, label = cap.verified + if kind == "lib": + self.page.libs.append((label, "css" if cap.tag == "style" else "js", text)) + else: + self.page.shells.append((label, text)) + elif cap.tag == "script" and not cap.json_data: + self.page.scripts.append(text) + elif cap.tag == "style": + self.page.css.append(text) + + def finish(self) -> None: + """Flush the input; an element left open at EOF still has its body scanned.""" + self.close() + if self._capture is not None: + self._capture.parts.append(self.rawdata) + self._close_capture(terminated=False) + + +def _scan_page(html: str) -> _Page: + page = _Page() + scanner = _Scanner(page) + scanner.feed(html) + scanner.finish() + return page + + def default_shells(skill_dir: Optional[Path] = None) -> dict[str, Path]: """Shell name to the shipped file its inlined block must match.""" base = Path(skill_dir) if skill_dir is not None else SKILL_DIR @@ -111,39 +274,31 @@ def _shell_checksum_failure(name: str, content: str, shells: dict[str, Path]) -> return None -def _css_contexts(scan: str, style_attrs: list[str]) -> list[str]: - return [m.group(1) for m in _STYLE_BLOCK.finditer(scan)] + style_attrs - - -def _reference_failures(scan: str, mode: str, tier: int) -> list[dict]: +def _reference_failures(page: _Page, mode: str, tier: int) -> list[dict]: strict = mode in ("offline", "companion") markup_all = strict and tier == 1 css_any = strict found: list[str] = [] style_attrs: list[str] = [] - for tag_match in _TAG.finditer(scan): - tag = tag_match.group(1).lower() - for attr in _ATTR.finditer(tag_match.group(2)): - attr_name = attr.group(1).lower() - value = html_lib.unescape(attr.group(2) or attr.group(3) or attr.group(4) or "") - if attr_name == "style": - style_attrs.append(value) - continue - if markup_all: - if attr_name not in _LOADING_ATTRS or (tag == "a" and attr_name == "href"): - continue - elif (tag, attr_name) not in _NARROW_ATTRS: + for tag, attr_name, value in page.attrs: + if attr_name == "style": + style_attrs.append(value) + continue + if markup_all: + if attr_name not in _LOADING_ATTRS or (tag == "a" and attr_name == "href"): continue - if attr_name == "srcset": - candidates = [c.strip().split(None, 1)[0] for c in value.split(",") if c.strip()] - else: - candidates = [value] - for candidate in candidates: - if _is_external(candidate): - found.append(f"<{tag} {attr_name}> loads {_clip(candidate)}") - - for css in _css_contexts(scan, style_attrs): + elif (tag, attr_name) not in _NARROW_ATTRS: + continue + if attr_name == "srcset": + candidates = [c.strip().split(None, 1)[0] for c in value.split(",") if c.strip()] + else: + candidates = [value] + for candidate in candidates: + if _is_external(candidate): + found.append(f"<{tag} {attr_name}> loads {_clip(candidate)}") + + for css in page.css + style_attrs: for m in _CSS_IMPORT.finditer(css): if _is_external(_first(m)): found.append(f"@import of {_clip(_first(m))}") @@ -156,7 +311,7 @@ def _reference_failures(scan: str, mode: str, tier: int) -> list[dict]: if markup_all: for pattern, label in _CALLS: - if pattern.search(scan): + if any(pattern.search(code) for code in page.scripts + page.handlers): found.append(f"page code calls {label}") return [{"check": "external-refs", "detail": detail} for detail in dict.fromkeys(found)] @@ -188,24 +343,19 @@ def run_checks( elif "schema" not in meta: failures.append({"check": "header", "detail": "header has no schema field"}) + page = _scan_page(html) labels: dict[str, None] = {} - for m in _LIB_BLOCK.finditer(html): - label, kind = m.group(2), "css" if m.group(1).lower() == "style" else "js" + for label, kind, content in page.libs: labels[label] = None - failure = _lib_checksum_failure(label, kind, m.group(3), vendor) + failure = _lib_checksum_failure(label, kind, content, vendor) if failure: failures.append(failure) failures.extend(_license_failures(html, labels)) - for m in _SHELL_BLOCK.finditer(html): - failure = _shell_checksum_failure(m.group(1), m.group(2), shell_map) + for name, content in page.shells: + failure = _shell_checksum_failure(name, content, shell_map) if failure: failures.append(failure) - - scan = _LIB_BLOCK.sub(" ", html) - scan = _SHELL_BLOCK.sub(" ", scan) - scan = _COMMENT.sub(" ", scan) - scan = _JSON_BLOCK.sub(" ", scan) - failures.extend(_reference_failures(scan, mode, tier)) + failures.extend(_reference_failures(page, mode, tier)) size = len(html.encode("utf-8")) if size > SIZE_FAIL: diff --git a/tests/test_htmlkit_core.py b/tests/test_htmlkit_core.py index 90936ed9..401f6cd8 100644 --- a/tests/test_htmlkit_core.py +++ b/tests/test_htmlkit_core.py @@ -918,6 +918,105 @@ def test_offline_allowed_content_passes(skill: Path, store: vendor.VendorStore, assert result["passed"], result +EVIL_IMG = '<img src="https://evil.example/p.png">' + +COMMENT_BYPASSES = [ + f"<!-->{EVIL_IMG}<!-- -->", + f"<!--->{EVIL_IMG}<!-- -->", + f"<!-- x --!>{EVIL_IMG}", + f'<script>var a="<!--";</script>{EVIL_IMG}<script>var b="-->";</script>', + f'<style>a::after{{content:"<!--"}}</style>{EVIL_IMG}<style>a::before{{content:"-->"}}</style>', + f"<title><!--{EVIL_IMG}

-->", + f" {EVIL_IMG} ]]>", + f"{EVIL_IMG}", + f'{EVIL_IMG}', + f"

{{slot:main}}
{{body_end}}\n" +) + + +def _template(tmp_path: Path) -> Path: + tdir = tmp_path / "basic" + tdir.mkdir() + (tdir / "skeleton.html").write_text(SKELETON, encoding="utf-8") + (tdir / "example.src.html").write_text("

hi

\n", encoding="utf-8") + meta = { + "schema": 1, + "name": "basic", + "description": DESCRIPTION, + "tier": 1, + "slots": {"main": {"required": True}}, + "skeleton": "skeleton.html", + "example": "example.src.html", + } + (tdir / "template.json").write_text(json.dumps(meta), encoding="utf-8") + return tdir + + +def _problems(tdir: Path, **kw: object) -> list[str]: + kw.setdefault("require_verified", False) + return manifest.validate_template(tdir, skill_dir=tdir.parent / "skill", **kw) # type: ignore[arg-type] + + +def test_non_utf8_skeleton_is_a_validation_error(tmp_path: Path) -> None: + tdir = _template(tmp_path) + assert _problems(tdir) == [] + (tdir / "skeleton.html").write_bytes(b"\xca\xfe\xba\xbe") + errors = _problems(tdir) + assert any("skeleton" in e and "cannot be read" in e for e in errors) + + +def test_non_utf8_tokens_is_a_validation_error(tmp_path: Path) -> None: + tdir = _template(tmp_path) + meta = json.loads((tdir / "template.json").read_text(encoding="utf-8")) + meta["tokens"] = "tokens.json" + (tdir / "template.json").write_text(json.dumps(meta), encoding="utf-8") + (tdir / "tokens.json").write_bytes(b"\xff\xfe\x00") + assert any(e.startswith("tokens") for e in _problems(tdir)) + + +def test_non_utf8_manifest_is_a_validation_error(tmp_path: Path) -> None: + tdir = _template(tmp_path) + (tdir / "template.json").write_bytes(b"\xff\xfe{") + assert _problems(tdir) != [] + + +def test_unreadable_verified_file_is_reported_unverified(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + tdir = _template(tmp_path) + (tdir / "verified.json").write_text( + json.dumps({"schema": 1, "hashes": manifest.compute_hashes(tdir)}), encoding="utf-8" + ) + real = manifest._sha256_file + + def flaky(path: Path) -> str: + if path.name == "skeleton.html": + raise PermissionError("denied") + return real(path) + + monkeypatch.setattr(manifest, "_sha256_file", flaky) + errors = _problems(tdir, require_verified=True) + assert any("unverified" in e and "skeleton.html" in e for e in errors) + + +def _lib(tmp_path: Path, *, license_file: str = "LICENSE", license: str = "MIT", name: str = "lib") -> vendor.LibraryVersion: + root = tmp_path / "vendor" / name / "1.0.0" + root.mkdir(parents=True) + (root / "LICENSE").write_text("Copyright (c) 2024 Fake Authors\n", encoding="utf-8") + (tmp_path / "secret.txt").write_text("Copyright (c) Somebody Private\n", encoding="utf-8") + return vendor.LibraryVersion( + name=name, + version="1.0.0", + package=name, + license=license, + license_file=license_file, + source="", + files=(), + root=root, + ) + + +def test_license_comment_happy_path_is_unchanged(tmp_path: Path) -> None: + assert vendor.license_comment(_lib(tmp_path)) == "" + + +@pytest.mark.parametrize("escape", ["../../../secret.txt", "../../../../secret.txt"]) +def test_license_file_cannot_climb_out_of_the_version_root(tmp_path: Path, escape: str) -> None: + with pytest.raises(vendor.VendorError, match="escapes"): + vendor.license_comment(_lib(tmp_path, license_file=escape)) + + +def test_license_file_cannot_be_absolute(tmp_path: Path) -> None: + with pytest.raises(vendor.VendorError, match="escapes"): + vendor.license_comment(_lib(tmp_path, license_file=str(tmp_path / "secret.txt"))) + + +def test_license_file_that_is_not_utf8_raises_vendor_error(tmp_path: Path) -> None: + lv = _lib(tmp_path) + (lv.root / "LICENSE").write_bytes(b"\xca\xfe") + with pytest.raises(vendor.VendorError, match="cannot read license file"): + vendor.license_comment(lv) + + +@pytest.mark.parametrize("field", ["license", "name"]) +def test_license_comment_neutralises_every_interpolated_field(tmp_path: Path, field: str) -> None: + hostile = "MIT --> b")] + assert "--" not in body and "<" not in body and ">" not in body + assert comment.startswith("") + + +def test_license_comment_neutralises_angle_brackets_in_the_copyright_line(tmp_path: Path) -> None: + lv = _lib(tmp_path) + (lv.root / "LICENSE").write_text("Copyright (c) A \n", encoding="utf-8") + comment = vendor.license_comment(lv) + assert "bcbcb", + "no closers é\n", +] + + +@pytest.mark.parametrize("kind", ["js", "css"]) +@pytest.mark.parametrize("text", ESCAPE_CASES) +def test_python_escape_implementations_agree(text: str, kind: str) -> None: + assert pins.escape_inline(text, kind) == vendor.escape_inline(text, kind) + + +def test_escape_rule_is_ascii_only() -> None: + text = "ab" + assert pins.escape_inline(text, "js") == text + assert vendor.escape_inline(text, "js") == text + + +def test_vendored_hashes_still_match_the_ascii_rule() -> None: + assert pins.verify(SKILL / "vendor") == [] From c6515035fc966dff33718561ca69c62ec9870d41 Mon Sep 17 00:00:00 2001 From: zpyoung Date: Tue, 13 Oct 2026 15:30:35 -0500 Subject: [PATCH 014/164] feat(writing-html-artifacts): add the tier-1 build CLI and report/components templates quirk_html.py builds a source file into one offline HTML artifact (templates list/validate, build, check, verify, note), with built-in report (Simple.css + Alpine) and components (Web Awesome + Alpine) templates and worked examples. --- .../scripts/htmlkit/assemble.py | 521 +++++++++++++++++ .../scripts/quirk_html.py | 229 ++++++++ .../templates/components/example.src.html | 78 +++ .../templates/components/rules.md | 31 + .../templates/components/skeleton.html | 48 ++ .../templates/components/template.json | 14 + .../templates/report/example.src.html | 168 ++++++ .../templates/report/rules.md | 37 ++ .../templates/report/skeleton.html | 55 ++ .../templates/report/template.json | 15 + .../templates/report/timings.json | 12 + tests/test_html_builder.py | 544 ++++++++++++++++++ 12 files changed, 1752 insertions(+) create mode 100644 skills/writing-html-artifacts/scripts/htmlkit/assemble.py create mode 100644 skills/writing-html-artifacts/scripts/quirk_html.py create mode 100644 skills/writing-html-artifacts/templates/components/example.src.html create mode 100644 skills/writing-html-artifacts/templates/components/rules.md create mode 100644 skills/writing-html-artifacts/templates/components/skeleton.html create mode 100644 skills/writing-html-artifacts/templates/components/template.json create mode 100644 skills/writing-html-artifacts/templates/report/example.src.html create mode 100644 skills/writing-html-artifacts/templates/report/rules.md create mode 100644 skills/writing-html-artifacts/templates/report/skeleton.html create mode 100644 skills/writing-html-artifacts/templates/report/template.json create mode 100644 skills/writing-html-artifacts/templates/report/timings.json create mode 100644 tests/test_html_builder.py diff --git a/skills/writing-html-artifacts/scripts/htmlkit/assemble.py b/skills/writing-html-artifacts/scripts/htmlkit/assemble.py new file mode 100644 index 00000000..59ddf0f7 --- /dev/null +++ b/skills/writing-html-artifacts/scripts/htmlkit/assemble.py @@ -0,0 +1,521 @@ +"""Tier-1 build: turn a ``.src.html`` source file into one self-contained HTML artifact. + +``parse_source`` reads the source format (header comment plus slot sections). ``build_tier1`` +resolves the template, inlines the pinned libraries, the tokens, the theme and ``chrome.js``, +writes the file atomically and runs the static checks. ``check_artifact`` and ``delivery_note`` +serve the ``check`` and ``note`` CLI commands for any built file. + +Problems the author can fix (unknown template, malformed source, missing slot, unknown +placeholder, a library version that is no longer vendored) raise ``BuildError`` so the CLI can +report them as usage errors. A build that finishes but fails a static check is not an error here; +the result carries ``checks`` and the caller decides the exit code. Nothing in this module +touches the network. +""" +from __future__ import annotations + +import csv +import html as html_lib +import io +import json +import re +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +from . import SKILL_DIR +from .checks import MODES, SIZE_FAIL, SIZE_WARN, default_shells, run_checks +from .fsutil import atomic_write, ensure_self_ignoring +from .header import read_header, render_header +from .manifest import Template +from .resolve import ( + TemplateNotFound, + default_project_dir, + default_user_dir, + find_template, + template_roots, +) +from .tokens import merge_tokens, token_layers, tokens_css +from .vendor import VendorError, VendorStore, shell_block +from .verify_runner import default_out_dir, report_is_stale + +SOURCE_MODES = ("offline", "connected") +_SOURCE_HEADER = re.compile(r"^[ \t]*$") +_SLOT_LINE = re.compile(r"^$", re.M) +_PLACEHOLDER = re.compile(r"\{\{[^{}]*\}\}") +_SIMPLE_PLACEHOLDERS = ("header", "title", "head", "chrome", "data", "body_end") + + +class BuildError(Exception): + """The build cannot proceed because of something the author must change.""" + + +class SourceError(BuildError): + """The ``.src.html`` file is malformed.""" + + +@dataclass +class Source: + """A parsed ``.src.html`` file; ``slots`` maps slot name to its raw HTML.""" + + path: Path + template: str + title: str + mode: str = "offline" + libraries: list[str] = field(default_factory=list) + interactions: list[str] = field(default_factory=list) + data: Optional[str] = None + tier_reason: Optional[str] = None + slots: dict[str, str] = field(default_factory=dict) + + +def _string_list(meta: dict, key: str) -> list[str]: + value = meta.get(key, []) + if not isinstance(value, list) or not all(isinstance(v, str) for v in value): + raise SourceError(f"header field {key!r} must be a list of strings") + return value + + +def parse_source(path: Path) -> Source: + """Parse a tier-1 source file; raises SourceError for any deviation from the format.""" + path = Path(path) + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + raise SourceError(f"cannot read source {path}: {exc}") from exc + text = text.replace("\r\n", "\n") + first, _, rest = text.partition("\n") + match = _SOURCE_HEADER.match(first) + if not match: + raise SourceError("line 1 must be a header comment: ") + try: + meta = json.loads(match.group(1)) + except ValueError as exc: + raise SourceError(f"source header is not valid JSON: {exc}") from exc + if not isinstance(meta, dict): + raise SourceError("source header must be a JSON object") + + for key in ("template", "title"): + if not isinstance(meta.get(key), str) or not meta[key].strip(): + raise SourceError(f"header field {key!r} is required and must be a non-empty string") + mode = meta.get("mode", "offline") + if mode not in SOURCE_MODES: + raise SourceError(f"header field 'mode' must be offline or connected, got {mode!r}") + data = meta.get("data") + if data is not None and not isinstance(data, str): + raise SourceError("header field 'data' must be a path string") + reason = meta.get("tier_reason") + if reason is not None and not isinstance(reason, str): + raise SourceError("header field 'tier_reason' must be a string") + + marks = list(_SLOT_LINE.finditer(rest)) + if not marks: + raise SourceError("source has no slot sections; start each with ") + if rest[: marks[0].start()].strip(): + raise SourceError("text before the first slot line; put all content inside a slot") + slots: dict[str, str] = {} + for i, mark in enumerate(marks): + end = marks[i + 1].start() if i + 1 < len(marks) else len(rest) + name = mark.group(1) + if name in slots: + raise SourceError(f"slot {name!r} appears twice") + slots[name] = rest[mark.end() : end].strip("\n") + + return Source( + path=path, + template=meta["template"], + title=meta["title"], + mode=mode, + libraries=_string_list(meta, "libraries"), + interactions=_string_list(meta, "interactions"), + data=data, + tier_reason=reason or None, + slots=slots, + ) + + +def _quirk_version(skill_dir: Path) -> str: + plugin = Path(skill_dir).parent.parent / ".claude-plugin" / "plugin.json" + try: + version = json.loads(plugin.read_text(encoding="utf-8")).get("version") + except (OSError, ValueError, AttributeError): + return "unknown" + return version if isinstance(version, str) and version else "unknown" + + +def _slug(source_path: Path) -> str: + name = source_path.name + return name[: -len(".src.html")] if name.endswith(".src.html") else source_path.stem + + +def _read_text_file(path: Path, what: str) -> str: + try: + return path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + raise SourceError(f"cannot read {what} {path}: {exc}") from exc + + +def _csv_rows(text: str) -> list[dict]: + rows = [r for r in csv.reader(io.StringIO(text.lstrip(""), newline="")) if r != []] + if not rows: + return [] + keys = rows[0] + return [{k: (r[i] if i < len(r) else "") for i, k in enumerate(keys)} for r in rows[1:]] + + +@dataclass +class _Data: + path: Path + suffix: str + embedded: str + + +def _load_data(source: Source) -> Optional[_Data]: + if source.data is None: + return None + path = (source.path.parent / source.data).resolve() + suffix = path.suffix.lower() + if suffix not in (".json", ".csv"): + raise SourceError(f"data file {source.data!r} must be .json or .csv") + text = _read_text_file(path, "data file") + if suffix == ".json": + try: + parsed = json.loads(text) + except ValueError as exc: + raise SourceError(f"data file {source.data!r} is not valid JSON: {exc}") from exc + else: + parsed = _csv_rows(text) + payload = json.dumps(parsed, ensure_ascii=False, separators=(",", ":")).replace("<", "\\u003c") + return _Data(path, suffix, payload) + + +def _picker(sidecar: str) -> str: + name = html_lib.escape(sidecar) + return ( + '
' + '
' + ) + + +def _chrome(sidecar: Optional[str]) -> str: + toggle = '' + return toggle + (_picker(sidecar) if sidecar else "") + + +def _substitute(skeleton: str, values: dict[str, str]) -> str: + def replace(match: "re.Match[str]") -> str: + token = match.group(0)[2:-2] + if token not in values: + raise BuildError(f"skeleton has an unknown placeholder {match.group(0)}") + return values[token] + + return _PLACEHOLDER.sub(replace, skeleton) + + +def _frozen_versions(out: Path) -> dict[str, str]: + try: + meta = read_header(out.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError): + return {} + libs = meta.get("libraries") if meta else None + if not isinstance(libs, dict): + return {} + return {k: v for k, v in libs.items() if isinstance(k, str) and isinstance(v, str)} + + +def _library_order(template: Template, source: Source) -> list[str]: + extra = [name for name in source.libraries if name not in template.libraries] + unknown = [name for name in extra if name not in template.optional_libraries] + if unknown: + raise BuildError( + f"libraries {', '.join(unknown)} are not optional libraries of template " + f"{template.name!r} (optional: {', '.join(template.optional_libraries) or 'none'})" + ) + names = list(dict.fromkeys(template.libraries + extra)) + return [n for n in names if n != "alpine"] + [n for n in names if n == "alpine"] + + +def _check_slots(template: Template, source: Source) -> None: + undeclared = [name for name in source.slots if name not in template.slots] + if undeclared: + raise SourceError( + f"source has slots the template does not declare: {', '.join(undeclared)} " + f"(declared: {', '.join(template.slots)})" + ) + missing = [n for n, info in template.slots.items() if info.get("required") and n not in source.slots] + if missing: + raise SourceError(f"source is missing required slot(s): {', '.join(missing)}") + + +def _skill_store(skill_dir: Path, template_dir: Optional[Path]) -> VendorStore: + paths = [Path(skill_dir) / "vendor" / "libraries.json"] + if template_dir is not None: + paths.insert(0, Path(template_dir) / "vendor" / "libraries.json") + return VendorStore(paths) + + +def build_tier1( + source_path: Path, + *, + out: Optional[Path], + upgrade: bool, + project_dir: Path, + user_dir: Optional[Path], + skill_dir: Optional[Path] = None, +) -> dict: + """Build ``source_path`` and return a ``BuildResult`` dict (see tech.md section 4.1). + + ``out`` defaults to ``/.quirk/html/.html``; only that directory gets a + self-ignoring ``.gitignore``. An existing output file pins every library to the version in + its header unless ``upgrade`` is set. ``sidecar`` in the result is the sidecar file's path. + Raises BuildError for anything the author must fix; static-check failures are reported in + ``checks`` and still leave the file written. + """ + skill = Path(skill_dir) if skill_dir is not None else SKILL_DIR + source_path = Path(source_path) + project_dir = Path(project_dir) + user = Path(user_dir) if user_dir is not None else default_user_dir() + source = parse_source(source_path) + + roots = template_roots(skill, project_dir, user) + try: + template, skipped = find_template(source.template, roots, skill_dir=skill) + except TemplateNotFound as exc: + raise BuildError(str(exc)) from exc + if template.tier != 1: + raise BuildError(f"template {template.name!r} is tier 2; build it with build-app") + warnings = [ + f"template {w['name']!r} at {w['level']} level was skipped ({w['path']}): {w['reason']}" + for w in skipped + ] + _check_slots(template, source) + + default_dir = project_dir / ".quirk" / "html" + slug = _slug(source_path) + out_path = Path(out) if out is not None else default_dir / f"{slug}.html" + frozen = _frozen_versions(out_path) + + store = _skill_store(skill, template.dir) + versions: dict[str, str] = {} + head_parts: list[str] = [] + js_parts: list[str] = [] + licenses: list[str] = [] + try: + for name in _library_order(template, source): + lv = store.resolve(name, None if upgrade else frozen.get(name)) + if upgrade and name in frozen and frozen[name] != lv.version: + warnings.append(f"library {name} upgraded from {frozen[name]} to {lv.version}") + versions[name] = lv.version + blocks = store.inline_blocks(lv) + if blocks: + licenses.append(store.license_comment(lv)) + for block in blocks: + (head_parts if block.kind == "css" else js_parts).append(block.text) + chrome_js = shell_block("chrome", skill / "theme" / "chrome.js").text + except VendorError as exc: + raise BuildError(str(exc)) from exc + + try: + token_css = tokens_css(merge_tokens(token_layers(skill, user, project_dir, template))) + except ValueError as exc: + raise BuildError(str(exc)) from exc + base_css = _read_text_file(skill / "theme" / "base.css", "theme") + head = "\n".join(head_parts + [f"", f""]) + body_end = "\n".join(js_parts + [chrome_js]) + + skeleton = _read_text_file(template.dir / str(template.skeleton), "skeleton") + signal = f"judgment: {source.tier_reason}" if source.tier_reason else None + built_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + data = _load_data(source) + + def render(sidecar: Optional[str]) -> str: + meta = { + "schema": 1, + "template": template.name, + "template_level": template.level, + "tier": 1, + "tier_signal": signal, + "mode": source.mode, + "libraries": versions, + "sidecar": sidecar, + "built_at": built_at, + "quirk_version": _quirk_version(skill), + } + embedded = "" + if data is not None and sidecar is None: + embedded = f'' + values = { + "header": "\n".join([render_header(meta), *licenses]), + "title": html_lib.escape(source.title), + "head": head, + "chrome": _chrome(sidecar), + "data": embedded, + "body_end": body_end, + } + values.update({f"slot:{name}": source.slots.get(name, "") for name in template.slots}) + return _substitute(skeleton, values) + + sidecar_name: Optional[str] = None + page = render(None) + if data is not None and len(page.encode("utf-8")) > SIZE_FAIL: + sidecar_name = f"{out_path.stem}.data{data.suffix}" + page = render(sidecar_name) + + if out is None or out_path.parent.resolve() == default_dir.resolve(): + ensure_self_ignoring(out_path.parent) + else: + out_path.parent.mkdir(parents=True, exist_ok=True) + sidecar_path: Optional[Path] = None + if sidecar_name is not None and data is not None: + sidecar_path = out_path.parent / sidecar_name + atomic_write(sidecar_path, data.path.read_bytes()) + atomic_write(out_path, page) + + result_checks = run_checks(page, mode=source.mode, tier=1, vendor=store, shells=default_shells(skill)) + return { + "path": str(out_path), + "size": len(page.encode("utf-8")), + "sidecar": str(sidecar_path) if sidecar_path else None, + "source_dir": None, + "template": template.name, + "template_level": template.level, + "tier": 1, + "tier_signal": signal, + "mode": source.mode, + "libraries": versions, + "warnings": warnings, + "checks": result_checks, + "cut": [], + } + + +def read_artifact_header(path: Path) -> dict: + """The header of a built file; raises BuildError when it is unreadable or has none.""" + try: + meta = read_header(Path(path).read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError) as exc: + raise BuildError(f"cannot read {path}: {exc}") from exc + if meta is None: + raise BuildError(f"{path} has no quirk-html-artifact header; it was not built by this skill") + return meta + + +def artifact_mode(meta: Optional[dict]) -> str: + """The header's mode when it is one the checks know, else ``offline``.""" + mode = meta.get("mode") if meta else None + return mode if mode in MODES else "offline" + + +def check_artifact( + path: Path, + *, + project_dir: Path, + user_dir: Optional[Path], + skill_dir: Optional[Path] = None, +) -> dict: + """Run the static checks on a built file, using its header's mode and tier. + + A file with no readable header still gets checked as an offline tier-1 page, which + reports the missing header as a failure. + """ + skill = Path(skill_dir) if skill_dir is not None else SKILL_DIR + path = Path(path) + try: + html = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + raise BuildError(f"cannot read {path}: {exc}") from exc + meta = read_header(html) + tier = meta.get("tier") if meta and meta.get("tier") in (1, 2) else 1 + template_dir: Optional[Path] = None + if meta and isinstance(meta.get("template"), str): + user = Path(user_dir) if user_dir is not None else default_user_dir() + try: + found, _ = find_template(meta["template"], template_roots(skill, project_dir, user), skill_dir=skill) + template_dir = found.dir + except TemplateNotFound: + template_dir = None + return run_checks( + html, + mode=artifact_mode(meta), + tier=tier, + vendor=_skill_store(skill, template_dir), + shells=default_shells(skill), + ) + + +def _verify_line(path: Path, stale: bool) -> list[str]: + report_path = default_out_dir(path) / "report.json" + try: + report = json.loads(report_path.read_text()) + except (OSError, ValueError): + return ["Verify: not browser-verified (verify has not been run on this file)."] + if stale: + return ["Verify: not browser-verified (the last report is for an earlier version of this file; run verify again)."] + status = report.get("status") + if status == "not-browser-verified": + reason = report.get("reason") + return ["Verify: not browser-verified" + (f" ({reason})." if reason else ".")] + lines = [f"Verify: {status} in {report.get('browser') or 'a browser'}."] + checks = report.get("checks") or {} + for name, result in checks.items(): + if isinstance(result, dict) and not result.get("passed", True): + for detail in result.get("details", [])[:5]: + lines.append(f" - {name}: {detail}") + shots = report.get("screenshots") or [] + if shots: + lines.append("Screenshots:") + lines.extend(f" - {shot}" for shot in shots) + return lines + + +def delivery_note( + path: Path, + *, + project_dir: Path, + user_dir: Optional[Path], + skill_dir: Optional[Path] = None, +) -> dict: + """The delivery note for a built file: ``{"markdown": str, "verify_stale": bool}``. + + ``verify_stale`` is true when no verify report exists for the file's current contents. + """ + path = Path(path).resolve() + meta = read_artifact_header(path) + size = path.stat().st_size + checked = check_artifact(path, project_dir=project_dir, user_dir=user_dir, skill_dir=skill_dir) + stale = report_is_stale(path, default_out_dir(path) / "report.json") + libraries = meta.get("libraries") if isinstance(meta.get("libraries"), dict) else {} + + lines = [f"Built `{path}` ({size:,} bytes)."] + sidecar = meta.get("sidecar") + if sidecar: + side = path.parent / sidecar + extra = f" ({side.stat().st_size:,} bytes)" if side.is_file() else "" + lines.append(f"- Sidecar: `{side}`{extra}. The page asks the reader to pick this file.") + source_dir = path.parent / path.stem + if source_dir.is_dir(): + lines.append(f"- Source folder: `{source_dir}`") + lines.append(f"- Template: {meta.get('template')} ({meta.get('template_level')})") + signal = meta.get("tier_signal") + lines.append(f"- Tier {meta.get('tier')}" + (f", chosen by: {signal}" if signal else " (no tier signal recorded)")) + mode = meta.get("mode") + lines.append( + f"- Mode: {mode}" + + (" (no network requests)" if mode == "offline" else " (the page's own data calls use the network)") + ) + lines.append("- Libraries: " + (", ".join(f"{k} {v}" for k, v in libraries.items()) or "none")) + if checked["passed"]: + lines.append("- Static checks: passed") + else: + lines.append("- Static checks: failed") + lines.extend(f" - {f['check']}: {f['detail']}" for f in checked["failures"]) + if size > SIZE_WARN: + why = " Mermaid alone is about 5 MB, so this is expected." if "mermaid" in libraries else "" + lines.append(f"- Size: over 2 MB, so the file may be slow to open.{why}") + lines.extend(f"- {line}" if not line.startswith(" ") else line for line in _verify_line(path, stale)) + lines.append("") + lines.append("Want me to open it or publish it?") + return {"markdown": "\n".join(lines), "verify_stale": stale} diff --git a/skills/writing-html-artifacts/scripts/quirk_html.py b/skills/writing-html-artifacts/scripts/quirk_html.py new file mode 100644 index 00000000..b9025978 --- /dev/null +++ b/skills/writing-html-artifacts/scripts/quirk_html.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +"""Command line for the writing-html-artifacts skill: build, check, verify and describe pages. + +Every subcommand prints exactly one JSON object to stdout and sends diagnostics to stderr. +Exit codes: 0 success; 1 the build finished but a static or verify check failed; 2 usage +error, unknown or invalid template, or malformed source. Subcommands are registered in +``COMMANDS`` so a new one is a ``configure`` function, a handler and one table entry. +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Callable, NoReturn, Optional + +# the skill directory holds htmlkit and is not on sys.path when this file is run by path +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from htmlkit import SKILL_DIR, assemble, manifest, resolve, verify_runner # noqa: E402 + +EXIT_OK, EXIT_CHECK_FAILED, EXIT_USAGE = 0, 1, 2 + + +class _Parser(argparse.ArgumentParser): + """Argument parser whose usage errors are one JSON object on stdout, exit 2.""" + + def error(self, message: str) -> NoReturn: + _emit({"error": message}) + print(f"{self.prog}: error: {message}", file=sys.stderr) + raise SystemExit(EXIT_USAGE) + + +def _emit(payload: dict) -> None: + print(json.dumps(payload)) + + +def _fail(message: str) -> int: + print(message, file=sys.stderr) + _emit({"error": message}) + return EXIT_USAGE + + +def _project_dir(args: argparse.Namespace) -> Path: + given = getattr(args, "project_dir", None) + return Path(given) if given else resolve.default_project_dir() + + +def _require_file(path: Path) -> Optional[str]: + return None if path.is_file() else f"{path} is not a file" + + +def _cmd_templates_list(args: argparse.Namespace) -> int: + roots = resolve.template_roots(SKILL_DIR, _project_dir(args), resolve.default_user_dir()) + templates, warnings = resolve.list_templates(roots, skill_dir=SKILL_DIR) + _emit( + { + "templates": [ + { + "name": t.name, + "level": t.level, + "tier": t.tier, + "description": t.description, + "path": str(t.dir), + } + for t in templates + ], + "warnings": warnings, + } + ) + return EXIT_OK + + +def _cmd_templates_validate(args: argparse.Namespace) -> int: + path = Path(args.path) + if not path.is_dir(): + errors = [f"{path} is not a directory"] + else: + errors = manifest.validate_template(path, require_verified=False, skill_dir=SKILL_DIR) + _emit({"valid": not errors, "errors": errors}) + return EXIT_OK if not errors else EXIT_USAGE + + +def _cmd_build(args: argparse.Namespace) -> int: + source = Path(args.source) + problem = _require_file(source) + if problem: + return _fail(problem) + try: + result = assemble.build_tier1( + source, + out=Path(args.out) if args.out else None, + upgrade=args.upgrade, + project_dir=_project_dir(args), + user_dir=resolve.default_user_dir(), + ) + except assemble.BuildError as exc: + return _fail(str(exc)) + _emit(result) + return EXIT_OK if result["checks"]["passed"] else EXIT_CHECK_FAILED + + +def _cmd_check(args: argparse.Namespace) -> int: + path = Path(args.file) + problem = _require_file(path) + if problem: + return _fail(problem) + try: + result = assemble.check_artifact( + path, project_dir=_project_dir(args), user_dir=resolve.default_user_dir() + ) + except assemble.BuildError as exc: + return _fail(str(exc)) + _emit(result) + return EXIT_OK if result["passed"] else EXIT_CHECK_FAILED + + +def _cmd_verify(args: argparse.Namespace) -> int: + path = Path(args.file) + problem = _require_file(path) + if problem: + return _fail(problem) + try: + meta = assemble.read_artifact_header(path) + except assemble.BuildError: + meta = None + mode = "connected" if meta and meta.get("mode") == "connected" else "offline" + result = verify_runner.run_verify(path, Path(args.out_dir) if args.out_dir else None, mode) + _emit(result) + return EXIT_CHECK_FAILED if result.get("status") == "failed" else EXIT_OK + + +def _cmd_note(args: argparse.Namespace) -> int: + path = Path(args.file) + problem = _require_file(path) + if problem: + return _fail(problem) + try: + note = assemble.delivery_note( + path, project_dir=_project_dir(args), user_dir=resolve.default_user_dir() + ) + except assemble.BuildError as exc: + return _fail(str(exc)) + _emit(note) + return EXIT_OK + + +def _project_option(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--project-dir", help="project root (default: git toplevel of the current directory)") + + +def _configure_templates(sub: argparse._SubParsersAction) -> None: + parser = sub.add_parser("templates", help="list and validate templates") + nested = parser.add_subparsers(dest="templates_command", required=True, parser_class=_Parser) + for configure in TEMPLATE_COMMANDS.values(): + configure(nested) + + +def _configure_templates_list(sub: argparse._SubParsersAction) -> None: + parser = sub.add_parser("list", help="list templates that resolve and the ones skipped") + _project_option(parser) + parser.set_defaults(func=_cmd_templates_list) + + +def _configure_templates_validate(sub: argparse._SubParsersAction) -> None: + parser = sub.add_parser("validate", help="check a template directory's manifest and skeleton") + parser.add_argument("path", help="template directory") + parser.set_defaults(func=_cmd_templates_validate) + + +def _configure_build(sub: argparse._SubParsersAction) -> None: + parser = sub.add_parser("build", help="build a tier-1 source file into one HTML file") + parser.add_argument("source", help="SRC.src.html") + parser.add_argument("--out", help="output file (default: /.quirk/html/.html)") + parser.add_argument("--upgrade", action="store_true", help="use current library versions, not the recorded ones") + _project_option(parser) + parser.set_defaults(func=_cmd_build) + + +def _configure_check(sub: argparse._SubParsersAction) -> None: + parser = sub.add_parser("check", help="run the static checks on a built file") + parser.add_argument("file") + _project_option(parser) + parser.set_defaults(func=_cmd_check) + + +def _configure_verify(sub: argparse._SubParsersAction) -> None: + parser = sub.add_parser("verify", help="verify a built file in a real browser") + parser.add_argument("file") + parser.add_argument("--out-dir", help="where the report and screenshots go (default: .verify/)") + parser.set_defaults(func=_cmd_verify) + + +def _configure_note(sub: argparse._SubParsersAction) -> None: + parser = sub.add_parser("note", help="render the delivery note for a built file") + parser.add_argument("file") + _project_option(parser) + parser.set_defaults(func=_cmd_note) + + +Configure = Callable[[argparse._SubParsersAction], None] +TEMPLATE_COMMANDS: dict[str, Configure] = { + "list": _configure_templates_list, + "validate": _configure_templates_validate, +} +COMMANDS: dict[str, Configure] = { + "templates": _configure_templates, + "build": _configure_build, + "check": _configure_check, + "verify": _configure_verify, + "note": _configure_note, +} + + +def build_parser() -> argparse.ArgumentParser: + parser = _Parser(prog="quirk_html.py", description=__doc__.splitlines()[0]) + sub = parser.add_subparsers(dest="command", required=True, parser_class=_Parser) + for configure in COMMANDS.values(): + configure(sub) + return parser + + +def main(argv: Optional[list[str]] = None) -> int: + args = build_parser().parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/writing-html-artifacts/templates/components/example.src.html b/skills/writing-html-artifacts/templates/components/example.src.html new file mode 100644 index 00000000..c261fa8d --- /dev/null +++ b/skills/writing-html-artifacts/templates/components/example.src.html @@ -0,0 +1,78 @@ + + +
+

Choose which deploy events notify the platform team and how often the digest goes out. Changes apply to the whole team.

+

Scope: the payments platform team, 14 members.

+ +
+

Settings

+ +General +Alerts +Data + + +
+ +Daily at 09:00 UTC +Weekly on Monday +Never + +Include flaky-test report in the digest +
+
+ + +
+Alert on failed deploys +Alert on rollbacks +
+
+ + +
+

Reset returns every setting to its default and clears the alert history shown on the dashboard.

+Reset all settings +
+
+
+
+ +
+

Current choices

+

Digest weekly; failed-deploy alerts on; rollback alerts on.

+

Changes are saved for this session only.

+
+ + +

Every setting goes back to its default. This cannot be undone.

+Keep my settings +Reset settings +
+
+ + diff --git a/skills/writing-html-artifacts/templates/components/rules.md b/skills/writing-html-artifacts/templates/components/rules.md new file mode 100644 index 00000000..a405503c --- /dev/null +++ b/skills/writing-html-artifacts/templates/components/rules.md @@ -0,0 +1,31 @@ +# Components template rules + +Read after the [design reference](../../references/design-reference.md). These rules add to it for pages built from `components`, which loads Web Awesome and Alpine. + +## Layout + +- The skeleton gives you the page: a masthead with the theme toggle and the `

` (from `title`), then `
` in a 960px column. Do not write an `

` or a second theme toggle in `main`. +- Open `main` with a one-paragraph lede (`class="q-lede"`) and a source or scope line (`class="q-meta"`), then the components in `
` blocks with `

` headings. +- Helper classes the skeleton defines: `q-lede`, `q-meta`, `q-stack` (a vertical stack with `--q-space-4` gaps), `q-chart`, `q-table-wrap`, and `num` on `th` and `td` for right-aligned numbers. +- Prose is capped at 70ch. Tables and charts fill the column. + +## Web Awesome + +- Use the `` elements for what plain HTML lacks (tabs, dialog, select, switch, button). Use native elements (`
`, ``, `` is not needed) for everything else. +- Every control has a visible `label` or an `aria-label`. A `wa-dialog` has a `label` attribute, which is its title. +- The theme maps Web Awesome's color variables onto the `--q-*` tokens, so components follow light and dark themes. Do not set `--wa-*` colors or hard-coded colors in the page. +- Web Awesome events: use the native `change` and `input` events for form components, `wa-tab-show` for tabs, `wa-after-hide` for a dialog. Read values from `event.target`. +- Do not use `` with a named icon: a named icon loads from a library at runtime and fails the offline checks. Use text labels, or inline SVG with a ``. +- Keep state in Alpine (`x-data`, registered with `alpine:init`), and keep it small. A readout that changes with the controls sits in an `aria-live="polite"` element. + +## Charts + +- Every chart `<canvas>` has `role="img"` and an `aria-label` that states the finding, and is followed by a table view in a `<details>`. +- Put the canvas in a `q-chart` wrapper with `maintainAspectRatio: false` and `animation: false`. +- Read colors with `getComputedStyle(document.documentElement)` and rebuild the chart in a `quirk-theme` listener on `document`. The theme toggle also sets the `wa-dark` class on `<html>`, so components and charts switch together. + +## Scripts and data + +- Slot scripts run before the library scripts, and Alpine starts before `chrome.js` defines `window.quirkData`. Call `quirkData()` and draw charts after the window `load` event. +- Declare every interaction you build in the source header's `interactions` list. +- No `fetch`, `XMLHttpRequest`, `WebSocket` or `EventSource` in offline mode. diff --git a/skills/writing-html-artifacts/templates/components/skeleton.html b/skills/writing-html-artifacts/templates/components/skeleton.html new file mode 100644 index 00000000..d4773308 --- /dev/null +++ b/skills/writing-html-artifacts/templates/components/skeleton.html @@ -0,0 +1,48 @@ +<!doctype html> +{{header}} +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>{{title}} +{{head}} + + + +
+
+
{{chrome}}
+

{{title}}

+
+
+
+
+{{slot:main}} +
+
+{{data}} +{{body_end}} + + diff --git a/skills/writing-html-artifacts/templates/components/template.json b/skills/writing-html-artifacts/templates/components/template.json new file mode 100644 index 00000000..3116bef9 --- /dev/null +++ b/skills/writing-html-artifacts/templates/components/template.json @@ -0,0 +1,14 @@ +{ + "schema": 1, + "name": "components", + "description": "Use when the page needs ready-made interface components such as tabs, dialogs, selects, switches or menus, for a settings panel, a form-heavy tool or a component-driven mini app.", + "tier": 1, + "libraries": ["webawesome", "alpine"], + "optional_libraries": ["chartjs", "mermaid"], + "slots": { + "main": { "required": true, "description": "The page body, built from Web Awesome components and plain semantic HTML. No h1; the title becomes it." } + }, + "skeleton": "skeleton.html", + "rules": "rules.md", + "example": "example.src.html" +} diff --git a/skills/writing-html-artifacts/templates/report/example.src.html b/skills/writing-html-artifacts/templates/report/example.src.html new file mode 100644 index 00000000..57eacdee --- /dev/null +++ b/skills/writing-html-artifacts/templates/report/example.src.html @@ -0,0 +1,168 @@ + + +

The integration suite now takes 5 min 42 s at the median, down from 8 min 18 s, and accounts for most of the week's improvement. Checkout end-to-end is the flakiest suite at 3.4% of runs.

+

Source: CI run export for 5 to 11 Oct 2026 (sample data for this template).

+ +
+
+

Findings

+
    +
  • Integration median fell 31%, from 8 min 18 s to 5 min 42 s.
  • +
  • Integration p95 is 10 min 11 s, the longest tail of any suite.
  • +
  • End-to-end checkout failed and then passed on retry in 3.4% of its 640 runs.
  • +
  • The other five suites each moved by less than 10%.
  • +
+
+ +
+

Time by suite

+ +
+ +
+
+Table view of the chart +
+
+ + + + + +
Time by suite for the selected measure
SuiteMedian (min)
+ +
+

+ +
+

All suites

+
+ + + + + + + + + + + + + + + +
Test timings by suite, week 41 against week 40
+
+
+ + + + + diff --git a/skills/writing-html-artifacts/templates/report/rules.md b/skills/writing-html-artifacts/templates/report/rules.md new file mode 100644 index 00000000..f49a9f46 --- /dev/null +++ b/skills/writing-html-artifacts/templates/report/rules.md @@ -0,0 +1,37 @@ +# Report template rules + +Read after the [design reference](../../references/design-reference.md). These rules add to it for pages built from `report`. + +## Layout + +- The skeleton gives you the page: a masthead with the theme toggle and the `

` (from `title`), then `
` in a 960px column. Do not write an `

` or a second theme toggle in a slot. +- `main` holds the body. Open with one paragraph that states the conclusion (`class="q-lede"`), then a line for the data source or date (`class="q-meta"`), then a "Findings" section of 3 to 5 one-sentence facts, then the evidence. +- Wrap each section in `
` with an `

`. Sections are separated by spacing, not boxes. +- `aside` is optional. When you use it, the slot content is the whole element: `

") < html.index(" None: +def test_a_regions_skeleton_places_another_declared_slot_itself(tmp_path: Path, env: dict, project: Path) -> None: slots = {"main": {"required": True}, "aside": {"required": False}} - custom_template(project, REGIONS_SKELETON, slots=slots) + custom_template(project, REGIONS_SKELETON.replace("{{regions}}", "{{regions}}{{slot:aside}}"), slots=slots) out = tmp_path / "o.html" src = src_file( tmp_path / "r.src.html", - {"template": "fx", "title": "T", "layout": "report"}, + {"template": "fx", "title": "T", "layout": "explainer"}, {"summary": "

S

", "main": "

M

", "aside": ''}, ) rc, result = build(src, out, env, project) @@ -667,8 +676,8 @@ def _tier1_problems(base: Path, skeleton: str, slots: dict) -> list[str]: MAIN_ASIDE = {"main": {"required": True}, "aside": {"required": False}} -def test_manifest_accepts_regions_without_main_or_aside_placeholders(tmp_path: Path) -> None: - assert _tier1_problems(tmp_path, REGIONS_SKELETON, MAIN_ASIDE) == [] +def test_manifest_accepts_regions_without_a_main_placeholder(tmp_path: Path) -> None: + assert _tier1_problems(tmp_path, REGIONS_SKELETON, MAIN_ONLY) == [] def test_manifest_accepts_the_layout_placeholder_with_either_main_style(tmp_path: Path) -> None: @@ -681,9 +690,9 @@ def test_manifest_rejects_regions_mixed_with_slot_main(tmp_path: Path) -> None: assert any("{{regions}}" in p and "{{slot:main}}" in p for p in problems) -def test_manifest_rejects_regions_mixed_with_slot_aside(tmp_path: Path) -> None: - problems = _tier1_problems(tmp_path, REGIONS_SKELETON.replace("{{regions}}", "{{regions}}{{slot:aside}}"), MAIN_ASIDE) - assert any("{{slot:aside}}" in p for p in problems) +def test_manifest_treats_aside_as_an_ordinary_slot_beside_regions(tmp_path: Path) -> None: + assert any("{{slot:aside}}" in p for p in _tier1_problems(tmp_path, REGIONS_SKELETON, MAIN_ASIDE)) + assert _tier1_problems(tmp_path, REGIONS_SKELETON.replace("{{regions}}", "{{regions}}{{slot:aside}}"), MAIN_ASIDE) == [] def test_manifest_still_needs_other_declared_slots_beside_regions(tmp_path: Path) -> None: diff --git a/tests/test_html_templates_create.py b/tests/test_html_templates_create.py index 6644814b..15053f0a 100644 --- a/tests/test_html_templates_create.py +++ b/tests/test_html_templates_create.py @@ -188,7 +188,7 @@ def test_from_a_quirk_built_artifact_copies_its_template_and_source(env): dest = env["project"] / ".quirk/html-templates/notes-kit" assert code == 0, out meta = json.loads((dest / "template.json").read_text()) - assert meta["name"] == "notes-kit" and list(meta["slots"]) == ["main", "aside"] + assert meta["name"] == "notes-kit" and list(meta["slots"]) == ["main"] example = (dest / "example.src.html").read_text() assert example.startswith('|--!>") def _schema_blocks(html: str) -> list[tuple[int, int]]: - """Spans of each ```` element, ending at the first closing tag that is not inside a ``", "") + page = "\n" + real + rc, result, out = report_build(tmp_path, env, project, page, name="schemadecoys") + assert rc == 0, result["checks"] + html = out.read_text(encoding="utf-8") + assert html.count('
html.index("") + assert 'data-q-schema-list-entity="charge"' in html + + +def test_q_schema_with_an_overnested_detail_fails_the_build_without_crashing(tmp_path, env, project): + bad = json.loads(json.dumps(BILLING)) + bad["entities"][0]["detail"] = "
  • " * 1100 + "x" + "
" * 1100 + rc, result, out = report_build(tmp_path, env, project, _schema_page(bad), name="schemaoverdetail") + assert rc == 1 + kit = [f["detail"] for f in result["checks"]["failures"] if f["check"] == "kit"] + assert any(d.endswith(" detail may hold only paragraphs, lists, code and links") for d in kit), kit + + def test_a_bad_q_schema_fails_the_build_and_gets_no_fallback(tmp_path, env, project): bad = json.loads(json.dumps(BILLING)) bad["relationships"][0]["to"] = "customers.id" diff --git a/tests/test_html_kit_check.py b/tests/test_html_kit_check.py index 0ed19464..9fef50f5 100644 --- a/tests/test_html_kit_check.py +++ b/tests/test_html_kit_check.py @@ -2306,6 +2306,40 @@ def noted(m): assert kitcheck.inject_schema_fallbacks(out) == out +def test_schema_fallback_ignores_a_closing_tag_spelled_inside_a_comment() -> None: + markup = schema_markup(billing_model()).replace( + "", "" + ) + out = kitcheck.inject_schema_fallbacks(markup) + assert out.count("data-q-flow-list-body") == 1 + assert out.index("data-q-flow-list-body") > out.index("") + assert out.endswith("
") + assert kitcheck.inject_schema_fallbacks(out) == out + + +def test_schema_fallback_ignores_opener_text_inside_scripts_and_comments() -> None: + real = schema_markup(billing_model()) + decoys = ( + '\n' + "\n" + ) + out = kitcheck.inject_schema_fallbacks(decoys + real) + assert out.startswith(decoys) + assert out.count("data-q-flow-list-body") == 1 + assert out.index("data-q-flow-list-body") > out.index(real[:40]) + assert kitcheck.inject_schema_fallbacks(out) == out + + +def test_schema_fallback_skips_an_entity_detail_nested_past_the_recursion_limit() -> None: + def deep(m): + m["entities"][0]["detail"] = "
  • " * 1100 + "x" + "
" * 1100 + + payload = er_with(deep) + markup = schema_markup(payload) + assert schema_failures(payload) == [" detail may hold only paragraphs, lists, code and links"] + assert kitcheck.inject_schema_fallbacks(markup) == markup + + def test_schema_fallback_fills_two_consecutive_blocks() -> None: one = schema_markup(billing_model(), 'summary="One."') two = schema_markup(parser_model(), 'notation="class" summary="Two."') diff --git a/tests/test_html_kit_elements.py b/tests/test_html_kit_elements.py index 6bae2035..734291d8 100644 --- a/tests/test_html_kit_elements.py +++ b/tests/test_html_kit_elements.py @@ -7778,6 +7778,39 @@ def test_schema_endpoints_whose_ids_and_field_names_hold_colons_keep_their_own_p assert got["orthogonal"] is True, (key, got) +def test_schema_fold_ignores_a_related_field_whose_joined_id_and_name_collide_with_another(schema_page: Path) -> None: + plain = [f"p{i}" for i in range(1, 9)] + model = { + "entities": [ + {"id": "a", "label": "A", "fields": [*[_schema_field(n, "text") for n in plain], _schema_field("b\u0000c", "text")]}, + {"id": "a\u0000b", "label": "AB", "fields": [_schema_field("id", "int", "PK"), _schema_field("c", "int", "FK")]}, + {"id": "z", "label": "Z", "fields": [_schema_field("id", "int", "PK")]}, + ], + "relationships": [_schema_rel("a\u0000b.c", "z.id", "zero-or-many", "one")], + } + out = run( + schema_page, + ev( + f"""(async () => {{ + {SCHEMA_MOUNT_JS} + const el = mountSchema({json.dumps(model)}); + const ready = await settle(el); + const c = el.querySelector('[data-q-schema-entity="a"]'); + return {{ + ready, + names: [...c.querySelectorAll('[data-q-schema-field]')].map((r) => r.getAttribute('data-q-schema-field')), + }}; + }})()""", + "d", + ), + ) + assert_clean(out) + d = out["results"]["d"] + assert d["ready"] == "true" + assert d["names"] == plain + assert d["names"][7] == "p8" + + def test_schema_ids_named_like_the_layout_graph_keep_every_card_and_line(schema_page: Path) -> None: ids = ["root", "rel0", "port:root:id:WEST"] fields = [_schema_field("id", "int", "PK"), _schema_field("ref", "int", "FK")] From e5cc80b01c0682e13615d87d48c3120060472993 Mon Sep 17 00:00:00 2001 From: zpyoung Date: Sat, 17 Oct 2026 14:05:27 -0500 Subject: [PATCH 139/164] chore: log BUG-23 and BUG-24, low-severity edge cases left after the q-schema review --- BUGS.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/BUGS.md b/BUGS.md index 26a21af3..1fdf7c9b 100644 --- a/BUGS.md +++ b/BUGS.md @@ -192,3 +192,22 @@ entries' IDs; manual edits to fix typos are fine. - **Severity**: low - **Proposed fix**: reserve the toolbar's height at the top when fitting, at least on narrow stages. - **Blocker for**: reading the top node of a tall graph on a phone without panning + +## BUG-23: q-flow's detail sanitizer crashes the build on deeply nested detail HTML +- **Observed**: 2026-10-17 +- **File**: skills/writing-html-artifacts/scripts/htmlkit/kitcheck.py:906 +- **Description**: _check_and_sanitize_flow_detail recurses per nested element, so a q-flow node detail with about 1,100 nested lists raises an uncaught RecursionError and aborts the build. q-schema now catches it and reports a validation failure (commit 797d789); q-flow does not. Observed 2026-10-17 in the q-schema SDD final review. +- **Introduced by**: unknown (q-flow kit check, before d6895d2) +- **Severity**: low +- **Proposed fix**: Sanitize iteratively, or cap nesting depth with a validation failure. +- **Blocker for**: builds of q-flow pages whose node detail is pathologically nested + +## BUG-24: QSchema change detection joins its parts with NUL, so some changes could skip a re-render +- **Observed**: 2026-10-17 +- **File**: skills/writing-html-artifacts/vendor/kit/1.0.0/kit.js:6577 +- **Description**: QSchema._signature joins the JSON text, summary and notation with a NUL separator to decide whether to re-render, so two different attribute and data combinations containing NUL could compare equal and leave a stale drawing. Observed 2026-10-17 in the q-schema SDD final review. +- **Introduced by**: this session (q-schema runtime) +- **Severity**: low +- **Proposed fix**: Compare the parts separately, or encode them unambiguously. +- **Blocker for**: live edits of a q-schema whose summary or data contains NUL + From 526fc67dfb42c6a2eb6901a161f4872d619880e4 Mon Sep 17 00:00:00 2001 From: zpyoung Date: Sat, 17 Oct 2026 14:08:10 -0500 Subject: [PATCH 140/164] fix(q-schema): match script and q-schema tags only by their exact names when scanning for fallbacks --- .../scripts/htmlkit/kitcheck.py | 4 ++-- tests/test_html_kit_check.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/skills/writing-html-artifacts/scripts/htmlkit/kitcheck.py b/skills/writing-html-artifacts/scripts/htmlkit/kitcheck.py index 1cefbe70..7cab6ed3 100644 --- a/skills/writing-html-artifacts/scripts/htmlkit/kitcheck.py +++ b/skills/writing-html-artifacts/scripts/htmlkit/kitcheck.py @@ -2283,8 +2283,8 @@ def render_top_level_groups() -> list[str]: _SCHEMA_CLOSER = re.compile(r"\Z", re.IGNORECASE) -_SCHEMA_SCAN = re.compile(r"|--!>") diff --git a/tests/test_html_kit_check.py b/tests/test_html_kit_check.py index 9fef50f5..436cce28 100644 --- a/tests/test_html_kit_check.py +++ b/tests/test_html_kit_check.py @@ -2330,6 +2330,20 @@ def test_schema_fallback_ignores_opener_text_inside_scripts_and_comments() -> No assert kitcheck.inject_schema_fallbacks(out) == out +def test_schema_fallback_ends_a_script_only_at_a_real_script_end_tag() -> None: + real = schema_markup(billing_model()) + # and look like tag names to a \b match but are not script or q-schema tags + decoys = ( + '\n' + '\n' + "\n" + ) + out = kitcheck.inject_schema_fallbacks(decoys + real) + assert out.count("data-q-flow-list-body") == 1 + assert out.index("data-q-flow-list-body") > out.index(real[:40]) + assert kitcheck.inject_schema_fallbacks(out) == out + + def test_schema_fallback_skips_an_entity_detail_nested_past_the_recursion_limit() -> None: def deep(m): m["entities"][0]["detail"] = "
  • " * 1100 + "x" + "
" * 1100 From b6eb88c08837c74eab3e0eab23f23f0b7f6d2feb Mon Sep 17 00:00:00 2001 From: zpyoung Date: Sat, 17 Oct 2026 06:36:22 -0500 Subject: [PATCH 141/164] docs: add the q-sequence logic spec --- .../specs/2026-10-17-q-sequence/logic.md | 715 ++++++++++++++++++ 1 file changed, 715 insertions(+) create mode 100644 docs/quirk/specs/2026-10-17-q-sequence/logic.md diff --git a/docs/quirk/specs/2026-10-17-q-sequence/logic.md b/docs/quirk/specs/2026-10-17-q-sequence/logic.md new file mode 100644 index 00000000..c10cafff --- /dev/null +++ b/docs/quirk/specs/2026-10-17-q-sequence/logic.md @@ -0,0 +1,715 @@ +# Logic spec: `q-sequence`, an interactive sequence diagram element + +**Status**: Draft + +Agents draw sequence diagrams in Mermaid today. Mermaid gives a static picture: the reader can't open +a message to see what it carries, can't pick out one participant's part in a long exchange, and can't +step through the calls one at a time with the payload in view. Wide diagrams shrink to fit the page, +so on a phone the labels are too small to read. + +`q-sequence` is a kit element for those pages. It draws participants as columns and messages as rows, +works out the activation bars itself, and lets the reader focus a participant, open a message, and +step through a trace with the payload at each message. Mermaid stays for diagrams inside Markdown. + +It is the third of four specs. It follows the element contract in +[the q-flow logic spec](../2026-10-16-q-flow/logic.md) and reuses the trace format and trace panel +from [the q-flow traces logic spec](../2026-10-17-q-flow-traces/logic.md), with one step per message +instead of one per node. Everything those specs say about kinds, details, the trace panel and +reduced motion holds here unless this spec changes it. + +## Conceptual model + +A **sequence** has five kinds of thing, all written by the agent as data: + +| Thing | What it is | What the agent gives it | +|---|---|---| +| participant | a person, service, worker, queue or store that sends and receives messages | an id and a label; optionally a kind and a detail | +| message | one call, reply or async message from one participant to another, or to itself | the two participants, a label and a type; optionally an id, a detail, and whether it creates its receiver | +| note | a short remark drawn over one or more participants | the text and the participants it sits over | +| fragment | a labeled box around a run of steps: `alt`, `opt`, `loop` or `par` | the fragment type, an optional label, and its steps or branches | +| kind | a category of participant with one chart color | a free-text name and one of the eight chart colors, as in q-flow | + +The steps are one ordered list, top to bottom. A step is a message, a note, a fragment, or the end of +a participant. `alt` and `par` fragments hold two or more **branches**, each with a label and its own +steps; `opt` and `loop` hold one run of steps. + +The agent never writes a position or an activation. Participants are columns in the order they are +listed, and every step is a row. The element works out the **activation bars** from the call and +reply pairs, and numbers the messages in the order they are drawn. + +The reader explores the sequence through **focus**. Focusing a participant lights its lifeline and +every message that touches it, and dims the rest. Opening a message lights it and opens the +**drawer** with its detail. With traces, **trace mode** steps through the messages one at a time, +with spec 2's trace panel showing the payload and what changed. + +Every sequence also renders as a **text list**: the participants, the numbered messages, the +fragments as nested lists, the notes, and the traces, in a "Diagram as a list" disclosure under the +diagram. It is the screen-reader and no-script view, so it carries everything the diagram does. + +## Data flow + +1. **Choosing.** The agent reaches for `q-sequence` for a sequence diagram on an HTML page: who calls + whom, in what order, and what comes back. It reaches for `q-flow` for a graph of what connects to + what, and for Mermaid for any diagram inside Markdown. +2. **Authoring.** The agent writes a `` tag with a one-sentence `summary` and the + sequence as JSON inside it: `kinds`, `participants`, `steps`, and optionally `traces`. +3. **Building.** The build runs the kit check over the sequence data (SC-2) and writes the no-script + fallback list. The sequence renderer is part of the kit, so the build inlines no graph library for + it; a page with only `q-sequence` carries no flow bundle (SC-20). +4. **Loading.** In the browser the element checks the data the same way, measures its labels, draws + the columns and rows, and writes the text list from the same data. Nothing moves. +5. **Reading.** The reader scrolls the diagram with the page, scrolls it sideways when it is wider + than the screen, focuses participants, opens messages, and steps through traces. The theme toggle + recolors the diagram live. +6. **Verifying.** Verify waits until every sequence on the page has drawn, then takes its screenshots + at 375px and 1280px in both themes. + +## Key decisions and rationale + +- **Its own element and its own SVG renderer, with no layout engine.** A sequence diagram is columns + and rows: the column is fixed by the participant's place in the list, and the row by the step's + place in the order. There is nothing for ELK to solve, and reusing `q-flow` would pull its 1.6 MB + bundle onto every page with a sequence. A separate element also keeps the data honest: a sequence + has order and replies, which a graph doesn't. +- **The type of a message is a field, not an arrow token.** Mermaid's arrow tokens mean different + things across its dialects and versions, and nothing checks that a "reply" answers a call. Here a + message says `call`, `reply` or `async`, the element draws each in the UML style (filled arrow, + dashed return, open arrow), and the check can tell a call that never gets its reply. +- **Activation bars are worked out, never written.** In Mermaid and PlantUML the author activates + and deactivates participants by hand, and a missed deactivation draws a bar to the bottom of the + diagram. The pairing of a call with its reply already says when the receiver is busy, so the + element draws the bar from it (SC-6). +- **The diagram scrolls with the page, at full text size, with the headers pinned.** A pan and zoom + canvas suits a graph the reader explores in two directions. A sequence is read top to bottom, so it + scrolls like the rest of the page, sideways only when it is wider than the screen. The participant + headers stay pinned to the top so the reader always knows which column is which. +- **Traces reuse spec 2's format and panel, pointing at messages.** A message is where the payload + travels in a sequence, so a step names a message and its value is the payload. Sharing the panel + and its value tree keeps the two elements reading the same way and gives one copy to maintain. +- **Steps are nested, so fragments can nest.** An `alt` inside a `loop` is common (retry until the + card clears). Nesting the steps in the JSON mirrors the boxes on the page, and the check can prove + every fragment is well formed. +- **Hard limits stop the diagram from becoming unreadable.** Sequence diagrams get hard to follow as + participants are added, so the check warns past 8 and fails past 16, warns on long labels, and warns + on a diagram past about 60 messages, nudging the agent to split it. + +## Behavior and scenarios + +The scenarios share one checkout sequence unless they say otherwise. Participants: Shopper (kind +"person", color 1), Checkout (kind "ui", color 2), Billing (kind "service", color 3), Stripe (kind +"external", color 4) and Ledger (kind "store", color 5). Its steps: + +1. Shopper → Checkout "place order" (call) +2. Checkout → Billing "charge order" (call) +3. `loop` "up to 3 attempts", holding: + - Billing → Stripe "create charge" (call, id "create-charge") + - `alt` with two branches: + - "card valid": Stripe → Billing "charge succeeded" (reply, id "charge-ok") + - "card declined": Stripe → Billing "card declined" (reply, id "charge-declined"), then a note over + Billing "waits 2 seconds before retrying" +4. `opt` "charge succeeded", holding Billing → Ledger "record payment" (async, id "record") +5. Billing → Checkout "order paid" (reply, id "paid") +6. Checkout → Shopper "show receipt" (reply) + +Drawn and numbered, that is 8 messages: 1 place order, 2 charge order, 3 create charge, 4 charge +succeeded, 5 card declined, 6 record payment, 7 order paid, 8 show receipt. + +It carries two traces, each a payload moving through the messages: + +- **"Card succeeds"**: place order, charge order, create charge, charge succeeded, record payment, + order paid. 6 steps. +- **"Declined, then retried"**: place order, charge order, create charge, card declined, create + charge, charge succeeded, record payment, order paid. 8 steps. Its values are in SC-14. + +### Authoring and building + +1. **SC-1** + - **Given** an agent writing an explainer page about how checkout charges a card + - **When** it writes the checkout sequence with its two traces and builds the page + - **Then** the kit check passes, the diagram shows five participant columns and eight numbered + messages inside a loop, an alt and an opt box, the toolbar has a "Step through" button, and + "Diagram as a list" carries every participant, message, fragment, note and trace + - **Data before:** **Source**: `` + with kinds person, ui, service, external and store; five participants; the steps above; two traces + - **Walkthrough:** + 1. The kit check confirms every message names known participants, every fragment is well formed, + and every trace step names a known message + 2. In the browser the element draws one column per participant in the order listed, one row per + step, and numbers the messages 1 to 8 top to bottom + 3. It writes the text list from the same data + - **Data after:** **Built page**: ✓ kit check passed; ✓ no flow bundle inlined; **Diagram**: ✓ five + columns, Shopper on the left; ✓ messages numbered 1 to 8; ✓ "Step through" in the toolbar; + ✓ nothing moving; **Text list**: ✓ everything above + - A sequence with no traces has no "Step through" button. + +2. **SC-2** + - **Given** a sequence whose data breaks the contract + - **When** the page is built + - **Then** the kit check fails or warns, names the element and the step, and the JS element reports + the same failures when it parses the data, for each case in the table + - **Kit check results:** + + | Sequence data | Result | + |---|---| + | a message from Billing to "strip", which no participant has | ✗ fails: message 3 names unknown participant "strip" | + | a note over "ledgr" | ✗ fails: note names unknown participant "ledgr" | + | two participants with the id "billing" | ✗ fails: duplicate id "billing" | + | a participant and a message both with the id "billing" | ✗ fails: duplicate id "billing" | + | a fragment of type "critical" | ✗ fails: unknown fragment type "critical"; use alt, opt, loop or par | + | an `alt` with one branch | ✗ fails: alt needs at least 2 branches | + | a `par` with one branch | ✗ fails: par needs at least 2 branches | + | a fragment or branch with no steps | ✗ fails: fragment "up to 3 attempts" has no steps | + | a message of type "return" | ✗ fails: message 7 has unknown type "return"; use call, reply or async | + | a trace step naming message "charge-retry", which no message has | ✗ fails: trace "declined" step 5 names unknown message "charge-retry" | + | 17 participants | ✗ fails: at most 16 participants | + | no `summary` | ✗ fails: summary is required | + | 9 participants | ✓ passes with a warning: sequences over 8 participants are hard to follow; split them | + | a call that no reply answers | ✓ passes with a warning: call 2 "charge order" has no matching reply | + | a message label of 52 characters | ✓ passes with a warning: message labels over about 40 characters crowd the row; move the rest to the detail | + | 64 messages | ✓ passes with a warning: sequences over about 60 messages are hard to follow; split them | + + - The q-flow rules for kinds and details hold unchanged: an undeclared kind fails, more than eight + kinds fails, and a detail may hold only paragraphs, lists, code and links. + - The spec 2 rules for traces hold unchanged: a duplicate trace id, a trace with fewer than 2 steps, + and a value that isn't JSON or runs over 400 pretty-printed lines fail; a value over about 40 + lines, a note over about 120 characters and a trace over about 30 steps warn. + - The create and destroy rules are in SC-8. + +### Drawing + +3. **SC-3** + - **Given** the checkout sequence on a 1280px page + - **When** it loads + - **Then** each message is drawn in the style for its type, numbered, on its own row, in the order + the steps list them, with fragment rows in drawn order too + - **Message styles:** + + | Type | Line | Arrow head | Example | + |---|---|---|---| + | call | ✓ solid | ✓ filled | 3. create charge, Billing → Stripe | + | reply | ✓ dashed, back toward the caller | ✓ open | 4. charge succeeded, Stripe → Billing | + | async | ✓ solid | ✓ open | 6. record payment, Billing → Ledger | + + - The number sits before the label, as in "3. create charge". Notes, fragments and branch labels + carry no number. A message drawn once inside a loop gets one number, however many times the loop + would run. + +4. **SC-4** + - **Given** a sequence where Billing sends itself "validate cart" (call) + - **When** it loads + - **Then** the message is drawn as an arrow leaving Billing's lifeline and looping back to it, on a + taller row so the label fits, and it opens no activation bar + - A self-message never activates its participant, whatever its type, because the participant is + already busy sending it. + +5. **SC-5** + - **Given** the checkout sequence + - **When** it loads + - **Then** each fragment is a labeled box around exactly its rows, wide enough to cover every + participant its steps touch, and `alt` and `par` are split into sections by a dashed line + - **Fragment boxes:** + + | Fragment | Tab | Rows it covers | Columns it spans | + |---|---|---|---| + | loop | ✓ "loop" with "up to 3 attempts" | ✓ messages 3 to 5 and the note | ✓ Billing to Stripe | + | alt | ✓ "alt", sections "card valid" and "card declined" | ✓ messages 4 and 5 and the note | ✓ Billing to Stripe | + | opt | ✓ "opt" with "charge succeeded" | ✓ message 6 | ✓ Billing to Ledger | + + - A nested fragment sits inside its parent with a small inset, so both edges stay visible. + - A note sits over its participants: centered on one, or stretched from the leftmost to the + rightmost when it names several. + +6. **SC-6** + - **Given** the checkout sequence + - **When** it loads + - **Then** an activation bar runs on each receiver from its call to the reply that answers it, and + async messages and replies open none + - **Data before:** **Steps**: 1 place order (call), 2 charge order (call), 3 create charge (call), + alt with 4 charge succeeded (reply) or 5 card declined (reply), 6 record payment (async), 7 order + paid (reply), 8 show receipt (reply) + - **Walkthrough:** + 1. Message 1 opens a bar on Checkout; message 2 opens a bar on Billing; message 3 opens a bar on + Stripe + 2. Each alt branch starts with the calls that were open when the alt began, so message 4 closes + Stripe's bar in the first branch and message 5 closes it again in the second + 3. Message 6 is async, so Ledger gets no bar + 4. Message 7 closes Billing's bar; message 8 closes Checkout's + - **Data after:** **Activation bars**: ✓ Checkout, from 1 to 8; ✓ Billing, from 2 to 7; ✓ Stripe, + from 3 to 4, and again from the start of the "card declined" section to 5; ✓ Ledger, none + - **Matching rules:** + + | Case | Result | + |---|---| + | a reply from B to A | ✓ closes the most recent open call from A to B | + | a call made while the receiver already has a bar | ✓ a second bar, offset, stacked on the first | + | after an `alt` | ✓ a call counts as answered only if every branch answers it | + | after a `par` | ✓ a call counts as answered if any branch answers it | + | after an `opt` or `loop` | ✓ a call counts as answered if its body answers it | + | a call opened inside a branch and not answered there | ✓ the bar ends at the branch's end, with the no-reply warning | + | a call never answered | ✓ the bar runs to the end of the diagram, or to the participant's end, with the no-reply warning | + | a reply that answers no open call | ✓ drawn as a reply, no bar changes, with a warning: reply 5 answers no call | + +7. **SC-7** + - **Given** the checkout sequence + - **When** it loads + - **Then** each participant has a header at the top of its column with its label, its kind as a + small tag, and an accent stripe in its kind's chart color, and a dashed lifeline runs down from + it; a participant with no kind has a plain header + - The kinds and colors match q-flow's, so the same kind reads the same way on both elements. + +8. **SC-8** + - **Given** a sequence where Billing sends "start session" to Receipt worker, marked as creating + it, and later Receipt worker ends + - **When** it loads + - **Then** Receipt worker's header is drawn at the row of "start session" rather than at the top, + its lifeline starts there, and it ends in an X at the row where it ends + - **Data before:** **Participant**: Receipt worker; **Steps**: Billing → Receipt worker "start + session" (call, creates its receiver); Receipt worker → Billing "session ready" (reply); Billing + → Receipt worker "send receipt" (async); end of Receipt worker + - **Data after:** **Diagram**: ✓ Receipt worker's header at the "start session" row; ✓ empty column + above it; ✓ lifeline ending in an X under "send receipt" + - **Create and destroy checks:** + + | Data | Result | + |---|---| + | a message touching Receipt worker before the message that creates it | ✗ fails: message 2 touches Receipt worker before it is created | + | a message touching Receipt worker after it ends | ✗ fails: message 6 touches Receipt worker after it ends | + | two messages that create Receipt worker | ✗ fails: Receipt worker is created twice | + | Receipt worker ended twice | ✗ fails: Receipt worker ends twice | + | a self-message marked as creating | ✗ fails: a message can't create its own sender | + + - A message creates its receiver, never its sender. Any participant can end, whether it created + itself or was there from the top. A participant ending while a call to it is open closes the + bar there, with the no-reply warning. + +### Narrow screens + +9. **SC-9** + - **Given** the checkout sequence on a 375px phone + - **When** the reader scrolls down the page and then sideways across the diagram + - **Then** the diagram keeps its full text size, scrolls sideways inside its own box, and scrolls + down with the page; once the header row scrolls off the top, the participant headers stay pinned + at the top of the screen, lined up with their columns as the reader scrolls sideways + - **Screen states:** + + | Moment | Shown | + |---|---| + | page load | ✓ Shopper, Checkout and part of Billing visible; ✓ the rest reachable by scrolling sideways | + | scrolled down past the headers | ✓ the headers pinned at the top; ✓ the page scrolls normally | + | scrolled sideways to Stripe | ✓ the pinned headers move with the columns, so Stripe's header sits over Stripe's lifeline | + | scrolled past the end of the diagram | ✓ the pinned headers leave with it | + + - The pinned headers name every participant, including one created further down, so the reader + always knows which column is which. There is no pan, no zoom and no expand button; the page itself + is the view. + +### Exploring + +10. **SC-10** + - **Given** the checkout sequence with nothing focused + - **When** the reader clicks Stripe's header + - **Then** Stripe's lifeline and the messages that touch it (3, 4 and 5) light, every other + message and lifeline dims, and the drawer opens with Stripe's label, kind, detail and the + numbered messages it sends and receives + - **Data before:** **Diagram**: nothing focused; drawer closed + - **Walkthrough:** + 1. The reader clicks Stripe's header + 2. The element lights Stripe and messages 3, 4 and 5, and dims messages 1, 2, 6, 7 and 8, the + note, and every other lifeline + 3. The drawer opens on Stripe + - **Data after:** **Diagram**: ✓ Stripe and messages 3 to 5 at full color; ✓ the rest dimmed; + ✓ fragment boxes still visible; **Drawer**: ✓ "Stripe", kind "external", its detail, "receives + 3. create charge", "sends 4. charge succeeded", "sends 5. card declined" + - Clicking the same header again, clicking empty space, or pressing Escape clears focus and closes + the drawer. + +11. **SC-11** + - **Given** the checkout sequence with nothing focused + - **When** the reader clicks message 2, "charge order" + - **Then** message 2 lights, its two participants' headers are marked, everything else dims, and + the drawer opens on the message + - **Drawer contents:** + + | Part | Shown | + |---|---| + | title | ✓ "2. charge order" | + | from and to | ✓ Checkout → Billing | + | type | ✓ call | + | answered by | ✓ "7. order paid", a link that opens message 7 | + | detail | ✓ the message's detail, or nothing if it has none | + + - A reply's drawer shows "answers 2. charge order" in the same place. A message inside fragments + also lists them, for example "inside loop: up to 3 attempts, alt: card declined". + - Clicking a message or participant named in the drawer moves focus to it. + - At 700px and narrower the drawer opens as a bottom sheet, as in q-flow; wider, it docks beside + the diagram and stays in view as the page scrolls. + +12. **SC-12** + - **Given** a keyboard user tabbing through the page + - **When** they tab into the diagram + - **Then** focus lands on the first participant's header, and the keys work as in the table, with + each move announced in a live region + - **Keyboard:** + + | Key | Result | + |---|---| + | Tab | ✓ enters the diagram on one header or message, and a second Tab leaves it | + | Left and Right arrows | ✓ move between participant headers | + | Down arrow on a header | ✓ moves to the first message touching that participant | + | Up and Down arrows on a message | ✓ move to the previous or next message by number | + | Up arrow on message 1 | ✓ moves back to its sender's header | + | Enter | ✓ focuses the participant or opens the message, as a click does, and announces it, for example "Message 3 of 8, Billing to Stripe, create charge, call" | + | Escape | ✓ clears focus and closes the drawer | + + - Moving keyboard focus scrolls the page and the diagram so the focused header or message is in + view. + +### Stepping through a trace + +13. **SC-13** + - **Given** the checkout sequence on a 1280px page, with Stripe focused + - **When** the reader presses "Step through" + - **Then** focus clears and the drawer closes, spec 2's trace panel opens beside the diagram on + the first trace, and the diagram shows step 1: message 1 lit with its arrow pulsing, messages off + the trace dimmed, and the value shown with no change marks + - **Data before:** **Diagram**: Stripe focused; drawer open + - **Walkthrough:** + 1. The reader presses "Step through" + 2. The element clears focus, closes the drawer and opens the panel on "Card succeeds" + 3. It lights message 1 and pulses its arrow from Shopper to Checkout, and dims messages 5 and 8 + and the note, because "Card succeeds" never reaches them + - **Data after:** **Diagram**: ✓ message 1 current, its arrow pulsing; ✓ messages 2, 3, 4, 6 and 7 at + full color; ✓ messages 5 and 8 and the note dimmed; **Panel**: ✓ beside the diagram, staying in + view as the page scrolls; ✓ "Step 1 of 6"; ✓ the value with no marks + - The panel's controls, change marks, "Changes only" toggle, collapsing, Play, keyboard and live + region work exactly as spec 2 sets out. The live region names the message, for example "Step 3 + of 6, message 3, Billing to Stripe, create charge". + - Only the current message's arrow pulses. Messages earlier in the trace stay at full color and + still. + +14. **SC-14** + - **Given** "Declined, then retried" open at step 1 + - **When** the reader presses Next seven times + - **Then** each step lights its message and shows the payload's changes in the table, step 5 + returns to message 3 because the loop runs again, and the page and diagram scroll so the current + message stays in view + - **Data before:** **Step 1, message 1 place order**: value { order "C-2207"*, items 2, total + 48.00 } + - **Changes at each step:** + + | Step | Message | Changes shown | + |---|---|---| + | 1 | 1. place order | none, the first step | + | 2 | 2. charge order | `+` amount 48.00; `+` currency "usd" | + | 3 | 3. create charge | `+` attempt 1; `+` card "•••• 4242"* | + | 4 | 5. card declined | `+` decline "insufficient_funds" | + | 5 | 3. create charge | attempt 1 → 2; decline struck through | + | 6 | 4. charge succeeded | `+` charge "ch_91"*; `+` status "succeeded" | + | 7 | 6. record payment | none; the payload passes through unchanged | + | 8 | 7. order paid | status "succeeded" → "paid" | + + - A trace's steps may name messages in any order, so it can go around a loop again or skip a + message, as this one skips 8. The element doesn't check that a trace follows the drawn order. + - A step that revisits a message lights the same row; the panel's counter tells the reader which + visit it is. + +15. **SC-15** + - **Given** "Declined, then retried" at step 2 + - **When** the reader clicks message 3, then clicks it again, then clicks message 8 + - **Then** the first click jumps to step 3 and the second to step 5, its next visit, as in spec 2; + the click on message 8, which is off the trace, leaves trace mode and opens message 8's drawer + - Clicking empty space doesn't leave trace mode. Escape or "Step through" again leaves it, and the + diagram returns to rest with nothing dimmed and nothing pulsing. + +16. **SC-16** + - **Given** the reader has reduced motion turned on + - **When** they step through a trace, by hand or with Play + - **Then** every step changes as usual, but nothing animates: the current message is a thicker + static line with its arrowhead, with no pulse, and the page jumps instead of easing when it + scrolls to the current message + +17. **SC-17** + - **Given** the checkout sequence on a 375px phone + - **When** the reader opens trace mode + - **Then** the trace panel is pinned to the bottom of the screen, at most about half its height, + and the page scrolls so the current message sits in view above the panel + - The panel's header row, with the counter, Back and Next, stays visible; the reader can collapse + the rest of the panel to see more of the diagram. It docks beside the diagram above 700px. + +### Text list and fallbacks + +18. **SC-18** + - **Given** the checkout sequence + - **When** the reader opens "Diagram as a list", or the page loads with scripts off + - **Then** the list carries the participants, then the steps as a numbered list with fragments + as nested lists and notes inline, then each trace as an ordered list of steps, and the no-script + fallback the build writes carries the same + - **List entries:** + + | Part | Entry | + |---|---| + | participant | Stripe (external): its detail | + | message | 3. Billing → Stripe: create charge (call) | + | async message | 6. Billing → Ledger: record payment (async) | + | fragment | loop: up to 3 attempts, with its steps nested under it | + | alt fragment | alt: card valid / card declined, each branch a nested list under its label | + | note | Note over Billing: waits 2 seconds before retrying | + | created participant | 1. Billing → Receipt worker: start session (call, creates Receipt worker) | + | end | Receipt worker ends | + | trace step | 4. message 5, Stripe → Billing, card declined, then the value as a code block | + + - With scripts off the diagram area shows a one-line notice and the list starts open, as in + q-flow. + +19. **SC-19** + - **Given** a screen-reader user + - **When** they reach the diagram + - **Then** they hear its summary, can open the text list, and find everything the diagram shows, + details and traces included + +### Other surfaces + +20. **SC-20** + - **Given** a page whose only diagram is a `q-sequence` + - **When** it is built + - **Then** the built page carries the kit and no flow bundle; a page with both a `q-sequence` and a + `q-flow` carries the flow bundle once, for the `q-flow` + +21. **SC-21** + - **Given** a tier-2 app view, or a companion screen during brainstorming + - **When** it renders a `q-sequence` + - **Then** it behaves as on a tier-1 page; in the companion it may sit in a screen's `content`, and + one in an option's `html` breaks the rule against interactive elements in option cards, as for + `q-flow` + +22. **SC-22** + - **Given** verify running on a page with a sequence + - **When** it takes screenshots + - **Then** it waits until every sequence has drawn, so no screenshot shows an empty diagram, and + the theme toggle recolors the headers, arrows, bars and fragment boxes without a reload + +### Choosing + +23. **SC-23** + - **Given** an agent asked for "an HTML page explaining how checkout talks to Billing and Stripe + when a card is declined" + - **When** it writes the page + - **Then** it writes a `q-sequence` with an `alt` for the declined card, keeps labels short and + payloads in details or traces, and doesn't use Mermaid; asked for the same diagram inside a + Markdown README, it writes Mermaid + - **Choosing:** + + | Request | Element | + |---|---| + | a sequence diagram on an HTML page | ✓ `q-sequence` | + | a graph of services the reader will explore | ✓ `q-flow` | + | any diagram inside Markdown | ✓ Mermaid | + | a state chart on an HTML page | ✓ Mermaid, as today | + +## Scope and non-goals + +In scope: + +- the `q-sequence` element: participants, kinds, messages and their three types, self-messages, + notes, the four fragment types and nesting, create and end, numbering, activation bars +- pinned headers and sideways scrolling, participant focus, the message drawer, keyboard and the live + region +- traces pointing at messages, trace mode with spec 2's trace panel, the pulse and reduced motion +- the text list and the no-script fallback +- the kit check rules in SC-2 and SC-8, in both the Python check and the JS element +- factoring spec 2's trace panel and value tree so both elements share them +- the skill's text on choosing `q-sequence`, `q-flow` and Mermaid in SKILL.md, the design reference, + the template rules and the evals; the generated components reference; the tier-2 typings +- an example page with the checkout sequence and its two traces + +Not in scope: + +- collapsing fragments +- pan and zoom, and an expand button +- editing a sequence on the page +- links from a message to a node in a `q-flow` +- `break` and `critical` fragments + +## Success criteria + +- An eval of "sequence diagram on an HTML page" prompts shows the agent writing `q-sequence`, and + Mermaid for the same diagram in Markdown. +- Verify screenshots of the example at 375px and 1280px in both themes show legible full-size labels, + pinned headers, activation bars and fragment boxes, and the trace panel beside the diagram on + desktop and pinned to the bottom on the phone. +- A page with only `q-sequence` carries no flow bundle. + +## Decisions Locked + +Element and data: + +- `` with a JSON ` + +``` + +Companion option html: not allowed (interactive) diff --git a/skills/writing-html-artifacts/scripts/htmlkit/assemble.py b/skills/writing-html-artifacts/scripts/htmlkit/assemble.py index d5562f9f..5bb142fa 100644 --- a/skills/writing-html-artifacts/scripts/htmlkit/assemble.py +++ b/skills/writing-html-artifacts/scripts/htmlkit/assemble.py @@ -522,14 +522,18 @@ def render(sidecar: Optional[str]) -> str: } values.update( { - f"slot:{name}": kitcheck.inject_schema_fallbacks(kitcheck.inject_flow_fallbacks(source.slots.get(name, ""))) + f"slot:{name}": kitcheck.inject_sequence_fallbacks( + kitcheck.inject_schema_fallbacks(kitcheck.inject_flow_fallbacks(source.slots.get(name, ""))) + ) for name in template.slots } ) values["layout"] = plan.layout values["regions"] = "" if plan.region_bearing: - values["slot:main" if plan.legacy_skeleton else "regions"] = kitcheck.inject_schema_fallbacks(kitcheck.inject_flow_fallbacks(plan.html)) + values["slot:main" if plan.legacy_skeleton else "regions"] = kitcheck.inject_sequence_fallbacks( + kitcheck.inject_schema_fallbacks(kitcheck.inject_flow_fallbacks(plan.html)) + ) return _substitute(skeleton, values) sidecar_name: Optional[str] = None diff --git a/skills/writing-html-artifacts/scripts/htmlkit/kitcheck.py b/skills/writing-html-artifacts/scripts/htmlkit/kitcheck.py index 7cab6ed3..a982090c 100644 --- a/skills/writing-html-artifacts/scripts/htmlkit/kitcheck.py +++ b/skills/writing-html-artifacts/scripts/htmlkit/kitcheck.py @@ -30,9 +30,20 @@ CODE_TAG = re.compile(r"]", re.IGNORECASE) FLOW_TAG = re.compile(r"]", re.IGNORECASE) SCHEMA_TAG = re.compile(r"]", re.IGNORECASE) +SEQUENCE_TAG = re.compile(r"]", re.IGNORECASE) KNOWN_RULES = frozenset( - {"max-series", "diff-notes", "heading-sections", "board-columns", "timeline-items", "compare-recommended", "flow-data", "schema-data"} + { + "max-series", + "diff-notes", + "heading-sections", + "board-columns", + "timeline-items", + "compare-recommended", + "flow-data", + "schema-data", + "sequence-data", + } ) ATTRIBUTE_KINDS = ("text", "enum", "boolean", "number-list", "integer", "ident") @@ -1011,6 +1022,30 @@ def _flow_value_lines(value: Any) -> Optional[int]: return text.count("\n") + 1 +def _check_trace_step_payload( + where: str, step: dict[str, Any], line: int, fail: Callable[[int, str], None], report_warn: Report +) -> None: + """Check one trace step's ``value`` and ``note`` size; ``where`` is the message prefix naming the step. + + Shared by the q-flow and q-sequence validators so a trace reads the same on both elements. + """ + if "value" in step: + lines = _flow_value_lines(step["value"]) + if lines is None: + fail(line, f"{where} has a value that isn't JSON") + elif lines > _FLOW_MAX_VALUE_LINES: + fail(line, f"{where} value is {lines} lines; at most 400") + elif lines > _FLOW_WARN_VALUE_LINES: + report_warn( + line, + f"{where} value is {lines} lines: values over 40 lines are hard to scan; " + "keep the fields the reader needs", + ) + note = step.get("note") + if isinstance(note, str) and len(note) > _FLOW_WARN_NOTE_CHARS: + report_warn(line, f"{where} note is {len(note)} characters: notes over 120 characters crowd the panel") + + def _validate_flow_traces( data: dict[str, Any], line: int, @@ -1104,23 +1139,7 @@ def _validate_flow_traces( else: edge_index = candidates[0] - if has_value: - lines = _flow_value_lines(step["value"]) - if lines is None: - fail(line, f"{where} has a value that isn't JSON") - elif lines > _FLOW_MAX_VALUE_LINES: - fail(line, f"{where} value is {lines} lines; at most 400") - elif lines > _FLOW_WARN_VALUE_LINES: - report_warn( - line, - f"{where} value is {lines} lines: values over 40 lines are hard to scan; " - "keep the fields the reader needs", - ) - if isinstance(note, str) and len(note) > _FLOW_WARN_NOTE_CHARS: - report_warn( - line, - f"{where} note is {len(note)} characters: notes over 120 characters crowd the panel", - ) + _check_trace_step_payload(where, step, line, fail, report_warn) clean_steps.append( { @@ -1171,13 +1190,12 @@ def _flow_has_unwritable_text(data: dict[str, Any]) -> bool: return False -def _validate_flow_node( - node: _Node, - report_fail: Report, - report_warn: Report, - *, - page_flow_ids: Optional[frozenset[str]] = None, -) -> Optional[dict[str, Any]]: +def _json_child(node: _Node, tag: str, report_fail: Report) -> Optional[dict[str, Any]]: + """Parse the ``\n
" + }, + { + "tag": "q-sequence", + "use_when": "Use when the reader needs to follow who calls whom, in what order and what comes back, and for any sequence diagram on an HTML page, including replies, retries, alternatives and the payload at each message.", + "parent": null, + "interactive": true, + "attributes": [ + { + "name": "summary", + "required": true, + "kind": "text", + "description": "One sentence stating what the sequence shows; it becomes the diagram's accessible label." + } + ], + "requires": [ + [ + "script" + ] + ], + "rules": [ + "sequence-data" + ], + "children": "A \n" } ] } diff --git a/tests/test_html_builder.py b/tests/test_html_builder.py index 48593831..157c512a 100644 --- a/tests/test_html_builder.py +++ b/tests/test_html_builder.py @@ -1413,3 +1413,328 @@ def test_a_bad_q_schema_fails_the_build_and_gets_no_fallback(tmp_path, env, proj assert rc == 1 kit = [f["detail"] for f in result["checks"]["failures"] if f["check"] == "kit"] assert len(kit) == 1 and kit[0].endswith(' relationship names unknown entity "customers"') + + + +# -- q-sequence: no-JS list, injection rules and libraries --------------------------------------- + + +def seq_page(payload: dict, attrs: str = 'summary="Checkout charges the card."') -> str: + return f'' + + +SEQ_SMALL = { + "kinds": {"person": 1, "service": 3}, + "participants": [ + {"id": "shopper", "label": "Shopper", "kind": "person"}, + {"id": "billing", "label": "Billing & Co", "kind": "service", "detail": "

Takes x.

"}, + {"id": "worker", "label": "Receipt worker"}, + ], + "steps": [ + {"id": "order", "from": "shopper", "to": "billing", "label": "place ", "type": "call"}, + { + "fragment": "loop", + "label": "up to 3 attempts", + "steps": [ + {"id": "start", "from": "billing", "to": "worker", "label": "start session", "type": "call", "create": True}, + { + "fragment": "alt", + "label": "ignored for alt", + "branches": [ + { + "label": "card valid", + "steps": [{"id": "ok", "from": "worker", "to": "billing", "label": "ok", "type": "reply"}], + }, + {"label": "card declined", "steps": [{"note": "wait", "over": ["billing", "worker"]}]}, + ], + }, + ], + }, + {"fragment": "opt", "steps": [{"id": "send", "from": "billing", "to": "worker", "label": "send", "type": "async"}]}, + {"id": "done", "from": "billing", "to": "shopper", "label": "done", "type": "reply"}, + {"end": "worker"}, + ], + "traces": [ + { + "id": "t", + "label": "Trace ", + "steps": [ + {"message": "order", "value": {"a": ""}, "note": "n & m"}, + {"message": "ok", "value": 2}, + {"message": "done", "value": None, "note": ""}, + ], + } + ], +} + +SEQ_SMALL_LIST = ( + '
' + '

JavaScript is off, so the diagram is shown as a list below.

' + "
" + '
' + "Diagram as a list" + "
" + "
    " + "
  • Shopper (person)
  • " + "
  • Billing & Co (service):

    Takes x.

  • " + "
  • Receipt worker
  • " + "
" + "
    " + '
  1. 1. Shopper → Billing & Co: place <order> (call)
  2. ' + '
  3. loop: up to 3 attempts
      ' + '
    1. 2. Billing & Co → Receipt worker: start session (call, creates Receipt worker)
    2. ' + '
    3. alt: card valid / card declined
        ' + '
      1. card valid
        1. 3. Receipt worker → Billing & Co: ok (reply)
      2. ' + "
      3. card declined
        1. Note over Billing & Co, Receipt worker: wait
      4. " + "
  4. " + '
  5. opt
      ' + '
    1. 4. Billing & Co → Receipt worker: send (async)
  6. ' + '
  7. 5. Billing & Co → Shopper: done (reply)
  8. ' + "
  9. Receipt worker ends
  10. " + "
" + '

Trace: Trace <T>

    ' + '
  1. 1. message 1, Shopper → Billing & Co, place <order>: n & m' + "
    {\n  "a": "<b>"\n}
  2. " + '
  3. 2. message 3, Receipt worker → Billing & Co, ok
    2
  4. ' + '
  5. 3. message 5, Billing & Co → Shopper, done
    null
  6. ' + "
" + "
" + "
" +) + + +def _message(frm: str, to: str, label: str, kind: str, mid: str | None = None, **more: object) -> dict: + return {**({"id": mid} if mid else {}), "from": frm, "to": to, "label": label, "type": kind, **more} + + +CHECKOUT_SEQUENCE = { + "kinds": {"person": 1, "ui": 2, "service": 3, "external": 4, "store": 5}, + "participants": [ + {"id": "shopper", "label": "Shopper", "kind": "person"}, + {"id": "checkout", "label": "Checkout", "kind": "ui"}, + {"id": "billing", "label": "Billing", "kind": "service"}, + {"id": "stripe", "label": "Stripe", "kind": "external", "detail": "

Card processor.

"}, + {"id": "ledger", "label": "Ledger", "kind": "store"}, + {"id": "worker", "label": "Receipt worker", "kind": "service"}, + ], + "steps": [ + _message("shopper", "checkout", "place order", "call", "place-order"), + _message("checkout", "billing", "charge order", "call", "charge-order"), + { + "fragment": "loop", + "label": "up to 3 attempts", + "steps": [ + _message("billing", "stripe", "create charge", "call", "create-charge"), + { + "fragment": "alt", + "branches": [ + {"label": "card valid", "steps": [_message("stripe", "billing", "charge succeeded", "reply", "charge-ok")]}, + { + "label": "card declined", + "steps": [ + _message("stripe", "billing", "card declined", "reply", "charge-declined"), + {"note": "waits 2 seconds before retrying", "over": ["billing"]}, + ], + }, + ], + }, + ], + }, + { + "fragment": "opt", + "label": "charge succeeded", + "steps": [_message("billing", "ledger", "record payment", "async", "record")], + }, + _message("billing", "checkout", "order paid", "reply", "paid"), + _message("checkout", "shopper", "show receipt", "reply", "show-receipt"), + _message("billing", "worker", "start session", "call", "start", create=True), + _message("worker", "billing", "session ready", "reply"), + {"end": "worker"}, + ], + "traces": [ + { + "id": "succeeds", + "label": "Card succeeds", + "steps": [ + {"message": "place-order", "value": {"order": "C-2207"}}, + {"message": "charge-order", "value": {"order": "C-2207", "amount": 48}}, + {"message": "create-charge", "value": {"attempt": 1}}, + {"message": "charge-ok", "value": {"status": "succeeded"}}, + {"message": "record", "value": {"charge": "ch_91"}}, + {"message": "paid", "value": {"status": "paid"}}, + ], + }, + { + "id": "declined", + "label": "Declined, then retried", + "steps": [ + {"message": "place-order", "value": {"order": "C-2207"}}, + {"message": "charge-order", "value": {"order": "C-2207", "amount": 48}}, + {"message": "create-charge", "value": {"attempt": 1}}, + {"message": "charge-declined", "value": {"decline": "insufficient_funds"}, "note": "The card is declined."}, + {"message": "create-charge", "value": {"attempt": 2}}, + {"message": "charge-ok", "value": {"status": "succeeded"}}, + {"message": "record", "value": {"charge": "ch_91"}}, + {"message": "paid", "value": {"status": "paid"}}, + ], + }, + ], +} + + +def without_scripts(html: str) -> str: + return re.sub(r"", "", html, flags=re.S) + + +def seq_injected(page: str) -> str: + from htmlkit import kitcheck + + return kitcheck.inject_sequence_fallbacks(page) + + +def test_sequence_fallback_writes_the_exact_list_for_every_part() -> None: + page = seq_page(SEQ_SMALL) + assert seq_injected(page) == page.replace("", SEQ_SMALL_LIST + "") + + +def test_sequence_fallback_appends_a_message_detail_inside_its_li() -> None: + payload = { + "participants": [{"id": "a", "label": "A"}, {"id": "b", "label": "B"}], + "steps": [ + {"from": "a", "to": "b", "label": "go", "type": "call", "detail": "

Sends x & more.

"}, + {"from": "b", "to": "a", "label": "back", "type": "reply"}, + ], + } + injected = seq_injected(seq_page(payload)) + assert ( + '
  • 1. A \u2192 B: go (call)' + "

    Sends x & more.

  • " + ) in injected + assert '
  • 2. B \u2192 A: back (reply)
  • ' in injected + + +def test_sequence_fallback_has_no_whitespace_between_tags() -> None: + injected = seq_injected(seq_page(SEQ_SMALL)) + fallback = injected.split("", 1)[1] + assert not re.search(r">[ \t\r]+<", fallback) + + +def test_sequence_fallback_without_traces_or_kinds_has_no_trace_block() -> None: + payload = { + "participants": [{"id": "a", "label": "A"}, {"id": "b", "label": "B"}], + "steps": [{"from": "a", "to": "b", "label": "go", "type": "async"}], + } + injected = seq_injected(seq_page(payload)) + assert "
  • A
  • B
  • " in injected + assert "data-q-sequence-list-trace" not in injected + + +def test_sequence_fallback_escapes_attribute_values() -> None: + payload = { + "participants": [{"id": "a", "label": "A"}, {"id": "b", "label": "B"}], + "steps": [{"id": 'x"y', "from": "a", "to": "b", "label": "go", "type": "async"}], + "traces": [ + {"id": 'q"1', "label": "T", "steps": [{"message": 'x"y', "value": 1}, {"message": 'x"y', "value": 2}]} + ], + } + assert 'data-q-sequence-list-trace="q"1"' in seq_injected(seq_page(payload)) + + +def test_sequence_fallback_skip_rules() -> None: + good = seq_page(SEQ_SMALL) + assert seq_injected("

    no sequence here

    ") == "

    no sequence here

    " + once = seq_injected(good) + assert seq_injected(once) == once + assert once.count("data-q-sequence-list-body") == 1 + no_summary = seq_page(SEQ_SMALL, attrs="") + assert seq_injected(no_summary) == no_summary + self_closed = '' + assert seq_injected(self_closed) == self_closed + invalid = seq_page({**SEQ_SMALL, "steps": [{"from": "shopper", "to": "nobody", "label": "x", "type": "call"}], "traces": []}) + assert seq_injected(invalid) == invalid + unparseable = '' + assert seq_injected(unparseable) == unparseable + no_script = '' + assert seq_injected(no_script) == no_script + + +def test_sequence_fallback_handles_several_sequences_and_leaves_q_flow_alone() -> None: + flow = FLOW + page = seq_page(SEQ_SMALL) + flow + seq_page(SEQ_SMALL) + injected = seq_injected(page) + assert injected.count("data-q-sequence-list-body") == 2 + assert "data-q-flow-list" not in injected + assert flow in injected + + +def test_sequence_with_scripts_off_fallback_covers_the_checkout_sequence(tmp_path, env, project): + rc, result, out = report_build(tmp_path, env, project, seq_page(CHECKOUT_SEQUENCE), name="seqcheckout") + assert rc == 0 and result["checks"]["passed"] is True, result["checks"] + html = without_scripts(out.read_text(encoding="utf-8")) + body = html.split("
    ", 1)[1].split("", 1)[0] + texts = [re.sub(r"<[^>]+>", "", m) for m in re.findall(r"
  • .*?
  • ", body)] + assert texts == [ + "1. Shopper → Checkout: place order (call)", + "2. Checkout → Billing: charge order (call)", + "3. Billing → Stripe: create charge (call)", + "4. Stripe → Billing: charge succeeded (reply)", + "5. Stripe → Billing: card declined (reply)", + "6. Billing → Ledger: record payment (async)", + "7. Billing → Checkout: order paid (reply)", + "8. Checkout → Shopper: show receipt (reply)", + "9. Billing → Receipt worker: start session (call, creates Receipt worker)", + "10. Receipt worker → Billing: session ready (reply)", + ] + assert '
  • loop: up to 3 attempts
      ' in body + assert '
    1. alt: card valid / card declined
      1. card valid
          ' in body + assert "
        1. card declined
          1. " in body + assert '
          2. Note over Billing: waits 2 seconds before retrying
          3. ' in body + assert '
          4. opt: charge succeeded
              ' in body + assert "
            1. Receipt worker ends
            2. " in body + assert "
            3. Stripe (external):

              Card processor.

            4. " in body + declined = body.split('data-q-sequence-list-trace="declined"', 1)[1] + assert '
            5. 4. message 5, Stripe → Billing, card declined: The card is declined.
              ' in declined
              +    assert '
            6. 5. message 3, Billing → Stripe, create charge
              ' in declined
              +    assert ""decline": "insufficient_funds"" in declined
              +    assert 'data-q-sequence-list-trace="succeeds"' in body
              +
              +
              +def test_page_with_only_q_sequence_lists_kit_and_not_flow(tmp_path, env, project):
              +    rc, result, out = report_build(tmp_path, env, project, seq_page(SEQ_SMALL), name="seqonly")
              +    assert rc == 0 and result["checks"]["passed"] is True, result["checks"]
              +    assert list(result["libraries"]) == ["simple-css", "kit", "alpine"]
              +    assert "flow" not in result["libraries"]
              +    html = out.read_text(encoding="utf-8")
              +    assert 'data-quirk-lib="flow@' not in html
              +    assert "|--!>")
               
               
              -def _schema_blocks(html: str) -> list[tuple[int, int]]:
              -    """Spans of each ```` element, from its opening tag to the first closing tag that is real markup.
              +def _element_blocks(html: str, tag: str) -> list[tuple[int, int]]:
              +    """Spans of each ```` element, from its opening tag to the first closing tag that is real markup.
               
                   Script content and comments are skipped both when looking for an opening tag and for the closing
              -    one: a browser ends a script only at ````, so ````
              -    or ```` text inside either is data.
              +    one: a browser ends a script only at ````, so an opening or
              +    closing ``tag`` inside either is data. ``tag`` matches by its exact name, so ```` is not it.
                   """
              +    scan = re.compile(
              +        rf"\n"
              +    )
              +    out = seq_injected(decoys + real)
              +    assert out.startswith(decoys)
              +    assert out.count("data-q-sequence-list-body") == 1
              +    assert out.index("data-q-sequence-list-body") > out.index(real[:40])
              +    assert seq_injected(out) == out
              +
              +
              +def test_sequence_fallback_matches_the_tag_name_exactly() -> None:
              +    real = seq_page(SEQ_SMALL)
              +    decoys = (
              +        '\n'
              +        '\n'
              +    )
              +    out = seq_injected(decoys + real)
              +    assert out.startswith(decoys)
              +    assert out.count("data-q-sequence-list-body") == 1
              +    assert out.index("data-q-sequence-list-body") > out.index(real[:40])
              +    assert seq_injected(decoys) == decoys
              +
              +
               def test_sequence_fallback_renders_every_nesting_depth_the_check_accepts() -> None:
                   from htmlkit import kitcheck
               
              diff --git a/tests/test_html_kit_check.py b/tests/test_html_kit_check.py
              index e63b6051..46011476 100644
              --- a/tests/test_html_kit_check.py
              +++ b/tests/test_html_kit_check.py
              @@ -2742,6 +2742,22 @@ def on_message(p: dict) -> None:
                   assert seq_messages(checkout(on_message)) == detail_rule
               
               
              +def test_sequence_a_detail_nested_too_deeply_fails_with_the_detail_rule() -> None:
              +    detail_rule = [" detail may hold only paragraphs, lists, code and links"]
              +    deep = "
              • " * 1200 + "x" + "
              " * 1200 + + def on_participant(p: dict) -> None: + p["participants"][4]["detail"] = deep + + def on_message(p: dict) -> None: + p["steps"][0]["detail"] = deep + + for patch in (on_participant, on_message): + page = seq_markup(checkout(patch)) + assert messages(page) == detail_rule + assert kitcheck.inject_sequence_fallbacks(page) == page + + def test_sequence_message_detail_is_sanitized_into_the_cleaned_message() -> None: cleaned = parsed_sequence(small(msg("a", "b", detail="

              Hello there

              "))) assert cleaned is not None From 636f684b7192ea34128b7652e6e6ef768f2efaa2 Mon Sep 17 00:00:00 2001 From: zpyoung Date: Sat, 17 Oct 2026 14:38:18 -0500 Subject: [PATCH 151/164] docs: name the shared canvas helpers q-sequence uses --- docs/quirk/specs/2026-10-17-q-sequence/tech.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/quirk/specs/2026-10-17-q-sequence/tech.md b/docs/quirk/specs/2026-10-17-q-sequence/tech.md index 71a69f08..46614954 100644 --- a/docs/quirk/specs/2026-10-17-q-sequence/tech.md +++ b/docs/quirk/specs/2026-10-17-q-sequence/tech.md @@ -2,7 +2,7 @@ **Status**: Reviewed (tech-spec reviewer, fixes applied) · Logic spec: [logic.md](logic.md) · Builds on [the q-flow tech spec](/docs/quirk/specs/2026-10-16-q-flow/tech.md) and [the q-flow traces tech spec](/docs/quirk/specs/2026-10-17-q-flow-traces/tech.md) -All paths are relative to the repository root. `S` = `skills/writing-html-artifacts`, `K` = `skills/writing-html-artifacts/vendor/kit/1.0.0`, `H` = `skills/writing-html-artifacts/scripts/htmlkit`. Line numbers are from commit `c4e34ff`. +All paths are relative to the repository root. `S` = `skills/writing-html-artifacts`, `K` = `skills/writing-html-artifacts/vendor/kit/1.0.0`, `H` = `skills/writing-html-artifacts/scripts/htmlkit`. Line numbers are from commit `c4e34ff`, before the rebase onto q-schema; task prompts carry re-resolved anchors. ## Architecture @@ -20,7 +20,7 @@ Back-links: [Conceptual model](logic.md#conceptual-model), [Data flow](logic.md# No new library. The renderer is plain SVG in `kit.js`; nothing new touches `window.quirkFlow`, so `needs_flow` and the flow bundle are unchanged. -**Reuse model.** Trace mode reuses q-flow's panel by borrowing `QFlow.prototype` methods onto `QSequence.prototype` (contract 5), not by copying them and not by moving them. The one q-flow change is a step-label hook. The pulse is borrowed the same way. Module-level pure helpers (`traceDiff`, `renderTraceField`, `traceValueOk`, `tracePretty`, `sanitizeFlowDetail`, `isValidFlowDetail`, `isBlankFlowId`, `svgNode`, `kitNode`, `uid`, `onTheme`, `watchChildren`, `nextFrame`, `pulseSchedule`) are called directly. +**Reuse model.** Trace mode reuses q-flow's panel by borrowing `QFlow.prototype` methods onto `QSequence.prototype` (contract 5), not by copying them and not by moving them. The one q-flow change is a step-label hook. The pulse is borrowed the same way. Shared canvas helpers that fit: `canvasMeasureFactory` for label widths and `canvasBuildDrawer` for the drawer shell; `canvasBuildControls` does not fit, because it always adds zoom, fit and expand over a canvas. Module-level pure helpers (`traceDiff`, `renderTraceField`, `traceValueOk`, `tracePretty`, `sanitizeFlowDetail`, `isValidFlowDetail`, `isBlankFlowId`, `svgNode`, `kitNode`, `uid`, `onTheme`, `watchChildren`, `nextFrame`, `pulseSchedule`) are called directly. ## Code references @@ -221,7 +221,7 @@ CONTRACT: QSequence parse result | message | `g[data-q-sequence-message=""][role=button][tabindex]` with a line, arrowhead, label text and `aria-label="Message N of M, to ,
            +

            Choose Step through on the diagram to follow a release that is promoted, or one that is rolled back.

            + + +
            Verify diff --git a/skills/writing-html-artifacts/templates/companion/examples/explainer.screen.json b/skills/writing-html-artifacts/templates/companion/examples/explainer.screen.json index b9da4d7a..b0c9e57e 100644 --- a/skills/writing-html-artifacts/templates/companion/examples/explainer.screen.json +++ b/skills/writing-html-artifacts/templates/companion/examples/explainer.screen.json @@ -20,6 +20,6 @@ "description": "Answer the five questions support hears most, in the order they get asked." } ], - "content": "

            New support staff ask how a failed charge is retried. The explainer should leave them able to tell a customer what happens next and when.

            ", + "content": "

            New support staff ask how a failed charge is retried. The explainer should leave them able to tell a customer what happens next and when.

            This is the path the explainer has to cover, whichever option you pick.

            ", "layout": "explainer" } diff --git a/skills/writing-html-artifacts/templates/components/examples/explainer.src.html b/skills/writing-html-artifacts/templates/components/examples/explainer.src.html index 2182c349..3e52969f 100644 --- a/skills/writing-html-artifacts/templates/components/examples/explainer.src.html +++ b/skills/writing-html-artifacts/templates/components/examples/explainer.src.html @@ -8,7 +8,7 @@

            Matching an alert to a rule

            Each alert arrives with a service name, a severity and a few labels. The router checks the rules from the top of the list and uses the first one that matches. Rule order therefore matters more than rule count: a broad rule near the top hides every narrower rule below it.

            An alert that matches no rule goes to the catch-all channel, which is read once a day. If something important arrives late, check this first.

            - + +

            Press "Step through" on the graph to follow one alert: a critical one that pages, a warning that resolves before anyone is told, and one that no rule matches.

            @@ -67,6 +104,61 @@

            Sending to a channel

            Escalation when nobody answers

            An acknowledged page stops escalating. An unacknowledged one goes to the secondary on-call after 10 minutes, and to the engineering manager after 15. Acknowledging does not resolve the alert, and an acknowledged alert that stays open for an hour pages again.

            + + + +

            Press "Step through" to see who has been told at each message. "Escalates to the manager" is the case to rehearse: everyone on the list is notified once, and nobody is notified twice.

            diff --git a/skills/writing-html-artifacts/templates/components/examples/reference.src.html b/skills/writing-html-artifacts/templates/components/examples/reference.src.html index 4fd8efb9..09be35ed 100644 --- a/skills/writing-html-artifacts/templates/components/examples/reference.src.html +++ b/skills/writing-html-artifacts/templates/components/examples/reference.src.html @@ -13,6 +13,72 @@

            Order

            Move a rule up to make it win over a broader one. Moving a rule is safer than editing it.

            +
            +

            How the settings connect

            +

            Every setting on this page hangs off a rule or a channel. A rule names one channel and one escalation policy, and a channel names its owner and its members. Click a table to see its fields and what points at it.

            + + + +

            Severity delays stand alone: they apply to every rule, so no rule points at them.

            +
            +

            Grouping

            From 72a5cfc1b14263e211a849bed3696bdc1f31a123 Mon Sep 17 00:00:00 2001 From: zpyoung Date: Sun, 18 Oct 2026 07:00:52 -0500 Subject: [PATCH 160/164] chore: log BUG-28, unstyled diagram list disclosure in tier-2 app builds --- BUGS.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/BUGS.md b/BUGS.md index 05f22a07..8a3a5e0d 100644 --- a/BUGS.md +++ b/BUGS.md @@ -232,3 +232,10 @@ entries' IDs; manual edits to fix typos are fine. - **Severity**: low - **Proposed fix**: Raise a distinct error from the constant hook and report the JSON-child message for it before the forbidden-markup search. +## BUG-28: The diagram list disclosure renders as plain text in tier-2 app builds +- **Observed**: 2026-10-18 +- **File**: skills/writing-html-artifacts/vendor/kit/1.0.0/kit.css:1282 +- **Description**: In tier-2 app builds the "Graph as a list" and "Diagram as a list" disclosures under q-flow and q-sequence render as plain unstyled text, with no bordered box or chevron. Tier-1 report and components pages show a bordered disclosure with a chevron. The kit's own rules (kit.css:1282 for q-flow and q-schema, :1668 for q-sequence) only set height, layout and cursor; the box and chevron on tier-1 pages come from the base stylesheet (Simple.css or Web Awesome), which the app's Astryx theme doesn't supply. Seen 2026-10-18 building templates/app/examples/explainer and reference at 4fe1e65 and screenshotting at 1280px light. +- **Severity**: low +- **Proposed fix**: Give the kit's diagram disclosures their own border, padding and marker so they look the same without a base stylesheet, or add matching details styles to the app theme. + From 5034cf07aff30de0432568087a4f04648405838c Mon Sep 17 00:00:00 2001 From: zpyoung Date: Sun, 18 Oct 2026 07:13:54 -0500 Subject: [PATCH 161/164] feat(q-flow, q-schema): fit the diagram to the full-screen stage when it expands --- .../vendor/kit/1.0.0/kit.js | 1 + .../vendor/libraries.json | 4 ++-- tests/test_html_kit_elements.py | 21 +++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/skills/writing-html-artifacts/vendor/kit/1.0.0/kit.js b/skills/writing-html-artifacts/vendor/kit/1.0.0/kit.js index e96c773f..fb06e240 100644 --- a/skills/writing-html-artifacts/vendor/kit/1.0.0/kit.js +++ b/skills/writing-html-artifacts/vendor/kit/1.0.0/kit.js @@ -3671,6 +3671,7 @@ host._expandBtn.setAttribute('aria-pressed', 'true'); host._expandBtn.textContent = 'Close'; } + host._fitBounds(host._bounds, false); } else { host.removeAttribute('data-q-flow-expanded'); host._focusedAfterExpand = false; diff --git a/skills/writing-html-artifacts/vendor/libraries.json b/skills/writing-html-artifacts/vendor/libraries.json index 7d7f298e..fdd0e2a0 100644 --- a/skills/writing-html-artifacts/vendor/libraries.json +++ b/skills/writing-html-artifacts/vendor/libraries.json @@ -157,8 +157,8 @@ "kind": "js", "position": "body-end", "inline": true, - "sha256": "50870fdbfb7224a42e2d3eed27d81a686e7866d46123aaea164044d8b8d00a6c", - "inline_sha256": "50870fdbfb7224a42e2d3eed27d81a686e7866d46123aaea164044d8b8d00a6c" + "sha256": "bfcbdd2b72f9a68089737de443423b51c95736bb6f49d8f5b95d66c6deb77aeb", + "inline_sha256": "bfcbdd2b72f9a68089737de443423b51c95736bb6f49d8f5b95d66c6deb77aeb" } ] } diff --git a/tests/test_html_kit_elements.py b/tests/test_html_kit_elements.py index 7f6f9136..cb08eddb 100644 --- a/tests/test_html_kit_elements.py +++ b/tests/test_html_kit_elements.py @@ -7356,6 +7356,27 @@ def test_schema_expand_fills_the_screen_and_escape_returns_it_to_the_page(schema assert out["results"]["after"] is False +@pytest.mark.parametrize(("fixture", "host"), [("flow_page", "#flow-orders"), ("schema_page", "#schema-billing")]) +def test_expand_fits_the_diagram_to_the_full_screen_stage(request: pytest.FixtureRequest, fixture: str, host: str) -> None: + page = request.getfixturevalue(fixture) + transform = f"document.querySelector('{host} [data-q-flow-viewport]').getAttribute('transform')" + # zoom in first so a kept transform can't pass for a fit + out = run( + page, + {"click": f'{host} [data-q-flow-action="zoom-in"]'}, + {"click": f'{host} [data-q-flow-action="zoom-in"]'}, + ev(transform, "zoomed"), + {"click": f'{host} [data-q-flow-action="expand"]'}, + ev(transform, "expanded"), + {"click": f'{host} [data-q-flow-action="fit"]'}, + ev(transform, "fitted"), + ) + assert_clean(out) + r = out["results"] + assert r["expanded"] != r["zoomed"] + assert r["expanded"] == r["fitted"] + + def test_schema_drawer_is_a_bottom_sheet_on_a_phone(schema_page: Path) -> None: out = run( schema_page, From c05675746d8aff977f0038f5b45dc7a3dcab94dd Mon Sep 17 00:00:00 2001 From: zpyoung Date: Sun, 18 Oct 2026 07:30:20 -0500 Subject: [PATCH 162/164] fix(q-flow, q-schema): give every group the root spacing and a 32px straight run at each edge end and self-loop leg --- .../vendor/kit/1.0.0/kit.js | 64 +++++++++++-------- .../vendor/libraries.json | 4 +- tests/test_html_kit_elements.py | 61 ++++++++++++++++++ 3 files changed, 100 insertions(+), 29 deletions(-) diff --git a/skills/writing-html-artifacts/vendor/kit/1.0.0/kit.js b/skills/writing-html-artifacts/vendor/kit/1.0.0/kit.js index fb06e240..e1de2f73 100644 --- a/skills/writing-html-artifacts/vendor/kit/1.0.0/kit.js +++ b/skills/writing-html-artifacts/vendor/kit/1.0.0/kit.js @@ -3488,6 +3488,23 @@ }; } + /** Shortest straight run, in layout pixels, an edge keeps at a node it leaves or enters, including a self-loop's legs, so its arrow reads as pointing at that node. */ + var CANVAS_EDGE_STUB = 32; + + /** + * ELK spacing for one level of a canvas graph. ELK doesn't pass layout options down to a compound + * node, so the root and every group take their own copy; `betweenLayers` and `nodeNode` are in pixels. + */ + function canvasSpacing(betweenLayers, nodeNode) { + return { + 'elk.layered.spacing.nodeNodeBetweenLayers': String(betweenLayers), + 'elk.spacing.nodeNode': String(nodeNode), + 'elk.spacing.edgeNode': '24', + 'elk.layered.spacing.edgeNodeBetweenLayers': String(CANVAS_EDGE_STUB), + 'elk.spacing.nodeSelfLoop': String(CANVAS_EDGE_STUB) + }; + } + /** Builds the zoom, fit and expand toolbar; `addExtra(addBtn)`, when given, adds element-specific buttons before Expand, each with its own click handler. */ function canvasBuildControls(host, noun, addExtra) { var bar = kitNode('div'); @@ -4434,9 +4451,9 @@ } function buildElkGroup(g) { - var opts = { + var opts = Object.assign({ 'elk.padding': '[top=38,left=18,bottom=18,right=18]' - }; + }, canvasSpacing(52, 28)); if (data.groupsMode === 'lanes') { opts['elk.partitioning.partition'] = String(topLaneIndex(g.id)); } @@ -4505,18 +4522,15 @@ }; }); - var rootOptions = { + var rootOptions = Object.assign({ 'elk.algorithm': 'layered', 'elk.direction': data.direction === 'down' ? 'DOWN' : 'RIGHT', 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', 'elk.edgeRouting': 'ORTHOGONAL', 'elk.json.shapeCoords': 'ROOT', 'elk.json.edgeCoords': 'ROOT', - 'elk.layered.spacing.nodeNodeBetweenLayers': '52', - 'elk.spacing.nodeNode': '28', - 'elk.spacing.edgeNode': '24', 'elk.edgeLabels.inline': 'true' - }; + }, canvasSpacing(52, 28)); if (data.groupsMode === 'lanes') { rootOptions['elk.partitioning.activate'] = 'true'; } @@ -4611,19 +4625,19 @@ var sideX = fbFrom.x + fbFrom.width; pts = [ { x: sideX, y: fbFrom.y + fbFrom.height * 0.35 }, - { x: sideX + 18, y: fbFrom.y + fbFrom.height * 0.35 }, - { x: sideX + 18, y: fbFrom.y + fbFrom.height * 0.65 }, + { x: sideX + CANVAS_EDGE_STUB, y: fbFrom.y + fbFrom.height * 0.35 }, + { x: sideX + CANVAS_EDGE_STUB, y: fbFrom.y + fbFrom.height * 0.65 }, { x: sideX, y: fbFrom.y + fbFrom.height * 0.65 } ]; - arcLabel = { x: sideX + 24, y: fbFrom.y + fbFrom.height / 2 - 10, width: 60, height: 20 }; + arcLabel = { x: sideX + CANVAS_EDGE_STUB + 6, y: fbFrom.y + fbFrom.height / 2 - 10, width: 60, height: 20 }; } else { pts = [ { x: fbFrom.x + fbFrom.width * 0.35, y: fbFrom.y }, - { x: fbFrom.x + fbFrom.width * 0.35, y: fbFrom.y - 18 }, - { x: fbFrom.x + fbFrom.width * 0.65, y: fbFrom.y - 18 }, + { x: fbFrom.x + fbFrom.width * 0.35, y: fbFrom.y - CANVAS_EDGE_STUB }, + { x: fbFrom.x + fbFrom.width * 0.65, y: fbFrom.y - CANVAS_EDGE_STUB }, { x: fbFrom.x + fbFrom.width * 0.65, y: fbFrom.y } ]; - arcLabel = { x: fbFrom.x + fbFrom.width / 2 - 30, y: fbFrom.y - 42, width: 60, height: 20 }; + arcLabel = { x: fbFrom.x + fbFrom.width / 2 - 30, y: fbFrom.y - CANVAS_EDGE_STUB - 24, width: 60, height: 20 }; } } else if (fbFrom && fbTo) { pts = [ @@ -6387,14 +6401,14 @@ return 'n' + entity.index; } - function schemaElkTree(data, measure, elkNode) { + function schemaElkTree(data, measure, elkNode, spacing) { var elkGroups = schemaMap(); data.groups.forEach(function (g) { elkGroups[g.id] = { id: schemaGroupElkId(g), width: Math.max(120, measure(g.label, 12, '600') + 36), height: 60, - layoutOptions: { 'elk.padding': '[top=38,left=18,bottom=18,right=18]' }, + layoutOptions: Object.assign({ 'elk.padding': '[top=38,left=18,bottom=18,right=18]' }, spacing), children: [] }; }); @@ -7364,7 +7378,8 @@ }; } - var rootChildren = schemaElkTree(data, measure, elkNode); + var spacing = canvasSpacing(52, 28); + var rootChildren = schemaElkTree(data, measure, elkNode, spacing); var elkEdges = []; data.relationships.forEach(function (r) { @@ -7385,18 +7400,15 @@ var elkGraph = { id: 'root', - layoutOptions: { + layoutOptions: Object.assign({ 'elk.algorithm': 'layered', 'elk.direction': 'RIGHT', 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', 'elk.edgeRouting': 'ORTHOGONAL', 'elk.json.shapeCoords': 'ROOT', 'elk.json.edgeCoords': 'ROOT', - 'elk.layered.spacing.nodeNodeBetweenLayers': '52', - 'elk.spacing.nodeNode': '28', - 'elk.spacing.edgeNode': '24', 'elk.edgeLabels.inline': 'true' - }, + }, spacing), children: rootChildren, edges: elkEdges }; @@ -7491,21 +7503,19 @@ }); this._elkSources = elkSources; + var spacing = canvasSpacing(56, 32); this._runElk({ id: 'root', - layoutOptions: { + layoutOptions: Object.assign({ 'elk.algorithm': 'layered', 'elk.direction': 'DOWN', 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', 'elk.edgeRouting': 'ORTHOGONAL', 'elk.json.shapeCoords': 'ROOT', 'elk.json.edgeCoords': 'ROOT', - 'elk.layered.spacing.nodeNodeBetweenLayers': '56', - 'elk.spacing.nodeNode': '32', - 'elk.spacing.edgeNode': '24', 'elk.edgeLabels.inline': 'true' - }, - children: schemaElkTree(data, measure, elkNode), + }, spacing), + children: schemaElkTree(data, measure, elkNode, spacing), edges: elkEdges }, data, seq); } diff --git a/skills/writing-html-artifacts/vendor/libraries.json b/skills/writing-html-artifacts/vendor/libraries.json index fdd0e2a0..9130b6ad 100644 --- a/skills/writing-html-artifacts/vendor/libraries.json +++ b/skills/writing-html-artifacts/vendor/libraries.json @@ -157,8 +157,8 @@ "kind": "js", "position": "body-end", "inline": true, - "sha256": "bfcbdd2b72f9a68089737de443423b51c95736bb6f49d8f5b95d66c6deb77aeb", - "inline_sha256": "bfcbdd2b72f9a68089737de443423b51c95736bb6f49d8f5b95d66c6deb77aeb" + "sha256": "6859230280f420907bbe5bda035985719b45404646139ba4c8d872fabe39aef6", + "inline_sha256": "6859230280f420907bbe5bda035985719b45404646139ba4c8d872fabe39aef6" } ] } diff --git a/tests/test_html_kit_elements.py b/tests/test_html_kit_elements.py index cb08eddb..a702fbf1 100644 --- a/tests/test_html_kit_elements.py +++ b/tests/test_html_kit_elements.py @@ -7356,6 +7356,67 @@ def test_schema_expand_fills_the_screen_and_escape_returns_it_to_the_page(schema assert out["results"]["after"] is False +GROUPED_ROUTING = """ + { + "groups": [{"id": "intake", "label": "Matching"}, {"id": "hold", "label": "Grouping and delay"}, {"id": "send", "label": "Delivery"}], + "nodes": [ + {"id": "matcher", "label": "Rule matcher", "group": "intake"}, + {"id": "matched", "label": "Rule matched?", "shape": "decision", "group": "intake"}, + {"id": "grouper", "label": "Alert grouper", "group": "hold"}, + {"id": "timer", "label": "Delay timer", "group": "hold"}, + {"id": "channel", "label": "Channel sender", "group": "send"}, + {"id": "catchall", "label": "Catch-all channel", "group": "send"} + ], + "edges": [ + {"from": "matcher", "to": "matched"}, + {"from": "matched", "to": "grouper", "label": "Yes"}, + {"from": "matched", "to": "catchall", "label": "No"}, + {"from": "grouper", "to": "grouper", "label": "collects for 30s"}, + {"from": "grouper", "to": "timer"}, + {"from": "timer", "to": "channel"}, + {"from": "channel", "to": "grouper", "label": "severity rises", "loop": true} + ] + } +""" + +GROUPED_PAGE = f""" + + +""" + +EDGE_SEGMENTS_JS = """(() => [...document.querySelectorAll('q-flow')].map((f) => { + const b = f._nodeBoxes; + const segs = [...f.querySelectorAll('[data-q-flow-edge-line]')].map((line) => { + const n = (line.getAttribute('d').match(/-?[\\d.]+/g) || []).map(Number); + const len = (i, j) => Math.hypot(n[j] - n[i], n[j + 1] - n[i + 1]); + return {edge: line.closest('[data-q-flow-edge]').getAttribute('data-q-flow-from') + '->' + line.closest('[data-q-flow-edge]').getAttribute('data-q-flow-to'), + first: len(0, 2), last: len(n.length - 4, n.length - 2)}; + }); + return {id: f.id, gap: b.matched.x - (b.matcher.x + b.matcher.width), segs}; +}))()""" + + +@pytest.fixture(scope="module") +def grouped_flow_page(tmp_path_factory: pytest.TempPathFactory) -> Path: + reason = _node_ready() + if reason: + pytest.skip(reason) + work = tmp_path_factory.mktemp("kit-flow-grouped") + src = work / "grouped.src.html" + src.write_text(HEADER + GROUPED_PAGE, encoding="utf-8") + return _build(src, work / "grouped.html") + + +def test_grouped_flow_keeps_the_root_spacing_and_a_straight_run_into_every_node(grouped_flow_page: Path) -> None: + # elk doesn't pass layout options down to a group, so nodes inside one used elk's 20px layer gap and 10px end stubs + out = run(grouped_flow_page, ev(EDGE_SEGMENTS_JS, "flows")) + assert_clean(out) + for flow in out["results"]["flows"]: + assert flow["gap"] >= 52, flow + for seg in flow["segs"]: + assert seg["first"] >= 24 and seg["last"] >= 24, (flow["id"], seg) + + @pytest.mark.parametrize(("fixture", "host"), [("flow_page", "#flow-orders"), ("schema_page", "#schema-billing")]) def test_expand_fits_the_diagram_to_the_full_screen_stage(request: pytest.FixtureRequest, fixture: str, host: str) -> None: page = request.getfixturevalue(fixture) From 7ae9039b6258da5070619905f8f9e132782fb36c Mon Sep 17 00:00:00 2001 From: zpyoung Date: Sun, 18 Oct 2026 08:33:53 -0500 Subject: [PATCH 163/164] feat(q-flow): zoom in on the current node at each trace step --- .../specs/2026-10-17-q-flow-traces/logic.md | 16 +++-- .../specs/2026-10-17-q-flow-traces/tech.md | 2 +- .../vendor/kit/1.0.0/kit.js | 63 ++++++++++++++++--- .../vendor/libraries.json | 4 +- tests/test_html_kit_elements.py | 58 +++++++++++++++++ 5 files changed, 127 insertions(+), 16 deletions(-) diff --git a/docs/quirk/specs/2026-10-17-q-flow-traces/logic.md b/docs/quirk/specs/2026-10-17-q-flow-traces/logic.md index b5fa9408..e2a559cf 100644 --- a/docs/quirk/specs/2026-10-17-q-flow-traces/logic.md +++ b/docs/quirk/specs/2026-10-17-q-flow-traces/logic.md @@ -326,10 +326,11 @@ It carries two traces: when it pans 14. **SC-14** - - **Given** the canvas zoomed in on Renewal scheduler and Pricing - - **When** the reader steps to Settlement, which is out of view - - **Then** the canvas pans, keeping the zoom, until Settlement is in view; it doesn't move when the - current node is already in view + - **Given** a trace open on a graph fitted below a zoom of 1.5 + - **When** the reader opens the trace or moves to any step + - **Then** the canvas eases to centre the current node at a zoom of 1.5, or keeps the reader's zoom + when they have zoomed in further; expanding or resizing while tracing centres it again instead of + fitting the whole graph ### Leaving trace mode @@ -447,8 +448,8 @@ Reading: step dots, Play, the note and the value. Escape, or a click on a node off the trace, leaves trace mode. - The canvas lights the current node and the edge it came in on, dims everything off the trace's path, - and pulses only the current edge. Clicking a node on the trace jumps to its step. If the current node - leaves the view, the canvas pans to it. + and pulses only the current edge. Clicking a node on the trace jumps to its step. Each step centres the + canvas on the current node at a readable zoom. - The value is an indented tree. Added fields are marked `+`, changed fields show `old → new`, removed fields appear struck through for one step. Marks use a symbol and a color, never color alone. A "Changes only" toggle hides unchanged fields. Nesting deeper than 3 levels starts collapsed. @@ -543,3 +544,6 @@ Build-only questions for the tech spec: - 2026-10-17: the example is two traces on the report explainer's existing retry flow rather than a new renewal graph page, because the shape examples are a fixed set of twelve pages and the retry flow already has the decision and loop a trace needs to show (tech spec authoring). +- 2026-10-18: SC-14 changed from panning a node that left the view at the same zoom to centring every + step's node at a zoom of at least 1.5, because a fitted graph left the current node too small to + follow (user request after the examples review). diff --git a/docs/quirk/specs/2026-10-17-q-flow-traces/tech.md b/docs/quirk/specs/2026-10-17-q-flow-traces/tech.md index a7e8e1ca..81eaa86a 100644 --- a/docs/quirk/specs/2026-10-17-q-flow-traces/tech.md +++ b/docs/quirk/specs/2026-10-17-q-flow-traces/tech.md @@ -285,7 +285,7 @@ Back-links: [Behavior and scenarios](logic.md#behavior-and-scenarios), [Success |---|---| | `tests/test_html_kit_check.py` (q-flow section, helpers `flow_markup` and `warn_messages` `:1000-1005`) | One test per contract-2 failure and warning; the logic spec's renewal graph with both traces passes with no warnings; a trace through a loop edge and a self-loop passes | | `tests/test_html_builder.py` (near `:980`) | The injected no-JS list carries both traces' steps, with the via part, the note and the escaped pretty JSON | -| `tests/test_html_kit_elements.py` (new `FLOW_TRACES` page and `flow_traces_page` fixture beside `FLOW_SHAPES` `:4372`) | No "Step through" button without traces; opening dims off-path nodes and edges, clears focus and closes the drawer (SC-3); Next lights the inferred edge, and `via` picks the right one of two parallel edges (SC-4); the loop edge lights and gets mask dots as the current edge (SC-5); added, changed and removed marks and the one-step strike-through (SC-5, SC-6); "Changes only" hides same fields and persists (SC-7); depth-3 branches start closed unless they hold a change (SC-8); node click jumps to the next visit, dots jump (SC-9); the picker resets to step 1 (SC-10); Play on `FLOW_FAKE_CLOCK` advances at 1500 ms and stops at the end (SC-11); arrow keys and the live counter text (SC-12); reduced motion steps with no mask dots and a 3.5 stroke (SC-13); a far node pans into view keeping zoom (SC-14); Escape, the toggle and an off-trace click leave trace mode and focus works as before (SC-15, SC-16); at 375px the panel is below the stage (SC-17); the runtime list matches the builder test (SC-18); a payload with an unknown `via` fails to the notice | +| `tests/test_html_kit_elements.py` (new `FLOW_TRACES` page and `flow_traces_page` fixture beside `FLOW_SHAPES` `:4372`) | No "Step through" button without traces; opening dims off-path nodes and edges, clears focus and closes the drawer (SC-3); Next lights the inferred edge, and `via` picks the right one of two parallel edges (SC-4); the loop edge lights and gets mask dots as the current edge (SC-5); added, changed and removed marks and the one-step strike-through (SC-5, SC-6); "Changes only" hides same fields and persists (SC-7); depth-3 branches start closed unless they hold a change (SC-8); node click jumps to the next visit, dots jump (SC-9); the picker resets to step 1 (SC-10); Play on `FLOW_FAKE_CLOCK` advances at 1500 ms and stops at the end (SC-11); arrow keys and the live counter text (SC-12); reduced motion steps with no mask dots and a 3.5 stroke (SC-13); each step centres its node at a zoom of at least 1.5 (SC-14); Escape, the toggle and an off-trace click leave trace mode and focus works as before (SC-15, SC-16); at 375px the panel is below the stage (SC-17); the runtime list matches the builder test (SC-18); a payload with an unknown `via` fails to the notice | | `tests/test_html_kit_catalog.py`, `tests/test_html_kit_examples.py`, `tests/test_html_kit_docs.py` | Updated catalog example is clean and builds; `kit_docs.py reference --check` passes | | `tests/test_html_layout_shapes.py` | The updated report explainer builds, checks and verifies | | `tests/test_html_vendor.py` | `pins.py verify` passes after restamping | diff --git a/skills/writing-html-artifacts/vendor/kit/1.0.0/kit.js b/skills/writing-html-artifacts/vendor/kit/1.0.0/kit.js index e1de2f73..d4007e6a 100644 --- a/skills/writing-html-artifacts/vendor/kit/1.0.0/kit.js +++ b/skills/writing-html-artifacts/vendor/kit/1.0.0/kit.js @@ -3150,6 +3150,7 @@ var FLOW_TRACE_MAX_LINES = 400; var FLOW_TRACE_STEP_MS = 1500; + var FLOW_TRACE_ZOOM = 1.5; /** A trace value as the 2-space pretty print that the text list shows and the line limit counts. */ function tracePretty(value) { @@ -3638,6 +3639,38 @@ canvasApplyTransform(host, tx, ty, k); } + /** + * Centres box `boxId` in the stage at zoom `minK` or the current zoom, whichever is larger. Eases + * over 300ms unless `jump` is set or the reader prefers reduced motion. + */ + function canvasCenterOn(host, boxId, minK, jump) { + var box = host._nodeBoxes && host._nodeBoxes[boxId]; + if (!box || !host._stage) { + return; + } + var rect = host._stage.getBoundingClientRect(); + // d3's zoom ease dips the scale mid-flight, so a step taken during one starts from where it was heading + var t = host._centerGoal || host._transform || { x: 0, y: 0, k: 1 }; + var k = Math.max(0.2, Math.min(4, Math.max(t.k, minK))); + var x = (rect.width || 640) / 2 - (box.x + box.width / 2) * k; + var y = (rect.height || 320) / 2 - (box.y + box.height / 2) * k; + var qf = window.quirkFlow; + var reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; + if (jump || reduced || !qf || !qf.zoomIdentity || !host._d3Selection || !host._d3Selection.transition) { + canvasApplyTransform(host, x, y, k); + return; + } + var goal = { x: x, y: y, k: k }; + host._centerGoal = goal; + host._d3Selection.transition().duration(300) + .call(host._d3Zoom.transform, qf.zoomIdentity.translate(x, y).scale(k)) + .on('end interrupt', function () { + if (host._centerGoal === goal) { + host._centerGoal = null; + } + }); + } + function canvasEnsureVisible(host, boxId, avoidDrawer) { var box = host._nodeBoxes && host._nodeBoxes[boxId]; if (!box || !host._stage) { @@ -3688,7 +3721,11 @@ host._expandBtn.setAttribute('aria-pressed', 'true'); host._expandBtn.textContent = 'Close'; } - host._fitBounds(host._bounds, false); + if (host._trace && host._frameTraceStep) { + host._frameTraceStep(true); + } else { + host._fitBounds(host._bounds, false); + } } else { host.removeAttribute('data-q-flow-expanded'); host._focusedAfterExpand = false; @@ -3788,7 +3825,12 @@ } }; this._onResize = this._onResize || function () { - if (self.isConnected && self._bounds && !self.hasAttribute('data-q-flow-expanded')) { + if (!self.isConnected || !self._bounds) { + return; + } + if (self._trace) { + self._frameTraceStep(true); + } else if (!self.hasAttribute('data-q-flow-expanded')) { self._fitBounds(self._bounds, false); } }; @@ -5738,8 +5780,8 @@ } /** - * Puts trace mode on step 1 of a trace: clears focus, opens the panel and fits the canvas to - * the narrower stage. Also what the picker runs, so it leaves DOM focus where it is. + * Puts trace mode on step 1 of a trace: clears focus, opens the panel and zooms in on step 1's + * node. Also what the picker runs, so it leaves DOM focus where it is. */ _startTrace(traceIndex) { var data = this._data; @@ -5768,8 +5810,6 @@ this._traceEls.picker.selectedIndex = traceIndex; this._buildTraceDots(trace); this._showTraceStep(0); - this._fitBounds(this._bounds, false); - this._ensureNodeVisible(trace.steps[0].node, false); return true; } @@ -5980,10 +6020,19 @@ this._stopPulse(); } - this._ensureNodeVisible(step.node, false); + this._frameTraceStep(false); this._renderTracePanel(i); } + /** Centres the current step's node at a readable zoom; `jump` skips the easing. */ + _frameTraceStep(jump) { + var tr = this._trace; + if (!tr || !this._data) { + return; + } + canvasCenterOn(this, this._data.traces[tr.index].steps[tr.step].node, FLOW_TRACE_ZOOM, jump); + } + _renderTracePanel(i) { var els = this._traceEls; var data = this._data; diff --git a/skills/writing-html-artifacts/vendor/libraries.json b/skills/writing-html-artifacts/vendor/libraries.json index 9130b6ad..5b8d8072 100644 --- a/skills/writing-html-artifacts/vendor/libraries.json +++ b/skills/writing-html-artifacts/vendor/libraries.json @@ -157,8 +157,8 @@ "kind": "js", "position": "body-end", "inline": true, - "sha256": "6859230280f420907bbe5bda035985719b45404646139ba4c8d872fabe39aef6", - "inline_sha256": "6859230280f420907bbe5bda035985719b45404646139ba4c8d872fabe39aef6" + "sha256": "83fb3cfa2234bfe2288a38097b47e290f5644f45119545880233c3d78242ab8c", + "inline_sha256": "83fb3cfa2234bfe2288a38097b47e290f5644f45119545880233c3d78242ab8c" } ] } diff --git a/tests/test_html_kit_elements.py b/tests/test_html_kit_elements.py index a702fbf1..7f47db46 100644 --- a/tests/test_html_kit_elements.py +++ b/tests/test_html_kit_elements.py @@ -5388,6 +5388,8 @@ def test_flow_subprocess_without_a_flow_has_no_flow_section(flow_shapes_page: Pa TRACE_BTN = '#renewal [data-q-flow-action="trace"]' TRACE_NEXT = "#renewal [data-q-flow-trace-next]" TRACE_PICKER = "#renewal [data-q-flow-trace-picker]" +# each step zooms in on its node, so a test that clicks another node presses Fit first, as a reader would +TRACE_FIT = '#renewal [data-q-flow-action="fit"]' def _trace_dot(n: int) -> str: @@ -5805,6 +5807,7 @@ def test_flow_trace_node_click_jumps_to_its_next_visit_and_dots_jump_to_a_step(f _pick_trace("Card declined, retried"), {"click": TRACE_NEXT}, ev("window.__tr.counter().slice(0, 9)", "start"), + {"click": TRACE_FIT}, {"click": '#renewal [data-q-flow-node="charge"]'}, ev("window.__tr.counter().slice(0, 9)", "first"), {"click": '#renewal [data-q-flow-node="charge"]'}, @@ -5872,6 +5875,7 @@ def test_flow_trace_off_path_click_leaves_empty_canvas_stays_and_the_button_togg flow_traces_page, ev(FLOW_TRACE_HELPERS, "h"), {"click": TRACE_BTN}, + {"click": TRACE_FIT}, {"click": '#renewal [data-q-flow-node="retry"]'}, ev( """(() => { @@ -6083,6 +6087,7 @@ def after(t: int, name: str) -> dict: {"click": _trace_dot(5)}, after(30000, "dot"), *started("p4"), + {"click": TRACE_FIT}, {"click": '#renewal [data-q-flow-node="tax"]'}, after(40000, "node"), *started("p5"), @@ -6382,6 +6387,7 @@ def test_flow_trace_pans_an_off_screen_current_node_into_view_at_the_same_zoom(f {"click": _trace_dot(1)}, ev(probe, "before"), {"click": _trace_dot(6)}, + {"wait": 500}, ev(probe, "after"), ) assert_clean(out) @@ -6392,6 +6398,58 @@ def test_flow_trace_pans_an_off_screen_current_node_into_view_at_the_same_zoom(f assert r["after"]["k"] == r["before"]["k"] +TRACE_FRAME_JS = """(() => { + const f = document.querySelector('#renewal'); + const stage = f.querySelector('[data-q-flow-stage]').getBoundingClientRect(); + const id = f.querySelector('[data-q-flow-node][data-q-flow-selected]')?.getAttribute('data-q-flow-node') ?? null; + const n = id && f.querySelector(`[data-q-flow-node="${id}"]`).getBoundingClientRect(); + const m = f.querySelector('[data-q-flow-viewport]').getAttribute('transform').match(/scale\\(([-\\d.e]+)\\)/); + return { + id, + k: Number(m[1]), + dx: n ? Math.abs(n.left + n.width / 2 - (stage.left + stage.width / 2)) : null, + dy: n ? Math.abs(n.top + n.height / 2 - (stage.top + stage.height / 2)) : null, + }; +})()""" + + +def test_flow_trace_zooms_in_on_the_current_node_at_each_step(flow_traces_page: Path) -> None: + out = run( + flow_traces_page, + ev(TRACE_FRAME_JS, "rest"), + {"click": TRACE_BTN}, + {"wait": 500}, + ev(TRACE_FRAME_JS, "first"), + {"click": TRACE_NEXT}, + {"wait": 500}, + ev(TRACE_FRAME_JS, "second"), + {"click": _trace_dot(6)}, + {"wait": 500}, + ev(TRACE_FRAME_JS, "sixth"), + ) + assert_clean(out) + r = out["results"] + assert r["rest"]["k"] < 1.5 + for name in ("first", "second", "sixth"): + frame = r[name] + assert frame["k"] >= 1.5 and frame["dx"] <= 2 and frame["dy"] <= 2, (name, frame) + assert r["first"]["id"] != r["second"]["id"] + + +def test_flow_trace_expand_keeps_the_current_node_centred_instead_of_fitting_the_graph(flow_traces_page: Path) -> None: + out = run( + flow_traces_page, + {"emulate": {"reducedMotion": "reduce"}}, + {"click": TRACE_BTN}, + {"click": _trace_dot(4)}, + {"click": '#renewal [data-q-flow-action="expand"]'}, + ev(TRACE_FRAME_JS, "expanded"), + ) + assert_clean(out) + frame = out["results"]["expanded"] + assert frame["k"] >= 1.5 and frame["dx"] <= 2 and frame["dy"] <= 2, frame + + def test_flow_trace_leaves_focus_mode_exactly_as_it_was_before_tracing(flow_traces_page: Path) -> None: lit = """(() => { const f = document.querySelector('#renewal'); From 38ab619124bd926dd3466f6e89a1be16019794b1 Mon Sep 17 00:00:00 2001 From: zpyoung Date: Mon, 19 Oct 2026 10:50:40 -0500 Subject: [PATCH 164/164] Fix CDATA handling in HTML checks Prevent newer stdlib parsers from bypassing marked-section parsing. --- skills/writing-html-artifacts/scripts/htmlkit/checks.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/skills/writing-html-artifacts/scripts/htmlkit/checks.py b/skills/writing-html-artifacts/scripts/htmlkit/checks.py index 1b87c618..18ebb7dc 100644 --- a/skills/writing-html-artifacts/scripts/htmlkit/checks.py +++ b/skills/writing-html-artifacts/scripts/htmlkit/checks.py @@ -109,12 +109,15 @@ class _Scanner(HTMLParser): def __init__(self, page: _Page) -> None: super().__init__(convert_charrefs=True) + # newer stdlib parsers skip parse_marked_section for ])" % re.escape(self.cdata_elem), re.IGNORECASE) def parse_comment(self, i, report=True):