diff --git a/README.md b/README.md index 2cb1d9c..5d61d80 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ output stays diff-friendly and cheap in tokens. - **Live reload**: `opencode-artifacts serve` refreshes open pages on every republish - **Sharing**: cost-free public snapshots via GitHub Pages or a user-operated Cloudflare Worker + KV; Cloudflare Access is a manual, unverified perimeter - **Safe by default**: no raw HTML passthrough, credential-pattern scan blocks accidental secret leaks, no external requests at view time +- **Contained local images**: Markdown images beneath the worktree are MIME-checked and embedded as hashed data URIs; missing, external, symlinked, active, oversized, or unlabelled inputs fail before publication ## Install @@ -166,14 +167,25 @@ opencode-artifacts import ./bundle Full reference: [`docs/component-spec.md`](docs/component-spec.md). Short version: -- **Frontmatter**: `title`, `icon` (emoji favicon), `description` (gallery subtitle) +- **Frontmatter**: `title`, `icon`, `description`, explicit `lang`/`dir`, `locale`, `timezone`, and an optional worktree-relative `font` (WOFF/WOFF2/TTF/OTF embedded under `font-src data:`) - **Components** (JSON fences): `stats` metric cards, `timeline`, `findings` (severity-coded), `compare` variant cards, `callout` insight cards, `progress`, `diff` (annotated), `copy` (copy-to-session button), `decisions` (workshop rows the session reads back via `artifact_state`) -- **Charts/diagrams**: ```` ```vega-lite ```` / ```` ```vega ```` / ```` ```echarts ```` / ```` ```mermaid ```` fences; runtimes inline only when used +- **Charts/diagrams**: ```` ```vega-lite ```` / ```` ```vega ```` / ```` ```echarts ```` require a top-level text `description`; Mermaid starts with `%% summary:`; runtimes inline only when used +- **Accessible data**: tables require `caption`; `num`, `date`, and `datetime` columns format under the declared locale/time zone, with zoned ISO input for dates - **Markdown extras**: GitHub alerts (`> [!WARNING]` etc.), task lists, heading anchors, `##` sections become cards -- Broken specs degrade to inline error boxes; the page always ships +- **Local images**: ordinary `![meaningful alt](path/to/image.png)` resolves from the worktree root and embeds PNG/JPEG/GIF/WebP or constrained static SVG. Use the exact title `"decorative"` with empty alt only for an intentionally decorative image. URLs, absolute paths, traversal, and symlinks are refused. +- **Local fonts**: `font: path/to/project.woff2` uses the same contained, MIME-checked pipeline and a generated `@font-face`; it never permits a viewer network request or arbitrary CSS. +- **Bounded design tokens**: `.opencode/artifact-tokens.json` supplies project defaults and one + version-1 `design-tokens` JSON fence supplies prompt overrides. Only documented + color, font, spacing, radius, and density slots are accepted; prompt > project > theme > + built-in precedence and provenance are recorded in the portable page. +- CLI/plugin publication preflights the whole document and refuses all detected errors before + permission or writes. Standalone rendering still degrades broken specs to escaped inline + error boxes for resilient inspection; warnings remain visible on successful publication. Worked examples for every canonical pattern: [`examples/patterns/`](examples/patterns/) with browser-verified screenshots in [`docs/evidence/patterns/`](docs/evidence/patterns/). +The bounded-token fixture and desktop/mobile offline observations are in +[`docs/evidence/renderer/goal-3-design-tokens-2026-08-17.md`](docs/evidence/renderer/goal-3-design-tokens-2026-08-17.md). ## Sharing and hosting diff --git a/benchmarks/renderer/v1/budgets.json b/benchmarks/renderer/v1/budgets.json new file mode 100644 index 0000000..476e743 --- /dev/null +++ b/benchmarks/renderer/v1/budgets.json @@ -0,0 +1,49 @@ +{ + "schemaVersion": 1, + "profile": "renderer-linux-container-v1", + "referenceEnvironment": { + "platform": "linux", + "arch": "x64", + "nodeMajor": 24, + "cpuQuotaCores": 2, + "memoryLimitBytes": 4294967296, + "browserName": "chrome", + "browserMajor": 151 + }, + "sampling": { + "cliSamples": 12, + "browserSamples": 7, + "minimumSamples": 5, + "noiseFloorMs": 250, + "maxRelativeP95Spread": 1 + }, + "workloads": { + "no-runtime": { + "fixture": "no-runtime.md", + "runtimeBundles": [], + "cliP95Ms": 2000, + "browserUsefulContentMs": 1500, + "browserKeyboardAdditionalMs": 1000, + "warningBytes": 131072, + "hardBytes": 196608 + }, + "one-chart": { + "fixture": "one-chart.md", + "runtimeBundles": ["vega", "vega-embed"], + "cliP95Ms": 5000, + "browserUsefulContentMs": 3000, + "browserKeyboardAdditionalMs": 1000, + "warningBytes": 1048576, + "hardBytes": 1572864 + }, + "multi-runtime": { + "fixture": "multi-runtime.md", + "runtimeBundles": ["vega", "vega-embed", "echarts", "mermaid"], + "cliP95Ms": 5000, + "browserUsefulContentMs": 5000, + "browserKeyboardAdditionalMs": 1000, + "warningBytes": 6291456, + "hardBytes": 8388608 + } + } +} diff --git a/benchmarks/renderer/v1/multi-runtime.md b/benchmarks/renderer/v1/multi-runtime.md new file mode 100644 index 0000000..186b472 --- /dev/null +++ b/benchmarks/renderer/v1/multi-runtime.md @@ -0,0 +1,34 @@ +--- +title: Renderer multi-runtime benchmark +lang: en +dir: ltr +locale: en-US +timezone: UTC +--- +# Renderer multi-runtime benchmark + +## Vega-Lite trend + +```vega-lite +{"description":"Throughput rises from 12 to 21 units across four samples.","width":"container","height":260,"data":{"values":[{"sample":"A","value":12},{"sample":"B","value":15},{"sample":"C","value":18},{"sample":"D","value":21}]},"mark":"line","encoding":{"x":{"field":"sample","type":"ordinal"},"y":{"field":"value","type":"quantitative"}}} +``` + +## ECharts distribution + +```echarts +{"description":"The accepted category is 84 and the rejected category is 16.","xAxis":{"type":"category","data":["accepted","rejected"]},"yAxis":{"type":"value"},"series":[{"type":"bar","data":[84,16]}]} +``` + +## Mermaid flow + +```mermaid +%% summary: Source content passes through preflight, rendering, and publication. +flowchart LR + A[Source] --> B[Preflight] + B --> C[Render] + C --> D[Publish] +``` + +```decisions +{"title":"Benchmark interaction","questions":[{"id":"continue","question":"Continue?","options":[{"id":"yes","label":"Yes"},{"id":"no","label":"No"}]}]} +``` diff --git a/benchmarks/renderer/v1/no-runtime.md b/benchmarks/renderer/v1/no-runtime.md new file mode 100644 index 0000000..5383947 --- /dev/null +++ b/benchmarks/renderer/v1/no-runtime.md @@ -0,0 +1,22 @@ +--- +title: Renderer no-runtime benchmark +lang: en +dir: ltr +locale: en-US +timezone: UTC +--- +# Renderer no-runtime benchmark + +## Summary + +```stats +[{"label":"Documents","value":"1,024","delta":"stable","tone":"good"},{"label":"Errors","value":"0","delta":"none","tone":"neutral"}] +``` + +```table +{"caption":"Build results","columns":[{"key":"name","label":"Name"},{"key":"count","label":"Count","type":"num"}],"rows":[{"name":"accepted","count":1024},{"name":"rejected","count":0}]} +``` + +```decisions +{"title":"Benchmark interaction","questions":[{"id":"continue","question":"Continue?","options":[{"id":"yes","label":"Yes"},{"id":"no","label":"No"}]}]} +``` diff --git a/benchmarks/renderer/v1/one-chart.md b/benchmarks/renderer/v1/one-chart.md new file mode 100644 index 0000000..f81527c --- /dev/null +++ b/benchmarks/renderer/v1/one-chart.md @@ -0,0 +1,18 @@ +--- +title: Renderer one-chart benchmark +lang: en +dir: ltr +locale: en-US +timezone: UTC +--- +# Renderer one-chart benchmark + +## Trend + +```vega-lite +{"description":"Throughput rises from 12 to 21 units across four samples.","width":"container","height":320,"data":{"values":[{"sample":"A","value":12},{"sample":"B","value":15},{"sample":"C","value":18},{"sample":"D","value":21}]},"mark":"line","encoding":{"x":{"field":"sample","type":"ordinal"},"y":{"field":"value","type":"quantitative"}}} +``` + +```decisions +{"title":"Benchmark interaction","questions":[{"id":"continue","question":"Continue?","options":[{"id":"yes","label":"Yes"},{"id":"no","label":"No"}]}]} +``` diff --git a/docs/component-spec.md b/docs/component-spec.md index 7fff3bb..ee22587 100644 --- a/docs/component-spec.md +++ b/docs/component-spec.md @@ -8,7 +8,7 @@ Reference: the official [Claude Code Artifact guide](https://code.claude.com/doc `docs/page-quality-benchmark.md` (comparative quality gate). Official media remains link-only unless explicit redistribution authority is recorded. -## Design tokens (adapted from the official visual reference) +## Built-in design tokens (adapted from the official visual reference) ``` --page-bg: #e9edf2 (light gray-blue) @@ -20,10 +20,10 @@ unless explicit redistribution authority is recorded. --ink-2: #4b5563 --ink-3: #9ca3af --line: #e5e7eb ---accent: #6d6bd6 (periwinkle, chart fill / pills) ---good: #2f9e6e on #e4f4ec ---bad: #d64550 on #fdeeee ---warn: #b45309 on #fdf0dc +--accent: #5f5dbf (AA periwinkle, chart fill / pills) +--good: #237a52 on #e4f4ec +--bad: #b42335 on #fdeeee +--warn: #92400e on #fdf0dc --info: #33526e on #dce6f2 --card-bad-bg: #fdeeee (whole metric card tinted when tone=bad) --card-info-bg: #e3eaf4 (insight card, blue-gray) @@ -36,6 +36,34 @@ unless explicit redistribution authority is recorded. Dark mode: same hues, backgrounds shifted (page `#151a21`, card `#1f2630`, ink `#e5e7eb`), via `color-scheme: light dark` + `@media (prefers-color-scheme: dark)` overrides. +### Accessibility and internationalization + +Declarative pages expose a skip link, header/main landmarks, Unicode-safe heading anchors, +visible focus, control names/state, reduced-motion behavior, responsive reflow, and a print +mode that removes interactive chrome. Frontmatter accepts `lang`, `dir`, `locale`, and +`timezone`; the deterministic fallback is English (`en-US`), left-to-right, and UTC. RTL +languages infer RTL direction unless `dir` is explicit. + +Every chart requires a top-level `description`, every Mermaid fence begins with +`%% summary: ...`, and every table requires `caption`. Tables accept `num`, `date`, and +`datetime`; date values are zoned ISO timestamps and render under the declared locale and IANA +time zone. Missing equivalents or invalid locale metadata refuse CLI/plugin publication. +Decisions use radio semantics and arrow-key navigation. Served comments expose a named +launcher and dialog with Escape/cancel/save focus restoration. + +### Bounded project and prompt overrides + +The renderer discovers only `.opencode/artifact-tokens.json` for project tokens. A document +may contain one `design-tokens` JSON fence for explicit prompt-level values. Both use +`{"schemaVersion":1,"tokens":{...}}`; the shipped authoring reference owns the exact key and +enum list. Precedence is prompt > project > curated theme > built-in defaults. + +Each source is capped at 8 KiB, parsed atomically, contrast checked against its effective lower +layers, and emitted in deterministic order through fixed CSS-variable slots. Unknown keys, +non-hex colors, arbitrary font strings, selectors, declarations, URLs, markup, imports, and +expressions refuse publication before permission or writes. The portable page retains named +per-token provenance. Trusted HTML does not participate in this bounded token path. + ## Page layout - Page background `--page-bg`; content column max-width 1080px. @@ -109,7 +137,9 @@ header toggle cycles system → dark → light and persists to localStorage - GitHub alerts: `> [!NOTE]`, `> [!TIP]`, `> [!WARNING]`, `> [!IMPORTANT]`, `> [!CAUTION]` → styled callout boxes (post-process rendered `
` HTML). - Task lists: `- [ ]` / `- [x]` render as styled checkboxes (read-only). -- Invalid JSON in any component fence → inline error box (existing behavior, reused). +- CLI/plugin publication aggregates invalid component JSON with other authoring diagnostics + and refuses before permission or writes. Standalone rendering retains the escaped inline + error box fallback. ## Mapping to the documented Claude patterns diff --git a/docs/engineering-principles.md b/docs/engineering-principles.md index 537f01f..b2acbfd 100644 --- a/docs/engineering-principles.md +++ b/docs/engineering-principles.md @@ -48,7 +48,8 @@ collectively exhausted by [`docs/product-spec.md`](product-spec.md), not repeate - Markdown uses `markdown-it` with `html: false`; raw HTML never passes through Markdown mode. Trusted HTML is a separately permissioned execution surface, never an implicit fallback. - Every emitted page carries the strict on-disk CSP: `default-src 'none'; script-src - 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; connect-src 'none'`. Served or + 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:; connect-src 'none'`. + The `font-src` allowance is confined to embedded bytes and grants no network authority. Served or hosted copies may relax `connect-src` only to the documented self boundary; the portable file never changes. No `unsafe-eval`, ever—Vega uses its `ast: true` interpreter. [check:csp-no-unsafe-eval] [check:vega-interpreter] @@ -117,6 +118,12 @@ collectively exhausted by [`docs/product-spec.md`](product-spec.md), not repeate conditionals scattered across established units. - The model authors declarative specifications. Arbitrary per-page JavaScript exists only in explicit trusted-HTML mode and does not inherit fixed-renderer guarantees. +- Declarative publication validates the complete document before permission or writes. Its + component/chart schemas are shared with the renderer, diagnostics are bounded and redacted, + and standalone rendering retains escaped inline fallbacks for inspection. +- Visual configuration follows prompt > bounded project file > curated theme > built-in + precedence. Sources are versioned, size/type/contrast checked atomically, retain per-token + provenance, and can populate only fixed CSS-variable slots—never CSS syntax or code. - The portable page is the long-term compatibility layer. Services are progressive enhancement; no page-view dependency, account, package runtime, or network is required. - Schemas, CLI/tool contracts, component syntax, exports, routes, and host adapters evolve by diff --git a/docs/evidence/governance/redistribution-2026-08-16.md b/docs/evidence/governance/redistribution-2026-08-16.md index f70625f..4fa43c3 100644 --- a/docs/evidence/governance/redistribution-2026-08-16.md +++ b/docs/evidence/governance/redistribution-2026-08-16.md @@ -12,7 +12,7 @@ machine-readable disposition is - Repository-authored source, policy, documentation, examples, fixtures, skills, tests, and generated evidence are covered by the root MIT license. -- All 21 retained binary assets are repository-generated screenshots under `docs/evidence/`. +- All 24 retained binary assets are repository-generated screenshots under `docs/evidence/`. Each entry names its synthetic/repository source, MIT disposition, contributor attribution, and exact SHA-256. - The repository contains zero embedded font files. Renderer CSS selects system fallback diff --git a/docs/evidence/renderer/goal-3-accessibility-2026-08-17.md b/docs/evidence/renderer/goal-3-accessibility-2026-08-17.md new file mode 100644 index 0000000..04f26ca --- /dev/null +++ b/docs/evidence/renderer/goal-3-accessibility-2026-08-17.md @@ -0,0 +1,77 @@ +# Goal 3 accessibility and internationalization evidence — 2026-08-17 + +Scope: implementation evidence for `renderer-accessibility-i18n`. It combines the retained +Linux/Chromium automation below with a dated, user-attested Fedora/Orca/Chrome manual +screen-reader observation. It does not establish broad supported-platform certification. + +## Automated coverage + +The checked-in `examples/patterns/accessibility-rtl.md` fixture declares Arabic, RTL, +`ar-EG`, and `Asia/Riyadh`, and includes an alert, tasks, progress, ECharts summary, captioned +numeric/zoned-date table, decisions, and served comments. `test/accessibility.test.ts` checks +the semantic output, deterministic locale/time-zone formatting, preflight refusals, logical +CSS, responsive/print/reduced-motion rules, and AA contrast pairs. `test/serve.test.ts` +retains the bridge-before-boot regression that makes persisted state and the comment launcher +available when the renderer initializes. + +## Real Chromium surface + +Harness: `scripts/accessibility-browser-evidence.ts` with Chromium 151 from +`selenium/standalone-chromium`. It retains Chrome's accessibility tree, keyboard state +transitions, computed media/color/layout observations, print observations, browser console, +requests, and screenshots. + +| Cell | CSS viewport | Media | Result | +|---|---:|---|---| +| desktop | 1440 × 1057 | light preference, motion allowed | pass | +| mobile-width | 390 × 701 | dark preference, reduced motion | pass | +| 200%-equivalent reflow | 640 × 500 at DPR 2 from a 1280 × 1000 physical surface | light preference | pass | + +The 200% cell uses Chromium device metrics: half the CSS viewport at two physical pixels per +CSS pixel. This deterministically exercises the same reflow width without claiming that the +headless browser's UI zoom shortcut changed state. + +Across all three cells: + +- the semantic audit was empty, the accessibility tree named the Arabic chart summary and + captioned table, horizontal page overflow was false, browser console entries were zero, + and external HTTP requests were zero; +- Tab focused the skip link and Enter moved focus to `artifact-main`; ArrowRight moved the + decision radio and updated `aria-checked`; Enter sorted the table and updated + `aria-sort="ascending"`; +- Enter on the comment launcher focused the named textarea, Escape restored launcher focus, + and keyboard save created one comment and restored focus without leaving a dialog open; +- the theme control switched state by keyboard with visible focus; the mobile cell reported + dark preference and reduced motion; and the 200%-equivalent RTL cell retained all content + without horizontal overflow or dock obstruction; +- print emulation hid theme/comment/filter controls and used a white page background. + +Retained artifacts: + +- `goal-3-accessibility-desktop-2026-08-17.{json,png}` +- `goal-3-accessibility-mobile-reduced-2026-08-17.{json,png}` +- `goal-3-accessibility-zoom-200-2026-08-17.{json,png}` + +## Manual screen-reader observation — 2026-08-18 + +Aaron Zeng (`aaron.zeng`) opened the served `examples/patterns/accessibility-rtl.md` fixture +from a host device through the VPS loopback SSH tunnel and reported that the full manual +screen-reader checklist passed. + +| Field | Observation | +|---|---| +| Operating system | Fedora 44 | +| Screen reader | Orca 50.2 | +| Browser | Chrome 151.0.7922.137 | +| Result | Pass | + +The attested checklist covered document language/direction and reading order; skip link, +landmarks, and headings; chart summary and table caption/headers; named controls and exposed +state; keyboard focus through decisions, sorting, theme, and comments; and understandable +state-change announcements without relying on color. No OS-level recording or assistive- +technology transcript was collected, so this record is a named human attestation rather than +an independently replayable trace. + +This closes the packet's mandatory manual assistive-technology cell. The observation does not +declare Fedora, Orca, or this Chrome build a broadly supported matrix, and it does not supply +physical-mobile or other browser/OS coverage. diff --git a/docs/evidence/renderer/goal-3-accessibility-desktop-2026-08-17.json b/docs/evidence/renderer/goal-3-accessibility-desktop-2026-08-17.json new file mode 100644 index 0000000..28a7b53 --- /dev/null +++ b/docs/evidence/renderer/goal-3-accessibility-desktop-2026-08-17.json @@ -0,0 +1,248 @@ +{ + "capturedAt": "2026-08-17T16:25:19.235Z", + "browser": "Chromium 151 via selenium/standalone-chromium", + "fixture": "http://127.0.0.1:4173/accessibility-rtl.html", + "requested": { + "viewportWidth": 1440, + "viewportHeight": 1200, + "colorScheme": "light", + "reducedMotion": "no-preference", + "zoomPercent": 100 + }, + "usefulContentMs": 852, + "keyboard": { + "skipFocus": "skip-link", + "skipTargetFocus": "artifact-main", + "decisionTrace": { + "active": "hold", + "checked": "true", + "selected": "hold" + }, + "tableSortTrace": { + "direction": "ascending", + "label": "الفئة" + }, + "commentDialogFocus": "artifact-comment-input", + "commentEscapeFocus": "comment-launcher", + "commentSaveTrace": { + "active": "comment-launcher", + "count": 1, + "dialogOpen": false + }, + "themeTrace": { + "label": "Theme: dark. Activate to switch.", + "pressed": null, + "state": "dark" + } + }, + "observations": { + "animationDuration": "0s", + "audit": [], + "chartSummary": "ترتفع الإشارة من ثلاث نقاط إلى خمس نقاط خلال يومين.", + "colorScheme": "light", + "computedColors": { + "accent": "#a8a6ff", + "page": "#151a21", + "surface": "#1f2630", + "text": "#e5e7eb" + }, + "dir": "rtl", + "focusOutline": "solid", + "headings": [ + { + "level": 1, + "name": "مراجعة الإشارات" + }, + { + "level": 1, + "name": "مراجعة الإشارات" + }, + { + "level": 2, + "name": "حالة المراجعة" + }, + { + "level": 2, + "name": "اتجاه الإشارة" + }, + { + "level": 2, + "name": "سجل القياسات" + }, + { + "level": 2, + "name": "القرار التالي" + }, + { + "level": "2", + "name": "Comments (1)" + } + ], + "horizontalOverflow": false, + "interactiveVisible": { + "comments": true, + "filter": true, + "theme": true + }, + "landmarks": { + "aside": 2, + "footer": 0, + "header": 1, + "main": 1 + }, + "lang": "ar", + "locale": "ar-EG", + "progress": { + "max": "4", + "name": "تقدم المراجعة", + "now": "3" + }, + "radioStates": [ + { + "checked": "false", + "name": "نشربعد اكتمال المراجعة", + "tabindex": "-1" + }, + { + "checked": "true", + "name": "انتظار", + "tabindex": "0" + } + ], + "readyState": "complete", + "reducedMotion": false, + "tableCaption": "سجل الإشارات", + "tableCount": "2 rows", + "timezone": "Asia/Riyadh", + "viewport": { + "devicePixelRatio": 1, + "height": 1057, + "method": "native 100% viewport", + "preZoomWidth": 1440, + "requestedZoom": 100, + "width": 1440 + } + }, + "accessibilityTree": [ + { + "role": "RootWebArea", + "name": "مراجعة الإشارات" + }, + { + "role": "banner", + "name": "" + }, + { + "role": "main", + "name": "" + }, + { + "role": "button", + "name": "Add comment" + }, + { + "role": "heading", + "name": "مراجعة الإشارات" + }, + { + "role": "button", + "name": "Theme: dark. Activate to switch." + }, + { + "role": "heading", + "name": "مراجعة الإشارات" + }, + { + "role": "note", + "name": "Note" + }, + { + "role": "heading", + "name": "Comments (1)" + }, + { + "role": "button", + "name": "Resolve comment: Page comment" + }, + { + "role": "heading", + "name": "حالة المراجعة" + }, + { + "role": "progressbar", + "name": "تقدم المراجعة" + }, + { + "role": "heading", + "name": "اتجاه الإشارة" + }, + { + "role": "figure", + "name": "" + }, + { + "role": "heading", + "name": "سجل القياسات" + }, + { + "role": "table", + "name": "سجل الإشارات" + }, + { + "role": "status", + "name": "" + }, + { + "role": "heading", + "name": "القرار التالي" + }, + { + "role": "radiogroup", + "name": "ما الخطوة التالية؟" + }, + { + "role": "image", + "name": "ترتفع الإشارة من ثلاث نقاط إلى خمس نقاط خلال يومين." + }, + { + "role": "caption", + "name": "" + }, + { + "role": "radio", + "name": "نشر بعد اكتمال المراجعة" + }, + { + "role": "radio", + "name": "انتظار" + }, + { + "role": "button", + "name": "Sort by الفئة" + }, + { + "role": "button", + "name": "Sort by العدد" + }, + { + "role": "button", + "name": "Sort by وقت الالتقاط" + } + ], + "printObservation": { + "commentsHidden": true, + "filterHidden": true, + "pageBackground": "rgb(255, 255, 255)", + "themeHidden": true + }, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/accessibility-rtl.html", + "http://127.0.0.1:4173/__state/accessibility-rtl", + "http://127.0.0.1:4173/__comments/accessibility-rtl", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/accessibility-rtl", + "http://127.0.0.1:4173/__comments/accessibility-rtl" + ], + "externalHttpRequests": [] +} diff --git a/docs/evidence/renderer/goal-3-accessibility-desktop-2026-08-17.png b/docs/evidence/renderer/goal-3-accessibility-desktop-2026-08-17.png new file mode 100644 index 0000000..9e6a831 Binary files /dev/null and b/docs/evidence/renderer/goal-3-accessibility-desktop-2026-08-17.png differ diff --git a/docs/evidence/renderer/goal-3-accessibility-mobile-reduced-2026-08-17.json b/docs/evidence/renderer/goal-3-accessibility-mobile-reduced-2026-08-17.json new file mode 100644 index 0000000..4ff286d --- /dev/null +++ b/docs/evidence/renderer/goal-3-accessibility-mobile-reduced-2026-08-17.json @@ -0,0 +1,248 @@ +{ + "capturedAt": "2026-08-17T16:25:33.901Z", + "browser": "Chromium 151 via selenium/standalone-chromium", + "fixture": "http://127.0.0.1:4173/accessibility-rtl.html", + "requested": { + "viewportWidth": 390, + "viewportHeight": 844, + "colorScheme": "dark", + "reducedMotion": "reduce", + "zoomPercent": 100 + }, + "usefulContentMs": 841, + "keyboard": { + "skipFocus": "skip-link", + "skipTargetFocus": "artifact-main", + "decisionTrace": { + "active": "hold", + "checked": "true", + "selected": "hold" + }, + "tableSortTrace": { + "direction": "ascending", + "label": "الفئة" + }, + "commentDialogFocus": "artifact-comment-input", + "commentEscapeFocus": "comment-launcher", + "commentSaveTrace": { + "active": "comment-launcher", + "count": 1, + "dialogOpen": false + }, + "themeTrace": { + "label": "Theme: dark. Activate to switch.", + "pressed": null, + "state": "dark" + } + }, + "observations": { + "animationDuration": "1e-05s", + "audit": [], + "chartSummary": "ترتفع الإشارة من ثلاث نقاط إلى خمس نقاط خلال يومين.", + "colorScheme": "dark", + "computedColors": { + "accent": "#a8a6ff", + "page": "#151a21", + "surface": "#1f2630", + "text": "#e5e7eb" + }, + "dir": "rtl", + "focusOutline": "solid", + "headings": [ + { + "level": 1, + "name": "مراجعة الإشارات" + }, + { + "level": 1, + "name": "مراجعة الإشارات" + }, + { + "level": 2, + "name": "حالة المراجعة" + }, + { + "level": 2, + "name": "اتجاه الإشارة" + }, + { + "level": 2, + "name": "سجل القياسات" + }, + { + "level": 2, + "name": "القرار التالي" + }, + { + "level": "2", + "name": "Comments (1)" + } + ], + "horizontalOverflow": false, + "interactiveVisible": { + "comments": true, + "filter": true, + "theme": true + }, + "landmarks": { + "aside": 2, + "footer": 0, + "header": 1, + "main": 1 + }, + "lang": "ar", + "locale": "ar-EG", + "progress": { + "max": "4", + "name": "تقدم المراجعة", + "now": "3" + }, + "radioStates": [ + { + "checked": "false", + "name": "نشربعد اكتمال المراجعة", + "tabindex": "-1" + }, + { + "checked": "true", + "name": "انتظار", + "tabindex": "0" + } + ], + "readyState": "complete", + "reducedMotion": true, + "tableCaption": "سجل الإشارات", + "tableCount": "2 rows", + "timezone": "Asia/Riyadh", + "viewport": { + "devicePixelRatio": 1, + "height": 701, + "method": "native 100% viewport", + "preZoomWidth": 390, + "requestedZoom": 100, + "width": 390 + } + }, + "accessibilityTree": [ + { + "role": "RootWebArea", + "name": "مراجعة الإشارات" + }, + { + "role": "banner", + "name": "" + }, + { + "role": "main", + "name": "" + }, + { + "role": "button", + "name": "Add comment" + }, + { + "role": "heading", + "name": "مراجعة الإشارات" + }, + { + "role": "button", + "name": "Theme: dark. Activate to switch." + }, + { + "role": "heading", + "name": "مراجعة الإشارات" + }, + { + "role": "note", + "name": "Note" + }, + { + "role": "heading", + "name": "Comments (1)" + }, + { + "role": "button", + "name": "Resolve comment: Page comment" + }, + { + "role": "heading", + "name": "حالة المراجعة" + }, + { + "role": "progressbar", + "name": "تقدم المراجعة" + }, + { + "role": "heading", + "name": "اتجاه الإشارة" + }, + { + "role": "figure", + "name": "" + }, + { + "role": "heading", + "name": "سجل القياسات" + }, + { + "role": "table", + "name": "سجل الإشارات" + }, + { + "role": "status", + "name": "" + }, + { + "role": "heading", + "name": "القرار التالي" + }, + { + "role": "radiogroup", + "name": "ما الخطوة التالية؟" + }, + { + "role": "image", + "name": "ترتفع الإشارة من ثلاث نقاط إلى خمس نقاط خلال يومين." + }, + { + "role": "caption", + "name": "" + }, + { + "role": "radio", + "name": "نشر بعد اكتمال المراجعة" + }, + { + "role": "radio", + "name": "انتظار" + }, + { + "role": "button", + "name": "Sort by الفئة" + }, + { + "role": "button", + "name": "Sort by العدد" + }, + { + "role": "button", + "name": "Sort by وقت الالتقاط" + } + ], + "printObservation": { + "commentsHidden": true, + "filterHidden": true, + "pageBackground": "rgb(255, 255, 255)", + "themeHidden": true + }, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/accessibility-rtl.html", + "http://127.0.0.1:4173/__state/accessibility-rtl", + "http://127.0.0.1:4173/__comments/accessibility-rtl", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/accessibility-rtl", + "http://127.0.0.1:4173/__comments/accessibility-rtl" + ], + "externalHttpRequests": [] +} diff --git a/docs/evidence/renderer/goal-3-accessibility-mobile-reduced-2026-08-17.png b/docs/evidence/renderer/goal-3-accessibility-mobile-reduced-2026-08-17.png new file mode 100644 index 0000000..a71e72a Binary files /dev/null and b/docs/evidence/renderer/goal-3-accessibility-mobile-reduced-2026-08-17.png differ diff --git a/docs/evidence/renderer/goal-3-accessibility-zoom-200-2026-08-17.json b/docs/evidence/renderer/goal-3-accessibility-zoom-200-2026-08-17.json new file mode 100644 index 0000000..895d5fe --- /dev/null +++ b/docs/evidence/renderer/goal-3-accessibility-zoom-200-2026-08-17.json @@ -0,0 +1,248 @@ +{ + "capturedAt": "2026-08-17T16:25:48.147Z", + "browser": "Chromium 151 via selenium/standalone-chromium", + "fixture": "http://127.0.0.1:4173/accessibility-rtl.html", + "requested": { + "viewportWidth": 1280, + "viewportHeight": 1000, + "colorScheme": "light", + "reducedMotion": "no-preference", + "zoomPercent": 200 + }, + "usefulContentMs": 816, + "keyboard": { + "skipFocus": "skip-link", + "skipTargetFocus": "artifact-main", + "decisionTrace": { + "active": "hold", + "checked": "true", + "selected": "hold" + }, + "tableSortTrace": { + "direction": "ascending", + "label": "الفئة" + }, + "commentDialogFocus": "artifact-comment-input", + "commentEscapeFocus": "comment-launcher", + "commentSaveTrace": { + "active": "comment-launcher", + "count": 1, + "dialogOpen": false + }, + "themeTrace": { + "label": "Theme: dark. Activate to switch.", + "pressed": null, + "state": "dark" + } + }, + "observations": { + "animationDuration": "0s", + "audit": [], + "chartSummary": "ترتفع الإشارة من ثلاث نقاط إلى خمس نقاط خلال يومين.", + "colorScheme": "light", + "computedColors": { + "accent": "#a8a6ff", + "page": "#151a21", + "surface": "#1f2630", + "text": "#e5e7eb" + }, + "dir": "rtl", + "focusOutline": "solid", + "headings": [ + { + "level": 1, + "name": "مراجعة الإشارات" + }, + { + "level": 1, + "name": "مراجعة الإشارات" + }, + { + "level": 2, + "name": "حالة المراجعة" + }, + { + "level": 2, + "name": "اتجاه الإشارة" + }, + { + "level": 2, + "name": "سجل القياسات" + }, + { + "level": 2, + "name": "القرار التالي" + }, + { + "level": "2", + "name": "Comments (1)" + } + ], + "horizontalOverflow": false, + "interactiveVisible": { + "comments": true, + "filter": true, + "theme": true + }, + "landmarks": { + "aside": 2, + "footer": 0, + "header": 1, + "main": 1 + }, + "lang": "ar", + "locale": "ar-EG", + "progress": { + "max": "4", + "name": "تقدم المراجعة", + "now": "3" + }, + "radioStates": [ + { + "checked": "false", + "name": "نشربعد اكتمال المراجعة", + "tabindex": "-1" + }, + { + "checked": "true", + "name": "انتظار", + "tabindex": "0" + } + ], + "readyState": "complete", + "reducedMotion": false, + "tableCaption": "سجل الإشارات", + "tableCount": "2 rows", + "timezone": "Asia/Riyadh", + "viewport": { + "devicePixelRatio": 2, + "height": 500, + "method": "Chromium device metrics: half CSS viewport at 2 physical pixels per CSS pixel", + "preZoomWidth": 1280, + "requestedZoom": 200, + "width": 640 + } + }, + "accessibilityTree": [ + { + "role": "RootWebArea", + "name": "مراجعة الإشارات" + }, + { + "role": "banner", + "name": "" + }, + { + "role": "main", + "name": "" + }, + { + "role": "button", + "name": "Add comment" + }, + { + "role": "heading", + "name": "مراجعة الإشارات" + }, + { + "role": "button", + "name": "Theme: dark. Activate to switch." + }, + { + "role": "heading", + "name": "مراجعة الإشارات" + }, + { + "role": "note", + "name": "Note" + }, + { + "role": "heading", + "name": "Comments (1)" + }, + { + "role": "button", + "name": "Resolve comment: Page comment" + }, + { + "role": "heading", + "name": "حالة المراجعة" + }, + { + "role": "progressbar", + "name": "تقدم المراجعة" + }, + { + "role": "heading", + "name": "اتجاه الإشارة" + }, + { + "role": "figure", + "name": "" + }, + { + "role": "heading", + "name": "سجل القياسات" + }, + { + "role": "table", + "name": "سجل الإشارات" + }, + { + "role": "status", + "name": "" + }, + { + "role": "heading", + "name": "القرار التالي" + }, + { + "role": "radiogroup", + "name": "ما الخطوة التالية؟" + }, + { + "role": "image", + "name": "ترتفع الإشارة من ثلاث نقاط إلى خمس نقاط خلال يومين." + }, + { + "role": "caption", + "name": "" + }, + { + "role": "radio", + "name": "نشر بعد اكتمال المراجعة" + }, + { + "role": "radio", + "name": "انتظار" + }, + { + "role": "button", + "name": "Sort by الفئة" + }, + { + "role": "button", + "name": "Sort by العدد" + }, + { + "role": "button", + "name": "Sort by وقت الالتقاط" + } + ], + "printObservation": { + "commentsHidden": true, + "filterHidden": true, + "pageBackground": "rgb(255, 255, 255)", + "themeHidden": true + }, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/accessibility-rtl.html", + "http://127.0.0.1:4173/__state/accessibility-rtl", + "http://127.0.0.1:4173/__comments/accessibility-rtl", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/accessibility-rtl", + "http://127.0.0.1:4173/__comments/accessibility-rtl" + ], + "externalHttpRequests": [] +} diff --git a/docs/evidence/renderer/goal-3-accessibility-zoom-200-2026-08-17.png b/docs/evidence/renderer/goal-3-accessibility-zoom-200-2026-08-17.png new file mode 100644 index 0000000..cc04158 Binary files /dev/null and b/docs/evidence/renderer/goal-3-accessibility-zoom-200-2026-08-17.png differ diff --git a/docs/evidence/renderer/goal-3-completion-2026-08-18.md b/docs/evidence/renderer/goal-3-completion-2026-08-18.md new file mode 100644 index 0000000..a1ab183 --- /dev/null +++ b/docs/evidence/renderer/goal-3-completion-2026-08-18.md @@ -0,0 +1,54 @@ +# Goal 3 portable rendering correctness completion — 2026-08-18 + +Scope: the correctness track assigned to Goal 3 in `docs/goal-runbook.md`, not the separate +Phase 2 comparative-quality and Local artifact core certification assigned to Goal 5. + +## Delivered packets + +All five approved Goal 3 packets are verified and archived: + +- `2026-08-17-portable-asset-pipeline` +- `2026-08-17-declarative-authoring-preflight` +- `2026-08-17-renderer-design-tokens` +- `2026-08-17-renderer-performance-budgets` +- `2026-08-18-renderer-accessibility-i18n` + +Together they provide contained offline assets and fonts, bounded aggregate diagnostics, +fixed-slot design tokens, semantic and internationalized interaction surfaces, and +reproducible renderer time/load/byte budgets. + +## Correctness gate + +| Gate | Result | Evidence | +|---|---|---| +| Mixed offline artifact with image, chart, table, and controls | Pass | `goal-3-portable-assets-2026-08-17.md`; checked-in `portable-mixed.md` fixture | +| Asset containment, MIME, mutation, and final-byte refusal | Pass | `test/assets.test.ts`; bounded model; portable-asset packet | +| Complete preflight before permission or write | Pass | `test/preflight.test.ts`; declarative-authoring packet | +| Fixed, contrast-checked design-token precedence | Pass | `test/design-tokens.test.ts`; `goal-3-design-tokens-2026-08-17.md` | +| Desktop/mobile-width, keyboard, color, motion, zoom, console, and RTL | Pass | `test/accessibility.test.ts`; retained Chromium 151 JSON/screenshots | +| Manual screen reader | Pass | Aaron Zeng (`aaron.zeng`) attestation on Fedora 44, Orca 50.2, Chrome 151.0.7922.137 | +| Renderer time, useful-load, interaction, and byte budgets | Pass | `test/performance.test.ts`; `goal-3-performance-2026-08-17.md` | + +The manual observation is retained in +`docs/evidence/renderer/goal-3-accessibility-2026-08-17.md`. It is a named human attestation; +no OS-level recording or assistive-technology transcript was collected. + +## Final verification + +Verification ran on Node 24 against commit `e0684d7` before this documentation-only completion +record; the completion record does not alter packed bytes. + +- `npm test`: pass, 214 tests, zero failures or skips. +- `npm run build`: pass. +- `npm run check`: pass, all 35 registered structural checks and matching principle tags. +- `npm pack --dry-run`: pass, 63 deliberate files, 116.5 kB packed and 499.0 kB unpacked; + reported SHA-1 `f481e5fea6998b9c55e3a2f806d2a3dc0e0f8`. +- `git diff --check`: pass. + +## Honest boundary and handoff + +Goal 3 correctness is complete. This record does not claim a supported Fedora/Orca/Chrome +matrix, physical-mobile coverage, equivalent-or-better page quality, representative-user +outcomes, or Local artifact core certification. Authorized current Claude Artifact runs, +retention permission, the full benchmark corpus, and independent blinded reviewers remain +Goal 5 inputs. Goal 4 may proceed independently under the stable packed OpenCode contract. diff --git a/docs/evidence/renderer/goal-3-design-tokens-2026-08-17.md b/docs/evidence/renderer/goal-3-design-tokens-2026-08-17.md new file mode 100644 index 0000000..3864722 --- /dev/null +++ b/docs/evidence/renderer/goal-3-design-tokens-2026-08-17.md @@ -0,0 +1,42 @@ +# Goal 3 bounded design-token evidence — 2026-08-17 + +## Scope + +This observation covers the checked-in `examples/patterns/design-tokens.md` fixture rendered +by the CLI and opened directly from `file://` in Chromium 151 with offline network emulation. +It verifies the real desktop and narrow-viewport surface for the approved +`renderer-design-tokens` packet; it is not a browser/OS support claim. + +## Reproduction + +1. Render the fixture with Node 24 using `opencode-artifacts render`. +2. Open the resulting portable HTML in `selenium/standalone-chromium` with CDP network + emulation set offline. +3. Capture computed token variables, provenance metadata, CSP, page overflow, browser console, + attempted requests, and screenshots at requested 1440×1600 and 390×1000 windows. + +The reusable capture script is `scripts/portable-browser-evidence.ts`. Chrome window chrome +left inner viewports of 1440×1457 and 390×857 respectively. + +## Result + +- Both surfaces computed the authored values: `#f5f1ff` page background, `#ffffff` surface, + `#211735` text, `#6d28d9` accent, `8px` radius, and the allowlisted serif stack. +- Both exposed `data-design-tokens`, the complete prompt provenance record, the named `report` + lower-precedence theme, and the unchanged strict on-disk CSP. +- Both reached `readyState=complete`, rendered one table, had no horizontal page overflow, + produced zero browser-console entries, and attempted zero HTTP(S) requests offline. +- The screenshots retain readable desktop composition and narrow single-column recomposition. + +Evidence files: + +- [desktop observation](goal-3-design-tokens-desktop-2026-08-17.json) +- [desktop screenshot](goal-3-design-tokens-desktop-2026-08-17.png) +- [mobile observation](goal-3-design-tokens-mobile-2026-08-17.json) +- [mobile screenshot](goal-3-design-tokens-mobile-2026-08-17.png) + +## Boundary + +This is one Linux-container Chromium observation. Native macOS/Windows, additional browsers, +assistive technology, print/PDF, localization, and performance claims remain governed by the +later Goal 3 accessibility and performance packets and the declared support matrix. diff --git a/docs/evidence/renderer/goal-3-design-tokens-desktop-2026-08-17.json b/docs/evidence/renderer/goal-3-design-tokens-desktop-2026-08-17.json new file mode 100644 index 0000000..9c27124 --- /dev/null +++ b/docs/evidence/renderer/goal-3-design-tokens-desktop-2026-08-17.json @@ -0,0 +1,44 @@ +{ + "capturedAt": "2026-08-17T15:51:26.616Z", + "browser": "Chromium 151 via selenium/standalone-chromium", + "offlineEmulation": true, + "usefulContentMs": 396, + "observations": { + "activeElement": "", + "bodyFontFamily": "Georgia, Charter, \"Times New Roman\", serif", + "chartVisuals": 0, + "computedDesign": { + "accent": "#6d28d9", + "pageBackground": "#f5f1ff", + "radius": "8px", + "surface": "#ffffff", + "text": "#211735" + }, + "csp": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:; connect-src 'none'", + "decisionButtons": 0, + "designProvenance": "{\"pageBackground\":\"prompt\",\"surface\":\"prompt\",\"text\":\"prompt\",\"mutedText\":\"prompt\",\"border\":\"prompt\",\"accent\":\"prompt\",\"font\":\"prompt\",\"spacing\":\"prompt\",\"radius\":\"prompt\",\"density\":\"prompt\"}", + "designTokens": true, + "fontSetStatus": "loaded", + "horizontalOverflow": false, + "imageComplete": false, + "imageHash": null, + "imageNaturalWidth": 0, + "imageSource": null, + "keyboardSelected": false, + "pageBytes": 35349, + "pageTheme": "report", + "projectFontFaces": [], + "readyState": "complete", + "tableCaptions": [], + "tables": 1, + "viewport": { + "height": 1457, + "width": 1440 + } + }, + "browserLogs": [], + "requestUrls": [ + "file:///evidence/goal-3-design-tokens.html" + ], + "networkRequestUrls": [] +} diff --git a/docs/evidence/renderer/goal-3-design-tokens-desktop-2026-08-17.png b/docs/evidence/renderer/goal-3-design-tokens-desktop-2026-08-17.png new file mode 100644 index 0000000..60166e4 Binary files /dev/null and b/docs/evidence/renderer/goal-3-design-tokens-desktop-2026-08-17.png differ diff --git a/docs/evidence/renderer/goal-3-design-tokens-mobile-2026-08-17.json b/docs/evidence/renderer/goal-3-design-tokens-mobile-2026-08-17.json new file mode 100644 index 0000000..3966a48 --- /dev/null +++ b/docs/evidence/renderer/goal-3-design-tokens-mobile-2026-08-17.json @@ -0,0 +1,44 @@ +{ + "capturedAt": "2026-08-17T15:51:29.178Z", + "browser": "Chromium 151 via selenium/standalone-chromium", + "offlineEmulation": true, + "usefulContentMs": 335, + "observations": { + "activeElement": "", + "bodyFontFamily": "Georgia, Charter, \"Times New Roman\", serif", + "chartVisuals": 0, + "computedDesign": { + "accent": "#6d28d9", + "pageBackground": "#f5f1ff", + "radius": "8px", + "surface": "#ffffff", + "text": "#211735" + }, + "csp": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:; connect-src 'none'", + "decisionButtons": 0, + "designProvenance": "{\"pageBackground\":\"prompt\",\"surface\":\"prompt\",\"text\":\"prompt\",\"mutedText\":\"prompt\",\"border\":\"prompt\",\"accent\":\"prompt\",\"font\":\"prompt\",\"spacing\":\"prompt\",\"radius\":\"prompt\",\"density\":\"prompt\"}", + "designTokens": true, + "fontSetStatus": "loaded", + "horizontalOverflow": false, + "imageComplete": false, + "imageHash": null, + "imageNaturalWidth": 0, + "imageSource": null, + "keyboardSelected": false, + "pageBytes": 35349, + "pageTheme": "report", + "projectFontFaces": [], + "readyState": "complete", + "tableCaptions": [], + "tables": 1, + "viewport": { + "height": 857, + "width": 390 + } + }, + "browserLogs": [], + "requestUrls": [ + "file:///evidence/goal-3-design-tokens.html" + ], + "networkRequestUrls": [] +} diff --git a/docs/evidence/renderer/goal-3-design-tokens-mobile-2026-08-17.png b/docs/evidence/renderer/goal-3-design-tokens-mobile-2026-08-17.png new file mode 100644 index 0000000..00fa367 Binary files /dev/null and b/docs/evidence/renderer/goal-3-design-tokens-mobile-2026-08-17.png differ diff --git a/docs/evidence/renderer/goal-3-performance-2026-08-17.md b/docs/evidence/renderer/goal-3-performance-2026-08-17.md new file mode 100644 index 0000000..8f8599d --- /dev/null +++ b/docs/evidence/renderer/goal-3-performance-2026-08-17.md @@ -0,0 +1,65 @@ +# Goal 3 renderer performance evidence — 2026-08-17 + +Scope: comparable reference-profile evidence for `renderer-performance-budgets`. This covers +portable rendering only; hosted collaboration, connectors, load/soak, and provider cost have +their own later requirements. + +## Versioned method + +Configuration and fixtures live under `benchmarks/renderer/v1/`. The reference profile is +Linux x64, Node 24, Chromium 151, two CPU cores, and 4 GiB memory. Both benchmark containers +were launched with `--cpus 2 --memory 4g`; retained Docker inspection returned NanoCPUs +`2000000000` and memory `4294967296` for the Selenium and local-server containers. The CLI +report independently observed `cpu.max=200000 100000` and `memory.max=4294967296`. + +The percentile method is nearest-rank. CLI uses 12 process-level samples per workload and +identifies the first as cold plus the remaining warm distribution. Browser uses seven cold +navigations per workload and viewport, each in a new WebDriver session/profile. A distribution +requires at least five samples. Relative p95 spread must be at most 1.0 and uses a documented +250ms scheduler-noise floor; every raw sample remains in the report. Setup, builds, container +startup, dependency installation, and report serialization are outside timed regions and +listed in each report. Dependencies were preinstalled and install time was not measured or +silently mixed into rendering. + +## CLI results + +Machine report: `goal-3-performance-cli-2026-08-17.json`. All distributions were stable and +the environment comparison was exact. + +| Workload | cold | p50 | p95 | limit | final bytes | runtime bytes | byte state | +|---|---:|---:|---:|---:|---:|---:|---| +| no runtime | 851ms | 739ms | 851ms | 2,000ms | 39,902 | 0 | pass | +| one chart family | 834ms | 786ms | 885ms | 5,000ms | 622,105 | 581,244 | pass | +| multi runtime | 894ms | 868ms | 964ms | 5,000ms | 5,311,264 | 5,269,229 | pass | + +The reports separately retain source, runtime, asset, and shell/content contributions, exact +fixture/output hashes, warning/hard thresholds, and remaining capacity. These fixtures contain +no assets, so asset contribution is exactly zero. + +## Browser results + +Machine report: `goal-3-performance-browser-2026-08-17.json`. Useful content requires the +main surface plus every expected chart/diagram visual. Keyboard readiness requires a focused +decision radio whose ArrowRight transition has completed and updated `aria-checked`. + +| Workload | viewport | useful p50 | useful p95 | useful limit | keyboard-additional p95 | limit | +|---|---|---:|---:|---:|---:|---:| +| no runtime | desktop | 724ms | 804ms | 1,500ms | 268ms | 1,000ms | +| no runtime | mobile | 814ms | 896ms | 3,000ms | 203ms | 2,000ms | +| one chart | desktop | 1,207ms | 1,299ms | 3,000ms | 299ms | 1,000ms | +| one chart | mobile | 1,101ms | 1,316ms | 6,000ms | 189ms | 2,000ms | +| multi runtime | desktop | 2,192ms | 2,392ms | 5,000ms | 132ms | 1,000ms | +| multi runtime | mobile | 1,917ms | 2,022ms | 10,000ms | 112ms | 2,000ms | + +All six distributions were stable. Every one of the 42 samples retained its timings, local +request inventory, console entries, readiness booleans, and hard-failure list. Across the +matrix there were zero missed readiness marks, runtime errors, severe console entries, +unexpected external requests, or keyboard failures. + +## Deterministic gates + +`test/performance.test.ts` covers nearest-rank calculation, exact time limits, the next unit, +warning/hard byte boundaries, missing/invalid/noisy distributions, the scheduler floor, +environment mismatches, injected runtime/request failures, fixture hard-byte validation, and +exact report/config/fixture hash binding. The existing absolute 15 MiB renderer and publisher +tests retain no-write behavior above the product cap. diff --git a/docs/evidence/renderer/goal-3-performance-browser-2026-08-17.json b/docs/evidence/renderer/goal-3-performance-browser-2026-08-17.json new file mode 100644 index 0000000..a46dd97 --- /dev/null +++ b/docs/evidence/renderer/goal-3-performance-browser-2026-08-17.json @@ -0,0 +1,1104 @@ +{ + "schemaVersion": 1, + "benchmark": "renderer-browser-v1", + "capturedAt": "2026-08-17T16:59:44.208Z", + "configPath": "benchmarks/renderer/v1/budgets.json", + "configSha256": "bf6961cdb235d513d4e604659bba19cd4fa4f8bbf059998552c5e65cb7a2b90e", + "percentileMethod": "nearest-rank", + "navigationState": "new WebDriver session and browser profile for every cold sample", + "excludedSetup": [ + "Selenium container start", + "artifact rendering", + "local server start", + "session creation before navigation", + "report serialization" + ], + "environment": { + "profile": "renderer-linux-container-v1", + "platform": "linux", + "arch": "x64", + "nodeMajor": 24, + "cpuQuotaCores": 2, + "memoryLimitBytes": 4294967296, + "browserName": "chrome", + "browserMajor": 151, + "browserVersion": "151.0.7922.108", + "cgroupCpuMax": null, + "cgroupMemoryMax": null, + "constraintSource": "explicit Docker constraints; verify retained docker inspect values" + }, + "expectedEnvironment": { + "profile": "renderer-linux-container-v1", + "platform": "linux", + "arch": "x64", + "nodeMajor": 24, + "cpuQuotaCores": 2, + "memoryLimitBytes": 4294967296, + "browserName": "chrome", + "browserMajor": 151 + }, + "environmentComparison": { + "comparable": true, + "mismatches": [] + }, + "sampling": { + "cliSamples": 12, + "browserSamples": 7, + "minimumSamples": 5, + "noiseFloorMs": 250, + "maxRelativeP95Spread": 1 + }, + "workloads": { + "no-runtime": { + "fixture": "no-runtime.md", + "fixtureSha256": "53068378d5707429795aaba82f864de5064f139364ec35490104e964bfb2d28b", + "finalBytes": 39902, + "byteBudget": { + "totalBytes": 39902, + "warningBytes": 131072, + "hardBytes": 196608, + "remainingToHardBytes": 156706, + "status": "pass" + }, + "cells": { + "desktop": { + "viewport": { + "width": 1440, + "height": 1200 + }, + "usefulContent": { + "samplesMs": [ + 724.2003849999999, + 791.2323139999999, + 798.3405169999996, + 702.7638720000014, + 608.9303039999995, + 709.7722099999992, + 803.6040079999984 + ], + "count": 7, + "minMs": 608.9303039999995, + "maxMs": 803.6040079999984, + "p50Ms": 724.2003849999999, + "p95Ms": 803.6040079999984, + "relativeP95Spread": 0.10964316595882309, + "disposition": "stable", + "reasons": [] + }, + "keyboardAdditional": { + "samplesMs": [ + 189.32228900000018, + 133.23756299999968, + 214.98325700000078, + 131.59186899999986, + 184.2976519999993, + 183.33082099999956, + 267.6638060000005 + ], + "count": 7, + "minMs": 131.59186899999986, + "maxMs": 267.6638060000005, + "p50Ms": 184.2976519999993, + "p95Ms": 267.6638060000005, + "relativeP95Spread": 0.33346461600000477, + "disposition": "stable", + "reasons": [] + }, + "budget": { + "usefulContent": { + "limitMs": 1500, + "p95Ms": 803.6040079999984, + "pass": true, + "reasons": [] + }, + "keyboardAdditional": { + "limitMs": 1000, + "p95Ms": 267.6638060000005, + "pass": true, + "reasons": [] + }, + "hardFailures": [], + "pass": true + }, + "samples": [ + { + "usefulContentMs": 724.2003849999999, + "keyboardAdditionalMs": 189.32228900000018, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/no-runtime.html", + "http://127.0.0.1:4173/__state/no-runtime", + "http://127.0.0.1:4173/__comments/no-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/no-runtime" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 791.2323139999999, + "keyboardAdditionalMs": 133.23756299999968, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/no-runtime.html", + "http://127.0.0.1:4173/__state/no-runtime", + "http://127.0.0.1:4173/__comments/no-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/no-runtime" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 798.3405169999996, + "keyboardAdditionalMs": 214.98325700000078, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/no-runtime.html", + "http://127.0.0.1:4173/__state/no-runtime", + "http://127.0.0.1:4173/__comments/no-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/no-runtime" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 702.7638720000014, + "keyboardAdditionalMs": 131.59186899999986, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/no-runtime.html", + "http://127.0.0.1:4173/__state/no-runtime", + "http://127.0.0.1:4173/__comments/no-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/no-runtime" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 608.9303039999995, + "keyboardAdditionalMs": 184.2976519999993, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/no-runtime.html", + "http://127.0.0.1:4173/__state/no-runtime", + "http://127.0.0.1:4173/__comments/no-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/no-runtime" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 709.7722099999992, + "keyboardAdditionalMs": 183.33082099999956, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/no-runtime.html", + "http://127.0.0.1:4173/__state/no-runtime", + "http://127.0.0.1:4173/__comments/no-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/no-runtime" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 803.6040079999984, + "keyboardAdditionalMs": 267.6638060000005, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/no-runtime.html", + "http://127.0.0.1:4173/__state/no-runtime", + "http://127.0.0.1:4173/__comments/no-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/no-runtime" + ], + "hardFailures": [] + } + ], + "pass": true + }, + "mobile": { + "viewport": { + "width": 390, + "height": 844 + }, + "usefulContent": { + "samplesMs": [ + 814.4445400000004, + 713.3828810000014, + 833.1989900000008, + 792.1811289999969, + 704.9057250000005, + 896.154128000002, + 870.7458740000002 + ], + "count": 7, + "minMs": 704.9057250000005, + "maxMs": 896.154128000002, + "p50Ms": 814.4445400000004, + "p95Ms": 896.154128000002, + "relativeP95Spread": 0.10032553966167111, + "disposition": "stable", + "reasons": [] + }, + "keyboardAdditional": { + "samplesMs": [ + 203.31916300000012, + 132.99150799999916, + 184.83487499999683, + 195.40626799999882, + 197.91248599999744, + 150.27274799999577, + 115.68558499999926 + ], + "count": 7, + "minMs": 115.68558499999926, + "maxMs": 203.31916300000012, + "p50Ms": 184.83487499999683, + "p95Ms": 203.31916300000012, + "relativeP95Spread": 0.07393715200001316, + "disposition": "stable", + "reasons": [] + }, + "budget": { + "usefulContent": { + "limitMs": 3000, + "p95Ms": 896.154128000002, + "pass": true, + "reasons": [] + }, + "keyboardAdditional": { + "limitMs": 2000, + "p95Ms": 203.31916300000012, + "pass": true, + "reasons": [] + }, + "hardFailures": [], + "pass": true + }, + "samples": [ + { + "usefulContentMs": 814.4445400000004, + "keyboardAdditionalMs": 203.31916300000012, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/no-runtime.html", + "http://127.0.0.1:4173/__state/no-runtime", + "http://127.0.0.1:4173/__comments/no-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/no-runtime" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 713.3828810000014, + "keyboardAdditionalMs": 132.99150799999916, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/no-runtime.html", + "http://127.0.0.1:4173/__state/no-runtime", + "http://127.0.0.1:4173/__comments/no-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/no-runtime" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 833.1989900000008, + "keyboardAdditionalMs": 184.83487499999683, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/no-runtime.html", + "http://127.0.0.1:4173/__state/no-runtime", + "http://127.0.0.1:4173/__comments/no-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/no-runtime" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 792.1811289999969, + "keyboardAdditionalMs": 195.40626799999882, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/no-runtime.html", + "http://127.0.0.1:4173/__state/no-runtime", + "http://127.0.0.1:4173/__comments/no-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/no-runtime" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 704.9057250000005, + "keyboardAdditionalMs": 197.91248599999744, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/no-runtime.html", + "http://127.0.0.1:4173/__state/no-runtime", + "http://127.0.0.1:4173/__comments/no-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/no-runtime" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 896.154128000002, + "keyboardAdditionalMs": 150.27274799999577, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/no-runtime.html", + "http://127.0.0.1:4173/__state/no-runtime", + "http://127.0.0.1:4173/__comments/no-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/no-runtime" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 870.7458740000002, + "keyboardAdditionalMs": 115.68558499999926, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/no-runtime.html", + "http://127.0.0.1:4173/__state/no-runtime", + "http://127.0.0.1:4173/__comments/no-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/no-runtime" + ], + "hardFailures": [] + } + ], + "pass": true + } + } + }, + "one-chart": { + "fixture": "one-chart.md", + "fixtureSha256": "9e2db7cd4b9ffd197baeb1eb539df779dadca192002769fedbe361e934192e26", + "finalBytes": 622105, + "byteBudget": { + "totalBytes": 622105, + "warningBytes": 1048576, + "hardBytes": 1572864, + "remainingToHardBytes": 950759, + "status": "pass" + }, + "cells": { + "desktop": { + "viewport": { + "width": 1440, + "height": 1200 + }, + "usefulContent": { + "samplesMs": [ + 1206.874284999998, + 1297.1714320000028, + 1296.8511890000009, + 1102.7182949999988, + 1113.333611999995, + 1093.5336229999957, + 1298.9593310000055 + ], + "count": 7, + "minMs": 1093.5336229999957, + "maxMs": 1298.9593310000055, + "p50Ms": 1206.874284999998, + "p95Ms": 1298.9593310000055, + "relativeP95Spread": 0.07630044582481726, + "disposition": "stable", + "reasons": [] + }, + "keyboardAdditional": { + "samplesMs": [ + 222.125377999997, + 299.17056800000137, + 204.15754999999626, + 185.23560500000167, + 205.93714599999657, + 142.3765130000029, + 213.09618300000147 + ], + "count": 7, + "minMs": 142.3765130000029, + "maxMs": 299.17056800000137, + "p50Ms": 205.93714599999657, + "p95Ms": 299.17056800000137, + "relativeP95Spread": 0.3729336880000192, + "disposition": "stable", + "reasons": [] + }, + "budget": { + "usefulContent": { + "limitMs": 3000, + "p95Ms": 1298.9593310000055, + "pass": true, + "reasons": [] + }, + "keyboardAdditional": { + "limitMs": 1000, + "p95Ms": 299.17056800000137, + "pass": true, + "reasons": [] + }, + "hardFailures": [], + "pass": true + }, + "samples": [ + { + "usefulContentMs": 1206.874284999998, + "keyboardAdditionalMs": 222.125377999997, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/one-chart.html", + "http://127.0.0.1:4173/__state/one-chart", + "http://127.0.0.1:4173/__comments/one-chart", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/one-chart" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 1297.1714320000028, + "keyboardAdditionalMs": 299.17056800000137, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/one-chart.html", + "http://127.0.0.1:4173/__state/one-chart", + "http://127.0.0.1:4173/__comments/one-chart", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/one-chart" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 1296.8511890000009, + "keyboardAdditionalMs": 204.15754999999626, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/one-chart.html", + "http://127.0.0.1:4173/__state/one-chart", + "http://127.0.0.1:4173/__comments/one-chart", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/one-chart" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 1102.7182949999988, + "keyboardAdditionalMs": 185.23560500000167, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/one-chart.html", + "http://127.0.0.1:4173/__state/one-chart", + "http://127.0.0.1:4173/__comments/one-chart", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/one-chart" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 1113.333611999995, + "keyboardAdditionalMs": 205.93714599999657, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/one-chart.html", + "http://127.0.0.1:4173/__state/one-chart", + "http://127.0.0.1:4173/__comments/one-chart", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/one-chart" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 1093.5336229999957, + "keyboardAdditionalMs": 142.3765130000029, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/one-chart.html", + "http://127.0.0.1:4173/__state/one-chart", + "http://127.0.0.1:4173/__comments/one-chart", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/one-chart" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 1298.9593310000055, + "keyboardAdditionalMs": 213.09618300000147, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/one-chart.html", + "http://127.0.0.1:4173/__state/one-chart", + "http://127.0.0.1:4173/__comments/one-chart", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/one-chart" + ], + "hardFailures": [] + } + ], + "pass": true + }, + "mobile": { + "viewport": { + "width": 390, + "height": 844 + }, + "usefulContent": { + "samplesMs": [ + 998.6703269999998, + 1101.1255730000048, + 1001.4078910000026, + 1185.0674359999975, + 1010.438227000006, + 1315.552217000004, + 1105.972775000002 + ], + "count": 7, + "minMs": 998.6703269999998, + "maxMs": 1315.552217000004, + "p50Ms": 1101.1255730000048, + "p95Ms": 1315.552217000004, + "relativeP95Spread": 0.19473405146317338, + "disposition": "stable", + "reasons": [] + }, + "keyboardAdditional": { + "samplesMs": [ + 72.086844999998, + 141.4500490000064, + 118.14236299999902, + 113.57948400000168, + 132.45028499999898, + 146.37378100000205, + 188.69771999999648 + ], + "count": 7, + "minMs": 72.086844999998, + "maxMs": 188.69771999999648, + "p50Ms": 132.45028499999898, + "p95Ms": 188.69771999999648, + "relativeP95Spread": 0.22498973999998997, + "disposition": "stable", + "reasons": [] + }, + "budget": { + "usefulContent": { + "limitMs": 6000, + "p95Ms": 1315.552217000004, + "pass": true, + "reasons": [] + }, + "keyboardAdditional": { + "limitMs": 2000, + "p95Ms": 188.69771999999648, + "pass": true, + "reasons": [] + }, + "hardFailures": [], + "pass": true + }, + "samples": [ + { + "usefulContentMs": 998.6703269999998, + "keyboardAdditionalMs": 72.086844999998, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/one-chart.html", + "http://127.0.0.1:4173/__state/one-chart", + "http://127.0.0.1:4173/__comments/one-chart", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/one-chart" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 1101.1255730000048, + "keyboardAdditionalMs": 141.4500490000064, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/one-chart.html", + "http://127.0.0.1:4173/__state/one-chart", + "http://127.0.0.1:4173/__comments/one-chart", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/one-chart" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 1001.4078910000026, + "keyboardAdditionalMs": 118.14236299999902, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/one-chart.html", + "http://127.0.0.1:4173/__state/one-chart", + "http://127.0.0.1:4173/__comments/one-chart", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/one-chart" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 1185.0674359999975, + "keyboardAdditionalMs": 113.57948400000168, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/one-chart.html", + "http://127.0.0.1:4173/__state/one-chart", + "http://127.0.0.1:4173/__comments/one-chart", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/one-chart" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 1010.438227000006, + "keyboardAdditionalMs": 132.45028499999898, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/one-chart.html", + "http://127.0.0.1:4173/__state/one-chart", + "http://127.0.0.1:4173/__comments/one-chart", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/one-chart" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 1315.552217000004, + "keyboardAdditionalMs": 146.37378100000205, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/one-chart.html", + "http://127.0.0.1:4173/__state/one-chart", + "http://127.0.0.1:4173/__comments/one-chart", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/one-chart" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 1105.972775000002, + "keyboardAdditionalMs": 188.69771999999648, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/one-chart.html", + "http://127.0.0.1:4173/__state/one-chart", + "http://127.0.0.1:4173/__comments/one-chart", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/one-chart" + ], + "hardFailures": [] + } + ], + "pass": true + } + } + }, + "multi-runtime": { + "fixture": "multi-runtime.md", + "fixtureSha256": "bd9ed6d6f370ccc67fadb6b5d44b9a9c1d9c66107f4336de9be283d31b0e9b14", + "finalBytes": 5311264, + "byteBudget": { + "totalBytes": 5311264, + "warningBytes": 6291456, + "hardBytes": 8388608, + "remainingToHardBytes": 3077344, + "status": "pass" + }, + "cells": { + "desktop": { + "viewport": { + "width": 1440, + "height": 1200 + }, + "usefulContent": { + "samplesMs": [ + 2282.6619599999976, + 2391.7948599999945, + 2131.6816409999883, + 2050.704291000002, + 2137.917189, + 2192.4700630000007, + 2342.384560000006 + ], + "count": 7, + "minMs": 2050.704291000002, + "maxMs": 2391.7948599999945, + "p50Ms": 2192.4700630000007, + "p95Ms": 2391.7948599999945, + "relativeP95Spread": 0.09091334945173835, + "disposition": "stable", + "reasons": [] + }, + "keyboardAdditional": { + "samplesMs": [ + 73.21688199999335, + 132.08785199999693, + 108.37364000000525, + 105.98075200000312, + 117.94366699999955, + 68.27672000000894, + 130.84225400000287 + ], + "count": 7, + "minMs": 68.27672000000894, + "maxMs": 132.08785199999693, + "p50Ms": 108.37364000000525, + "p95Ms": 132.08785199999693, + "relativeP95Spread": 0.0948568479999667, + "disposition": "stable", + "reasons": [] + }, + "budget": { + "usefulContent": { + "limitMs": 5000, + "p95Ms": 2391.7948599999945, + "pass": true, + "reasons": [] + }, + "keyboardAdditional": { + "limitMs": 1000, + "p95Ms": 132.08785199999693, + "pass": true, + "reasons": [] + }, + "hardFailures": [], + "pass": true + }, + "samples": [ + { + "usefulContentMs": 2282.6619599999976, + "keyboardAdditionalMs": 73.21688199999335, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/multi-runtime.html", + "http://127.0.0.1:4173/__state/multi-runtime", + "http://127.0.0.1:4173/__comments/multi-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/multi-runtime" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 2391.7948599999945, + "keyboardAdditionalMs": 132.08785199999693, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/multi-runtime.html", + "http://127.0.0.1:4173/__state/multi-runtime", + "http://127.0.0.1:4173/__comments/multi-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/multi-runtime" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 2131.6816409999883, + "keyboardAdditionalMs": 108.37364000000525, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/multi-runtime.html", + "http://127.0.0.1:4173/__state/multi-runtime", + "http://127.0.0.1:4173/__comments/multi-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/multi-runtime" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 2050.704291000002, + "keyboardAdditionalMs": 105.98075200000312, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/multi-runtime.html", + "http://127.0.0.1:4173/__state/multi-runtime", + "http://127.0.0.1:4173/__comments/multi-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/multi-runtime" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 2137.917189, + "keyboardAdditionalMs": 117.94366699999955, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/multi-runtime.html", + "http://127.0.0.1:4173/__state/multi-runtime", + "http://127.0.0.1:4173/__comments/multi-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/multi-runtime" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 2192.4700630000007, + "keyboardAdditionalMs": 68.27672000000894, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/multi-runtime.html", + "http://127.0.0.1:4173/__state/multi-runtime", + "http://127.0.0.1:4173/__comments/multi-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/multi-runtime" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 2342.384560000006, + "keyboardAdditionalMs": 130.84225400000287, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/multi-runtime.html", + "http://127.0.0.1:4173/__state/multi-runtime", + "http://127.0.0.1:4173/__comments/multi-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/multi-runtime" + ], + "hardFailures": [] + } + ], + "pass": true + }, + "mobile": { + "viewport": { + "width": 390, + "height": 844 + }, + "usefulContent": { + "samplesMs": [ + 2021.8173769999994, + 1941.2457080000022, + 1916.8928400000004, + 1902.3792320000066, + 1643.6308049999934, + 1737.5025679999962, + 2001.2831710000028 + ], + "count": 7, + "minMs": 1643.6308049999934, + "maxMs": 2021.8173769999994, + "p50Ms": 1916.8928400000004, + "p95Ms": 2021.8173769999994, + "relativeP95Spread": 0.0547367775655101, + "disposition": "stable", + "reasons": [] + }, + "keyboardAdditional": { + "samplesMs": [ + 85.51755699999921, + 89.53573200000392, + 107.24107299999741, + 111.54290499999479, + 91.45189199999731, + 106.81601100000262, + 105.07546999999613 + ], + "count": 7, + "minMs": 85.51755699999921, + "maxMs": 111.54290499999479, + "p50Ms": 105.07546999999613, + "p95Ms": 111.54290499999479, + "relativeP95Spread": 0.025869739999994635, + "disposition": "stable", + "reasons": [] + }, + "budget": { + "usefulContent": { + "limitMs": 10000, + "p95Ms": 2021.8173769999994, + "pass": true, + "reasons": [] + }, + "keyboardAdditional": { + "limitMs": 2000, + "p95Ms": 111.54290499999479, + "pass": true, + "reasons": [] + }, + "hardFailures": [], + "pass": true + }, + "samples": [ + { + "usefulContentMs": 2021.8173769999994, + "keyboardAdditionalMs": 85.51755699999921, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/multi-runtime.html", + "http://127.0.0.1:4173/__state/multi-runtime", + "http://127.0.0.1:4173/__comments/multi-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/multi-runtime" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 1941.2457080000022, + "keyboardAdditionalMs": 89.53573200000392, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/multi-runtime.html", + "http://127.0.0.1:4173/__state/multi-runtime", + "http://127.0.0.1:4173/__comments/multi-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/multi-runtime" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 1916.8928400000004, + "keyboardAdditionalMs": 107.24107299999741, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/multi-runtime.html", + "http://127.0.0.1:4173/__state/multi-runtime", + "http://127.0.0.1:4173/__comments/multi-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/multi-runtime" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 1902.3792320000066, + "keyboardAdditionalMs": 111.54290499999479, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/multi-runtime.html", + "http://127.0.0.1:4173/__state/multi-runtime", + "http://127.0.0.1:4173/__comments/multi-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/multi-runtime" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 1643.6308049999934, + "keyboardAdditionalMs": 91.45189199999731, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/multi-runtime.html", + "http://127.0.0.1:4173/__state/multi-runtime", + "http://127.0.0.1:4173/__comments/multi-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/multi-runtime" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 1737.5025679999962, + "keyboardAdditionalMs": 106.81601100000262, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/multi-runtime.html", + "http://127.0.0.1:4173/__state/multi-runtime", + "http://127.0.0.1:4173/__comments/multi-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/multi-runtime" + ], + "hardFailures": [] + }, + { + "usefulContentMs": 2001.2831710000028, + "keyboardAdditionalMs": 105.07546999999613, + "ready": true, + "keyboardReady": true, + "browserLogs": [], + "requestUrls": [ + "http://127.0.0.1:4173/multi-runtime.html", + "http://127.0.0.1:4173/__state/multi-runtime", + "http://127.0.0.1:4173/__comments/multi-runtime", + "http://127.0.0.1:4173/__sse", + "http://127.0.0.1:4173/__state/multi-runtime" + ], + "hardFailures": [] + } + ], + "pass": true + } + } + } + }, + "pass": true +} diff --git a/docs/evidence/renderer/goal-3-performance-cli-2026-08-17.json b/docs/evidence/renderer/goal-3-performance-cli-2026-08-17.json new file mode 100644 index 0000000..b2701b1 --- /dev/null +++ b/docs/evidence/renderer/goal-3-performance-cli-2026-08-17.json @@ -0,0 +1,278 @@ +{ + "schemaVersion": 1, + "benchmark": "renderer-cli-v1", + "capturedAt": "2026-08-17T16:57:23.174Z", + "configPath": "benchmarks/renderer/v1/budgets.json", + "configSha256": "bf6961cdb235d513d4e604659bba19cd4fa4f8bbf059998552c5e65cb7a2b90e", + "percentileMethod": "nearest-rank", + "dependencyInstall": { + "includedInTiming": false, + "measured": false, + "durationMs": null, + "reason": "dependencies were preinstalled before the timed harness" + }, + "excludedSetup": [ + "dependency installation", + "TypeScript build", + "fixture discovery", + "report serialization" + ], + "environment": { + "profile": "renderer-linux-container-v1", + "platform": "linux", + "arch": "x64", + "nodeMajor": 24, + "cpuQuotaCores": 2, + "memoryLimitBytes": 4294967296, + "nodeVersion": "v24.19.0", + "cpuModel": "AMD EPYC Processor (with IBPB)", + "visibleLogicalCpus": 10, + "hostVisibleMemoryBytes": 37831741440, + "cgroupCpuMax": "200000 100000", + "cgroupMemoryMax": "4294967296" + }, + "expectedEnvironment": { + "profile": "renderer-linux-container-v1", + "platform": "linux", + "arch": "x64", + "nodeMajor": 24, + "cpuQuotaCores": 2, + "memoryLimitBytes": 4294967296 + }, + "environmentComparison": { + "comparable": true, + "mismatches": [] + }, + "sampling": { + "cliSamples": 12, + "browserSamples": 7, + "minimumSamples": 5, + "noiseFloorMs": 250, + "maxRelativeP95Spread": 1 + }, + "workloads": { + "no-runtime": { + "fixture": "no-runtime.md", + "fixtureSha256": "53068378d5707429795aaba82f864de5064f139364ec35490104e964bfb2d28b", + "sourceBytes": 655, + "coldSampleMs": 851.015055, + "allSamples": { + "samplesMs": [ + 851.015055, + 687.301785, + 656.52416, + 734.415279, + 658.528653, + 692.539923, + 738.83361, + 843.687812, + 807.595147, + 784.690215, + 801.376816, + 764.353577 + ], + "count": 12, + "minMs": 656.52416, + "maxMs": 851.015055, + "p50Ms": 738.83361, + "p95Ms": 851.015055, + "relativeP95Spread": 0.151835871408178, + "disposition": "stable", + "reasons": [] + }, + "warmSamples": { + "samplesMs": [ + 687.301785, + 656.52416, + 734.415279, + 658.528653, + 692.539923, + 738.83361, + 843.687812, + 807.595147, + 784.690215, + 801.376816, + 764.353577 + ], + "count": 11, + "minMs": 656.52416, + "maxMs": 843.687812, + "p50Ms": 738.83361, + "p95Ms": 843.687812, + "relativeP95Spread": 0.14191855998538017, + "disposition": "stable", + "reasons": [] + }, + "timeBudget": { + "limitMs": 2000, + "p95Ms": 851.015055, + "pass": true, + "reasons": [] + }, + "bytes": { + "finalBytes": 39902, + "runtimeBytes": 0, + "assetBytes": 0, + "shellAndContentBytes": 39902, + "outputSha256": "7d068fbece8897a48c2e4e5292d6c37cb4ef4371bfb21baf6de21dabf841d889", + "budget": { + "totalBytes": 39902, + "warningBytes": 131072, + "hardBytes": 196608, + "remainingToHardBytes": 156706, + "status": "pass" + } + }, + "pass": true + }, + "one-chart": { + "fixture": "one-chart.md", + "fixtureSha256": "9e2db7cd4b9ffd197baeb1eb539df779dadca192002769fedbe361e934192e26", + "sourceBytes": 664, + "coldSampleMs": 833.890006, + "allSamples": { + "samplesMs": [ + 833.890006, + 871.725619, + 885.194959, + 786.423978, + 854.793916, + 795.823998, + 773.595365, + 751.316989, + 850.402418, + 767.813518, + 743.59533, + 702.147249 + ], + "count": 12, + "minMs": 702.147249, + "maxMs": 885.194959, + "p50Ms": 786.423978, + "p95Ms": 885.194959, + "relativeP95Spread": 0.12559507818058926, + "disposition": "stable", + "reasons": [] + }, + "warmSamples": { + "samplesMs": [ + 871.725619, + 885.194959, + 786.423978, + 854.793916, + 795.823998, + 773.595365, + 751.316989, + 850.402418, + 767.813518, + 743.59533, + 702.147249 + ], + "count": 11, + "minMs": 702.147249, + "maxMs": 885.194959, + "p50Ms": 786.423978, + "p95Ms": 885.194959, + "relativeP95Spread": 0.12559507818058926, + "disposition": "stable", + "reasons": [] + }, + "timeBudget": { + "limitMs": 5000, + "p95Ms": 885.194959, + "pass": true, + "reasons": [] + }, + "bytes": { + "finalBytes": 622105, + "runtimeBytes": 581244, + "assetBytes": 0, + "shellAndContentBytes": 40861, + "outputSha256": "c4bdcade511fe79368bf7d473c0c196b47b970d905e3eafdb1cbf77cb83a9960", + "budget": { + "totalBytes": 622105, + "warningBytes": 1048576, + "hardBytes": 1572864, + "remainingToHardBytes": 950759, + "status": "pass" + } + }, + "pass": true + }, + "multi-runtime": { + "fixture": "multi-runtime.md", + "fixtureSha256": "bd9ed6d6f370ccc67fadb6b5d44b9a9c1d9c66107f4336de9be283d31b0e9b14", + "sourceBytes": 1120, + "coldSampleMs": 893.988745, + "allSamples": { + "samplesMs": [ + 893.988745, + 868.115776, + 929.151282, + 894.420794, + 849.837884, + 833.724561, + 885.679363, + 959.629345, + 964.345481, + 831.334802, + 823.100566, + 863.60425 + ], + "count": 12, + "minMs": 823.100566, + "maxMs": 964.345481, + "p50Ms": 868.115776, + "p95Ms": 964.345481, + "relativeP95Spread": 0.11084893013164177, + "disposition": "stable", + "reasons": [] + }, + "warmSamples": { + "samplesMs": [ + 868.115776, + 929.151282, + 894.420794, + 849.837884, + 833.724561, + 885.679363, + 959.629345, + 964.345481, + 831.334802, + 823.100566, + 863.60425 + ], + "count": 11, + "minMs": 823.100566, + "maxMs": 964.345481, + "p50Ms": 868.115776, + "p95Ms": 964.345481, + "relativeP95Spread": 0.11084893013164177, + "disposition": "stable", + "reasons": [] + }, + "timeBudget": { + "limitMs": 5000, + "p95Ms": 964.345481, + "pass": true, + "reasons": [] + }, + "bytes": { + "finalBytes": 5311264, + "runtimeBytes": 5269229, + "assetBytes": 0, + "shellAndContentBytes": 42035, + "outputSha256": "ccae51e37954eaeebc1dab15ec4fc3000ff07855946ec997c236a9738d2b718f", + "budget": { + "totalBytes": 5311264, + "warningBytes": 6291456, + "hardBytes": 8388608, + "remainingToHardBytes": 3077344, + "status": "pass" + } + }, + "pass": true + } + }, + "pass": true +} diff --git a/docs/evidence/renderer/goal-3-portable-assets-2026-08-17.md b/docs/evidence/renderer/goal-3-portable-assets-2026-08-17.md new file mode 100644 index 0000000..e74bf05 --- /dev/null +++ b/docs/evidence/renderer/goal-3-portable-assets-2026-08-17.md @@ -0,0 +1,65 @@ +# Goal 3 portable asset evidence — 2026-08-17 + +Scope: implementation evidence for `portable-asset-pipeline` on the Goal 3 branch. This is a +renderer observation, not a supported-platform or accessibility certification. + +## Automated boundary evidence + +Environment: official `node:24-bookworm`, Node 24.19.0, npm 11.7.0, container network disabled. + +- `npm run build`: passed. +- `npm test`: 181/181 passed at the final packet gate; the focused asset suite passed 11/11. +- `npm run check`: all 35 registered structural checks passed. +- `test/assets.test.ts`: contained PNG expansion and hash, explicit decorative semantics, + constrained SVG reconstruction, active SVG refusal, external/traversal/encoded path + refusal, every-symlink refusal, non-regular/missing/MIME mismatch refusal, exact source/count/ + file/final boundaries, descriptor mutation detection, WOFF/WOFF2/TTF/OTF typing, synchronous broken-URL + prevention, plugin no-write refusal, and plugin expanded-byte publication. +- `test/model/asset-pipeline-model.ts`: exhaustively enumerates 32 authority/property masks + across seven byte boundaries. Every result either returns the exact base64 contribution for + a contained stable regular allowlisted sequence or refuses with zero returned source bytes + and zero view-time requests. +- Existing `test/gallery.test.ts` and lifecycle tests retain the footer-expanded 15 MiB refusal + before transactional write. + +Diagnostics carry only a bounded relative path, code, size metadata, and next action. They do +not carry asset bytes. + +## Real offline browser observation + +Fixture: `examples/patterns/portable-mixed.md`, rendered by the built CLI to a 740,456-byte +file containing a local PNG, Vega-Lite chart, table, and decision buttons. + +Harness: `scripts/portable-browser-evidence.ts` against Selenium standalone Chromium 151 at a +1440 × 1600 viewport. CDP network emulation was set offline before navigation. The retained +mixed-page machine-readable report is +[`goal-3-portable-assets-chromium-2026-08-17.json`](goal-3-portable-assets-chromium-2026-08-17.json) +and the retained visual is +[`goal-3-portable-assets-chromium-2026-08-17.png`](goal-3-portable-assets-chromium-2026-08-17.png). +The loaded-font report and visual are +[`goal-3-portable-font-chromium-2026-08-17.json`](goal-3-portable-font-chromium-2026-08-17.json) +and +[`goal-3-portable-font-chromium-2026-08-17.png`](goal-3-portable-font-chromium-2026-08-17.png). + +Observed: + +- document ready and useful chart content in 669 ms in the final single non-benchmark run; +- embedded image complete at natural width 1011 with the expected exact SHA-256; +- one chart visual, one table, and two decision buttons present; +- Enter selected the focused decision button through WebDriver keyboard actions; +- zero browser console entries; +- request inventory contained only the `file:` document and embedded `data:image/png`; zero + HTTP(S) requests occurred; +- on-disk CSP was `default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; + img-src data:; font-src data:; connect-src 'none'`. + +## Embedded-font observation + +After explicit CSP-change approval, a separate temporary fixture embedded the OS-packaged +DejaVu Sans Mono TTF from an explicit read-only font root. The font bytes were not retained in +the repository. Offline Chromium reported the `Artifact Project` font face as `loaded`, used +it as the computed body family, emitted zero console entries, and requested only the `file:` +document plus its `data:font/ttf` resource; HTTP(S) requests remained zero. + +Mobile, keyboard traversal beyond the exercised control, screen reader, and supported-browser +evidence belong to the later Goal 3 accessibility packet and remain unverified. diff --git a/docs/evidence/renderer/goal-3-portable-assets-chromium-2026-08-17.json b/docs/evidence/renderer/goal-3-portable-assets-chromium-2026-08-17.json new file mode 100644 index 0000000..3e555ff --- /dev/null +++ b/docs/evidence/renderer/goal-3-portable-assets-chromium-2026-08-17.json @@ -0,0 +1,30 @@ +{ + "capturedAt": "2026-08-17T15:25:25.160Z", + "browser": "Chromium 151 via selenium/standalone-chromium", + "offlineEmulation": true, + "usefulContentMs": 669, + "observations": { + "activeElement": "decision-opt selected", + "bodyFontFamily": "system-ui, -apple-system, \"Segoe UI\", sans-serif", + "chartVisuals": 1, + "csp": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:; connect-src 'none'", + "decisionButtons": 2, + "fontSetStatus": "loaded", + "imageComplete": true, + "imageHash": "62f848af2e23c26493d3f9e24b6af4e59516e8d9ce1e0fd2b68169dfc98281dc", + "imageNaturalWidth": 1011, + "imageSource": "data:image/png;base64,iVBORw0KGg", + "keyboardSelected": true, + "pageBytes": 753596, + "projectFontFaces": [], + "readyState": "complete", + "tableCaptions": [], + "tables": 1 + }, + "browserLogs": [], + "requestUrls": [ + "file:///evidence/portable-mixed.html", + "data:image/png;base64,[embedded]" + ], + "networkRequestUrls": [] +} diff --git a/docs/evidence/renderer/goal-3-portable-assets-chromium-2026-08-17.png b/docs/evidence/renderer/goal-3-portable-assets-chromium-2026-08-17.png new file mode 100644 index 0000000..6e4a476 Binary files /dev/null and b/docs/evidence/renderer/goal-3-portable-assets-chromium-2026-08-17.png differ diff --git a/docs/evidence/renderer/goal-3-portable-font-chromium-2026-08-17.json b/docs/evidence/renderer/goal-3-portable-font-chromium-2026-08-17.json new file mode 100644 index 0000000..f5afad4 --- /dev/null +++ b/docs/evidence/renderer/goal-3-portable-font-chromium-2026-08-17.json @@ -0,0 +1,35 @@ +{ + "capturedAt": "2026-08-17T15:25:13.232Z", + "browser": "Chromium 151 via selenium/standalone-chromium", + "offlineEmulation": true, + "usefulContentMs": 629, + "observations": { + "activeElement": "", + "bodyFontFamily": "\"Artifact Project\", system-ui, -apple-system, \"Segoe UI\", sans-serif", + "chartVisuals": 0, + "csp": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:; connect-src 'none'", + "decisionButtons": 0, + "fontSetStatus": "loaded", + "imageComplete": false, + "imageHash": null, + "imageNaturalWidth": 0, + "imageSource": null, + "keyboardSelected": false, + "pageBytes": 489511, + "projectFontFaces": [ + { + "family": "Artifact Project", + "status": "loaded" + } + ], + "readyState": "complete", + "tableCaptions": [], + "tables": 1 + }, + "browserLogs": [], + "requestUrls": [ + "file:///evidence/portable-font.html", + "data:font/ttf;base64,[embedded]" + ], + "networkRequestUrls": [] +} diff --git a/docs/evidence/renderer/goal-3-portable-font-chromium-2026-08-17.png b/docs/evidence/renderer/goal-3-portable-font-chromium-2026-08-17.png new file mode 100644 index 0000000..2fd8bac Binary files /dev/null and b/docs/evidence/renderer/goal-3-portable-font-chromium-2026-08-17.png differ diff --git a/docs/redistribution-inventory.json b/docs/redistribution-inventory.json index e56dcbf..b0787a0 100644 --- a/docs/redistribution-inventory.json +++ b/docs/redistribution-inventory.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "reviewedAt": "2026-08-16", + "reviewedAt": "2026-08-17", "repositoryLicense": "MIT", "authoredScopes": [ { "path": ".github/", "provenance": "repository-authored", "license": "MIT" }, @@ -210,6 +210,69 @@ "attribution": "opencode-artifacts contributors", "status": "approved" }, + { + "path": "docs/evidence/renderer/goal-3-accessibility-desktop-2026-08-17.png", + "sha256": "c0196d055b365b98e5ddc92d7719180956bacccfc5950693d4402b76d8bbc9e9", + "provenance": "repository-generated browser capture", + "source": "examples/patterns/accessibility-rtl.md rendered in Chromium 151 at desktop width", + "license": "MIT", + "attribution": "opencode-artifacts contributors", + "status": "approved" + }, + { + "path": "docs/evidence/renderer/goal-3-accessibility-mobile-reduced-2026-08-17.png", + "sha256": "67a442c420494c9ed75e53c64ffd839ee0a59622f802b5b813f43495bd0af80f", + "provenance": "repository-generated browser capture", + "source": "examples/patterns/accessibility-rtl.md rendered in Chromium 151 at mobile width with dark and reduced-motion preferences", + "license": "MIT", + "attribution": "opencode-artifacts contributors", + "status": "approved" + }, + { + "path": "docs/evidence/renderer/goal-3-accessibility-zoom-200-2026-08-17.png", + "sha256": "b1134713a6c63f842f9bd267a182a76cf50c50a53ea3732bd596c52881495645", + "provenance": "repository-generated browser capture", + "source": "examples/patterns/accessibility-rtl.md rendered in Chromium 151 at a 200%-equivalent CSS viewport", + "license": "MIT", + "attribution": "opencode-artifacts contributors", + "status": "approved" + }, + { + "path": "docs/evidence/renderer/goal-3-design-tokens-desktop-2026-08-17.png", + "sha256": "84ebacab7a6258b611aa73973ad9ca9f1da29eb88b8f4498b4d5e64111f78e84", + "provenance": "repository-generated browser capture", + "source": "examples/patterns/design-tokens.md rendered offline in Chromium 151 at desktop width", + "license": "MIT", + "attribution": "opencode-artifacts contributors", + "status": "approved" + }, + { + "path": "docs/evidence/renderer/goal-3-design-tokens-mobile-2026-08-17.png", + "sha256": "6f7d64d0183cbd7632dea94f73aedf3cd0a1263b9fc16bb848df3cfd90badaad", + "provenance": "repository-generated browser capture", + "source": "examples/patterns/design-tokens.md rendered offline in Chromium 151 at narrow width", + "license": "MIT", + "attribution": "opencode-artifacts contributors", + "status": "approved" + }, + { + "path": "docs/evidence/renderer/goal-3-portable-assets-chromium-2026-08-17.png", + "sha256": "de64548e9c0e40b594f31b3bc84cb09d261ecc73c3dd5b756ecd5ac74a7a529d", + "provenance": "repository-generated browser capture", + "source": "examples/patterns/portable-mixed.md rendered offline in Chromium 151", + "license": "MIT", + "attribution": "opencode-artifacts contributors", + "status": "approved" + }, + { + "path": "docs/evidence/renderer/goal-3-portable-font-chromium-2026-08-17.png", + "sha256": "98ab1a439c223319cf6b31c85790429751559cd448eb0ff35d640c53ebf3e97a", + "provenance": "repository-generated browser capture", + "source": "temporary offline fixture using the OS-packaged DejaVu Sans Mono font; font bytes are not retained", + "license": "MIT", + "attribution": "opencode-artifacts contributors", + "status": "approved" + }, { "path": "docs/evidence/theme-toggle-dark.png", "sha256": "b495dfbb3bc4a8c5b3e7a445cd63e5eb32f3e59ff3f857f0c1d087796033b742", diff --git a/docs/redistribution-policy.md b/docs/redistribution-policy.md index ef06ebb..3659c2d 100644 --- a/docs/redistribution-policy.md +++ b/docs/redistribution-policy.md @@ -1,6 +1,6 @@ # Redistribution and attribution inventory -Policy version: 1. Last reviewed: 2026-08-16. +Policy version: 1. Last reviewed: 2026-08-17. The machine-readable [`redistribution-inventory.json`](redistribution-inventory.json) is the complete Phase 0 disposition for repository documentation, examples, retained binary assets, @@ -9,7 +9,9 @@ documentation, fixtures, skills, tests, and generated screenshots are distribute root [MIT license](../LICENSE). Every retained binary asset is bound to exact bytes by SHA-256 and names its source and attribution. -No font file is embedded or redistributed. The renderer uses system font-family fallbacks. +No font file is retained or redistributed by this repository. The renderer uses system +fallbacks by default; a user-declared worktree font may be embedded in that user's generated +page and remains subject to the user's own redistribution authority. Runtime dependency terms and the three exceptional branch choices are governed by [`license-dispositions.json`](license-dispositions.json) and the exact candidate evidence in [`renderer-remediation-2026-08-16.md`](evidence/governance/renderer-remediation-2026-08-16.md). diff --git a/docs/roadmap.md b/docs/roadmap.md index 1c2d985..e7b0c3a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -210,6 +210,13 @@ pairs are rated OpenCode equivalent or better, no task family loses a reviewer m OpenCode meets or exceeds Claude's median in every rubric dimension while scoring at least 4/5 absolutely. +Status: **Goal 3's Phase 2 correctness track passed on 2026-08-18**. The portable asset, +declarative preflight, bounded design-token, accessibility/internationalization, and renderer +performance packets are archived with offline browser, keyboard/mobile-width, manual +screen-reader, byte-boundary, and percentile evidence. Phase 2 as a whole remains incomplete: +the corpus expansion and comparative-quality work in items 8–12, including authorized Claude +runs and independent reviewers, belongs to Goal 5 and retains its external-input blockers. + ## Phase 3 — Native OpenCode lifecycle Goal: make artifact behavior feel built into OpenCode rather than merely callable. diff --git a/docs/security.md b/docs/security.md index c411283..ba33e65 100644 --- a/docs/security.md +++ b/docs/security.md @@ -41,3 +41,10 @@ For a suspected credential or release compromise: Never paste tokens, private advisory content, raw participant data, or private artifacts into public issues, diagnostics, fixtures, or release evidence. Secret scanning reduces accidental exposure but is not exhaustive; final audience-bound bytes and staged metadata require review. + +Markdown design configuration is data-only. The renderer reads at most the documented 8 KiB +project token file without following its file or parent-directory symlinks, accepts one +versioned prompt fence, validates fixed names/types and contrast atomically, and emits only +fixed CSS-variable slots. It never accepts selectors, declarations, URLs, markup, imports, +expressions, arbitrary font stacks, or raw CSS; trusted HTML remains a separately disclosed +permission mode. diff --git a/examples/patterns/accessibility-rtl.md b/examples/patterns/accessibility-rtl.md new file mode 100644 index 0000000..8ae2e8e --- /dev/null +++ b/examples/patterns/accessibility-rtl.md @@ -0,0 +1,42 @@ +--- +title: مراجعة الإشارات +description: RTL accessibility, locale, keyboard, chart, table, and comment fixture +lang: ar +dir: rtl +locale: ar-EG +timezone: Asia/Riyadh +--- +# مراجعة الإشارات + +هذه صفحة تحقق ثابتة لقراءة المحتوى العربي والتنقل بلوحة المفاتيح. + +> [!NOTE] الملخص واضح ولا يعتمد على اللون وحده. + +## حالة المراجعة + +- [x] اكتملت مراجعة البيانات +- [ ] بقيت مراجعة التعليقات + +```progress +{"label":"تقدم المراجعة","done":3,"total":4} +``` + +## اتجاه الإشارة + +```echarts +{"description":"ترتفع الإشارة من ثلاث نقاط إلى خمس نقاط خلال يومين.","xAxis":{"type":"category","data":["الاثنين","الثلاثاء"]},"yAxis":{"type":"value"},"series":[{"type":"line","data":[3,5]}]} +``` + +## سجل القياسات + +```table +{"caption":"سجل الإشارات","columns":[{"key":"label","label":"الفئة"},{"key":"count","label":"العدد","type":"num"},{"key":"captured","label":"وقت الالتقاط","type":"datetime"}],"rows":[{"label":"ألفا","count":1234.5,"captured":"2026-08-17T15:00:00Z"},{"label":"بيتا","count":987.25,"captured":"2026-08-18T06:30:00+03:00"}]} +``` + +## القرار التالي + +```decisions +{"title":"قرار النشر","questions":[{"id":"next","question":"ما الخطوة التالية؟","options":[{"id":"ship","label":"نشر","note":"بعد اكتمال المراجعة"},{"id":"hold","label":"انتظار"}]}]} +``` + +يمكن إضافة تعليق على الصفحة من زر التعليقات عند تشغيل الخادم المحلي. diff --git a/examples/patterns/dashboard.md b/examples/patterns/dashboard.md index ed897e0..e413444 100644 --- a/examples/patterns/dashboard.md +++ b/examples/patterns/dashboard.md @@ -18,6 +18,7 @@ A week of deploy failures across all services, with the two hotspots called out. ```vega-lite { + "description": "Daily failures peak at 14 on Wednesday and fall to 5 by Saturday before ending at 7.", "data": { "values": [ { "day": "Mon", "failures": 6 }, { "day": "Tue", "failures": 9 }, { "day": "Wed", "failures": 14 }, { "day": "Thu", "failures": 12 }, diff --git a/examples/patterns/design-tokens.md b/examples/patterns/design-tokens.md new file mode 100644 index 0000000..356c6a3 --- /dev/null +++ b/examples/patterns/design-tokens.md @@ -0,0 +1,49 @@ +--- +title: Signal Review +icon: ◈ +description: A bounded project visual system applied without raw CSS. +theme: report +--- +```design-tokens +{"schemaVersion":1,"tokens":{"pageBackground":"#f5f1ff","surface":"#ffffff","text":"#211735","mutedText":"#5e5074","border":"#d9d0e8","accent":"#6d28d9","font":"serif","spacing":"spacious","radius":"soft","density":"airy"}} +``` + +# Signal Review + +The explicit document tokens outrank the report theme while retaining a fixed, offline +renderer and validated contrast. + +```stats +[ + {"label":"Qualified signals","value":"148","delta":"+19%","direction":"up","tone":"good","emphasis":true}, + {"label":"Needs review","value":"23","delta":"-8%","direction":"down","tone":"warn"}, + {"label":"Confidence","value":"92%","delta":"high","tone":"neutral"} +] +``` + +## Readout + +```callout +{"tone":"info","title":"The system stays declarative","body":"Colors, type, spacing, radius, and density flow through allowlisted slots. Selectors, URLs, markup, imports, and executable expressions never enter the page CSS."} +``` + +## Provenance + +```table +{ + "caption":"Effective design decisions", + "columns":[ + {"key":"layer","label":"Layer"}, + {"key":"decision","label":"Decision"}, + {"key":"source","label":"Winning source"} + ], + "rows":[ + {"layer":"Color","decision":"Violet accent on lavender canvas","source":"Prompt fence"}, + {"layer":"Spacing","decision":"Spacious rhythm","source":"Prompt fence"}, + {"layer":"Structure","decision":"Fixed responsive renderer","source":"Built-in"} + ] +} +``` + +> [!NOTE] +> The generated page records token provenance in metadata and makes no view-time request. diff --git a/examples/patterns/funnel-analysis.md b/examples/patterns/funnel-analysis.md index 0c34829..92250a1 100644 --- a/examples/patterns/funnel-analysis.md +++ b/examples/patterns/funnel-analysis.md @@ -18,6 +18,7 @@ theme: report ```vega-lite { + "description": "Conversion holds near 33 percent before falling to roughly 24 percent after the March 19 release.", "data": { "values": [ {"day": "Mar 11", "rate": 33.4}, {"day": "Mar 12", "rate": 33.1}, {"day": "Mar 13", "rate": 33.8}, {"day": "Mar 14", "rate": 32.9}, {"day": "Mar 15", "rate": 33.5}, {"day": "Mar 16", "rate": 33.2}, diff --git a/examples/patterns/incident.md b/examples/patterns/incident.md index 6f978d9..55ac92b 100644 --- a/examples/patterns/incident.md +++ b/examples/patterns/incident.md @@ -27,6 +27,7 @@ icon: 🚨 ```echarts { + "description": "Server errors spike from 4 to 63 per minute at 14:05, then recover to 3 by 14:30.", "xAxis": { "type": "category", "data": ["13:50", "13:55", "14:00", "14:05", "14:10", "14:15", "14:20", "14:25", "14:30"] }, "yAxis": { "type": "value", "name": "5xx / min" }, "series": [{ "type": "line", "areaStyle": {}, "smooth": true, "data": [2, 4, 41, 63, 58, 47, 22, 9, 3] }] diff --git a/examples/patterns/plan.md b/examples/patterns/plan.md index 4d9ba37..eb31bef 100644 --- a/examples/patterns/plan.md +++ b/examples/patterns/plan.md @@ -22,6 +22,7 @@ Move session storage from Redis to Postgres over three deploys, keeping both sto ## Schema sketch ```mermaid +%% summary: Sessions contain events and both entities carry stable identifiers and timestamps. erDiagram SESSION ||--o{ EVENT : has SESSION { diff --git a/examples/patterns/portable-mixed.md b/examples/patterns/portable-mixed.md new file mode 100644 index 0000000..f7d34fd --- /dev/null +++ b/examples/patterns/portable-mixed.md @@ -0,0 +1,65 @@ +--- +title: Portable release pulse +icon: 🧳 +description: Offline mixed-content fixture for the portable renderer gate +source: repository-authored synthetic release data, 2026-08-17 +--- + +This fixture combines a contained local image, a chart, a semantic table, and keyboard-operable +controls in one strict-CSP file. + +![Deploy-failure dashboard used as a contained local image](docs/evidence/patterns/dashboard.png) + +## Weekly readiness + +```vega-lite +{ + "description": "Readiness rises steadily from 61 on Monday to 92 on Friday.", + "data": { "values": [ + { "day": "Mon", "ready": 61 }, + { "day": "Tue", "ready": 68 }, + { "day": "Wed", "ready": 76 }, + { "day": "Thu", "ready": 84 }, + { "day": "Fri", "ready": 92 } + ]}, + "mark": { "type": "line", "point": true, "color": "#6d6bd6" }, + "encoding": { + "x": { "field": "day", "type": "ordinal", "title": null }, + "y": { "field": "ready", "type": "quantitative", "title": "readiness %", "scale": { "domain": [0, 100] } } + } +} +``` + +```table +{ + "caption": "Portable gate checks", + "columns": [ + { "key": "surface", "label": "Surface" }, + { "key": "result", "label": "Result" }, + { "key": "bytes", "label": "Bytes", "type": "num" } + ], + "rows": [ + { "surface": "Local image", "result": "embedded", "bytes": 1 }, + { "surface": "Chart runtime", "result": "conditional", "bytes": 2 }, + { "surface": "Viewer requests", "result": "zero", "bytes": 0 } + ] +} +``` + +## Release decision + +```decisions +{ + "title": "Portable candidate", + "questions": [ + { + "id": "candidate", + "question": "Does this candidate preserve the offline contract?", + "options": [ + { "id": "yes", "label": "Yes", "note": "Record the browser evidence" }, + { "id": "no", "label": "No", "note": "Keep the gate failed" } + ] + } + ] +} +``` diff --git a/examples/patterns/tune-controls.md b/examples/patterns/tune-controls.md index f9f5834..cbd7dd4 100644 --- a/examples/patterns/tune-controls.md +++ b/examples/patterns/tune-controls.md @@ -9,6 +9,7 @@ Drag the sliders: the charts update live. When the shape looks right, copy the v ```vega-lite { + "description": "Interactive controls change the frequency and amplitude of the displayed sine wave.", "params": [ { "name": "freq", "value": 2, "bind": { "input": "range", "min": 0.5, "max": 8, "step": 0.5, "name": "Frequency " } }, { "name": "amp", "value": 1, "bind": { "input": "range", "min": 0.2, "max": 3, "step": 0.2, "name": "Amplitude " } } @@ -27,6 +28,7 @@ Drag the sliders: the charts update live. When the shape looks right, copy the v ```echarts { + "description": "The twelve-week series rises overall from 12 to 40 with several temporary dips.", "xAxis": { "type": "category", "data": ["W1","W2","W3","W4","W5","W6","W7","W8","W9","W10","W11","W12"] }, "yAxis": { "type": "value" }, "dataZoom": [{ "type": "slider" }], diff --git a/scripts/accessibility-browser-evidence.ts b/scripts/accessibility-browser-evidence.ts new file mode 100644 index 0000000..74191ab --- /dev/null +++ b/scripts/accessibility-browser-evidence.ts @@ -0,0 +1,257 @@ +import { writeFile } from "node:fs/promises"; + +interface WebDriverResponse { + value: T; +} + +interface BrowserLog { + level: string; + message: string; + timestamp: number; +} + +interface PerformanceLog { + message: string; +} + +interface AxNode { + role?: { value?: string }; + name?: { value?: string }; + ignored?: boolean; +} + +const endpoint = process.argv[2] ?? "http://127.0.0.1:4444"; +const pageUrl = process.argv[3]; +const screenshotPath = process.argv[4]; +const reportPath = process.argv[5]; +const viewportWidth = Number(process.argv[6] ?? 1440); +const viewportHeight = Number(process.argv[7] ?? 1200); +const colorScheme = process.argv[8] ?? "light"; +const reducedMotion = process.argv[9] ?? "no-preference"; +const zoomPercent = Number(process.argv[10] ?? 100); + +if (!pageUrl || !screenshotPath || !reportPath) { + throw new Error("usage: accessibility-browser-evidence [width] [height] [light|dark] [reduce|no-preference] [100|200]"); +} +if (!Number.isInteger(viewportWidth) || !Number.isInteger(viewportHeight) || viewportWidth < 320 || viewportHeight < 480) throw new Error("viewport is invalid"); +if (!new Set(["light", "dark"]).has(colorScheme)) throw new Error("color scheme is invalid"); +if (!new Set(["reduce", "no-preference"]).has(reducedMotion)) throw new Error("reduced motion is invalid"); +if (!new Set([100, 200]).has(zoomPercent)) throw new Error("zoom must be 100 or 200"); + +async function request(method: string, path: string, body?: unknown): Promise { + const response = await fetch(`${endpoint}${path}`, { + method, + headers: body === undefined ? undefined : { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const responseText = await response.text(); + if (!response.ok) throw new Error(`WebDriver ${method} ${path} failed (${response.status}): ${responseText.slice(0, 1000)}`); + return (JSON.parse(responseText) as WebDriverResponse).value; +} + +const session = await request<{ sessionId: string }>("POST", "/session", { + capabilities: { + alwaysMatch: { + browserName: "chrome", + "goog:loggingPrefs": { browser: "ALL", performance: "ALL" }, + "goog:chromeOptions": { + args: ["--headless=new", "--no-sandbox", "--disable-dev-shm-usage"], + }, + }, + }, +}); +const route = `/session/${session.sessionId}`; + +async function script(source: string): Promise { + return request("POST", `${route}/execute/sync`, { script: source, args: [] }); +} + +async function keys(values: string[]): Promise { + await request("POST", `${route}/actions`, { + actions: [{ + type: "key", + id: "keyboard", + actions: values.flatMap((value) => [{ type: "keyDown", value }, { type: "keyUp", value }]), + }], + }); +} + +async function focused(selector: string): Promise { + return script(`const node=document.querySelector(${JSON.stringify(selector)});if(node)node.focus();return !!node;`); +} + +try { + await request("POST", `${route}/window/rect`, { width: viewportWidth, height: viewportHeight, x: 0, y: 0 }); + await request("POST", `${route}/goog/cdp/execute`, { cmd: "Network.enable", params: {} }); + await request("POST", `${route}/goog/cdp/execute`, { + cmd: "Emulation.setEmulatedMedia", + params: { + media: "screen", + features: [ + { name: "prefers-color-scheme", value: colorScheme }, + { name: "prefers-reduced-motion", value: reducedMotion }, + ], + }, + }); + + const startedAt = Date.now(); + await request("POST", `${route}/url`, { url: pageUrl }); + let ready = false; + for (let attempt = 0; attempt < 120; attempt++) { + ready = await script("return document.readyState==='complete'&&!!document.querySelector('.chart canvas,.chart svg');"); + if (ready) break; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + const usefulContentMs = Date.now() - startedAt; + + await script("document.activeElement&&document.activeElement.blur();return true;"); + await keys(["\uE004"]); + const skipFocus = await script("return document.activeElement?.className||'';"); + await keys(["\uE007"]); + const skipTargetFocus = await script("return document.activeElement?.id||'';"); + + await focused(".decision-opt"); + await keys(["\uE014"]); + const decisionTrace = await script>(` + const selected=document.querySelector('.decision-opt[aria-checked="true"]'); + return {selected:selected?.getAttribute('data-option')||null,active:document.activeElement?.getAttribute('data-option')||null,checked:selected?.getAttribute('aria-checked')||null}; + `); + + await focused(".th-sort"); + await keys(["\uE007"]); + const tableSortTrace = await script>(` + const sorted=document.querySelector('th[aria-sort]:not([aria-sort="none"])'); + return {label:sorted?.textContent?.trim()||null,direction:sorted?.getAttribute('aria-sort')||null}; + `); + + await focused(".comment-launcher"); + await keys(["\uE007"]); + const commentDialogFocus = await script("return document.activeElement?.id||'';"); + await keys(["\uE00C"]); + const commentEscapeFocus = await script("return document.activeElement?.className||'';"); + await keys(["\uE007"]); + await keys(Array.from("تعليق لوحة المفاتيح")); + await keys(["\uE004", "\uE007"]); + for (let attempt = 0; attempt < 40; attempt++) { + const saved = await script("return !!document.querySelector('.comment:not(.comment-empty)');"); + if (saved) break; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + const commentSaveTrace = await script>(`return { + count:document.querySelectorAll('.comment:not(.comment-empty)').length, + active:document.activeElement?.className||'', + dialogOpen:!!document.querySelector('[role="dialog"]') + };`); + + await focused(".theme-toggle"); + await keys(["\uE007"]); + const themeTrace = await script>(`return { + state:document.documentElement.getAttribute('data-theme')||'system', + pressed:document.querySelector('.theme-toggle')?.getAttribute('aria-pressed')||null, + label:document.querySelector('.theme-toggle')?.getAttribute('aria-label')||null + };`); + + const preZoomWidth = await script("return innerWidth;"); + if (zoomPercent === 200) { + await request("POST", `${route}/goog/cdp/execute`, { + cmd: "Emulation.setDeviceMetricsOverride", + params: { + width: Math.floor(viewportWidth / 2), + height: Math.floor(viewportHeight / 2), + deviceScaleFactor: 2, + mobile: false, + screenWidth: viewportWidth, + screenHeight: viewportHeight, + }, + }); + } + + const observations = await script>(` + const root=getComputedStyle(document.documentElement); + const progress=document.querySelector('[role="progressbar"]'); + const chart=document.querySelector('.chart[role="img"]'); + const table=document.querySelector('table'); + const caption=table?.querySelector('caption'); + const radios=Array.from(document.querySelectorAll('[role="radio"]')); + const visible=(selector)=>{const node=document.querySelector(selector);return !!node&&getComputedStyle(node).display!=='none'&&getComputedStyle(node).visibility!=='hidden';}; + const audit=[]; + if(document.documentElement.lang!=='ar')audit.push('html language'); + if(document.documentElement.dir!=='rtl')audit.push('html direction'); + if(!document.querySelector('main'))audit.push('main landmark'); + if(!document.querySelector('.skip-link'))audit.push('skip link'); + if(!chart?.getAttribute('aria-labelledby'))audit.push('chart description'); + if(!caption?.textContent?.trim())audit.push('table caption'); + if(!document.querySelector('label[for="component-1-filter"]'))audit.push('table filter label'); + if(!progress?.getAttribute('aria-valuenow'))audit.push('progress state'); + if(radios.some((node)=>!node.getAttribute('aria-checked')))audit.push('radio state'); + return { + readyState:document.readyState, + lang:document.documentElement.lang, + dir:document.documentElement.dir, + locale:document.documentElement.dataset.locale, + timezone:document.documentElement.dataset.timezone, + viewport:{width:innerWidth,height:innerHeight,devicePixelRatio:devicePixelRatio,preZoomWidth:${preZoomWidth},requestedZoom:${zoomPercent},method:${zoomPercent === 200 ? "'Chromium device metrics: half CSS viewport at 2 physical pixels per CSS pixel'" : "'native 100% viewport'"}}, + horizontalOverflow:document.documentElement.scrollWidth>document.documentElement.clientWidth, + reducedMotion:matchMedia('(prefers-reduced-motion: reduce)').matches, + colorScheme:matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light', + computedColors:{page:root.getPropertyValue('--page-bg').trim(),surface:root.getPropertyValue('--card-bg').trim(),text:root.getPropertyValue('--ink').trim(),accent:root.getPropertyValue('--accent').trim()}, + landmarks:{main:document.querySelectorAll('main').length,header:document.querySelectorAll('header').length,footer:document.querySelectorAll('footer').length,aside:document.querySelectorAll('aside').length}, + headings:Array.from(document.querySelectorAll('h1,h2,[role="heading"]')).map((node)=>({level:node.tagName==='H1'?1:node.tagName==='H2'?2:node.getAttribute('aria-level'),name:node.textContent?.trim()})), + chartSummary:document.querySelector('.chart-summary')?.textContent?.trim()||null, + tableCaption:caption?.textContent?.trim()||null, + tableCount:document.querySelector('.table-count')?.textContent?.trim()||null, + radioStates:radios.map((node)=>({name:node.textContent?.trim(),checked:node.getAttribute('aria-checked'),tabindex:node.getAttribute('tabindex')})), + progress:{name:progress?.getAttribute('aria-label')||null,now:progress?.getAttribute('aria-valuenow')||null,max:progress?.getAttribute('aria-valuemax')||null}, + focusOutline:getComputedStyle(document.querySelector('.theme-toggle')).outlineStyle, + animationDuration:getComputedStyle(document.querySelector('.progress-fill')).animationDuration, + interactiveVisible:{theme:visible('.theme-toggle'),comments:visible('.comment-launcher'),filter:visible('.table-filter')}, + audit + }; + `); + + const axResult = await request<{ nodes?: AxNode[] }>("POST", `${route}/goog/cdp/execute`, { + cmd: "Accessibility.getFullAXTree", + params: {}, + }); + const retainedRoles = new Set(["RootWebArea", "banner", "main", "contentinfo", "heading", "note", "figure", "image", "table", "caption", "progressbar", "radiogroup", "radio", "button", "textbox", "status"]); + const accessibilityTree = (axResult.nodes ?? []) + .filter((node) => !node.ignored && retainedRoles.has(node.role?.value ?? "")) + .map((node) => ({ role: node.role?.value ?? "", name: node.name?.value ?? "" })) + .slice(0, 120); + + const screenshot = await request("GET", `${route}/screenshot`); + await writeFile(screenshotPath, Buffer.from(screenshot, "base64")); + + await request("POST", `${route}/goog/cdp/execute`, { cmd: "Emulation.setEmulatedMedia", params: { media: "print" } }); + const printObservation = await script>(` + const hidden=(selector)=>{const node=document.querySelector(selector);return !node||getComputedStyle(node).display==='none';}; + return {themeHidden:hidden('.theme-toggle'),commentsHidden:hidden('.comment-launcher'),filterHidden:hidden('.table-filter'),pageBackground:getComputedStyle(document.body).backgroundColor}; + `); + + const browserLogs = await request("POST", `${route}/log`, { type: "browser" }); + const performanceLogs = await request("POST", `${route}/log`, { type: "performance" }); + const rawRequestUrls = performanceLogs.flatMap((entry) => { + const parsed = JSON.parse(entry.message) as { message?: { method?: string; params?: { request?: { url?: string } } } }; + const url = parsed.message?.method === "Network.requestWillBeSent" ? parsed.message.params?.request?.url : undefined; + return url === undefined ? [] : [url]; + }); + const origin = new URL(pageUrl).origin; + const report = { + capturedAt: new Date().toISOString(), + browser: "Chromium 151 via selenium/standalone-chromium", + fixture: pageUrl, + requested: { viewportWidth, viewportHeight, colorScheme, reducedMotion, zoomPercent }, + usefulContentMs, + keyboard: { skipFocus, skipTargetFocus, decisionTrace, tableSortTrace, commentDialogFocus, commentEscapeFocus, commentSaveTrace, themeTrace }, + observations, + accessibilityTree, + printObservation, + browserLogs, + requestUrls: rawRequestUrls, + externalHttpRequests: rawRequestUrls.filter((url) => /^https?:/i.test(url) && new URL(url).origin !== origin), + }; + await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + console.log(JSON.stringify(report, null, 2)); +} finally { + await request("DELETE", route); +} diff --git a/scripts/portable-browser-evidence.ts b/scripts/portable-browser-evidence.ts new file mode 100644 index 0000000..4db7847 --- /dev/null +++ b/scripts/portable-browser-evidence.ts @@ -0,0 +1,146 @@ +import { writeFile } from "node:fs/promises"; + +interface WebDriverResponse { + value: T; +} + +const endpoint = process.argv[2] ?? "http://127.0.0.1:4444"; +const pageUrl = process.argv[3]; +const screenshotPath = process.argv[4]; +const reportPath = process.argv[5]; +const viewportWidth = Number(process.argv[6] ?? 1440); +const viewportHeight = Number(process.argv[7] ?? 1600); +if (!pageUrl || !screenshotPath || !reportPath) { + throw new Error("usage: portable-browser-evidence [width] [height]"); +} +if (!Number.isInteger(viewportWidth) || !Number.isInteger(viewportHeight) || viewportWidth < 320 || viewportHeight < 480) throw new Error("viewport is invalid"); + +async function request(method: string, path: string, body?: unknown): Promise { + const response = await fetch(`${endpoint}${path}`, { + method, + headers: body === undefined ? undefined : { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await response.text(); + if (!response.ok) throw new Error(`WebDriver ${method} ${path} failed (${response.status}): ${text.slice(0, 1000)}`); + return (JSON.parse(text) as WebDriverResponse).value; +} + +const session = await request<{ sessionId: string }>("POST", "/session", { + capabilities: { + alwaysMatch: { + browserName: "chrome", + "goog:loggingPrefs": { browser: "ALL", performance: "ALL" }, + "goog:chromeOptions": { + args: ["--headless=new", "--no-sandbox", "--disable-dev-shm-usage", "--allow-file-access-from-files"], + }, + }, + }, +}); +const sessionId = session.sessionId; +const route = `/session/${sessionId}`; + +try { + await request("POST", `${route}/window/rect`, { width: viewportWidth, height: viewportHeight, x: 0, y: 0 }); + await request("POST", `${route}/goog/cdp/execute`, { + cmd: "Network.enable", + params: {}, + }); + await request("POST", `${route}/goog/cdp/execute`, { + cmd: "Network.emulateNetworkConditions", + params: { offline: true, latency: 0, downloadThroughput: 0, uploadThroughput: 0 }, + }); + const startedAt = Date.now(); + await request("POST", `${route}/url`, { url: pageUrl }); + let ready = false; + for (let attempt = 0; attempt < 100; attempt++) { + ready = await request("POST", `${route}/execute/sync`, { + script: "return document.readyState === 'complete' && (!document.querySelector('.chart') || !!document.querySelector('.chart svg, .chart canvas'));", + args: [], + }); + if (ready) break; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + const usefulContentMs = Date.now() - startedAt; + await request("POST", `${route}/execute/async`, { + script: "const done = arguments[arguments.length - 1]; document.fonts.ready.then(() => done(true), () => done(false));", + args: [], + }); + const hasDecision = await request("POST", `${route}/execute/sync`, { + script: "const button = document.querySelector('.decision-opt'); if (button) button.focus(); return !!button;", + args: [], + }); + if (hasDecision) { + await request("POST", `${route}/actions`, { + actions: [{ + type: "key", + id: "keyboard", + actions: [ + { type: "keyDown", value: "\uE007" }, + { type: "keyUp", value: "\uE007" }, + ], + }], + }); + } + const observations = await request>("POST", `${route}/execute/sync`, { + script: ` + const image = document.querySelector('img[data-asset-sha256]'); + const button = document.querySelector('.decision-opt'); + return { + readyState: document.readyState, + imageComplete: !!image && image.complete, + imageNaturalWidth: image ? image.naturalWidth : 0, + imageSource: image ? image.getAttribute('src').slice(0, 32) : null, + imageHash: image ? image.dataset.assetSha256 : null, + chartVisuals: document.querySelectorAll('.chart svg, .chart canvas').length, + tables: document.querySelectorAll('table').length, + tableCaptions: Array.from(document.querySelectorAll('table caption')).map((node) => node.textContent), + decisionButtons: document.querySelectorAll('.decision-opt').length, + keyboardSelected: !!button && button.classList.contains('selected'), + activeElement: document.activeElement ? document.activeElement.className : null, + csp: document.querySelector('meta[http-equiv="Content-Security-Policy"]')?.getAttribute('content'), + fontSetStatus: document.fonts.status, + projectFontFaces: Array.from(document.fonts).filter((face) => face.family.includes('Artifact Project')).map((face) => ({ family: face.family, status: face.status })), + bodyFontFamily: getComputedStyle(document.body).fontFamily, + viewport: { width: innerWidth, height: innerHeight }, + horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth, + designTokens: document.documentElement.hasAttribute('data-design-tokens'), + pageTheme: document.documentElement.getAttribute('data-page-theme'), + designProvenance: document.querySelector('meta[name="artifact-design-provenance"]')?.getAttribute('content'), + computedDesign: { + pageBackground: getComputedStyle(document.documentElement).getPropertyValue('--page-bg').trim(), + surface: getComputedStyle(document.documentElement).getPropertyValue('--card-bg').trim(), + text: getComputedStyle(document.documentElement).getPropertyValue('--ink').trim(), + accent: getComputedStyle(document.documentElement).getPropertyValue('--accent').trim(), + radius: getComputedStyle(document.documentElement).getPropertyValue('--radius').trim(), + }, + pageBytes: new TextEncoder().encode(document.documentElement.outerHTML).length, + }; + `, + args: [], + }); + const browserLogs = await request>("POST", `${route}/log`, { type: "browser" }); + const performanceLogs = await request>("POST", `${route}/log`, { type: "performance" }); + const rawRequestUrls = performanceLogs.flatMap((entry) => { + const parsed = JSON.parse(entry.message) as { message?: { method?: string; params?: { request?: { url?: string } } } }; + const url = parsed.message?.method === "Network.requestWillBeSent" ? parsed.message.params?.request?.url : undefined; + return url === undefined ? [] : [url]; + }); + const requestUrls = rawRequestUrls.map((url) => url.startsWith("data:") ? `${url.slice(0, url.indexOf(",") + 1)}[embedded]` : url); + const screenshot = await request("GET", `${route}/screenshot`); + await writeFile(screenshotPath, Buffer.from(screenshot, "base64")); + const report = { + capturedAt: new Date().toISOString(), + browser: "Chromium 151 via selenium/standalone-chromium", + offlineEmulation: true, + usefulContentMs, + observations, + browserLogs, + requestUrls, + networkRequestUrls: rawRequestUrls.filter((url) => /^https?:/i.test(url)), + }; + await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + console.log(JSON.stringify(report, null, 2)); +} finally { + await request("DELETE", route); +} diff --git a/scripts/renderer-browser-benchmark.ts b/scripts/renderer-browser-benchmark.ts new file mode 100644 index 0000000..2167427 --- /dev/null +++ b/scripts/renderer-browser-benchmark.ts @@ -0,0 +1,254 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { arch, platform } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { + compareRendererEnvironment, + evaluateBrowserBudget, + evaluateByteBudget, + summarizeTimings, + type RendererEnvironment, + type RendererWorkload, +} from "../src/performance.ts"; + +interface WebDriverResponse { value: T } +interface BrowserLog { level: string; message: string; timestamp: number } +interface PerformanceLog { message: string } +interface WorkloadConfig { + fixture: string; + runtimeBundles: string[]; + cliP95Ms: number; + browserUsefulContentMs: number; + browserKeyboardAdditionalMs: number; + warningBytes: number; + hardBytes: number; +} +interface BenchmarkConfig { + schemaVersion: number; + profile: string; + referenceEnvironment: { + platform: string; arch: string; nodeMajor: number; cpuQuotaCores: number; memoryLimitBytes: number; + browserName: string; browserMajor: number; + }; + sampling: { cliSamples: number; browserSamples: number; minimumSamples: number; noiseFloorMs: number; maxRelativeP95Spread: number }; + workloads: Record; +} + +const endpoint = process.argv[2] ?? "http://127.0.0.1:4444"; +const baseUrl = process.argv[3] ?? "http://127.0.0.1:4173"; +const root = resolve(import.meta.dirname, ".."); +const reportPath = resolve(process.argv[4] ?? join(root, "docs", "evidence", "renderer", "goal-3-performance-browser-2026-08-17.json")); +const configPath = resolve(process.argv[5] ?? join(root, "benchmarks", "renderer", "v1", "budgets.json")); + +async function request(method: string, path: string, body?: unknown): Promise { + const response = await fetch(`${endpoint}${path}`, { + method, + headers: body === undefined ? undefined : { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const responseText = await response.text(); + if (!response.ok) throw new Error(`WebDriver ${method} ${path} failed (${response.status}): ${responseText.slice(0, 1000)}`); + return (JSON.parse(responseText) as WebDriverResponse).value; +} + +async function optionalText(path: string): Promise { + try { return (await readFile(path, "utf8")).trim(); } catch { return undefined; } +} + +function sha256(value: string | Buffer): string { + return createHash("sha256").update(value).digest("hex"); +} + +interface BrowserSample { + usefulContentMs: number; + keyboardAdditionalMs: number; + ready: boolean; + keyboardReady: boolean; + browserLogs: BrowserLog[]; + requestUrls: string[]; + hardFailures: string[]; +} + +let observedBrowserName = ""; +let observedBrowserVersion = ""; + +async function sample(pageUrl: string, workload: RendererWorkload, width: number, height: number): Promise { + const session = await request<{ sessionId: string; capabilities?: Record }>("POST", "/session", { + capabilities: { + alwaysMatch: { + browserName: "chrome", + "goog:loggingPrefs": { browser: "ALL", performance: "ALL" }, + "goog:chromeOptions": { args: ["--headless=new", "--no-sandbox", "--disable-dev-shm-usage"] }, + }, + }, + }); + const route = `/session/${session.sessionId}`; + observedBrowserName ||= String(session.capabilities?.["browserName"] ?? "chrome"); + observedBrowserVersion ||= String(session.capabilities?.["browserVersion"] ?? "unknown"); + const hardFailures: string[] = []; + try { + await request("POST", `${route}/window/rect`, { width, height, x: 0, y: 0 }); + await request("POST", `${route}/goog/cdp/execute`, { cmd: "Network.enable", params: {} }); + await request("POST", `${route}/goog/cdp/execute`, { + cmd: "Emulation.setEmulatedMedia", + params: { media: "screen", features: [{ name: "prefers-color-scheme", value: "light" }, { name: "prefers-reduced-motion", value: "reduce" }] }, + }); + const startedAt = performance.now(); + await request("POST", `${route}/url`, { url: pageUrl }); + const expectedCharts = workload === "no-runtime" ? 0 : workload === "one-chart" ? 1 : 2; + const expectsMermaid = workload === "multi-runtime"; + const ready = await request("POST", `${route}/execute/async`, { + script: `const done=arguments[arguments.length-1];const started=performance.now();(function check(){ + const chartVisuals=document.querySelectorAll('.chart svg,.chart canvas').length; + const mermaidReady=${expectsMermaid ? "!!document.querySelector('.mermaid svg, svg[id^=\"mermaid-\"]')" : "true"}; + const ready=document.readyState==='complete'&&!!document.querySelector('main')&&chartVisuals>=${expectedCharts}&&mermaidReady; + if(ready||performance.now()-started>=12000){done(ready);return;}setTimeout(check,25); + })();`, + args: [], + }); + const usefulContentMs = performance.now() - startedAt; + if (!ready) hardFailures.push("useful-content readiness mark was missed"); + + const interactionStartedAt = performance.now(); + const hasDecision = await request("POST", `${route}/execute/sync`, { + script: "const node=document.querySelector('.decision-opt');if(node)node.focus();return !!node;", + args: [], + }); + if (hasDecision) { + await request("POST", `${route}/actions`, { + actions: [{ type: "key", id: "keyboard", actions: [{ type: "keyDown", value: "\uE014" }, { type: "keyUp", value: "\uE014" }] }], + }); + } + const keyboardReady = await request("POST", `${route}/execute/sync`, { + script: "const node=document.activeElement;return !!node&&node.matches('.decision-opt')&&node.getAttribute('aria-checked')==='true';", + args: [], + }); + const keyboardAdditionalMs = performance.now() - interactionStartedAt; + if (!keyboardReady) hardFailures.push("keyboard readiness mark was missed"); + + const pageErrors = await request("POST", `${route}/execute/sync`, { + script: "return Array.from(document.querySelectorAll('[role=alert],.chart-error')).map((node)=>node.textContent.trim()).filter(Boolean);", + args: [], + }); + for (const error of pageErrors) hardFailures.push(`runtime error: ${error.slice(0, 160)}`); + const browserLogs = await request("POST", `${route}/log`, { type: "browser" }); + for (const log of browserLogs) if (log.level === "SEVERE") hardFailures.push(`browser console: ${log.message.slice(0, 240)}`); + const performanceLogs = await request("POST", `${route}/log`, { type: "performance" }); + const requestUrls = performanceLogs.flatMap((entry) => { + const parsed = JSON.parse(entry.message) as { message?: { method?: string; params?: { request?: { url?: string } } } }; + const url = parsed.message?.method === "Network.requestWillBeSent" ? parsed.message.params?.request?.url : undefined; + return url === undefined ? [] : [url]; + }); + const origin = new URL(pageUrl).origin; + for (const url of requestUrls) { + if (/^https?:/i.test(url) && new URL(url).origin !== origin) hardFailures.push(`unexpected request: ${url}`); + } + return { usefulContentMs, keyboardAdditionalMs, ready, keyboardReady, browserLogs, requestUrls, hardFailures }; + } finally { + await request("DELETE", route); + } +} + +const configBytes = await readFile(configPath); +const config = JSON.parse(configBytes.toString("utf8")) as BenchmarkConfig; +if (config.schemaVersion !== 1) throw new Error("renderer benchmark config schema is unsupported"); +const cpuMax = await optionalText("/sys/fs/cgroup/cpu.max"); +const [quota, period] = cpuMax?.split(/\s+/) ?? []; +const memoryMax = await optionalText("/sys/fs/cgroup/memory.max"); +const declaredCpuQuota = process.env["RENDER_BENCH_CPU_CORES"] === undefined ? null : Number(process.env["RENDER_BENCH_CPU_CORES"]); +const declaredMemoryLimit = process.env["RENDER_BENCH_MEMORY_BYTES"] === undefined ? null : Number(process.env["RENDER_BENCH_MEMORY_BYTES"]); +const environmentBase = { + profile: config.profile, + platform: platform(), + arch: arch(), + nodeMajor: Number(process.versions.node.split(".")[0]), + cpuQuotaCores: quota !== undefined && quota !== "max" && period !== undefined ? Number(quota) / Number(period) : declaredCpuQuota, + memoryLimitBytes: memoryMax !== undefined && memoryMax !== "max" ? Number(memoryMax) : declaredMemoryLimit, +}; +const workloadReports: Record = {}; + +for (const workload of ["no-runtime", "one-chart", "multi-runtime"] as const) { + const budget = config.workloads[workload]; + const slug = budget.fixture.replace(/\.md$/, ""); + const cells: Record = {}; + for (const cell of [{ name: "desktop", width: 1440, height: 1200, multiplier: 1 }, { name: "mobile", width: 390, height: 844, multiplier: 2 }]) { + const samples: BrowserSample[] = []; + for (let index = 0; index < config.sampling.browserSamples; index++) { + samples.push(await sample(`${baseUrl}/${slug}.html`, workload, cell.width, cell.height)); + } + const usefulContent = summarizeTimings(samples.map((item) => item.usefulContentMs), config.sampling); + const keyboardAdditional = summarizeTimings(samples.map((item) => item.keyboardAdditionalMs), config.sampling); + const hardFailures = samples.flatMap((item, index) => item.hardFailures.map((failure) => `sample ${index + 1}: ${failure}`)); + const result = evaluateBrowserBudget( + usefulContent, + budget.browserUsefulContentMs * cell.multiplier, + keyboardAdditional, + budget.browserKeyboardAdditionalMs * cell.multiplier, + hardFailures, + ); + cells[cell.name] = { + viewport: { width: cell.width, height: cell.height }, + usefulContent, + keyboardAdditional, + budget: result, + samples, + pass: result.pass, + }; + } + const fixturePath = resolve(dirname(configPath), budget.fixture); + const fixture = await readFile(fixturePath); + const pageBytes = await readFile(resolve(process.argv[6] ?? join(root, ".benchmark-artifacts"), `${slug}.html`)); + workloadReports[workload] = { + fixture: budget.fixture, + fixtureSha256: sha256(fixture), + finalBytes: pageBytes.length, + byteBudget: evaluateByteBudget(pageBytes.length, budget.warningBytes, budget.hardBytes), + cells, + }; +} + +const actualEnvironment: RendererEnvironment = { + ...environmentBase, + browserName: observedBrowserName, + browserMajor: Number(observedBrowserVersion.split(".")[0]), +}; +const expectedEnvironment: RendererEnvironment = { + profile: config.profile, + platform: config.referenceEnvironment.platform, + arch: config.referenceEnvironment.arch, + nodeMajor: config.referenceEnvironment.nodeMajor, + cpuQuotaCores: config.referenceEnvironment.cpuQuotaCores, + memoryLimitBytes: config.referenceEnvironment.memoryLimitBytes, + browserName: config.referenceEnvironment.browserName, + browserMajor: config.referenceEnvironment.browserMajor, +}; +const environmentComparison = compareRendererEnvironment(actualEnvironment, expectedEnvironment); +const workloadPass = Object.values(workloadReports).every((value) => { + const report = value as { byteBudget: { status: string }; cells: Record }; + return report.byteBudget.status !== "fail" && Object.values(report.cells).every((cell) => cell.pass); +}); +const report = { + schemaVersion: 1, + benchmark: "renderer-browser-v1", + capturedAt: new Date().toISOString(), + configPath: configPath.slice(root.length + 1), + configSha256: sha256(configBytes), + percentileMethod: "nearest-rank", + navigationState: "new WebDriver session and browser profile for every cold sample", + excludedSetup: ["Selenium container start", "artifact rendering", "local server start", "session creation before navigation", "report serialization"], + environment: { + ...actualEnvironment, + browserVersion: observedBrowserVersion, + cgroupCpuMax: cpuMax ?? null, + cgroupMemoryMax: memoryMax ?? null, + constraintSource: cpuMax !== undefined && memoryMax !== undefined ? "cgroup observation" : "explicit Docker constraints; verify retained docker inspect values", + }, + expectedEnvironment, + environmentComparison, + sampling: config.sampling, + workloads: workloadReports, + pass: environmentComparison.comparable && workloadPass, +}; +await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); +console.log(JSON.stringify(report, null, 2)); +if (!report.pass) process.exitCode = 1; diff --git a/scripts/renderer-cli-benchmark.ts b/scripts/renderer-cli-benchmark.ts new file mode 100644 index 0000000..34e920c --- /dev/null +++ b/scripts/renderer-cli-benchmark.ts @@ -0,0 +1,160 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { arch, cpus, platform, tmpdir, totalmem } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { + compareRendererEnvironment, + evaluateByteBudget, + evaluateTimeBudget, + summarizeTimings, + type RendererEnvironment, + type RendererWorkload, +} from "../src/performance.ts"; +import { runtimeBundle, type RuntimeName } from "../src/runtime.ts"; + +interface WorkloadConfig { + fixture: string; + runtimeBundles: RuntimeName[]; + cliP95Ms: number; + browserUsefulContentMs: number; + browserKeyboardAdditionalMs: number; + warningBytes: number; + hardBytes: number; +} + +interface BenchmarkConfig { + schemaVersion: number; + profile: string; + referenceEnvironment: { + platform: string; + arch: string; + nodeMajor: number; + cpuQuotaCores: number; + memoryLimitBytes: number; + browserName: string; + browserMajor: number; + }; + sampling: { cliSamples: number; browserSamples: number; minimumSamples: number; noiseFloorMs: number; maxRelativeP95Spread: number }; + workloads: Record; +} + +const execFileAsync = promisify(execFile); +const root = resolve(import.meta.dirname, ".."); +const configPath = resolve(process.argv[2] ?? join(root, "benchmarks", "renderer", "v1", "budgets.json")); +const reportPath = resolve(process.argv[3] ?? join(root, "docs", "evidence", "renderer", "goal-3-performance-cli-2026-08-17.json")); + +async function optionalText(path: string): Promise { + try { return (await readFile(path, "utf8")).trim(); } catch { return undefined; } +} + +async function environment(profile: string): Promise> { + const cpuMax = await optionalText("/sys/fs/cgroup/cpu.max"); + const [quota, period] = cpuMax?.split(/\s+/) ?? []; + const cpuQuotaCores = quota !== undefined && quota !== "max" && period !== undefined + ? Number(quota) / Number(period) + : null; + const memoryMax = await optionalText("/sys/fs/cgroup/memory.max"); + const memoryLimitBytes = memoryMax !== undefined && memoryMax !== "max" ? Number(memoryMax) : null; + return { + profile, + platform: platform(), + arch: arch(), + nodeMajor: Number(process.versions.node.split(".")[0]), + cpuQuotaCores, + memoryLimitBytes, + nodeVersion: process.version, + cpuModel: cpus()[0]?.model ?? "unknown", + visibleLogicalCpus: cpus().length, + hostVisibleMemoryBytes: totalmem(), + cgroupCpuMax: cpuMax ?? null, + cgroupMemoryMax: memoryMax ?? null, + }; +} + +function sha256(value: string | Buffer): string { + return createHash("sha256").update(value).digest("hex"); +} + +const config = JSON.parse(await readFile(configPath, "utf8")) as BenchmarkConfig; +if (config.schemaVersion !== 1) throw new Error("renderer benchmark config schema is unsupported"); +const actualEnvironment = await environment(config.profile); +const expectedEnvironment: RendererEnvironment = { + profile: config.profile, + platform: config.referenceEnvironment.platform, + arch: config.referenceEnvironment.arch, + nodeMajor: config.referenceEnvironment.nodeMajor, + cpuQuotaCores: config.referenceEnvironment.cpuQuotaCores, + memoryLimitBytes: config.referenceEnvironment.memoryLimitBytes, +}; +const environmentComparison = compareRendererEnvironment(actualEnvironment, expectedEnvironment); +const temporary = await mkdtemp(join(tmpdir(), "renderer-cli-benchmark-")); +const workloadReports: Record = {}; +let passed = environmentComparison.comparable; + +try { + for (const workload of ["no-runtime", "one-chart", "multi-runtime"] as const) { + const budget = config.workloads[workload]; + const fixturePath = resolve(dirname(configPath), budget.fixture); + const fixture = await readFile(fixturePath, "utf8"); + const outputPath = join(temporary, `${workload}.html`); + const samplesMs: number[] = []; + const outputHashes = new Set(); + for (let sample = 0; sample < config.sampling.cliSamples; sample++) { + const started = process.hrtime.bigint(); + await execFileAsync(process.execPath, [join(root, "dist", "cli.js"), "render", fixturePath, "-o", outputPath], { cwd: root }); + samplesMs.push(Number(process.hrtime.bigint() - started) / 1_000_000); + outputHashes.add(sha256(await readFile(outputPath))); + } + if (outputHashes.size !== 1) throw new Error(`${workload} output changed between samples`); + const finalBytes = (await stat(outputPath)).size; + const runtimeBytes = budget.runtimeBundles.reduce((total, name) => total + Buffer.byteLength(runtimeBundle(name), "utf8"), 0); + const allSummary = summarizeTimings(samplesMs, config.sampling); + const warmSummary = summarizeTimings(samplesMs.slice(1), config.sampling); + const timeBudget = evaluateTimeBudget(allSummary, budget.cliP95Ms); + const byteBudget = evaluateByteBudget(finalBytes, budget.warningBytes, budget.hardBytes); + const workloadPass = timeBudget.pass && byteBudget.status !== "fail"; + passed &&= workloadPass; + workloadReports[workload] = { + fixture: basename(fixturePath), + fixtureSha256: sha256(fixture), + sourceBytes: Buffer.byteLength(fixture, "utf8"), + coldSampleMs: samplesMs[0], + allSamples: allSummary, + warmSamples: warmSummary, + timeBudget, + bytes: { + finalBytes, + runtimeBytes, + assetBytes: 0, + shellAndContentBytes: finalBytes - runtimeBytes, + outputSha256: [...outputHashes][0], + budget: byteBudget, + }, + pass: workloadPass, + }; + } +} finally { + await rm(temporary, { recursive: true, force: true }); +} + +const report = { + schemaVersion: 1, + benchmark: "renderer-cli-v1", + capturedAt: new Date().toISOString(), + configPath: configPath.slice(root.length + 1), + configSha256: sha256(await readFile(configPath)), + percentileMethod: "nearest-rank", + dependencyInstall: { includedInTiming: false, measured: false, durationMs: null, reason: "dependencies were preinstalled before the timed harness" }, + excludedSetup: ["dependency installation", "TypeScript build", "fixture discovery", "report serialization"], + environment: actualEnvironment, + expectedEnvironment, + environmentComparison, + sampling: config.sampling, + workloads: workloadReports, + pass: passed, +}; +await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); +console.log(JSON.stringify(report, null, 2)); +if (!passed) process.exitCode = 1; diff --git a/skills/artifact-pages/SKILL.md b/skills/artifact-pages/SKILL.md index c3dc8d5..d2f33ac 100644 --- a/skills/artifact-pages/SKILL.md +++ b/skills/artifact-pages/SKILL.md @@ -47,6 +47,11 @@ Decide the treatment before writing — the theme is part of the read, not an af Set it in frontmatter (`theme: report`). When nothing fits, `default` is always respectable — an over-styled page is worse than a plain one. +If the project contains `.opencode/artifact-tokens.json`, its bounded version-1 visual tokens +automatically outrank the theme. Use one `design-tokens` JSON fence only when the request needs +an explicit per-page override; it outranks the project file. Never write CSS, selectors, URLs, +or font names into tokens—use only the documented values in `reference/components.md`. + ## Pre-publish checklist 1. The title names the page like a product (2–4 words, no appended explainer) @@ -54,6 +59,8 @@ an over-styled page is worse than a plain one. 3. Every chart's title states the finding, not the axes 4. Components carry `tone` where attention matters 5. No error boxes survive to publish — fix and re-render first +6. Every local Markdown image has meaningful alt text; use `![](path "decorative")` only when + the image genuinely adds no information ## Naming (adapted from Claude Code's artifact-design skill) @@ -90,7 +97,13 @@ Grown from observed failures; add new ones as they bite: - Vega-Lite `text` channels need `{"field": "..."}` objects, not bare strings. - Area charts under layered marks need `"clip": true` or the fill escapes the plot. - A chart spec containing `` is safe (payload is `\u003c`-escaped) — don't "fix" it. -- Malformed component JSON renders an inline error box and the page still ships; fix the spec - and republish rather than working around the box. +- Malformed component JSON is returned with all other detected authoring errors before the + publish permission or any write. The standalone renderer retains escaped inline error boxes + for inspection; fix every reported spec and publish again. - `serve`-mode extras (live reload, decisions persistence, comments) exist only on served pages; a `file://` artifact keeps its strict CSP and localStorage-only state. +- Markdown image paths are worktree-root-relative. The publisher embeds allowlisted local + bytes and refuses URLs, absolute/traversal paths, symlinks, missing alt text, and unsupported + or oversized content before asking permission. +- A project font is opt-in via worktree-root-relative frontmatter `font:` and must be WOFF, + WOFF2, TTF, or OTF. The renderer generates the font rule; never add CSS to Markdown. diff --git a/skills/artifact-pages/reference/components.md b/skills/artifact-pages/reference/components.md index f58c0f4..bb0dfb7 100644 --- a/skills/artifact-pages/reference/components.md +++ b/skills/artifact-pages/reference/components.md @@ -1,8 +1,10 @@ # Authoring reference — artifact pages The `artifact_publish` tool takes `markdown`. Frontmatter (`---` fences) sets `title:`, -`icon:` (emoji favicon), and `description:` (gallery subtitle). `##` sections become white -cards on the page. Republish with the same title to update in place; `version: true` keeps +`icon:` (emoji favicon), `description:` (gallery subtitle), `lang:` (BCP 47), `dir:` +(`ltr|rtl`), `locale:` (Intl locale), and `timezone:` (IANA zone). Locale defaults to +`en-US`, time zone to `UTC`, and direction is inferred from the language when omitted. `##` +sections become white cards on the page. Republish with the same title to update in place; `version: true` keeps numbered history; `open: true` opens the browser; `expectedHash` guards against overwriting unseen changes; `force: true` overrides the credential scan. @@ -18,9 +20,9 @@ unseen changes; `force: true` overrides the credential scan. | ```` ```progress ```` | `{label?, done, total}` | progress bars | | ```` ```diff ```` | unified diff text; lines starting `## note:` become annotation rows | annotated diffs | | ```` ```copy ```` | `{label?, text}` | copy-to-clipboard button (for handing text back to the session) | -| ```` ```mermaid ```` | raw mermaid source (not JSON) | diagrams: graph/sequence/ER/... | +| ```` ```mermaid ```` | first line `%% summary: meaningful equivalent`, then raw Mermaid | diagrams: graph/sequence/ER/... | | ```` ```decisions ```` | `{title?, questions: [{id, question, options: [{id, label, note?}]}]}` | workshop rows; answers read back via `artifact_state` | -| ```` ```table ```` | `{caption?, columns: [{key, label, type?: num}], rows: [{...}]}` | sortable, filterable data tables | +| ```` ```table ```` | `{caption, columns: [{key, label, type?: num\|date\|datetime}], rows: [{...}]}` | captioned sortable/filterable tables; dates require zoned ISO values | ## Data honesty (non-negotiable) @@ -43,6 +45,7 @@ unseen changes; `force: true` overrides the credential scan. ## Charts ```` ```vega-lite ```` / ```` ```vega ```` / ```` ```echarts ```` fences take one JSON spec. +Every chart spec requires a top-level `description` containing its meaningful text equivalent. Vega-Lite compiles at render time and runs in the CSP-safe interpreter. Interactivity needs no custom JS: vega-lite `params` with `bind` render as sliders/dropdowns; echarts `dataZoom` gives pan/zoom. Title the finding, not the axes. @@ -51,11 +54,28 @@ gives pan/zoom. Title the finding, not the axes. GitHub alerts — `> [!NOTE]` `[!TIP]` `[!IMPORTANT]` `[!WARNING]` `[!CAUTION]` — become toned callout boxes. `- [ ]` / `- [x]` become styled checkboxes. Headings get id anchors. Raw HTML -is never passed through; broken component specs render as inline error boxes (the page still -ships — fix and republish). +is never passed through. CLI/plugin publication reports broken component specs together and +refuses before permission or writes; standalone rendering retains escaped inline error boxes +for inspection. ## Themes (optional) Frontmatter `theme:` selects a curated variant: `default` (gray-blue canvas, white cards), `report` (warm paper, serif headings), `ops` (dark-first terminal), `editorial` (magazine display type). Anything else falls back to `default`. + +## Bounded design tokens (optional) + +Project defaults live only at `.opencode/artifact-tokens.json`; a page-level prompt override +uses one `design-tokens` fence. Both use the same atomic versioned form: + +```json +{"schemaVersion":1,"tokens":{"accent":"#6d28d9","font":"serif","spacing":"spacious","radius":"soft","density":"airy"}} +``` + +Allowed token names are `pageBackground`, `surface`, `text`, `mutedText`, `border`, `accent`, +`font`, `spacing`, `radius`, and `density`. Colors are six-digit hex; font is +`system|serif|mono`; spacing is `compact|comfortable|spacious`; radius is +`square|sharp|soft|round`; density is `compact|comfortable|airy`. Prompt > project > curated theme > +built-in defaults. Unknown/unsafe/low-contrast values reject the whole source before publish; +raw CSS, selectors, URLs, markup, imports, expressions, and remote fonts are never accepted. diff --git a/specs/archive/2026-08-17-declarative-authoring-preflight/change.json b/specs/archive/2026-08-17-declarative-authoring-preflight/change.json new file mode 100644 index 0000000..fb3593e --- /dev/null +++ b/specs/archive/2026-08-17-declarative-authoring-preflight/change.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "id": "declarative-authoring-preflight", + "title": "Preflight declarative authoring errors", + "lane": "standard", + "status": "archived", + "affectedRequirements": [ + "RENDER-02", + "QUAL-02" + ], + "currentSpecs": [ + "specs/current/portable-renderer.spec.md" + ], + "currentSpecsUpdated": true, + "approval": { + "by": "aaron.zeng", + "at": "2026-08-17T15:02:07Z" + }, + "withdrawal": { + "by": "", + "at": "", + "reason": "" + }, + "createdAt": "2026-08-17", + "archivedAt": "2026-08-17" +} diff --git a/specs/archive/2026-08-17-declarative-authoring-preflight/delta.md b/specs/archive/2026-08-17-declarative-authoring-preflight/delta.md new file mode 100644 index 0000000..39cce97 --- /dev/null +++ b/specs/archive/2026-08-17-declarative-authoring-preflight/delta.md @@ -0,0 +1,51 @@ +# Specification delta: Preflight declarative authoring errors + +## MODIFIED + +### Requirement: RENDER-02 + +The declarative format validates frontmatter, components, tables, charts, Mermaid, anchors, +task lists, alerts, and asset declarations in one side-effect-free pass. It returns bounded, +ordered diagnostics with stable code, severity, source location, and next action, while the +standalone renderer preserves escaped inline error fallbacks. + +#### Scenario: Normal behavior + +- **Given:** a document with several independent authoring mistakes +- **When:** preflight runs through the CLI or plugin +- **Then:** all detectable errors are returned in source order before any artifact write + +#### Scenario: Failure or refusal + +- **Given:** a diagnostic would include a large or sensitive payload +- **When:** it is formatted +- **Then:** content is redacted/truncated and the error remains actionable + +#### Scenario: Relevant boundary + +- **Given:** diagnostics exceed the count or byte ceiling +- **When:** aggregation reaches the ceiling +- **Then:** output ends with an explicit omitted-count diagnostic rather than partial success + +### Requirement: QUAL-02 + +Preflight and render acceptance share schemas and deterministic fixtures so no public authoring +path bypasses validation and inline fallback behavior cannot silently disagree with preflight. + +#### Scenario: Normal behavior + +- **Given:** the valid and invalid declarative corpus +- **When:** domain, CLI, plugin, and standalone render tests run +- **Then:** each surface reports the same codes and valid documents have no errors + +#### Scenario: Failure or refusal + +- **Given:** a fence kind lacks a preflight or fallback test +- **When:** verification runs +- **Then:** the packet and Phase 2 correctness gate fail + +#### Scenario: Relevant boundary + +- **Given:** the same input runs from different cwd paths and times +- **When:** diagnostics are compared +- **Then:** codes, order, locations, and redaction are identical diff --git a/specs/archive/2026-08-17-declarative-authoring-preflight/evidence.md b/specs/archive/2026-08-17-declarative-authoring-preflight/evidence.md new file mode 100644 index 0000000..fd90e4d --- /dev/null +++ b/specs/archive/2026-08-17-declarative-authoring-preflight/evidence.md @@ -0,0 +1,22 @@ +# Evidence: Preflight declarative authoring errors + +## Requirement: RENDER-02 +- Validation: authors need all actionable schema errors before publication without losing safe fallbacks. +- Verification: the golden fixture exercises frontmatter, component, Mermaid, anchor, and alert + diagnostics through the domain, CLI, and plugin; count/byte ceilings, redaction, repeated + asset locations, no-write refusal, visible warnings, and trusted-mode permission metadata + are asserted directly. +- Result: passed. Reports are source ordered and stable, overflow ends with an exact omitted + marker, CLI/plugin errors precede permission and writes, and standalone rendering retains + escaped inline error boxes. +- Evidence: [@test](test/preflight.test.ts) [@test](test/assets.test.ts) + +## Requirement: QUAL-02 +- Validation: every declarative surface must share deterministic validation behavior. +- Verification: every registered component kind and invalid Vega-Lite input exercises the + shared validator/renderer fallback; every checked-in example has zero preflight errors. + The complete repository suite, TypeScript build, all 35 structural checks, package dry run, + and diff whitespace check passed under Node 24 with network disabled. +- Result: passed with 192/192 tests; diagnostics compare exactly across repeated runs and the + valid corpus remains accepted. +- Evidence: [@test](test/preflight.test.ts) [@test](test/components.test.ts) [@test](test/render.test.ts) diff --git a/specs/archive/2026-08-17-declarative-authoring-preflight/proposal.md b/specs/archive/2026-08-17-declarative-authoring-preflight/proposal.md new file mode 100644 index 0000000..eaa1ad2 --- /dev/null +++ b/specs/archive/2026-08-17-declarative-authoring-preflight/proposal.md @@ -0,0 +1,32 @@ +# Proposal: Preflight declarative authoring errors + +## Outcome + +Authors receive one bounded, ordered preflight report containing every detectable Markdown, +frontmatter, component, chart, table, Mermaid, asset-reference, and trusted-mode error before +publication, while standalone rendering retains safe inline fallbacks. + +## Context + +The current renderer converts malformed component fences to inline error cards one at a time. +That resilience is useful for reading but inefficient for agent authoring and can allow a +publish attempt before the complete error set is known. + +## Scope + +- In scope: side-effect-free preflight, stable diagnostic codes/severity/source locations, + bounded aggregation and truncation, CLI/plugin results, trusted-HTML disclosure, and inline + error parity. +- Out of scope: arbitrary HTML repair, execution of component code, network validation, or + treating warnings as silent success. + +## Risks and rollback + +- Risk: parser/preflight/render divergence or diagnostics echoing sensitive payloads. +- Rollback: retain inline safe fallbacks and disable the aggregate preflight surface without + accepting invalid content. + +## Validation plan + +Golden multi-error fixtures must produce complete, stable, redacted diagnostics through the +domain, CLI, and plugin before writes; valid corpus pages must have zero error diagnostics. diff --git a/specs/archive/2026-08-17-declarative-authoring-preflight/tasks.md b/specs/archive/2026-08-17-declarative-authoring-preflight/tasks.md new file mode 100644 index 0000000..617f5cb --- /dev/null +++ b/specs/archive/2026-08-17-declarative-authoring-preflight/tasks.md @@ -0,0 +1,8 @@ +# Tasks: Preflight declarative authoring errors + +- [x] Confirm proposal validation and human approval. +- [x] Implement shared typed diagnostics and side-effect-free aggregate preflight. +- [x] Expose bounded preflight through CLI/plugin while retaining inline fallback parity. +- [x] Add multi-error, redaction/truncation, valid-corpus, parity, and no-write tests. +- [x] Record evidence and update `specs/current/portable-renderer.spec.md`. +- [x] Run repository validation and archive the packet. diff --git a/specs/archive/2026-08-17-portable-asset-pipeline/change.json b/specs/archive/2026-08-17-portable-asset-pipeline/change.json new file mode 100644 index 0000000..1ae5fca --- /dev/null +++ b/specs/archive/2026-08-17-portable-asset-pipeline/change.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "id": "portable-asset-pipeline", + "title": "Add a contained offline asset pipeline", + "lane": "high-risk", + "status": "archived", + "affectedRequirements": [ + "RENDER-04", + "RENDER-05", + "SEC-02" + ], + "currentSpecs": [ + "specs/current/portable-renderer.spec.md" + ], + "currentSpecsUpdated": true, + "approval": { + "by": "aaron.zeng", + "at": "2026-08-17T15:02:07Z" + }, + "withdrawal": { + "by": "", + "at": "", + "reason": "" + }, + "createdAt": "2026-08-17", + "archivedAt": "2026-08-17" +} diff --git a/specs/archive/2026-08-17-portable-asset-pipeline/delta.md b/specs/archive/2026-08-17-portable-asset-pipeline/delta.md new file mode 100644 index 0000000..31e3566 --- /dev/null +++ b/specs/archive/2026-08-17-portable-asset-pipeline/delta.md @@ -0,0 +1,76 @@ +# Specification delta: Add a contained offline asset pipeline + +## MODIFIED + +### Requirement: RENDER-04 + +The renderer inlines only required runtimes and embeds each declared local asset from an +explicit worktree root after containment, regular-file, content-type, active-content, and size +validation. Missing, external, unsupported, changed-during-read, or unlabelled meaningful +assets fail preflight; strict offline pages never emit a broken remote reference. + +#### Scenario: Normal behavior + +- **Given:** a contained allowlisted image with meaningful alt text +- **When:** Markdown rendering completes +- **Then:** the page contains a hashed data URI and makes no view-time request + +#### Scenario: Failure or refusal + +- **Given:** a missing, external, mislabeled, active, or oversized asset +- **When:** preflight resolves it +- **Then:** publication performs no writes and returns a bounded actionable diagnostic + +#### Scenario: Relevant boundary + +- **Given:** a path or symlink resolves outside the explicit worktree root +- **When:** containment is checked before and after reading +- **Then:** the asset bytes are not returned or embedded + +### Requirement: RENDER-05 + +The 15 MiB default final-page cap applies after data-URI encoding, generated markup, runtimes, +and footer expansion. Source, per-asset, asset-count, aggregate-read, encoded, and final-output +limits are checked before publication without allocating unbounded content. + +#### Scenario: Normal behavior + +- **Given:** all expanded contributions remain within every limit +- **When:** final HTML is assembled +- **Then:** reported byte accounting equals the bytes passed to the lifecycle transaction + +#### Scenario: Failure or refusal + +- **Given:** base64 or footer expansion would exceed the final cap +- **When:** final accounting runs +- **Then:** publication refuses before selecting a new revision + +#### Scenario: Relevant boundary + +- **Given:** input is exactly at a documented hard limit +- **When:** it is encoded deterministically +- **Then:** the boundary is accepted while the next byte is rejected + +### Requirement: SEC-02 + +Asset paths, MIME claims, metadata, SVG content, counts, and sizes are untrusted and bounded. +Resolution uses safe relative syntax plus realpath containment and refuses special files, +encoded separators, symlink changes, and ambiguous types before authority or output expansion. + +#### Scenario: Normal behavior + +- **Given:** a stable regular file beneath the authorized root +- **When:** its declared and detected properties agree +- **Then:** only its exact bounded bytes and safe metadata enter the renderer + +#### Scenario: Failure or refusal + +- **Given:** traversal, a device/FIFO, or content whose bytes contradict its declaration +- **When:** validation runs +- **Then:** access fails closed without reading beyond the configured bound + +#### Scenario: Relevant boundary + +- **Given:** the file identity changes between inspection and read +- **When:** post-read verification runs +- **Then:** the candidate is discarded and no publication mutation occurs diff --git a/specs/archive/2026-08-17-portable-asset-pipeline/design.md b/specs/archive/2026-08-17-portable-asset-pipeline/design.md new file mode 100644 index 0000000..dc28c67 --- /dev/null +++ b/specs/archive/2026-08-17-portable-asset-pipeline/design.md @@ -0,0 +1,56 @@ +# Design: Add a contained offline asset pipeline + +Required for high-risk changes. + +## Context and constraints + +Asset declarations are untrusted input crossing from Markdown into filesystem reads and then +into a strict-CSP portable file. Resolution must be independent of process cwd, never follow +an escaping symlink, never fetch, and account for encoded expansion before publication. The +existing 15 MiB final-page cap and credential scan remain authoritative. + +## Chosen design + +Parse asset references into typed declarations, reject absolute/URL/encoded traversal forms, +resolve from an explicit worktree root, and compare real paths for both the root and regular +file. Read with per-file and aggregate ceilings, identify allowlisted media from bytes, sanitize +active SVG into a constrained generated representation or refuse it, and produce data URIs with +declared MIME, byte hash, source-relative path, and alt-text status. Rendering returns a staged +result; publication applies footer expansion, scans final bytes, enforces the cap, and only then +enters the lifecycle transaction. Errors are bounded and contain paths/metadata, never bytes. + +## Alternatives + +- Emit ordinary relative URLs: rejected because strict CSP/offline removal would break them. +- Fetch or cache HTTP assets: rejected because it adds network authority, nondeterminism, and + provenance/licensing risk. +- Trust filename extensions: rejected because mislabeled active content crosses the boundary. +- Permit raw SVG unchanged: rejected because scripts, external references, and parser features + exceed the Markdown trust model. +- Inline first and check size afterward only: rejected because reads/base64 allocation also need + pre-allocation bounds; both source and final representations are bounded. + +## Trust, privacy, and failure boundaries + +Only the explicit worktree root is readable. Symlinks, non-regular files, device paths, special +files, encoded separators, and changed-during-read identities fail closed. Diagnostics include +relative path, code, size, and next action but no asset body. Data URIs cannot request at view +time; the strict CSP stays unchanged. Private or credential-looking final content remains under +the existing scanner and explicit override contract. Unsupported/missing/external assets block +strict publication rather than yielding broken output. + +## Migration, rollout, and rollback + +Ship behind explicit declarations with no migration of existing HTML. Preflight and browser +offline evidence precede use in canonical fixtures. Rollback removes asset declaration support; +already-generated portable files remain self-contained. No schema/default-enable migration and +no destructive data operation is required. + +## Formal-method decision + +- Decision: bounded property model. +- Property and rationale: for every modeled reference and size boundary, the result is either a + contained allowlisted immutable byte sequence whose encoded contribution is counted exactly, + or a refusal with zero publication writes; no reference yields a view-time request. +- Model/evidence path: `test/model/asset-pipeline-model.ts` plus filesystem/symlink mutation and + offline browser tests to be added during implementation. diff --git a/specs/archive/2026-08-17-portable-asset-pipeline/evidence.md b/specs/archive/2026-08-17-portable-asset-pipeline/evidence.md new file mode 100644 index 0000000..c073f6e --- /dev/null +++ b/specs/archive/2026-08-17-portable-asset-pipeline/evidence.md @@ -0,0 +1,24 @@ +# Evidence: Add a contained offline asset pipeline + +## Requirement: RENDER-04 +- Validation: Phase 2 requires contained assets and a self-contained offline mixed-content page. +- Verification: `test/assets.test.ts` covers filesystem, MIME, SVG, alt, refusal/no-write, + and expansion behavior; the mixed fixture was exercised offline in real Chromium 151. +- Result: passed for images, constrained SVG, and WOFF/WOFF2/TTF/OTF. After explicit approval, + real offline Chromium loaded the embedded TTF under the narrow `font-src data:` directive + with zero console entries and zero HTTP(S) requests. +- Evidence: [@test](test/assets.test.ts) [@manual](docs/evidence/renderer/goal-3-portable-assets-2026-08-17.md) + +## Requirement: RENDER-05 +- Validation: final output must retain the existing 15 MiB safety boundary after expansion. +- Verification: exact source, count, per-file, aggregate, encoded-contribution, rendered, and + existing footer-expanded publication boundaries run in the automated suite. +- Result: passed; the offline mixed fixture rendered to 740,456 bytes below the 15 MiB cap. +- Evidence: [@test](test/assets.test.ts) [@test](test/gallery.test.ts) [@manual](docs/evidence/renderer/goal-3-portable-assets-2026-08-17.md) + +## Requirement: SEC-02 +- Validation: assets add an untrusted filesystem and active-content boundary. +- Verification: traversal, encoded separator, every-symlink, non-regular, content mismatch, + changed-descriptor, bounded diagnostic, and exhaustive modeled authority cases. +- Result: passed; refused plugin inputs made no permission request and no publication write. +- Evidence: [@test](test/assets.test.ts) [@test](test/model/asset-pipeline-model.ts) diff --git a/specs/archive/2026-08-17-portable-asset-pipeline/proposal.md b/specs/archive/2026-08-17-portable-asset-pipeline/proposal.md new file mode 100644 index 0000000..49d5e32 --- /dev/null +++ b/specs/archive/2026-08-17-portable-asset-pipeline/proposal.md @@ -0,0 +1,34 @@ +# Proposal: Add a contained offline asset pipeline + +## Outcome + +Markdown artifacts can declare contained local images, SVG, fonts, and supported media and +receive one self-contained offline HTML file whose final bytes and provenance are verified. + +## Context + +The renderer currently bundles code runtimes but has no declared worktree-asset contract. +Ordinary image references can therefore remain external, break under the strict CSP, escape +the intended root, or bypass the final 15 MiB accounting boundary. + +## Scope + +- In scope: declared worktree-root resolution, realpath containment, regular-file checks, + content-based MIME validation, allowlisted formats, per-asset/count/aggregate bounds, data + URI embedding, alt-text diagnostics, provenance, final-byte accounting, and explicit + missing/external/unsupported failures. +- Out of scope: network fetching, remote import, arbitrary SVG script execution, project CSS, + secret scanning changes, asset optimization that changes bytes, or broad font licensing. + +## Risks and rollback + +- Risk: path/symlink escape, decompression/resource abuse, mislabeled content, SVG active + content, private-file disclosure, and a footer/asset expansion that exceeds the cap. +- Rollback: disable declared assets and return actionable preflight failures; existing pages + remain standalone and the pre-asset Markdown behavior remains available. + +## Validation plan + +Use synthetic contained/escaping/missing/mislabeled/oversized assets, a property model for +resolution and byte accounting, offline browser request observation, final-file secret scan, +and a representative page containing an image, chart, table, and controls. diff --git a/specs/archive/2026-08-17-portable-asset-pipeline/tasks.md b/specs/archive/2026-08-17-portable-asset-pipeline/tasks.md new file mode 100644 index 0000000..6118ac8 --- /dev/null +++ b/specs/archive/2026-08-17-portable-asset-pipeline/tasks.md @@ -0,0 +1,8 @@ +# Tasks: Add a contained offline asset pipeline + +- [x] Confirm proposal validation and human approval. +- [x] Add the typed declaration/parser, bounded resolver, MIME/active-content validators, and exact byte accounting. +- [x] Integrate asset preflight and expansion before final scan/cap/lifecycle publication. +- [x] Add property, traversal/symlink/special-file, boundary, offline-browser, and mixed-page tests. +- [x] Retain final request/console/byte evidence and update `specs/current/portable-renderer.spec.md`. +- [x] Run repository validation and archive the packet. diff --git a/specs/archive/2026-08-17-renderer-design-tokens/change.json b/specs/archive/2026-08-17-renderer-design-tokens/change.json new file mode 100644 index 0000000..fea8730 --- /dev/null +++ b/specs/archive/2026-08-17-renderer-design-tokens/change.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "id": "renderer-design-tokens", + "title": "Add bounded renderer design tokens", + "lane": "standard", + "status": "archived", + "affectedRequirements": [ + "RENDER-07", + "SEC-04" + ], + "currentSpecs": [ + "specs/current/portable-renderer.spec.md" + ], + "currentSpecsUpdated": true, + "approval": { + "by": "aaron.zeng", + "at": "2026-08-17T15:02:07Z" + }, + "withdrawal": { + "by": "", + "at": "", + "reason": "" + }, + "createdAt": "2026-08-17", + "archivedAt": "2026-08-17" +} diff --git a/specs/archive/2026-08-17-renderer-design-tokens/delta.md b/specs/archive/2026-08-17-renderer-design-tokens/delta.md new file mode 100644 index 0000000..c458ef1 --- /dev/null +++ b/specs/archive/2026-08-17-renderer-design-tokens/delta.md @@ -0,0 +1,52 @@ +# Specification delta: Add bounded renderer design tokens + +## MODIFIED + +### Requirement: RENDER-07 + +Renderer design tokens use a versioned allowlisted schema and deterministic precedence: +explicit prompt values override the project token file, which overrides a curated theme and +built-in defaults. Each source records provenance; invalid higher-precedence input refuses or +falls back explicitly and no token source can execute code. + +#### Scenario: Normal behavior + +- **Given:** valid values at prompt, project, theme, and default levels +- **When:** tokens resolve +- **Then:** each emitted CSS variable has the highest-precedence value and named provenance + +#### Scenario: Failure or refusal + +- **Given:** an unknown token, unsafe value, invalid contrast pair, or oversized file +- **When:** project tokens load +- **Then:** the source is rejected with no partial application or executable output + +#### Scenario: Relevant boundary + +- **Given:** the same valid token set on different platforms +- **When:** CSS variables are generated +- **Then:** ordering and bytes are deterministic while system-font fallback remains explicit + +### Requirement: SEC-04 + +Markdown design configuration accepts data values only: no selectors, declarations, URLs, +markup, script, imports, expressions, or raw CSS. Values are parsed by type and emitted through +fixed CSS-variable slots without changing the strict CSP. + +#### Scenario: Normal behavior + +- **Given:** allowlisted colors, spacing, radius, density, and font-stack identifiers +- **When:** the style block is generated +- **Then:** only fixed property templates receive validated serialized values + +#### Scenario: Failure or refusal + +- **Given:** a token contains CSS/script breakout or a remote URL +- **When:** schema validation runs +- **Then:** rendering refuses the token source and the payload remains inert text + +#### Scenario: Relevant boundary + +- **Given:** trusted HTML mode also supplies page styles +- **When:** metadata is produced +- **Then:** trusted code remains explicitly distinguished from bounded Markdown tokens diff --git a/specs/archive/2026-08-17-renderer-design-tokens/evidence.md b/specs/archive/2026-08-17-renderer-design-tokens/evidence.md new file mode 100644 index 0000000..88c3c0d --- /dev/null +++ b/specs/archive/2026-08-17-renderer-design-tokens/evidence.md @@ -0,0 +1,22 @@ +# Evidence: Add bounded renderer design tokens + +## Requirement: RENDER-07 +- Validation: project/prompt visual choices need safe deterministic precedence. +- Verification: shared resolver tests cover prompt > project > theme > default precedence, + per-token provenance, atomic fallback, contrast, property-order-independent CSS, project + discovery boundaries, and real plugin/CLI output. The checked-in fixture was rendered + offline in Chromium 151 at desktop and narrow widths. +- Result: passed. Both real surfaces computed the exact authored palette/type/radius, retained + prompt provenance and the unchanged CSP, had no horizontal overflow or console entries, and + attempted zero HTTP(S) requests. +- Evidence: [@test](test/design-tokens.test.ts) [@manual](docs/evidence/renderer/goal-3-design-tokens-2026-08-17.md) + +## Requirement: SEC-04 +- Validation: token configuration must not become raw CSS or an executable Markdown escape hatch. +- Verification: hostile color/font/spacing/radius/density values, unknown keys, low contrast, + exact 8 KiB/overflow, file and parent symlinks, duplicate declarations, and invalid-project + plugin no-permission/no-write behavior are asserted. Full Node 24 gates passed: 202/202 + tests, TypeScript build, all 35 structural checks, package dry run, and diff whitespace. +- Result: passed. Invalid sources never apply partially or enter generated CSS; only fixed + variables with parser-owned serializations are emitted, and trusted HTML remains separate. +- Evidence: [@test](test/design-tokens.test.ts) [@test](test/governance-policy.test.ts) [@manual](docs/security.md) diff --git a/specs/archive/2026-08-17-renderer-design-tokens/proposal.md b/specs/archive/2026-08-17-renderer-design-tokens/proposal.md new file mode 100644 index 0000000..5ceae0f --- /dev/null +++ b/specs/archive/2026-08-17-renderer-design-tokens/proposal.md @@ -0,0 +1,32 @@ +# Proposal: Add bounded renderer design tokens + +## Outcome + +Portable pages accept a bounded declarative token set with precedence prompt > project file > +curated theme > built-in defaults, without accepting CSS, URLs, markup, or executable values. + +## Context + +Curated themes exist, but projects cannot safely express a consistent visual system and prompt +choices do not have a formal precedence contract. Arbitrary CSS would weaken CSP, portability, +and predictable accessibility. + +## Scope + +- In scope: versioned token schema, allowlisted color/type/spacing/radius/density values, + bounded project discovery, prompt overrides, provenance, contrast validation, and deterministic + CSS-variable emission. +- Out of scope: arbitrary CSS, JavaScript, remote fonts/assets, selectors, layout escape + hatches, or unbounded token names. + +## Risks and rollback + +- Risk: hostile token strings could break style/script boundaries or create inaccessible + contrast and cross-platform font drift. +- Rollback: ignore/refuse the invalid source and fall back to the last lower-precedence valid + theme without changing stored artifact identity. + +## Validation plan + +Schema boundary tests, injection strings, precedence fixtures, contrast checks, and retained +desktop/mobile screenshots must show deterministic bounded output and useful fallback. diff --git a/specs/archive/2026-08-17-renderer-design-tokens/tasks.md b/specs/archive/2026-08-17-renderer-design-tokens/tasks.md new file mode 100644 index 0000000..9c2bb1b --- /dev/null +++ b/specs/archive/2026-08-17-renderer-design-tokens/tasks.md @@ -0,0 +1,8 @@ +# Tasks: Add bounded renderer design tokens + +- [x] Confirm proposal validation and human approval. +- [x] Implement the versioned allowlisted token schema, bounded project discovery, and precedence/provenance resolver. +- [x] Emit fixed CSS-variable slots with contrast and injection validation and unchanged CSP. +- [x] Add precedence, fallback, hostile-value, deterministic-byte, contrast, and real-surface tests. +- [x] Retain screenshots and update `specs/current/portable-renderer.spec.md`. +- [x] Run repository validation and archive the packet. diff --git a/specs/archive/2026-08-17-renderer-performance-budgets/change.json b/specs/archive/2026-08-17-renderer-performance-budgets/change.json new file mode 100644 index 0000000..bc9bf36 --- /dev/null +++ b/specs/archive/2026-08-17-renderer-performance-budgets/change.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "id": "renderer-performance-budgets", + "title": "Enforce renderer performance budgets", + "lane": "standard", + "status": "archived", + "affectedRequirements": [ + "PERF-01", + "PERF-02", + "PERF-03", + "PERF-05" + ], + "currentSpecs": [ + "specs/current/portable-renderer.spec.md" + ], + "currentSpecsUpdated": true, + "approval": { + "by": "aaron.zeng", + "at": "2026-08-17T15:02:07Z" + }, + "withdrawal": { + "by": "", + "at": "", + "reason": "" + }, + "createdAt": "2026-08-17", + "archivedAt": "2026-08-17" +} diff --git a/specs/archive/2026-08-17-renderer-performance-budgets/delta.md b/specs/archive/2026-08-17-renderer-performance-budgets/delta.md new file mode 100644 index 0000000..5208d81 --- /dev/null +++ b/specs/archive/2026-08-17-renderer-performance-budgets/delta.md @@ -0,0 +1,101 @@ +# Specification delta: Enforce renderer performance budgets + +## MODIFIED + +### Requirement: PERF-01 + +The repository keeps a reproducible versioned harness for no-runtime, one-chart, and +multi-runtime portable pages. Reports identify fixture hashes, CPU/memory profile, browser and +Node versions, cold/warm state, sample count, percentile method, variance/noise disposition, +final bytes, and excluded setup time. + +#### Scenario: Normal behavior + +- **Given:** a comparable reference environment and unchanged fixture hashes +- **When:** the harness completes its configured samples +- **Then:** it emits machine-readable p50/p95, byte, useful-content, and interaction results + +#### Scenario: Failure or refusal + +- **Given:** samples are missing, noisy beyond policy, or environment metadata differs +- **When:** comparison is requested +- **Then:** the result is non-comparable and cannot pass the budget gate + +#### Scenario: Relevant boundary + +- **Given:** a cold dependency install is present +- **When:** render timing is calculated +- **Then:** install time is separately reported and never hidden inside or silently removed + +### Requirement: PERF-02 + +On the documented two-core/4 GiB reference profile, CLI rendering p95 is at most two seconds +for the no-runtime fixture and five seconds for the multi-runtime fixture, excluding only the +separately reported first dependency install. + +#### Scenario: Normal behavior + +- **Given:** valid comparable samples on the reference profile +- **When:** p95 is computed +- **Then:** both fixture classes meet their documented limits + +#### Scenario: Failure or refusal + +- **Given:** either p95 exceeds its limit +- **When:** the Phase 2 correctness gate runs +- **Then:** the performance requirement fails with the full distribution retained + +#### Scenario: Relevant boundary + +- **Given:** p95 equals the exact limit +- **When:** the gate evaluates it +- **Then:** it passes while any greater result fails + +### Requirement: PERF-03 + +On the benchmark browser profile, useful content appears within 1.5 seconds for no-runtime, +three seconds for one-runtime, and five seconds for multi-runtime pages; keyboard interaction +is ready within one additional second. Mobile-width/device results are separate and may not +exceed twice the corresponding desktop limit. + +#### Scenario: Normal behavior + +- **Given:** each workload in the benchmark browser +- **When:** cold navigation and keyboard readiness marks are measured +- **Then:** desktop and mobile p95 results meet their respective limits + +#### Scenario: Failure or refusal + +- **Given:** a runtime error, unexpected request, or missed readiness mark +- **When:** the sample completes +- **Then:** it is a hard failure rather than a discarded timing outlier + +#### Scenario: Relevant boundary + +- **Given:** a mobile result equals twice its desktop-class budget +- **When:** the gate evaluates it +- **Then:** it passes while any greater result fails + +### Requirement: PERF-05 + +No-runtime, one-runtime, and multi-runtime final-page byte budgets are versioned alongside the +15 MiB absolute cap. Runtime and asset contributions are reported separately; warning and hard +thresholds are tested before Phase 2 completion. + +#### Scenario: Normal behavior + +- **Given:** a fixture below its warning and hard thresholds +- **When:** final bytes are written +- **Then:** total and contribution breakdown are reported with remaining capacity + +#### Scenario: Failure or refusal + +- **Given:** final expansion exceeds a workload hard budget or the absolute cap +- **When:** publication or benchmark validation runs +- **Then:** it fails without selecting an oversized artifact revision + +#### Scenario: Relevant boundary + +- **Given:** a fixture crosses only the warning threshold +- **When:** the report is emitted +- **Then:** it remains usable but carries an actionable regression warning diff --git a/specs/archive/2026-08-17-renderer-performance-budgets/evidence.md b/specs/archive/2026-08-17-renderer-performance-budgets/evidence.md new file mode 100644 index 0000000..e4b9b4e --- /dev/null +++ b/specs/archive/2026-08-17-renderer-performance-budgets/evidence.md @@ -0,0 +1,29 @@ +# Evidence: Enforce renderer performance budgets + +## Requirement: PERF-01 +- Validation: comparable percentile claims require a versioned environment, corpus, and noise method. +- Verification: planned deterministic harness/report schema and non-comparable environment tests. +- Result: version-1 config/fixtures, exact hashes, cold/warm state, nearest-rank distributions, + explicit noise policy, excluded setup, contributions, and comparable environment metadata retained. +- Evidence: [@test](test/performance.test.ts) [@manual](docs/evidence/renderer/goal-3-performance-2026-08-17.md) + +## Requirement: PERF-02 +- Validation: CLI p95 budgets are already normative Phase 2 limits. +- Verification: planned cold/warm no-runtime and multi-runtime reference-profile samples. +- Result: comparable 12-sample CLI distributions passed; p95 was 851ms no-runtime and 964ms + multi-runtime against 2,000ms and 5,000ms limits. +- Evidence: [@test](test/performance.test.ts) [@manual](docs/evidence/renderer/goal-3-performance-cli-2026-08-17.json) + +## Requirement: PERF-03 +- Validation: useful-content and keyboard readiness budgets are part of portable-page correctness. +- Verification: planned real-browser desktop/mobile samples including runtime errors and request checks. +- Result: all six seven-sample desktop/mobile cells passed; useful-content p95 ranged from + 804ms to 2,392ms and keyboard-additional p95 from 112ms to 299ms, with zero hard failures. +- Evidence: [@test](test/performance.test.ts) [@manual](docs/evidence/renderer/goal-3-performance-browser-2026-08-17.json) + +## Requirement: PERF-05 +- Validation: workload byte budgets and contribution breakdown prevent silent bundle regressions below the absolute cap. +- Verification: planned warning/hard threshold, final-byte, and no-selected-oversize tests. +- Result: workload warnings/hard limits and contribution breakdowns are versioned; current + pages are 39,902, 622,105, and 5,311,264 bytes and all remain below warning thresholds. +- Evidence: [@test](test/performance.test.ts) [@manual](docs/evidence/renderer/goal-3-performance-2026-08-17.md) diff --git a/specs/archive/2026-08-17-renderer-performance-budgets/proposal.md b/specs/archive/2026-08-17-renderer-performance-budgets/proposal.md new file mode 100644 index 0000000..6ec7d1a --- /dev/null +++ b/specs/archive/2026-08-17-renderer-performance-budgets/proposal.md @@ -0,0 +1,32 @@ +# Proposal: Enforce renderer performance budgets + +## Outcome + +A reproducible harness reports render and browser percentiles for no-runtime, one-chart, and +multi-runtime fixtures and fails when the documented time or final-byte budgets regress. + +## Context + +The project enforces a final byte cap but lacks a versioned reference environment, warm/cold +method, noise policy, percentile samples, useful-content/interaction marks, and regression +gate for the Phase 2 workload classes. + +## Scope + +- In scope: synthetic stable fixtures, two-core/4 GiB reference profile, render p50/p95, + browser useful-content and keyboard-interactive marks, desktop/mobile separation, final-byte + budgets, sample metadata, noise handling, and machine-readable reports. +- Out of scope: provider/network latency, hosted load/soak, connector cost, or performance + claims on unavailable target platforms. + +## Risks and rollback + +- Risk: noisy CI or hidden cache state can create false passes/failures and encourage fixture- + specific optimization. +- Rollback: preserve reports, mark the environment non-comparable, and block the performance + claim rather than weakening budgets or deleting a failing fixture. + +## Validation plan + +Repeated cold/warm runs must report environment and variance, meet PERF-02/PERF-03 p95 limits, +stay under final byte caps, and fail deterministically for injected regressions. diff --git a/specs/archive/2026-08-17-renderer-performance-budgets/tasks.md b/specs/archive/2026-08-17-renderer-performance-budgets/tasks.md new file mode 100644 index 0000000..ca544f5 --- /dev/null +++ b/specs/archive/2026-08-17-renderer-performance-budgets/tasks.md @@ -0,0 +1,9 @@ +# Tasks: Enforce renderer performance budgets + +- [x] Confirm proposal validation and human approval. +- [x] Implement versioned fixtures, environment capture, sampling, percentile/noise logic, and machine-readable reports. +- [x] Measure CLI no-runtime/multi-runtime render budgets and final-byte contributions. +- [x] Measure real-browser desktop/mobile useful-content and keyboard-ready budgets. +- [x] Add boundary, injected-regression, unexpected-request/error, and non-comparable-environment tests. +- [x] Retain benchmark reports and update `specs/current/portable-renderer.spec.md`. +- [x] Run repository validation and archive the packet. diff --git a/specs/archive/2026-08-18-renderer-accessibility-i18n/change.json b/specs/archive/2026-08-18-renderer-accessibility-i18n/change.json new file mode 100644 index 0000000..0713434 --- /dev/null +++ b/specs/archive/2026-08-18-renderer-accessibility-i18n/change.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "id": "renderer-accessibility-i18n", + "title": "Complete renderer accessibility and internationalization", + "lane": "standard", + "status": "archived", + "affectedRequirements": [ + "RENDER-06", + "QUAL-04" + ], + "currentSpecs": [ + "specs/current/portable-renderer.spec.md" + ], + "currentSpecsUpdated": true, + "approval": { + "by": "aaron.zeng", + "at": "2026-08-17T15:02:07Z" + }, + "withdrawal": { + "by": "", + "at": "", + "reason": "" + }, + "createdAt": "2026-08-17", + "archivedAt": "2026-08-18" +} diff --git a/specs/archive/2026-08-18-renderer-accessibility-i18n/delta.md b/specs/archive/2026-08-18-renderer-accessibility-i18n/delta.md new file mode 100644 index 0000000..1386397 --- /dev/null +++ b/specs/archive/2026-08-18-renderer-accessibility-i18n/delta.md @@ -0,0 +1,54 @@ +# Specification delta: Complete renderer accessibility and internationalization + +## MODIFIED + +### Requirement: RENDER-06 + +Built-in pages meet WCAG 2.2 AA with meaningful landmarks/headings, chart summaries, table +captions, control names/states, visible focus, logical keyboard order, 200% zoom/reflow, +reduced motion, supported color modes, Unicode, explicit language/direction, and deterministic +locale/time-zone formatting. Decision/comment conflict and recovery flows are keyboard usable +and announced without relying on color. + +#### Scenario: Normal behavior + +- **Given:** a representative page opened at desktop/mobile widths with keyboard only +- **When:** the user navigates content, decisions, comments, tables, and theme controls +- **Then:** order, focus, names, states, notices, and reflow remain operable and understandable + +#### Scenario: Failure or refusal + +- **Given:** a chart lacks a meaningful author summary or data-derived fallback +- **When:** accessibility preflight runs +- **Then:** publication reports the missing semantic equivalent rather than hiding the chart + +#### Scenario: Relevant boundary + +- **Given:** RTL Unicode content at 200% zoom with reduced motion enabled +- **When:** the page renders and interactive state changes +- **Then:** logical reading order, labels, content, and focus remain intact without clipping + +### Requirement: QUAL-04 + +Accessibility changes retain real-surface desktop/mobile-width, keyboard-only, color-mode, +zoom, reduced-motion, automated WCAG, console, and screenshot evidence. Manual screen-reader +results are mandatory for certification and remain explicitly unavailable rather than inferred +from automation. + +#### Scenario: Normal behavior + +- **Given:** the declared accessibility fixture matrix and available assistive technology +- **When:** verification runs +- **Then:** exact browser/tool versions, results, screenshots, and manual observations are retained + +#### Scenario: Failure or refusal + +- **Given:** an automated or manual check fails +- **When:** gate status is reconciled +- **Then:** the affected claim remains failed and content is not removed merely to raise a score + +#### Scenario: Relevant boundary + +- **Given:** supported browsers or screen-reader access is unavailable +- **When:** implementation diagnostics pass +- **Then:** independent work continues but the corresponding Phase 2/certification evidence stays open diff --git a/specs/archive/2026-08-18-renderer-accessibility-i18n/evidence.md b/specs/archive/2026-08-18-renderer-accessibility-i18n/evidence.md new file mode 100644 index 0000000..61424d7 --- /dev/null +++ b/specs/archive/2026-08-18-renderer-accessibility-i18n/evidence.md @@ -0,0 +1,21 @@ +# Evidence: Complete renderer accessibility and internationalization + +## Requirement: RENDER-06 +- Validation: Phase 2 requires WCAG, keyboard, zoom, motion, Unicode, locale, and RTL correctness. +- Verification: planned semantic/automated checks plus real desktop/mobile-width keyboard and assistive-technology matrix. +- Result: semantic/unit checks and Chromium desktop/mobile/200%-equivalent RTL runs passed with + empty audits, no horizontal overflow, no console errors, and no external requests. On + 2026-08-18, Aaron Zeng (`aaron.zeng`) reported the manual checklist passed on Fedora 44 with + Orca 50.2 and Chrome 151.0.7922.137. +- Evidence: `test/accessibility.test.ts`; `test/serve.test.ts`; + `docs/evidence/renderer/goal-3-accessibility-2026-08-17.md`; [@manual](docs/roadmap.md) + +## Requirement: QUAL-04 +- Validation: user-visible accessibility cannot be certified from source assertions alone. +- Verification: planned retained browser versions, console results, screenshots, keyboard traces, and manual outcomes. +- Result: Chromium 151 retained keyboard traces, accessibility-tree roles/names, light/dark, + reduced-motion, 390-pixel, 640-CSS-pixel-at-2x, print, console, request, and screenshots. + The named Fedora/Orca/Chrome user attestation supplies the previously open manual + screen-reader result without extending the supported-platform matrix. +- Evidence: `docs/evidence/renderer/goal-3-accessibility-{desktop,mobile-reduced,zoom-200}-2026-08-17.{json,png}`; + `docs/evidence/renderer/goal-3-accessibility-2026-08-17.md`; [@manual](docs/goal-runbook.md) diff --git a/specs/archive/2026-08-18-renderer-accessibility-i18n/proposal.md b/specs/archive/2026-08-18-renderer-accessibility-i18n/proposal.md new file mode 100644 index 0000000..7cc36f5 --- /dev/null +++ b/specs/archive/2026-08-18-renderer-accessibility-i18n/proposal.md @@ -0,0 +1,35 @@ +# Proposal: Complete renderer accessibility and internationalization + +## Outcome + +Built-in pages expose semantic landmarks, summaries, labels, logical keyboard order, visible +focus, reduced-motion behavior, 200% zoom resilience, Unicode/locale/RTL correctness, and +accessible decision/comment workflows through the real browser surface. + +## Context + +The shell has responsive styling and keyboard-capable controls, but Phase 2 requires a complete +audited contract for charts, tables, forms, comments, color modes, motion, bidirectionality, +dates/numbers, and degraded states. Automated checks cannot substitute for manual assistive +technology evidence. + +## Scope + +- In scope: landmarks/headings, chart text summaries, table captions, control names/states, + skip/focus order, keyboard flows, contrast, zoom/reflow, reduced motion, `lang`/`dir`, Unicode, + locale/time-zone formatting, and print/PDF only where behavior can be justified and tested. +- Out of scope: claiming every author-supplied fact/alt text is correct, broad platform support, + or passing manual screen-reader/mobile evidence that was not collected. + +## Risks and rollback + +- Risk: visually hidden content can diverge from charts; focus repair can disrupt reading order; + locale defaults can make output nondeterministic. +- Rollback: retain semantic static summaries and native document order while disabling only the + faulty enhancement; never remove content to make an automated score pass. + +## Validation plan + +Automated WCAG tooling, semantic assertions, keyboard-only desktop/mobile-width workflows, +200% zoom, reduced-motion, LTR/RTL/Unicode/locale fixtures, console checks, screenshots, and +retained manual screen-reader results or an explicit unavailable gate. diff --git a/specs/archive/2026-08-18-renderer-accessibility-i18n/tasks.md b/specs/archive/2026-08-18-renderer-accessibility-i18n/tasks.md new file mode 100644 index 0000000..6385e00 --- /dev/null +++ b/specs/archive/2026-08-18-renderer-accessibility-i18n/tasks.md @@ -0,0 +1,9 @@ +# Tasks: Complete renderer accessibility and internationalization + +- [x] Confirm proposal validation and human approval. +- [x] Implement semantic summaries/captions/labels, focus/keyboard behavior, zoom/reflow, and reduced-motion support. +- [x] Add explicit language/direction plus deterministic Unicode, locale, time-zone, and RTL behavior. +- [x] Add semantic/automated checks and real desktop/mobile-width keyboard/color/zoom/motion/console tests. +- [x] Retain screenshots and available manual screen-reader evidence; keep unavailable cells explicit. +- [x] Update `specs/current/portable-renderer.spec.md`. +- [x] Run repository validation and archive the packet. diff --git a/specs/current/portable-renderer.spec.md b/specs/current/portable-renderer.spec.md new file mode 100644 index 0000000..2697a94 --- /dev/null +++ b/specs/current/portable-renderer.spec.md @@ -0,0 +1,112 @@ +# Portable renderer correctness + +## Current behavior + +- `RENDER-02`: CLI and plugin Markdown publication runs one side-effect-free preflight before + permission or writes. Frontmatter, component/table, chart, Mermaid, heading-anchor, + task-marker, alert, and asset diagnostics carry stable codes, severity, line/column, and a + next action in source order. Errors refuse publication; warnings remain visible on success. +- `RENDER-02`: reports retain at most 50 diagnostics and 16 KiB of diagnostic JSON by default. + Sensitive-looking values are redacted, messages/actions are truncated, and any overflow + ends in a `diagnostics-omitted` error with the exact omitted count. Raw HTML remains an + explicit trusted mode and produces a visible `trusted-html-mode` warning plus permission + metadata. +- `QUAL-02`: component and chart acceptance is shared by preflight and rendering. Standalone + rendering preserves escaped inline error boxes, while every checked-in pattern must pass + preflight without errors and CLI/plugin refusals are verified as no-write behavior. +- `RENDER-04`: Markdown images published through the CLI or plugin resolve only from the + explicit project worktree. PNG, JPEG, GIF, WebP, and a conservatively reconstructed SVG + subset become hashed data URIs. External, absolute, traversal, encoded-separator, missing, + symlinked, non-regular, mislabeled, active, changed-during-read, or unlabelled image inputs + fail before the permission prompt or any publication write. An exact image title of + `decorative` explicitly selects empty-alt presentation semantics. +- `RENDER-04`: optional frontmatter `font:` declarations accept contained WOFF, WOFF2, TTF, + or OTF bytes under the same resolver and generate a fixed `@font-face`. The on-disk CSP adds + only `font-src data:`, so embedded fonts load without granting network authority. +- `RENDER-05`: Markdown source, declaration count, each source asset, aggregate decoded asset + bytes, encoded contributions, rendered HTML, and footer-expanded publication bytes are + bounded. The final default limit remains 15 MiB and is enforced before lifecycle commit. +- `RENDER-07`: one version-1 `design-tokens` fence supplies prompt-level values and the fixed + `.opencode/artifact-tokens.json` file supplies project values. The deterministic precedence + is prompt > project > curated theme > built-in defaults; each effective token records named + provenance in portable-page metadata. Invalid higher sources are rejected atomically before + permission or writes, while standalone rendering shows an escaped fallback. +- `SEC-04`: token sources are capped at 8 KiB, project discovery refuses worktree/file/parent + symlinks and non-regular files, and values are restricted to six-digit colors plus fixed + font, spacing, radius, and density enums. Effective text/accent pairs are contrast checked; + deterministic output populates only fixed CSS variables and leaves the CSP unchanged. Raw + CSS, selectors, declarations, URLs, markup, imports, expressions, and arbitrary font stacks + are not an authoring surface. +- `SEC-02`: resolution is independent of process cwd after the caller supplies its worktree + root. It checks every path segment, opens regular files without following the final symlink, + compares descriptor identity across a bounded read, repeats realpath containment, detects + MIME from bytes, and never fetches. +- `RENDER-06`: pages expose a skip link and header/main landmarks, Unicode-safe anchors, + meaningful chart/Mermaid equivalents, table captions/filter labels/sort state, progress and + decision state, visible focus, keyboard decision/comment flows, reduced motion, narrow and + 200%-equivalent reflow, and print behavior. `lang`, `dir`, `locale`, and `timezone` are + explicit and deterministic; RTL direction can be inferred and numbers/zoned timestamps use + the declared locale context. Missing equivalents and invalid metadata refuse publication. +- `QUAL-04`: semantic tests and retained Chromium desktop, 390-pixel dark/reduced-motion, and + 200%-equivalent RTL evidence are green. A named 2026-08-18 user attestation records the + manual screen-reader checklist passing on Fedora 44, Orca 50.2, and Chrome 151.0.7922.137; + this closes the packet gate without declaring a broad supported-platform matrix. +- `PERF-01`: `benchmarks/renderer/v1/` owns hashed no-runtime, one-chart, and multi-runtime + fixtures plus the two-core/4 GiB Node 24/Chromium 151 reference profile. Reports retain raw + cold/warm samples, nearest-rank p50/p95, a five-sample minimum, 250ms scheduler floor, + relative-spread disposition, exact environment comparison, excluded setup, and hashes. +- `PERF-02`: comparable 12-sample CLI p95 is below the 2,000ms no-runtime and 5,000ms + multi-runtime limits; an explicit preinstalled-dependency record prevents install time from + being silently mixed into or removed from render samples. +- `PERF-03`: seven fresh-profile Chromium navigations per desktop/mobile workload require + useful visuals and a completed keyboard radio transition. Runtime errors, severe console + entries, external requests, and missed marks are retained hard failures; all six current + cells pass their desktop or 2× mobile limits. +- `PERF-05`: version-1 workload warning/hard byte budgets cover 128/192 KiB no-runtime, + 1/1.5 MiB one-chart, and 6/8 MiB multi-runtime pages below the absolute 15 MiB cap. Reports + separate final, runtime, asset, and shell/content bytes and exact remaining capacity. + +## Limits + +| Boundary | Default | +|---|---:| +| Markdown source | 1 MiB | +| Declared assets | 64 | +| One decoded asset | 4 MiB | +| All decoded assets | 10 MiB | +| One project or prompt design-token source | 8 KiB | +| Final footer-expanded page | 15 MiB | + +Exact limits are accepted; the next byte or declaration is refused. SVG accepts only a +generated allowlist of static geometry/text elements and attributes. It rejects entities, +processing instructions, external references, style, event handlers, active elements, and +malformed or unknown markup. + +## Evidence boundary + +- `test/assets.test.ts` and `test/model/asset-pipeline-model.ts` cover the resolver, exact + accounting, mutation, refusal/no-write, and no-view-time-request properties. +- `test/preflight.test.ts` covers ordered multi-error reports, redaction, count/byte ceilings, + repeated asset locations, every component kind, chart fallback parity, valid examples, + CLI/plugin no-write refusal, visible warnings, and trusted-mode disclosure. +- `test/design-tokens.test.ts` covers schema aggregation, precedence, provenance, atomic + fallback, contrast, hostile values, project file/symlink/byte boundaries, deterministic + serialization, standalone fallback, and real CLI/plugin no-write/application paths. +- `examples/patterns/design-tokens.md` plus + `docs/evidence/renderer/goal-3-design-tokens-2026-08-17.md` retain the offline Chromium 151 + desktop/mobile computed-style, provenance, CSP, console, request, overflow, and screenshot + observations. +- `examples/patterns/portable-mixed.md` plus + `docs/evidence/renderer/goal-3-portable-assets-2026-08-17.md` retain the real Chromium + offline mixed-page and loaded-font observations. +- `test/accessibility.test.ts`, the served-bridge regression in `test/serve.test.ts`, and + `examples/patterns/accessibility-rtl.md` cover semantic equivalents, contrast, Unicode, + locale/time-zone/RTL output, keyboard state, reduced motion, reflow, and print behavior. + `docs/evidence/renderer/goal-3-accessibility-2026-08-17.md` retains the Chromium 151 + accessibility-tree, keyboard, desktop/mobile/zoom, console, request, and screenshot results. +- `test/performance.test.ts`, `scripts/renderer-{cli,browser}-benchmark.ts`, and + `docs/evidence/renderer/goal-3-performance-2026-08-17.md` retain exact boundary/refusal + logic and comparable full-distribution CLI/browser reports for all three workloads. +- This evidence combines a retained Linux/Chromium automated observation with one named + Fedora/Orca/Chrome manual assistive-technology attestation. It does not certify a supported + browser/OS matrix, a physical mobile device, or other assistive-technology combinations. diff --git a/src/assets.ts b/src/assets.ts new file mode 100644 index 0000000..cf3df0a --- /dev/null +++ b/src/assets.ts @@ -0,0 +1,409 @@ +import { constants as fsConstants } from "node:fs"; +import { open, lstat, realpath } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { isAbsolute, relative, resolve, sep } from "node:path"; +import MarkdownIt from "markdown-it"; + +export const DEFAULT_MAX_ASSET_COUNT = 64; +export const DEFAULT_MAX_ASSET_BYTES = 4 * 1024 * 1024; +export const DEFAULT_MAX_ASSET_TOTAL_BYTES = 10 * 1024 * 1024; +export const DEFAULT_MAX_MARKDOWN_BYTES = 1024 * 1024; + +export type AssetKind = "image" | "font"; + +export interface AssetLimits { + maxAssetCount?: number; + maxAssetBytes?: number; + maxTotalBytes?: number; + maxMarkdownBytes?: number; +} + +export interface AssetResolutionHooks { + /** Test/fault-injection seam invoked after the descriptor identity is captured. */ + afterOpen?: (resolvedPath: string) => Promise; +} + +export interface PortableAsset { + source: string; + relativePath: string; + kind: AssetKind; + mime: string; + bytes: number; + encodedBytes: number; + sha256: string; + dataUri: string; + alt: string | undefined; + decorative: boolean; +} + +export interface PortableAssets { + bySource: ReadonlyMap; + font: PortableAsset | undefined; + sourceBytes: number; + assetBytes: number; + encodedBytes: number; +} + +export type AssetErrorCode = + | "source-too-large" + | "too-many-assets" + | "invalid-path" + | "external-asset" + | "missing-asset" + | "unsafe-path" + | "not-regular" + | "unsupported-type" + | "type-mismatch" + | "active-content" + | "asset-too-large" + | "assets-too-large" + | "changed-during-read" + | "missing-alt"; + +function bounded(value: string): string { + return value.length <= 200 ? value : `${value.slice(0, 197)}...`; +} + +export class AssetPreflightError extends Error { + readonly code: AssetErrorCode; + readonly assetPath: string | undefined; + readonly nextAction: string; + + constructor(code: AssetErrorCode, message: string, assetPath: string | undefined, nextAction: string) { + super(`${message}${assetPath === undefined ? "" : `: ${bounded(assetPath)}`}. ${nextAction}`); + this.name = "AssetPreflightError"; + this.code = code; + this.assetPath = assetPath === undefined ? undefined : bounded(assetPath); + this.nextAction = nextAction; + } +} + +interface AssetDeclaration { + source: string; + kind: AssetKind; + alt?: string; + decorative?: boolean; +} + +function frontmatterFont(source: string): string | undefined { + const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); + if (!match) return undefined; + for (const line of match[1].split(/\r?\n/)) { + const field = line.match(/^font\s*:\s*(.*?)\s*$/i); + if (field?.[1]) return field[1]; + } + return undefined; +} + +function imageDeclarations(source: string): AssetDeclaration[] { + const md = new MarkdownIt({ html: false, linkify: true }); + const declarations: AssetDeclaration[] = []; + const visit = (tokens: readonly { type: string; children?: unknown; attrGet(name: string): string | null; content: string }[]): void => { + for (const token of tokens) { + if (token.type === "image") { + const assetSource = token.attrGet("src"); + if (assetSource !== null) { + const title = token.attrGet("title"); + declarations.push({ + source: assetSource, + kind: "image", + alt: token.content.trim(), + decorative: title?.trim().toLowerCase() === "decorative", + }); + } + } + if (Array.isArray(token.children)) { + visit(token.children as readonly { type: string; children?: unknown; attrGet(name: string): string | null; content: string }[]); + } + } + }; + visit(md.parse(source, {})); + return declarations; +} + +function declarations(source: string): AssetDeclaration[] { + const result = imageDeclarations(source); + const font = frontmatterFont(source); + if (font !== undefined) result.push({ source: font, kind: "font" }); + return result; +} + +function safeRelativePath(source: string): string { + if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(source)) { + throw new AssetPreflightError("external-asset", "external assets are not imported", source, "use a contained worktree-local file"); + } + if (source.includes("\0") || source.includes("\\") || /%2f|%5c/i.test(source)) { + throw new AssetPreflightError("invalid-path", "asset path contains an encoded or platform-dependent separator", source, "use forward-slash relative path segments"); + } + if (source.includes("?") || source.includes("#")) { + throw new AssetPreflightError("invalid-path", "asset paths may not contain URL query or fragment syntax", source, "use the plain worktree-relative file path"); + } + let decoded: string; + try { + decoded = decodeURIComponent(source); + } catch { + throw new AssetPreflightError("invalid-path", "asset path has malformed percent encoding", source, "use a valid relative path"); + } + if (isAbsolute(decoded) || /^[a-z]:/i.test(decoded)) { + throw new AssetPreflightError("invalid-path", "absolute asset paths are not allowed", source, "use a path relative to the worktree root"); + } + const segments = decoded.replace(/^\.\//, "").split("/"); + if (segments.length === 0 || segments.some((segment) => segment === "" || segment === "." || segment === "..")) { + throw new AssetPreflightError("invalid-path", "asset path contains an empty or traversal segment", source, "use a direct relative path beneath the worktree root"); + } + return segments.join(sep); +} + +function contained(root: string, candidate: string): boolean { + const child = relative(root, candidate); + return child !== "" && !child.startsWith(`..${sep}`) && child !== ".." && !isAbsolute(child); +} + +async function rejectSymlinkSegments(root: string, path: string, source: string): Promise { + let cursor = root; + const segments = relative(root, path).split(sep); + for (const [index, segment] of segments.entries()) { + cursor = resolve(cursor, segment); + let info; + try { + info = await lstat(cursor); + } catch (error) { + if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") { + throw new AssetPreflightError("missing-asset", "asset does not exist", source, "create the file or correct the declaration"); + } + throw error; + } + if (info.isSymbolicLink()) { + throw new AssetPreflightError("unsafe-path", "asset paths may not contain symbolic links", source, "use a regular file stored directly beneath the worktree root"); + } + if (index === segments.length - 1 && !info.isFile()) { + throw new AssetPreflightError("not-regular", "asset is not a regular file", source, "use a regular file"); + } + } +} + +function declaredMime(path: string, kind: AssetKind): string { + const lower = path.toLowerCase(); + const entries: ReadonlyArray = [ + [".png", "image/png", "image"], + [".jpg", "image/jpeg", "image"], + [".jpeg", "image/jpeg", "image"], + [".gif", "image/gif", "image"], + [".webp", "image/webp", "image"], + [".svg", "image/svg+xml", "image"], + [".woff", "font/woff", "font"], + [".woff2", "font/woff2", "font"], + [".ttf", "font/ttf", "font"], + [".otf", "font/otf", "font"], + ]; + const match = entries.find(([extension]) => lower.endsWith(extension)); + if (!match || match[2] !== kind) { + throw new AssetPreflightError("unsupported-type", `unsupported ${kind} asset type`, path, kind === "font" ? "use a WOFF, WOFF2, TTF, or OTF file" : "use PNG, JPEG, GIF, WebP, or a constrained SVG"); + } + return match[1]; +} + +function detectedMime(bytes: Buffer): string | undefined { + if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return "image/png"; + if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg"; + if (bytes.length >= 6 && (bytes.subarray(0, 6).toString("ascii") === "GIF87a" || bytes.subarray(0, 6).toString("ascii") === "GIF89a")) return "image/gif"; + if (bytes.length >= 12 && bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp"; + if (bytes.length >= 4 && bytes.subarray(0, 4).toString("ascii") === "wOFF") return "font/woff"; + if (bytes.length >= 4 && bytes.subarray(0, 4).toString("ascii") === "wOF2") return "font/woff2"; + if (bytes.length >= 4 && bytes.subarray(0, 4).equals(Buffer.from([0, 1, 0, 0]))) return "font/ttf"; + if (bytes.length >= 4 && bytes.subarray(0, 4).toString("ascii") === "OTTO") return "font/otf"; + try { + const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes).trim(); + if (text.startsWith("")) return "image/svg+xml"; + } catch { + return undefined; + } + return undefined; +} + +const SVG_ELEMENTS = new Set(["svg", "g", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon", "title", "desc", "text", "tspan"]); +const SVG_ATTRIBUTES = new Set(["xmlns", "viewBox", "width", "height", "role", "aria-label", "fill", "stroke", "stroke-width", "opacity", "transform", "d", "x", "y", "x1", "x2", "y1", "y2", "cx", "cy", "r", "rx", "ry", "points", "text-anchor", "font-size", "font-family", "font-weight"]); + +function escapeXml(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function safeSvgAttribute(name: string, value: string): boolean { + if (name === "xmlns") return value === "http://www.w3.org/2000/svg"; + if (name === "role") return value === "img" || value === "presentation"; + if (name === "aria-label" || name === "font-family") return /^[\p{L}\p{N} _.,-]{1,120}$/u.test(value); + if (name === "fill" || name === "stroke") return value === "none" || value === "currentColor" || /^#[0-9a-f]{3,8}$/i.test(value) || /^[a-z]{1,24}$/i.test(value); + if (name === "d") return /^[MmZzLlHhVvCcSsQqTtAaEe0-9.,+\-\s]+$/.test(value); + if (name === "transform") return /^(?:(?:matrix|translate|scale|rotate|skewX|skewY)\([0-9.,+\-\s]+\)\s*)+$/.test(value); + if (name === "viewBox" || name === "points") return /^[0-9.,+\-\s]+$/.test(value); + if (name === "text-anchor") return value === "start" || value === "middle" || value === "end"; + return /^[0-9.+\-%]{1,32}$/.test(value) || /^[a-z]{1,16}$/i.test(value); +} + +export function sanitizeSvg(bytes: Buffer, source: string): Buffer { + let input: string; + try { + input = new TextDecoder("utf-8", { fatal: true }).decode(bytes).trim(); + } catch { + throw new AssetPreflightError("active-content", "SVG is not valid UTF-8", source, "export a static UTF-8 SVG using the supported subset"); + } + if (/]*>/g; + const stack: string[] = []; + const output: string[] = []; + let cursor = 0; + for (const match of input.matchAll(tag)) { + const index = match.index; + const text = input.slice(cursor, index); + if (text.includes("<") || text.includes(">")) throw new AssetPreflightError("active-content", "SVG markup is malformed", source, "export a static SVG using the supported subset"); + if (text !== "") output.push(escapeXml(text)); + const raw = match[0]; + const closing = raw.startsWith(""); + const nameMatch = raw.match(/^<\/?([A-Za-z][A-Za-z0-9-]*)/); + const name = nameMatch?.[1]; + if (name === undefined || !SVG_ELEMENTS.has(name)) throw new AssetPreflightError("active-content", "SVG contains an unsupported element", source, "export a static SVG using supported geometry and text elements"); + if (closing) { + if (raw !== `` || stack.pop() !== name) throw new AssetPreflightError("active-content", "SVG element nesting is invalid", source, "export a well-formed static SVG"); + output.push(raw); + } else { + const attributesText = raw.slice(name.length + 1, raw.length - (selfClosing ? 2 : 1)); + const attributes: string[] = []; + let attrCursor = 0; + const attr = /\s+([A-Za-z][A-Za-z0-9:-]*)\s*=\s*("([^"]*)"|'([^']*)')/gy; + while (attrCursor < attributesText.length) { + attr.lastIndex = attrCursor; + const attribute = attr.exec(attributesText); + if (!attribute) { + if (attributesText.slice(attrCursor).trim() === "") break; + throw new AssetPreflightError("active-content", "SVG contains malformed attributes", source, "export a static SVG with quoted supported attributes"); + } + const attributeName = attribute[1]; + const value = attribute[3] ?? attribute[4] ?? ""; + if (!SVG_ATTRIBUTES.has(attributeName) || !safeSvgAttribute(attributeName, value)) throw new AssetPreflightError("active-content", `SVG attribute ${attributeName} is unsupported or unsafe`, source, "remove active styling and external references"); + attributes.push(` ${attributeName}="${escapeXml(value)}"`); + attrCursor = attr.lastIndex; + } + output.push(`<${name}${attributes.join("")}${selfClosing ? "/>" : ">"}`); + if (!selfClosing) stack.push(name); + } + cursor = index + raw.length; + } + if (cursor !== input.length) { + const tail = input.slice(cursor); + if (tail.includes("<") || tail.includes(">")) throw new AssetPreflightError("active-content", "SVG markup is malformed", source, "export a well-formed static SVG"); + output.push(escapeXml(tail)); + } + if (stack.length !== 0 || output.length === 0 || !output[0].startsWith(" { + const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + try { + const before = await handle.stat(); + if (!before.isFile()) throw new AssetPreflightError("not-regular", "asset is not a regular file", source, "use a regular file"); + if (before.size > maxBytes) throw new AssetPreflightError("asset-too-large", `asset is ${before.size} bytes; limit is ${maxBytes}`, source, "reduce or optimize the asset"); + await hooks.afterOpen?.(path); + const buffer = Buffer.alloc(before.size); + let offset = 0; + while (offset < buffer.length) { + const result = await handle.read(buffer, offset, buffer.length - offset, offset); + if (result.bytesRead === 0) break; + offset += result.bytesRead; + } + const extra = Buffer.alloc(1); + const extraRead = await handle.read(extra, 0, 1, offset); + const after = await handle.stat(); + const identity = `${before.dev}:${before.ino}:${before.size}:${before.mtimeMs}`; + const afterIdentity = `${after.dev}:${after.ino}:${after.size}:${after.mtimeMs}`; + if (offset !== before.size || extraRead.bytesRead !== 0 || identity !== afterIdentity) throw new AssetPreflightError("changed-during-read", "asset changed while it was being read", source, "retry after writers have finished"); + return { bytes: buffer, identity }; + } finally { + await handle.close(); + } +} + +async function resolveAsset(root: string, declaration: AssetDeclaration, maxBytes: number, hooks: AssetResolutionHooks): Promise { + if (declaration.kind === "image" && declaration.alt?.trim() === "" && declaration.decorative !== true) { + throw new AssetPreflightError("missing-alt", "image is missing meaningful alt text", declaration.source, "add alt text, or use the exact title \"decorative\" for a decorative image"); + } + const relativePath = safeRelativePath(declaration.source); + const candidate = resolve(root, relativePath); + if (!contained(root, candidate)) throw new AssetPreflightError("unsafe-path", "asset resolves outside the worktree root", declaration.source, "use a contained relative path"); + await rejectSymlinkSegments(root, candidate, declaration.source); + const resolvedBefore = await realpath(candidate); + if (!contained(root, resolvedBefore)) throw new AssetPreflightError("unsafe-path", "asset realpath escapes the worktree root", declaration.source, "store the file directly beneath the worktree root"); + const declared = declaredMime(relativePath, declaration.kind); + const read = await boundedRead(resolvedBefore, declaration.source, maxBytes, hooks); + const resolvedAfter = await realpath(candidate); + if (resolvedAfter !== resolvedBefore) throw new AssetPreflightError("changed-during-read", "asset path changed while it was being read", declaration.source, "retry after filesystem changes have finished"); + const detected = detectedMime(read.bytes); + if (detected !== declared) throw new AssetPreflightError("type-mismatch", `asset bytes do not match declared ${declared} type`, declaration.source, "correct the extension or provide an allowlisted file"); + const safeBytes = declared === "image/svg+xml" ? sanitizeSvg(read.bytes, declaration.source) : read.bytes; + const encoded = safeBytes.toString("base64"); + const dataUri = `data:${declared};base64,${encoded}`; + return { + source: declaration.source, + relativePath: relativePath.split(sep).join("/"), + kind: declaration.kind, + mime: declared, + bytes: safeBytes.length, + encodedBytes: Buffer.byteLength(dataUri, "utf8"), + sha256: createHash("sha256").update(safeBytes).digest("hex"), + dataUri, + alt: declaration.alt, + decorative: declaration.decorative === true, + }; +} + +export async function resolvePortableAssets( + markdown: string, + worktreeRoot: string, + limits: AssetLimits = {}, + hooks: AssetResolutionHooks = {}, +): Promise { + const sourceBytes = Buffer.byteLength(markdown, "utf8"); + const maxMarkdownBytes = limits.maxMarkdownBytes ?? DEFAULT_MAX_MARKDOWN_BYTES; + if (sourceBytes > maxMarkdownBytes) throw new AssetPreflightError("source-too-large", `Markdown source is ${sourceBytes} bytes; limit is ${maxMarkdownBytes}`, undefined, "reduce the authoring source"); + const requestedRoot = resolve(worktreeRoot); + const requestedRootInfo = await lstat(requestedRoot); + if (!requestedRootInfo.isDirectory() || requestedRootInfo.isSymbolicLink()) throw new AssetPreflightError("unsafe-path", "worktree root is not a real directory", worktreeRoot, "select a real worktree directory"); + const root = await realpath(requestedRoot); + const rootInfo = await lstat(root); + if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) throw new AssetPreflightError("unsafe-path", "worktree root is not a real directory", worktreeRoot, "select a real worktree directory"); + const parsed = declarations(markdown); + const maxAssetCount = limits.maxAssetCount ?? DEFAULT_MAX_ASSET_COUNT; + if (parsed.length > maxAssetCount) throw new AssetPreflightError("too-many-assets", `document declares ${parsed.length} assets; limit is ${maxAssetCount}`, undefined, "reduce the number of declared assets"); + for (const declaration of parsed) { + if (declaration.kind === "image" && declaration.alt?.trim() === "" && declaration.decorative !== true) { + throw new AssetPreflightError("missing-alt", "image is missing meaningful alt text", declaration.source, "add alt text, or use the exact title \"decorative\" for a decorative image"); + } + } + const unique = new Map(); + for (const declaration of parsed) { + const existing = unique.get(`${declaration.kind}:${declaration.source}`); + if (existing?.alt && declaration.alt && existing.alt !== declaration.alt) { + // The bytes are shared, but each rendered token retains its own Markdown alt text. + continue; + } + unique.set(`${declaration.kind}:${declaration.source}`, declaration); + } + const bySource = new Map(); + let font: PortableAsset | undefined; + let assetBytes = 0; + let encodedBytes = 0; + const maxAssetBytes = limits.maxAssetBytes ?? DEFAULT_MAX_ASSET_BYTES; + const maxTotalBytes = limits.maxTotalBytes ?? DEFAULT_MAX_ASSET_TOTAL_BYTES; + for (const declaration of unique.values()) { + const asset = await resolveAsset(root, declaration, maxAssetBytes, hooks); + assetBytes += asset.bytes; + encodedBytes += asset.encodedBytes; + if (assetBytes > maxTotalBytes) throw new AssetPreflightError("assets-too-large", `asset bytes total ${assetBytes}; limit is ${maxTotalBytes}`, declaration.source, "reduce or remove assets"); + if (declaration.kind === "font") font = asset; + else bySource.set(declaration.source, asset); + } + return { bySource, font, sourceBytes, assetBytes, encodedBytes }; +} diff --git a/src/cli.ts b/src/cli.ts index c609de7..bf00b20 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,6 +2,9 @@ import { lstat, mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { renderArtifact, renderRawHtml } from "./render.ts"; +import type { PortableAssets } from "./assets.ts"; +import type { ResolvedDesignTokens } from "./design-tokens.ts"; +import { formatPreflight, preflightDocument, trustedHtmlDiagnostic } from "./preflight.ts"; import { FilePublisher, slugify } from "./publisher.ts"; import { GitHubPagesPublisher } from "./github-pages.ts"; import { CloudflarePublisher } from "./cloudflare-publisher.ts"; @@ -106,9 +109,31 @@ async function renderCommand(args: string[]): Promise { ); process.exit(1); } + let assets: PortableAssets | undefined; + let designTokens: ResolvedDesignTokens | undefined; + if (format === "html") { + console.error(`warning: ${trustedHtmlDiagnostic().message}`); + } else { + const preflight = await preflightDocument(markdown, { worktreeRoot: process.cwd() }); + const errors = preflight.diagnostics.filter((item) => item.severity === "error"); + if (errors.length > 0 || preflight.omitted > 0) { + console.error(formatPreflight(preflight)); + process.exit(1); + } + for (const warning of preflight.diagnostics) console.error(`warning: ${warning.code} at ${warning.line}:${warning.column}: ${warning.message}`); + assets = preflight.assets; + designTokens = preflight.designTokens; + } const rendered = - format === "html" ? renderRawHtml(markdown, title ? { title } : {}) : renderArtifact(markdown); + format === "html" ? renderRawHtml(markdown, title ? { title } : {}) : renderArtifact(markdown, { assets, designTokens }); const finalTitle = title ?? rendered.meta.title ?? "Artifact"; + const finalFindings = scanSensitive(rendered.html); + if (finalFindings.length > 0 && !force) { + console.error( + `publish blocked: final portable bytes contain credential-looking strings: ${formatFindings(finalFindings)}. Re-run with --force to publish anyway.`, + ); + process.exit(1); + } let outPath: string; if (out) { diff --git a/src/components.ts b/src/components.ts index 5a4bff1..cc2c2fd 100644 --- a/src/components.ts +++ b/src/components.ts @@ -1,4 +1,5 @@ import { escapeHtmlText } from "./text.ts"; +import { formatZonedTimestamp, isZonedIsoTimestamp, type LocaleContext } from "./locale.ts"; export type ComponentKind = | "stats" @@ -44,7 +45,7 @@ function num(record: Record, key: string): number | undefined { } function errorBox(kind: string, reason: string): string { - return `
${escapeHtmlText(`Component '${kind}' failed to render: ${reason}`)}
`; + return ``; } function toneClass(prefix: string, tone: string | undefined, allowed: readonly string[]): string { @@ -55,6 +56,165 @@ const STAT_TONES = ["good", "bad", "warn", "neutral"] as const; const ITEM_TONES = ["good", "bad", "warn", "info", "neutral"] as const; const SEVERITIES = ["critical", "high", "medium", "low"] as const; +export interface ComponentIssue { + code: string; + reason: string; + nextAction: string; +} + +export interface ComponentRenderContext { + locale: LocaleContext; +} + +const DEFAULT_CONTEXT: ComponentRenderContext = { + locale: { lang: "en", dir: "ltr", locale: "en-US", timeZone: "UTC" }, +}; + +function mermaidParts(source: string): { summary?: string; body: string } { + const lines = source.trim().split("\n"); + const match = lines[0]?.match(/^%%\s*summary:\s*(.+)$/i); + return { summary: match?.[1].trim(), body: match ? lines.slice(1).join("\n").trim() : source.trim() }; +} + +function issue(code: string, reason: string, nextAction: string): ComponentIssue[] { + return [{ code, reason, nextAction }]; +} + +function recordEntries(value: unknown, kind: string): ComponentIssue[] { + if (!Array.isArray(value)) return issue(`${kind}-shape`, "expected a JSON array", "provide the documented array schema"); + return value.flatMap((entry, index) => + asRecord(entry) === undefined + ? issue(`${kind}-entry`, `entry ${index + 1} must be an object`, "replace non-object entries") + : [], + ); +} + +function requiredStrings(value: unknown[], kind: string, fields: string[]): ComponentIssue[] { + const issues: ComponentIssue[] = []; + for (const [index, entry] of value.entries()) { + const item = asRecord(entry); + if (!item) continue; + for (const field of fields) { + if (typeof item[field] !== "string" || item[field] === "") { + issues.push(...issue(`${kind}-${field}`, `entry ${index + 1} requires string '${field}'`, `add ${field} to every ${kind} entry`)); + } + } + } + return issues; +} + +export function validateComponent(kind: ComponentKind, source: string): ComponentIssue[] { + if (kind === "diff") return source.trim() === "" ? issue("diff-empty", "diff source is empty", "add unified diff lines") : []; + if (kind === "mermaid") { + if (source.trim() === "") return issue("mermaid-empty", "Mermaid source is empty", "add a Mermaid diagram"); + const parsed = mermaidParts(source); + if (!parsed.summary) return issue("mermaid-summary", "Mermaid diagram needs a text summary", "start with %% summary: followed by the diagram's meaning"); + if (parsed.body === "") return issue("mermaid-empty", "Mermaid diagram body is empty", "add diagram syntax after the summary"); + return []; + } + let value: unknown; + try { + value = JSON.parse(source); + } catch { + return issue(`${kind}-json`, "component JSON is invalid", "provide valid JSON matching the documented schema"); + } + if (["stats", "timeline", "findings", "compare"].includes(kind)) { + const issues = recordEntries(value, kind); + if (!Array.isArray(value)) return issues; + const required = kind === "stats" ? ["label", "value"] : kind === "timeline" ? ["time", "title"] : ["title"]; + issues.push(...requiredStrings(value, kind, required)); + for (const [index, entry] of value.entries()) { + const item = asRecord(entry); + if (!item) continue; + if (kind === "findings" && !SEVERITIES.includes(str(item, "severity") as typeof SEVERITIES[number])) { + issues.push(...issue("findings-severity", `entry ${index + 1} needs an allowlisted severity`, "use critical, high, medium, or low")); + } + if (kind === "stats") { + const direction = str(item, "direction"); + if (direction !== undefined && direction !== "up" && direction !== "down") { + issues.push(...issue("stats-direction", `entry ${index + 1} direction must be up or down`, "correct or remove direction")); + } + } + if (kind === "compare") { + const annotations = item["annotations"]; + if (annotations !== undefined && (!Array.isArray(annotations) || annotations.some((note) => typeof note !== "string"))) { + issues.push(...issue("compare-annotations", `entry ${index + 1} annotations must be strings`, "replace non-string annotations")); + } + } + } + return issues; + } + const item = asRecord(value); + if (!item) return issue(`${kind}-shape`, "expected a JSON object", "provide the documented object schema"); + if (kind === "callout") { + const tone = str(item, "tone"); + return tone !== undefined && !ITEM_TONES.includes(tone as typeof ITEM_TONES[number]) ? issue("callout-tone", "tone is not allowlisted", "use good, bad, warn, info, or neutral") : []; + } + if (kind === "progress") { + const done = num(item, "done"); + const total = num(item, "total"); + return done === undefined || total === undefined || total <= 0 || done < 0 || done > total ? issue("progress-range", "done and total must describe a finite range", "use 0 <= done <= total and total > 0") : []; + } + if (kind === "copy") return typeof item["text"] !== "string" ? issue("copy-text", "copy text is missing", "add string 'text'") : []; + if (kind === "decisions") { + if (!Array.isArray(item["questions"])) return issue("decisions-questions", "questions must be an array", "add the documented questions array"); + const ids = new Set(); + const issues: ComponentIssue[] = []; + for (const [index, questionValue] of item["questions"].entries()) { + const question = asRecord(questionValue); + if (!question || !str(question, "id") || !str(question, "question") || !Array.isArray(question["options"])) { + issues.push(...issue("decisions-question", `question ${index + 1} needs id, question, and options`, "complete every question object")); + continue; + } + const id = str(question, "id")!; + if (ids.has(id)) issues.push(...issue("decisions-id", `question id '${id}' is duplicated`, "assign stable unique ids")); + ids.add(id); + for (const [optionIndex, optionValue] of question["options"].entries()) { + const option = asRecord(optionValue); + if (!option || !str(option, "id") || !str(option, "label")) { + issues.push(...issue("decisions-option", `question ${index + 1} option ${optionIndex + 1} needs id and label`, "complete every option object")); + } + } + } + return issues; + } + if (kind === "table") { + if (!Array.isArray(item["columns"]) || !Array.isArray(item["rows"])) return issue("table-shape", "columns and rows must be arrays", "add both documented arrays"); + if (!str(item, "caption")) return issue("table-caption", "table caption is required", "add a concise caption describing the table"); + const keys = new Set(); + const issues: ComponentIssue[] = []; + const dateColumns: Array<{ key: string; type: "date" | "datetime" }> = []; + for (const [index, columnValue] of item["columns"].entries()) { + const column = asRecord(columnValue); + const key = column && str(column, "key"); + if (!column || !key || !str(column, "label")) { + issues.push(...issue("table-column", `column ${index + 1} needs key and label`, "complete every column object")); + continue; + } + if (keys.has(key)) issues.push(...issue("table-key", `column key '${key}' is duplicated`, "assign unique column keys")); + keys.add(key); + const type = str(column, "type"); + if (type !== undefined && type !== "num" && type !== "date" && type !== "datetime") issues.push(...issue("table-type", `column ${index + 1} type is unsupported`, "use num, date, datetime, or omit type")); + if (type === "date" || type === "datetime") dateColumns.push({ key, type }); + } + for (const [index, row] of item["rows"].entries()) { + const record = asRecord(row); + if (record === undefined) { + issues.push(...issue("table-row", `row ${index + 1} must be an object`, "replace non-object rows")); + continue; + } + for (const column of dateColumns) { + const value = record[column.key]; + if (value !== undefined && (typeof value !== "string" || !isZonedIsoTimestamp(value))) { + issues.push(...issue("table-date", `row ${index + 1} '${column.key}' needs an ISO timestamp with time zone`, "use a timestamp ending in Z or an explicit offset")); + } + } + } + return issues; + } + return []; +} + function renderStats(spec: unknown): string { if (!Array.isArray(spec)) return errorBox("stats", "expected a JSON array"); const cards = spec.map((entry) => { @@ -170,7 +330,7 @@ function renderProgress(spec: unknown): string { const label = str(item, "label"); const percent = Math.min(100, Math.round((done / total) * 100)); return [ - '
', + `
`, `
${escapeHtmlText(label ?? "Progress")} — ${done}/${total}
`, `
`, "
", @@ -205,9 +365,9 @@ function renderCopy(spec: unknown, id: string | undefined): string { const item } function renderMermaid(source: string): string { - const trimmed = source.trim(); - if (trimmed === "") return errorBox("mermaid", "empty diagram source"); - return `
${escapeHtmlText(trimmed)}
`; + const parsed = mermaidParts(source); + if (!parsed.summary || parsed.body === "") return errorBox("mermaid", "missing summary or diagram source"); + return `
${escapeHtmlText(parsed.summary)}
`; } function renderDecisions(spec: unknown): string { @@ -216,21 +376,22 @@ function renderDecisions(spec: unknown): string { const questions = item["questions"]; if (!Array.isArray(questions)) return errorBox("decisions", "missing 'questions' array"); - const blocks = questions.map((entry) => { + const blocks = questions.map((entry, questionIndex) => { const question = asRecord(entry); if (!question) return errorBox("decisions", "questions must be objects"); const qid = str(question, "id") ?? "q"; const text = str(question, "question") ?? ""; const options = Array.isArray(question["options"]) ? question["options"] : []; + const groupId = `decision-question-${questionIndex}`; const buttons = options - .map((optEntry) => { + .map((optEntry, optionIndex) => { const opt = asRecord(optEntry); if (!opt) return ""; const oid = str(opt, "id") ?? "opt"; const label = str(opt, "label") ?? ""; const note = str(opt, "note"); return [ - ``, + ``, ) .join(""); @@ -292,9 +455,13 @@ function renderTable(spec: unknown): string { .map((c) => { const raw = record[c!.key]; const isNum = c!.type === "num" && typeof raw === "number" && Number.isFinite(raw); + const isDate = (c!.type === "date" || c!.type === "datetime") && typeof raw === "string"; + const formattedDate = isDate ? formatZonedTimestamp(raw, context.locale, c!.type === "datetime") : undefined; const display = isNum - ? raw.toLocaleString("en-US") - : escapeHtmlText(raw === undefined || raw === null ? "—" : String(raw)); + ? new Intl.NumberFormat(context.locale.locale).format(raw) + : formattedDate !== undefined + ? `` + : escapeHtmlText(raw === undefined || raw === null ? "—" : String(raw)); const dataV = isNum ? ` data-v="${raw}"` : ` data-v="${escapeHtmlText(String(raw ?? ""))}"`; return `${display}`; }) @@ -304,17 +471,22 @@ function renderTable(spec: unknown): string { .join("\n"); const caption = str(item, "caption"); + const tableId = escapeHtmlText(id ?? "table-0"); + const countId = `${tableId}-count`; return [ '
', - '', - `
${head}`, + ``, + ``, + `
${head}`, `${body}
${escapeHtmlText(caption ?? "Table")}
`, - `
${rows.length} rows${caption ? `${escapeHtmlText(caption)}` : ""}
`, + `
${rows.length} rows
`, "
", ].join("\n"); } -export function renderComponent(kind: ComponentKind, json: string, id?: string): string { +export function renderComponent(kind: ComponentKind, json: string, id?: string, context: ComponentRenderContext = DEFAULT_CONTEXT): string { + const issues = validateComponent(kind, json); + if (issues.length > 0) return errorBox(kind, issues[0].reason); if (kind === "diff") return renderDiff(json); if (kind === "mermaid") return renderMermaid(json); let spec: unknown; @@ -341,6 +513,6 @@ export function renderComponent(kind: ComponentKind, json: string, id?: string): case "decisions": return renderDecisions(spec); case "table": - return renderTable(spec); + return renderTable(spec, context, id); } } diff --git a/src/design-tokens.ts b/src/design-tokens.ts new file mode 100644 index 0000000..0514d11 --- /dev/null +++ b/src/design-tokens.ts @@ -0,0 +1,353 @@ +import { constants } from "node:fs"; +import { lstat, open } from "node:fs/promises"; +import { join } from "node:path"; + +export const DESIGN_TOKEN_FILE = join(".opencode", "artifact-tokens.json"); +export const MAX_DESIGN_TOKEN_BYTES = 8 * 1024; + +const TOKEN_NAMES = [ + "pageBackground", + "surface", + "text", + "mutedText", + "border", + "accent", + "font", + "spacing", + "radius", + "density", +] as const; +const COLOR_NAMES = ["pageBackground", "surface", "text", "mutedText", "border", "accent"] as const; + +export type DesignTokenName = typeof TOKEN_NAMES[number]; +export type DesignTokenSource = "default" | "theme" | "project" | "prompt"; +export type DesignTokenValues = Record; + +export interface DesignTokenIssue { + code: string; + reason: string; + nextAction: string; + source: "project" | "prompt"; + promptIndex?: number; +} + +export interface ResolvedDesignTokens { + css: string; + values: DesignTokenValues; + provenance: Record; + active: boolean; + fixesColorMode: boolean; +} + +export interface DesignTokenResolution { + designTokens: ResolvedDesignTokens; + issues: DesignTokenIssue[]; +} + +export interface ProjectDesignTokenSource { + value?: unknown; + issue?: DesignTokenIssue; +} + +const BASE: DesignTokenValues = { + pageBackground: "#e9edf2", + surface: "#ffffff", + text: "#111827", + mutedText: "#4b5563", + border: "#e5e7eb", + accent: "#5f5dbf", + font: "system", + spacing: "comfortable", + radius: "round", + density: "comfortable", +}; + +const THEME_VALUES: Record> = { + report: { + pageBackground: "#f6f0e4", + surface: "#fffdf7", + text: "#2b251a", + mutedText: "#6b5f49", + border: "#e3d9c4", + accent: "#8f3f13", + }, + ops: { + pageBackground: "#0f140f", + surface: "#171f17", + text: "#d5e5cf", + mutedText: "#8fa389", + border: "#263026", + accent: "#4ade80", + }, + editorial: { + pageBackground: "#fafafa", + surface: "#ffffff", + text: "#141414", + mutedText: "#525252", + border: "#e5e5e5", + accent: "#141414", + radius: "sharp", + }, +}; + +const FONT_STACKS: Record = { + system: `system-ui,-apple-system,"Segoe UI",sans-serif`, + serif: `Georgia,Charter,"Times New Roman",serif`, + mono: `ui-monospace,SFMono-Regular,Menlo,monospace`, +}; +const SPACING: Record = { + compact: ["1rem", "2rem", ".9rem"], + comfortable: ["1.5rem", "3rem", "1.25rem"], + spacious: ["2rem", "4rem", "1.75rem"], +}; +const RADII: Record = { square: "0", sharp: "4px", soft: "8px", round: "16px" }; +const DENSITY: Record = { + compact: ["1rem", "1.15rem", ".82rem"], + comfortable: ["1.5rem", "1.75rem", ".86rem"], + airy: ["2rem", "2.25rem", ".92rem"], +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function issue(source: "project" | "prompt", code: string, reason: string, nextAction: string, promptIndex?: number): DesignTokenIssue { + return { code, reason, nextAction, source, ...(promptIndex === undefined ? {} : { promptIndex }) }; +} + +function parsePacket(value: unknown, source: "project" | "prompt", promptIndex?: number): { tokens?: Partial; issues: DesignTokenIssue[] } { + if (!isRecord(value)) return { issues: [issue(source, "design-shape", "design token source must be an object", "use the documented schemaVersion and tokens object", promptIndex)] }; + const issues: DesignTokenIssue[] = []; + for (const key of Object.keys(value)) { + if (key !== "schemaVersion" && key !== "tokens") issues.push(issue(source, "design-root-key", `unknown design root key '${key}'`, "keep only schemaVersion and tokens", promptIndex)); + } + if (value["schemaVersion"] !== 1 || !isRecord(value["tokens"])) { + issues.push(issue(source, "design-schema", "design token schema is not version 1", "use {\"schemaVersion\":1,\"tokens\":{...}}", promptIndex)); + return { issues }; + } + const tokenObject = value["tokens"]; + for (const key of Object.keys(tokenObject)) { + if (!(TOKEN_NAMES as readonly string[]).includes(key)) issues.push(issue(source, "design-token-unknown", `unknown design token '${key}'`, "remove it or use a documented token name", promptIndex)); + } + const tokens: Partial = {}; + for (const name of TOKEN_NAMES) { + const tokenValue = tokenObject[name]; + if (tokenValue === undefined) continue; + if (typeof tokenValue !== "string") { + issues.push(issue(source, "design-token-type", `design token '${name}' must be a string`, "use a documented string value", promptIndex)); + continue; + } + if ((COLOR_NAMES as readonly string[]).includes(name)) { + if (!/^#[0-9a-fA-F]{6}$/.test(tokenValue)) { + issues.push(issue(source, "design-color", `design token '${name}' is not a six-digit hex color`, "use a value such as #1f2937", promptIndex)); + continue; + } + tokens[name] = tokenValue.toLowerCase(); + } else if (name === "font") { + if (!(tokenValue in FONT_STACKS)) { + issues.push(issue(source, "design-font", "font is not allowlisted", "use system, serif, or mono", promptIndex)); + continue; + } + tokens[name] = tokenValue; + } else if (name === "spacing") { + if (!(tokenValue in SPACING)) { + issues.push(issue(source, "design-spacing", "spacing is not allowlisted", "use compact, comfortable, or spacious", promptIndex)); + continue; + } + tokens[name] = tokenValue; + } else if (name === "radius") { + if (!(tokenValue in RADII)) { + issues.push(issue(source, "design-radius", "radius is not allowlisted", "use square, sharp, soft, or round", promptIndex)); + continue; + } + tokens[name] = tokenValue; + } else { + if (!(tokenValue in DENSITY)) { + issues.push(issue(source, "design-density", "density is not allowlisted", "use compact, comfortable, or airy", promptIndex)); + continue; + } + tokens[name] = tokenValue; + } + } + return { tokens, issues }; +} + +function channel(value: number): number { + const normalized = value / 255; + return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; +} + +function luminance(color: string): number { + return 0.2126 * channel(Number.parseInt(color.slice(1, 3), 16)) + + 0.7152 * channel(Number.parseInt(color.slice(3, 5), 16)) + + 0.0722 * channel(Number.parseInt(color.slice(5, 7), 16)); +} + +export function contrastRatio(first: string, second: string): number { + const [lighter, darker] = [luminance(first), luminance(second)].sort((a, b) => b - a); + return (lighter + 0.05) / (darker + 0.05); +} + +function contrastIssue(values: DesignTokenValues, source: "project" | "prompt", promptIndex?: number): DesignTokenIssue | undefined { + const pairs: Array<[string, string, number, string]> = [ + [values.text, values.pageBackground, 4.5, "text/pageBackground"], + [values.text, values.surface, 4.5, "text/surface"], + [values.mutedText, values.pageBackground, 4.5, "mutedText/pageBackground"], + [values.mutedText, values.surface, 4.5, "mutedText/surface"], + [values.accent, values.pageBackground, 4.5, "accent/pageBackground"], + [values.accent, values.surface, 4.5, "accent/surface"], + ]; + const failed = pairs.find(([foreground, background, minimum]) => contrastRatio(foreground, background) < minimum); + return failed === undefined + ? undefined + : issue(source, "design-contrast", `design color pair ${failed[3]} does not meet its contrast floor`, "choose colors meeting WCAG 2.2 AA contrast", promptIndex); +} + +function cssFor(values: DesignTokenValues, provenance: Record, fixesColorMode: boolean): string { + const [bodyPad, bodyPadBottom, sectionGap] = SPACING[values.spacing]; + const [sectionPadY, sectionPadX, tableSize] = DENSITY[values.density]; + const font = FONT_STACKS[values.font]; + const customized = (name: DesignTokenName): boolean => provenance[name] === "project" || provenance[name] === "prompt"; + const declarations: string[] = []; + if (fixesColorMode) { + const accentInk = contrastRatio(values.accent, "#ffffff") >= 4.5 ? "#ffffff" : "#111827"; + declarations.push(`--page-bg:${values.pageBackground}`, `--card-bg:${values.surface}`, `--ink:${values.text}`, `--ink-2:${values.mutedText}`, `--line:${values.border}`, `--accent:${values.accent}`, `--accent-ink:${accentInk}`); + } + if (customized("font")) declarations.push(`--artifact-font:${font}`, `--artifact-heading-font:${font}`); + if (customized("spacing")) declarations.push(`--body-pad:${bodyPad}`, `--body-pad-bottom:${bodyPadBottom}`, `--section-gap:${sectionGap}`); + if (customized("density")) declarations.push(`--section-pad-y:${sectionPadY}`, `--section-pad-x:${sectionPadX}`, `--table-font-size:${tableSize}`); + if (customized("radius")) declarations.push(`--radius:${RADII[values.radius]}`); + return `:root[data-design-tokens]{${declarations.join(";")}}`; +} + +function parsedJson(source: string, index: number): { value?: unknown; issue?: DesignTokenIssue } { + if (Buffer.byteLength(source, "utf8") > MAX_DESIGN_TOKEN_BYTES) { + return { issue: issue("prompt", "design-prompt-too-large", "prompt design tokens exceed the 8192-byte limit", "reduce the design token declaration", index) }; + } + try { + return { value: JSON.parse(source) as unknown }; + } catch { + return { issue: issue("prompt", "design-json", "prompt design tokens are not valid JSON", "provide the version 1 JSON object", index) }; + } +} + +export function resolveDesignTokens(theme: string | undefined, project: ProjectDesignTokenSource | undefined, promptSources: readonly string[]): DesignTokenResolution { + const namedTheme = theme !== undefined && THEME_VALUES[theme] !== undefined; + let values: DesignTokenValues = { ...BASE, ...(namedTheme ? THEME_VALUES[theme] : {}) }; + const baseline: DesignTokenSource = namedTheme ? "theme" : "default"; + const provenance = Object.fromEntries(TOKEN_NAMES.map((name) => [name, baseline])) as Record; + const issues: DesignTokenIssue[] = []; + let active = false; + let fixesColorMode = false; + + const apply = (tokens: Partial, source: "project" | "prompt", promptIndex?: number): void => { + const candidate = { ...values, ...tokens }; + const invalidContrast = contrastIssue(candidate, source, promptIndex); + if (invalidContrast) { + issues.push(invalidContrast); + return; + } + values = candidate; + for (const name of TOKEN_NAMES) { + if (tokens[name] !== undefined) provenance[name] = source; + } + active ||= Object.keys(tokens).length > 0; + fixesColorMode ||= COLOR_NAMES.some((name) => tokens[name] !== undefined); + }; + + if (project?.issue) issues.push(project.issue); + else if (project?.value !== undefined) { + const parsed = parsePacket(project.value, "project"); + if (parsed.issues.length > 0) issues.push(...parsed.issues); + else apply(parsed.tokens ?? {}, "project"); + } + + if (promptSources.length > 1) { + issues.push(issue("prompt", "design-prompt-duplicate", "only one design-tokens fence is allowed", "merge prompt overrides into one version 1 declaration", 1)); + for (const [index, source] of promptSources.entries()) { + const decoded = parsedJson(source, index); + if (decoded.issue) { + issues.push(decoded.issue); + continue; + } + const parsed = parsePacket(decoded.value, "prompt", index); + if (parsed.issues.length > 0) { + issues.push(...parsed.issues); + continue; + } + const invalidContrast = contrastIssue({ ...values, ...(parsed.tokens ?? {}) }, "prompt", index); + if (invalidContrast) issues.push(invalidContrast); + } + } else if (promptSources.length === 1) { + const decoded = parsedJson(promptSources[0], 0); + if (decoded.issue) issues.push(decoded.issue); + else { + const parsed = parsePacket(decoded.value, "prompt", 0); + if (parsed.issues.length > 0) issues.push(...parsed.issues); + else apply(parsed.tokens ?? {}, "prompt", 0); + } + } + + return { + designTokens: { css: active ? cssFor(values, provenance, fixesColorMode) : "", values, provenance, active, fixesColorMode }, + issues, + }; +} + +function projectIssue(code: string, reason: string, nextAction: string): ProjectDesignTokenSource { + return { issue: issue("project", code, reason, nextAction) }; +} + +export async function loadProjectDesignTokens(worktreeRoot: string): Promise { + let rootInfo; + try { + rootInfo = await lstat(worktreeRoot); + } catch { + return projectIssue("design-project-root", "design token worktree root is unavailable", "select an existing worktree directory"); + } + if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) return projectIssue("design-project-root", "design token worktree root is unsafe", "select a real worktree directory"); + const directory = join(worktreeRoot, ".opencode"); + try { + const directoryInfo = await lstat(directory); + if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink()) return projectIssue("design-project-path", "the .opencode design token directory is unsafe", "use a real project directory without symlinks"); + } catch (error) { + if (isRecord(error) && error["code"] === "ENOENT") return undefined; + return projectIssue("design-project-path", "the .opencode design token directory cannot be inspected", "repair the project directory"); + } + const path = join(worktreeRoot, DESIGN_TOKEN_FILE); + try { + const info = await lstat(path); + if (info.isSymbolicLink() || !info.isFile()) return projectIssue("design-project-file", "project design tokens are not a regular file", `replace ${DESIGN_TOKEN_FILE} with a regular file`); + } catch (error) { + if (isRecord(error) && error["code"] === "ENOENT") return undefined; + return projectIssue("design-project-file", "project design tokens cannot be inspected", `repair ${DESIGN_TOKEN_FILE}`); + } + let handle; + try { + handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + const before = await handle.stat(); + if (!before.isFile()) return projectIssue("design-project-file", "project design tokens are not a regular file", `replace ${DESIGN_TOKEN_FILE} with a regular file`); + if (before.size > MAX_DESIGN_TOKEN_BYTES) return projectIssue("design-project-too-large", "project design tokens exceed the 8192-byte limit", "reduce the file to the documented schema"); + const buffer = Buffer.alloc(MAX_DESIGN_TOKEN_BYTES + 1); + let offset = 0; + while (offset < buffer.length) { + const read = await handle.read(buffer, offset, buffer.length - offset, offset); + if (read.bytesRead === 0) break; + offset += read.bytesRead; + } + const after = await handle.stat(); + if (offset > MAX_DESIGN_TOKEN_BYTES) return projectIssue("design-project-too-large", "project design tokens exceed the 8192-byte limit", "reduce the file to the documented schema"); + if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs) { + return projectIssue("design-project-changed", "project design tokens changed while being read", "retry after project writers finish"); + } + try { + return { value: JSON.parse(buffer.subarray(0, offset).toString("utf8")) as unknown }; + } catch { + return projectIssue("design-json", "project design tokens are not valid JSON", "provide the version 1 JSON object"); + } + } catch { + return projectIssue("design-project-file", "project design tokens cannot be opened safely", `repair ${DESIGN_TOKEN_FILE}`); + } finally { + await handle?.close(); + } +} diff --git a/src/locale.ts b/src/locale.ts new file mode 100644 index 0000000..8d29bba --- /dev/null +++ b/src/locale.ts @@ -0,0 +1,58 @@ +import type { Frontmatter } from "./markdown.ts"; + +export type TextDirection = "ltr" | "rtl"; + +export interface LocaleContext { + lang: string; + dir: TextDirection; + locale: string; + timeZone: string; +} + +const RTL_LANGUAGES = new Set(["ar", "ckb", "dv", "fa", "he", "ku", "ps", "sd", "ug", "ur", "yi"]); + +export function canonicalLocale(value: string): string | undefined { + try { + return Intl.getCanonicalLocales(value)[0]; + } catch { + return undefined; + } +} + +export function validTimeZone(value: string): boolean { + try { + new Intl.DateTimeFormat("en-US", { timeZone: value }).format(0); + return true; + } catch { + return false; + } +} + +export function resolveLocaleContext(meta: Frontmatter): LocaleContext { + const locale = canonicalLocale(meta.locale ?? meta.lang ?? "en-US") ?? "en-US"; + const lang = canonicalLocale(meta.lang ?? meta.locale ?? "en") ?? "en"; + const language = new Intl.Locale(lang).language; + const dir = meta.dir === "ltr" || meta.dir === "rtl" + ? meta.dir + : RTL_LANGUAGES.has(language) ? "rtl" : "ltr"; + return { + lang, + dir, + locale, + timeZone: meta.timezone !== undefined && validTimeZone(meta.timezone) ? meta.timezone : "UTC", + }; +} + +export function isZonedIsoTimestamp(value: string): boolean { + return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) + && Number.isFinite(Date.parse(value)); +} + +export function formatZonedTimestamp(value: string, context: LocaleContext, includeTime: boolean): string | undefined { + if (!isZonedIsoTimestamp(value)) return undefined; + return new Intl.DateTimeFormat(context.locale, { + timeZone: context.timeZone, + dateStyle: "medium", + ...(includeTime ? { timeStyle: "short" as const } : {}), + }).format(new Date(value)); +} diff --git a/src/markdown.ts b/src/markdown.ts index 2a327fd..25f0f72 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -1,5 +1,6 @@ import MarkdownIt from "markdown-it"; import { COMPONENT_KINDS, type ComponentKind } from "./components.ts"; +import type { PortableAsset } from "./assets.ts"; export interface Frontmatter { title?: string; @@ -7,6 +8,11 @@ export interface Frontmatter { description?: string; theme?: string; source?: string; + font?: string; + lang?: string; + dir?: string; + locale?: string; + timezone?: string; } export type ChartKind = "vega-lite" | "vega" | "echarts"; @@ -22,20 +28,30 @@ export interface ComponentBlock { json: string; } +export interface DesignTokenBlock { + json: string; + line: number; +} + export interface ParsedDocument { meta: Frontmatter; bodyHtml: string; charts: ChartSpec[]; components: ComponentBlock[]; + designTokens: DesignTokenBlock[]; warnings: string[]; } +export interface ParseDocumentOptions { + assets?: ReadonlyMap; +} + const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/; const KEY_VALUE_RE = /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/; -function parseFrontmatter(source: string, warnings: string[]): { meta: Frontmatter; body: string } { +function parseFrontmatter(source: string, warnings: string[]): { meta: Frontmatter; body: string; lineOffset: number } { const match = source.match(FRONTMATTER_RE); - if (!match) return { meta: {}, body: source }; + if (!match) return { meta: {}, body: source, lineOffset: 0 }; const meta: Frontmatter = {}; for (const line of match[1].split(/\r?\n/)) { if (!line.trim()) continue; @@ -51,20 +67,41 @@ function parseFrontmatter(source: string, warnings: string[]): { meta: Frontmatt else if (key === "description") meta.description = value; else if (key === "theme") meta.theme = value; else if (key === "source") meta.source = value; + else if (key === "font") meta.font = value; + else if (key === "lang") meta.lang = value; + else if (key === "dir") meta.dir = value; + else if (key === "locale") meta.locale = value; + else if (key === "timezone") meta.timezone = value; else warnings.push(`frontmatter key ignored: ${key}`); } - return { meta, body: source.slice(match[0].length) }; + return { meta, body: source.slice(match[0].length), lineOffset: (match[0].match(/\n/g) ?? []).length }; } -export function parseDocument(source: string): ParsedDocument { +export function parseDocument(source: string, options: ParseDocumentOptions = {}): ParsedDocument { const warnings: string[] = []; - const { meta, body } = parseFrontmatter(source, warnings); + const { meta, body, lineOffset } = parseFrontmatter(source, warnings); const charts: ChartSpec[] = []; const components: ComponentBlock[] = []; + const designTokens: DesignTokenBlock[] = []; const md = new MarkdownIt({ html: false, linkify: true }); const escapeHtml = md.utils.escapeHtml; + md.renderer.rules.image = (tokens, idx, renderOptions, env, renderer) => { + const token = tokens[idx]; + const assetSource = token.attrGet("src") ?? ""; + const asset = options.assets?.get(assetSource); + if (asset === undefined) { + return `Asset preflight required: ${escapeHtml(assetSource)}`; + } + const alt = renderer.renderInlineAsText(token.children ?? [], renderOptions, env); + const decorative = token.attrGet("title")?.trim().toLowerCase() === "decorative"; + const title = token.attrGet("title"); + const titleAttribute = title !== null && !decorative ? ` title="${escapeHtml(title)}"` : ""; + const decorativeAttributes = decorative ? ' role="presentation"' : ""; + return `${decorative ? `; + }; + md.renderer.rules.fence = (tokens, idx) => { const token = tokens[idx]; const info = token.info.trim().split(/\s+/)[0] ?? ""; @@ -78,9 +115,13 @@ export function parseDocument(source: string): ParsedDocument { components.push({ kind: info as ComponentKind, json: token.content }); return `
\n`; } + if (info === "design-tokens") { + designTokens.push({ json: token.content, line: (token.map?.[0] ?? 0) + lineOffset + 1 }); + return ""; + } return `
${escapeHtml(token.content)}
\n`; }; const bodyHtml = md.render(body); - return { meta, bodyHtml, charts, components, warnings }; + return { meta, bodyHtml, charts, components, designTokens, warnings }; } diff --git a/src/performance.ts b/src/performance.ts new file mode 100644 index 0000000..09537bd --- /dev/null +++ b/src/performance.ts @@ -0,0 +1,140 @@ +export type RendererWorkload = "no-runtime" | "one-chart" | "multi-runtime"; + +export interface SamplingPolicy { + minimumSamples: number; + noiseFloorMs?: number; + maxRelativeP95Spread: number; +} + +export interface TimingSummary { + samplesMs: number[]; + count: number; + minMs: number; + maxMs: number; + p50Ms: number; + p95Ms: number; + relativeP95Spread: number; + disposition: "stable" | "noisy" | "insufficient" | "invalid"; + reasons: string[]; +} + +export interface TimeBudgetResult { + limitMs: number; + p95Ms: number | null; + pass: boolean; + reasons: string[]; +} + +export interface ByteBudgetResult { + totalBytes: number; + warningBytes: number; + hardBytes: number; + remainingToHardBytes: number; + status: "pass" | "warning" | "fail"; +} + +export interface RendererEnvironment { + profile: string; + platform: string; + arch: string; + nodeMajor: number; + cpuQuotaCores: number | null; + memoryLimitBytes: number | null; + browserName?: string; + browserMajor?: number; +} + +export interface EnvironmentComparison { + comparable: boolean; + mismatches: string[]; +} + +export interface BrowserBudgetResult { + usefulContent: TimeBudgetResult; + keyboardAdditional: TimeBudgetResult; + hardFailures: string[]; + pass: boolean; +} + +function finiteNonNegative(value: number): boolean { + return Number.isFinite(value) && value >= 0; +} + +export function nearestRank(samples: readonly number[], percentile: number): number { + if (samples.length === 0) throw new Error("percentile requires at least one sample"); + if (!samples.every(finiteNonNegative)) throw new Error("samples must be finite non-negative numbers"); + if (!Number.isFinite(percentile) || percentile <= 0 || percentile > 1) throw new Error("percentile must be in (0, 1]"); + const sorted = [...samples].sort((a, b) => a - b); + return sorted[Math.ceil(sorted.length * percentile) - 1]; +} + +export function summarizeTimings(samples: readonly number[], policy: SamplingPolicy): TimingSummary { + const values = [...samples]; + const reasons: string[] = []; + if (!Number.isInteger(policy.minimumSamples) || policy.minimumSamples < 1) throw new Error("minimumSamples must be a positive integer"); + if (policy.noiseFloorMs !== undefined && !finiteNonNegative(policy.noiseFloorMs)) throw new Error("noiseFloorMs must be non-negative"); + if (!finiteNonNegative(policy.maxRelativeP95Spread)) throw new Error("maxRelativeP95Spread must be non-negative"); + if (!values.every(finiteNonNegative)) reasons.push("samples contain an invalid duration"); + if (values.length < policy.minimumSamples) reasons.push(`requires at least ${policy.minimumSamples} samples`); + if (reasons.some((reason) => reason.includes("invalid")) || values.length === 0) { + return { samplesMs: values, count: values.length, minMs: 0, maxMs: 0, p50Ms: 0, p95Ms: 0, relativeP95Spread: 0, disposition: "invalid", reasons }; + } + const minMs = Math.min(...values); + const maxMs = Math.max(...values); + const p50Ms = nearestRank(values, 0.5); + const p95Ms = nearestRank(values, 0.95); + const relativeP95Spread = (p95Ms - p50Ms) / Math.max(p50Ms, policy.noiseFloorMs ?? 1); + if (relativeP95Spread > policy.maxRelativeP95Spread) reasons.push(`relative p95 spread ${relativeP95Spread.toFixed(4)} exceeds ${policy.maxRelativeP95Spread}`); + const disposition = values.length < policy.minimumSamples + ? "insufficient" + : reasons.length > 0 ? "noisy" : "stable"; + return { samplesMs: values, count: values.length, minMs, maxMs, p50Ms, p95Ms, relativeP95Spread, disposition, reasons }; +} + +export function evaluateTimeBudget(summary: TimingSummary, limitMs: number): TimeBudgetResult { + if (!finiteNonNegative(limitMs)) throw new Error("time limit must be non-negative"); + const reasons = [...summary.reasons]; + if (summary.disposition !== "stable") reasons.push(`sample disposition is ${summary.disposition}`); + if (summary.p95Ms > limitMs) reasons.push(`p95 ${summary.p95Ms} ms exceeds ${limitMs} ms`); + return { limitMs, p95Ms: summary.count === 0 ? null : summary.p95Ms, pass: reasons.length === 0, reasons }; +} + +export function evaluateByteBudget(totalBytes: number, warningBytes: number, hardBytes: number): ByteBudgetResult { + if (![totalBytes, warningBytes, hardBytes].every((value) => Number.isInteger(value) && value >= 0)) throw new Error("byte budgets must be non-negative integers"); + if (warningBytes > hardBytes) throw new Error("warning byte threshold cannot exceed hard threshold"); + return { + totalBytes, + warningBytes, + hardBytes, + remainingToHardBytes: Math.max(0, hardBytes - totalBytes), + status: totalBytes > hardBytes ? "fail" : totalBytes >= warningBytes ? "warning" : "pass", + }; +} + +export function compareRendererEnvironment(actual: RendererEnvironment, expected: RendererEnvironment): EnvironmentComparison { + const mismatches: string[] = []; + const keys: Array = ["profile", "platform", "arch", "nodeMajor", "cpuQuotaCores", "memoryLimitBytes"]; + if (expected.browserName !== undefined) keys.push("browserName"); + if (expected.browserMajor !== undefined) keys.push("browserMajor"); + for (const key of keys) { + if (actual[key] !== expected[key]) mismatches.push(`${key}: expected ${String(expected[key])}, observed ${String(actual[key])}`); + } + return { comparable: mismatches.length === 0, mismatches }; +} + +export function evaluateBrowserBudget( + usefulContent: TimingSummary, + usefulLimitMs: number, + keyboardAdditional: TimingSummary, + keyboardAdditionalLimitMs: number, + hardFailures: readonly string[], +): BrowserBudgetResult { + const usefulResult = evaluateTimeBudget(usefulContent, usefulLimitMs); + const keyboardResult = evaluateTimeBudget(keyboardAdditional, keyboardAdditionalLimitMs); + return { + usefulContent: usefulResult, + keyboardAdditional: keyboardResult, + hardFailures: [...hardFailures], + pass: usefulResult.pass && keyboardResult.pass && hardFailures.length === 0, + }; +} diff --git a/src/plugin.ts b/src/plugin.ts index 45e2c1a..5bd407a 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -9,6 +9,10 @@ import { renderRawHtml, type RenderedArtifact, } from "./render.ts"; +import { AssetPreflightError, type PortableAssets } from "./assets.ts"; +import type { ResolvedDesignTokens } from "./design-tokens.ts"; +import { parseDocument } from "./markdown.ts"; +import { formatPreflight, preflightDocument, trustedHtmlDiagnostic, type AuthoringDiagnostic } from "./preflight.ts"; import { FilePublisher, slugify, StaleArtifactError } from "./publisher.ts"; import { GitHubPagesPublisher } from "./github-pages.ts"; import { CloudflarePublisher } from "./cloudflare-publisher.ts"; @@ -187,23 +191,40 @@ export const ArtifactsPlugin: Plugin = async (_input, options) => { async execute(args, ctx) { let slug = "artifact"; try { - const rendered: RenderedArtifact = - args.format === "html" - ? renderRawHtml(args.markdown, args.title ? { title: args.title } : {}) - : renderArtifact(args.markdown); - const title = args.title ?? rendered.meta.title ?? "Artifact"; + const parsedTitle = args.format === "html" ? undefined : parseDocument(args.markdown).meta.title; + const title = args.title ?? parsedTitle ?? "Artifact"; slug = slugify(title); - const findings = scanSensitive(`${args.markdown}\n${title}`); if (findings.length > 0 && args.force !== true) { return `Publish blocked: the content contains credential-looking strings: ${formatFindings(findings)}. If these are intentional (e.g. redacted examples), call again with force: true.`; } + let preflightWarnings: AuthoringDiagnostic[] = []; + let portableAssets: PortableAssets | undefined; + let designTokens: ResolvedDesignTokens | undefined; + if (args.format === "html") { + preflightWarnings = [trustedHtmlDiagnostic()]; + } else { + const preflight = await preflightDocument(args.markdown, { worktreeRoot: workRoot(ctx) }); + const errors = preflight.diagnostics.filter((item) => item.severity === "error"); + if (errors.length > 0 || preflight.omitted > 0) return formatPreflight(preflight); + preflightWarnings = preflight.diagnostics; + portableAssets = preflight.assets; + designTokens = preflight.designTokens; + } + const rendered: RenderedArtifact = + args.format === "html" + ? renderRawHtml(args.markdown, args.title ? { title: args.title } : {}) + : renderArtifact(args.markdown, { assets: portableAssets, designTokens }); + const finalFindings = scanSensitive(rendered.html); + if (finalFindings.length > 0 && args.force !== true) { + return `Publish blocked: the final portable bytes contain credential-looking strings: ${formatFindings(finalFindings)}. If these are intentional, call again with force: true.`; + } await ctx.ask({ permission: "artifact_publish", patterns: [slug], always: ["*"], - metadata: { title, slug }, + metadata: { title, slug, format: args.format ?? "markdown", trustedHtml: args.format === "html" }, }); const localDir = join(workRoot(ctx), ".opencode", "artifacts"); @@ -276,11 +297,15 @@ export const ArtifactsPlugin: Plugin = async (_input, options) => { hash: result.hash, }, }); - return `Artifact published to ${result.path}${result.url ? ` — live at ${result.url}` : ""} (gallery: ${result.gallery}, hash: ${result.hash})`; + const warning = preflightWarnings.length === 0 ? "" : `\nPreflight warnings: ${preflightWarnings.map((item) => `${item.code} at ${item.line}:${item.column}`).join(", ")}`; + return `Artifact published to ${result.path}${result.url ? ` — live at ${result.url}` : ""} (gallery: ${result.gallery}, hash: ${result.hash})${warning}`; } catch (err) { if (err instanceof ArtifactTooLargeError) { return `Artifact too large: ${err.message}`; } + if (err instanceof AssetPreflightError) { + return JSON.stringify({ error: err.code, path: err.assetPath, message: err.message, nextAction: err.nextAction }, null, 2); + } if (err instanceof StaleArtifactError) { const currentPath = join(workRoot(ctx), ".opencode", "artifacts", `${slug}.html`); let current = ""; diff --git a/src/preflight.ts b/src/preflight.ts new file mode 100644 index 0000000..6defb5d --- /dev/null +++ b/src/preflight.ts @@ -0,0 +1,229 @@ +import MarkdownIt from "markdown-it"; +import { validateComponent, COMPONENT_KINDS, type ComponentKind } from "./components.ts"; +import { resolvePortableAssets, AssetPreflightError, type PortableAssets } from "./assets.ts"; +import { validateChartSpec } from "./render.ts"; +import { headingSlugify } from "./text.ts"; +import { canonicalLocale, validTimeZone } from "./locale.ts"; +import { parseDocument } from "./markdown.ts"; +import { loadProjectDesignTokens, resolveDesignTokens, type ResolvedDesignTokens } from "./design-tokens.ts"; + +export type DiagnosticSeverity = "error" | "warning"; +export type DiagnosticSource = "frontmatter" | "component" | "chart" | "markdown" | "asset" | "design" | "mode"; + +export interface AuthoringDiagnostic { + code: string; + severity: DiagnosticSeverity; + source: DiagnosticSource; + line: number; + column: number; + message: string; + nextAction: string; +} + +export interface PreflightOptions { + worktreeRoot?: string; + maxDiagnostics?: number; + maxDiagnosticBytes?: number; +} + +export interface PreflightResult { + diagnostics: AuthoringDiagnostic[]; + omitted: number; + assets?: PortableAssets; + designTokens?: ResolvedDesignTokens; +} + +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/; +const KNOWN_FRONTMATTER = new Set(["title", "icon", "description", "theme", "source", "font", "lang", "dir", "locale", "timezone"]); +const THEMES = new Set(["default", "report", "ops", "editorial"]); +const CHART_KINDS = new Set(["vega-lite", "vega", "echarts"]); +const ALERT_KINDS = new Set(["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"]); + +function clean(value: string): string { + const redacted = value + .replace(/\b(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{12,}\b/g, "[REDACTED]") + .replace(/\bsk-(?:ant-)?[A-Za-z0-9_-]{16,}\b/g, "[REDACTED]") + .replace(/-----BEGIN [^-]*PRIVATE KEY-----/g, "[REDACTED]"); + return redacted.length <= 240 ? redacted : `${redacted.slice(0, 237)}...`; +} + +function diagnostic(code: string, severity: DiagnosticSeverity, source: DiagnosticSource, line: number, message: string, nextAction: string): AuthoringDiagnostic { + return { code, severity, source, line, column: 1, message: clean(message), nextAction: clean(nextAction) }; +} + +function frontmatterDiagnostics(markdown: string): AuthoringDiagnostic[] { + const match = markdown.match(FRONTMATTER_RE); + if (!match) return []; + const seen = new Set(); + const output: AuthoringDiagnostic[] = []; + for (const [index, line] of match[1].split(/\r?\n/).entries()) { + if (line.trim() === "") continue; + const parsed = line.match(/^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/); + if (!parsed) { + output.push(diagnostic("frontmatter-syntax", "error", "frontmatter", index + 2, "frontmatter line is not key: value", "use a documented key and scalar value")); + continue; + } + const key = parsed[1]; + if (seen.has(key)) output.push(diagnostic("frontmatter-duplicate", "error", "frontmatter", index + 2, `frontmatter key '${key}' is duplicated`, "keep one value for each key")); + seen.add(key); + if (!KNOWN_FRONTMATTER.has(key)) output.push(diagnostic("frontmatter-unknown", "warning", "frontmatter", index + 2, `frontmatter key '${key}' is ignored`, "remove it or use a documented key")); + if (key === "theme" && parsed[2] !== "" && !THEMES.has(parsed[2].trim())) output.push(diagnostic("theme-unknown", "warning", "frontmatter", index + 2, "unknown theme falls back to default", "use default, report, ops, or editorial")); + if (key === "lang" && canonicalLocale(parsed[2].trim()) === undefined) output.push(diagnostic("language-invalid", "error", "frontmatter", index + 2, "lang is not a valid BCP 47 language tag", "use a tag such as en, ar, or zh-Hant")); + if (key === "dir" && parsed[2].trim() !== "ltr" && parsed[2].trim() !== "rtl") output.push(diagnostic("direction-invalid", "error", "frontmatter", index + 2, "dir must be ltr or rtl", "choose the document's logical direction")); + if (key === "locale" && canonicalLocale(parsed[2].trim()) === undefined) output.push(diagnostic("locale-invalid", "error", "frontmatter", index + 2, "locale is not a valid BCP 47 locale", "use a locale such as en-US, de-DE, or ar-EG")); + if (key === "timezone" && !validTimeZone(parsed[2].trim())) output.push(diagnostic("timezone-invalid", "error", "frontmatter", index + 2, "timezone is not a valid IANA time zone", "use a zone such as UTC, Europe/Berlin, or Asia/Tokyo")); + } + return output; +} + +function markdownDiagnostics(markdown: string): AuthoringDiagnostic[] { + const md = new MarkdownIt({ html: false, linkify: true }); + const tokens = md.parse(markdown, {}); + const output: AuthoringDiagnostic[] = []; + const anchors = new Map(); + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index]; + const line = (token.map?.[0] ?? 0) + 1; + if (token.type === "fence") { + const kind = token.info.trim().split(/\s+/)[0] ?? ""; + if (COMPONENT_KINDS.has(kind)) { + for (const componentIssue of validateComponent(kind as ComponentKind, token.content)) { + output.push(diagnostic(componentIssue.code, "error", "component", line, `component '${kind}' is invalid: ${componentIssue.reason}`, componentIssue.nextAction)); + } + } else if (CHART_KINDS.has(kind)) { + const result = validateChartSpec({ kind: kind as "vega-lite" | "vega" | "echarts", json: token.content }); + if (result.error) { + const missingSummary = result.code === "chart-summary-missing"; + output.push(diagnostic( + result.code ?? `${kind}-invalid`, + "error", + "chart", + line, + missingSummary ? `chart '${kind}' lacks a semantic equivalent` : `chart '${kind}' is invalid`, + missingSummary ? "add a meaningful top-level description summarizing the chart" : "provide valid JSON and a valid chart schema", + )); + } + } + } + if (token.type === "heading_open") { + const inline = tokens[index + 1]; + const anchor = headingSlugify(inline?.content ?? ""); + const first = anchors.get(anchor); + if (anchor !== "" && first !== undefined) output.push(diagnostic("anchor-duplicate", "error", "markdown", line, `heading anchor '${anchor}' duplicates line ${first}`, "rename one heading so every anchor is unique")); + else if (anchor !== "") anchors.set(anchor, line); + } + } + for (const [index, line] of markdown.split(/\r?\n/).entries()) { + const alert = line.match(/^\s*>\s*\[!([A-Z]+)\]/); + if (alert && !ALERT_KINDS.has(alert[1])) output.push(diagnostic("alert-unknown", "warning", "markdown", index + 1, `alert kind '${alert[1]}' is not supported`, "use NOTE, TIP, IMPORTANT, WARNING, or CAUTION")); + if (/^\s*[-*+]\s+\[[^ xX\]]\]/.test(line)) output.push(diagnostic("task-marker-invalid", "warning", "markdown", index + 1, "task marker is not [ ] or [x]", "use [ ] for open or [x] for complete")); + } + return output; +} + +interface AssetProbe { + markdown: string; + line: number; +} + +function assetProbes(markdown: string): AssetProbe[] { + const md = new MarkdownIt({ html: false, linkify: true }); + const probes: AssetProbe[] = []; + const visit = (tokens: readonly { type: string; map: [number, number] | null; children?: unknown; content: string; attrGet(name: string): string | null }[], inheritedLine: number): void => { + for (const token of tokens) { + const line = (token.map?.[0] ?? inheritedLine - 1) + 1; + if (token.type === "image") { + const source = token.attrGet("src") ?? ""; + const decorative = token.attrGet("title")?.trim().toLowerCase() === "decorative"; + probes.push({ markdown: `![${token.content}](<${source}>${decorative ? ' "decorative"' : ""})`, line }); + } + if (Array.isArray(token.children)) visit(token.children as readonly { type: string; map: [number, number] | null; children?: unknown; content: string; attrGet(name: string): string | null }[], line); + } + }; + visit(md.parse(markdown, {}), 1); + const frontmatter = markdown.match(FRONTMATTER_RE); + if (frontmatter) { + for (const [index, line] of frontmatter[1].split(/\r?\n/).entries()) { + const font = line.match(/^font\s*:\s*(.*?)\s*$/); + if (font?.[1]) probes.push({ markdown: `---\nfont: ${font[1]}\n---\nx`, line: index + 2 }); + } + } + return probes; +} + +async function preflightAssets(markdown: string, root: string): Promise<{ diagnostics: AuthoringDiagnostic[]; assets?: PortableAssets }> { + const diagnostics: AuthoringDiagnostic[] = []; + for (const probe of assetProbes(markdown)) { + try { + await resolvePortableAssets(probe.markdown, root); + } catch (error) { + if (!(error instanceof AssetPreflightError)) throw error; + diagnostics.push(diagnostic(`asset-${error.code}`, "error", "asset", probe.line, error.message, error.nextAction)); + } + } + if (diagnostics.length > 0) return { diagnostics }; + try { + return { diagnostics, assets: await resolvePortableAssets(markdown, root) }; + } catch (error) { + if (!(error instanceof AssetPreflightError)) throw error; + return { diagnostics: [diagnostic(`asset-${error.code}`, "error", "asset", 1, error.message, error.nextAction)] }; + } +} + +function bounded(diagnostics: AuthoringDiagnostic[], options: PreflightOptions): { diagnostics: AuthoringDiagnostic[]; omitted: number } { + const maxCount = Math.max(1, options.maxDiagnostics ?? 50); + const maxBytes = Math.max(512, options.maxDiagnosticBytes ?? 16 * 1024); + const kept: AuthoringDiagnostic[] = []; + let bytes = 2; + for (const item of diagnostics.sort((a, b) => a.line - b.line || a.column - b.column)) { + const itemBytes = Buffer.byteLength(JSON.stringify(item), "utf8") + (kept.length === 0 ? 0 : 1); + if (kept.length >= maxCount || bytes + itemBytes > maxBytes) break; + kept.push(item); + bytes += itemBytes; + } + let omitted = diagnostics.length - kept.length; + if (omitted > 0) { + let marker = diagnostic("diagnostics-omitted", "error", "markdown", kept.at(-1)?.line ?? 1, `${omitted} additional diagnostics were omitted by the report limit`, "fix reported errors, then run preflight again"); + while (kept.length >= maxCount || Buffer.byteLength(JSON.stringify([...kept, marker]), "utf8") > maxBytes) { + if (kept.length === 0) break; + kept.pop(); + omitted++; + marker = diagnostic("diagnostics-omitted", "error", "markdown", kept.at(-1)?.line ?? 1, `${omitted} additional diagnostics were omitted by the report limit`, "fix reported errors, then run preflight again"); + } + kept.push(marker); + } + return { diagnostics: kept, omitted }; +} + +export async function preflightDocument(markdown: string, options: PreflightOptions = {}): Promise { + const all = [...frontmatterDiagnostics(markdown), ...markdownDiagnostics(markdown)]; + const parsed = parseDocument(markdown); + const projectDesign = options.worktreeRoot === undefined ? undefined : await loadProjectDesignTokens(options.worktreeRoot); + const design = resolveDesignTokens(parsed.meta.theme, projectDesign, parsed.designTokens.map((block) => block.json)); + for (const designIssue of design.issues) { + const line = designIssue.source === "project" + ? 1 + : parsed.designTokens[designIssue.promptIndex ?? 0]?.line ?? 1; + all.push(diagnostic(designIssue.code, "error", "design", line, designIssue.reason, designIssue.nextAction)); + } + let assets: PortableAssets | undefined; + if (options.worktreeRoot !== undefined) { + const assetResult = await preflightAssets(markdown, options.worktreeRoot); + all.push(...assetResult.diagnostics); + assets = assetResult.assets; + } + const result = bounded(all, options); + return { + ...result, + ...(assets === undefined ? {} : { assets }), + designTokens: design.designTokens, + }; +} + +export function trustedHtmlDiagnostic(): AuthoringDiagnostic { + return diagnostic("trusted-html-mode", "warning", "mode", 1, "trusted HTML executes page-authored markup outside Markdown guarantees", "review the complete HTML and permission prompt before publishing"); +} + +export function formatPreflight(result: PreflightResult): string { + return JSON.stringify({ error: "authoring-preflight", diagnostics: result.diagnostics, omitted: result.omitted }, null, 2); +} diff --git a/src/render.ts b/src/render.ts index 649a298..f303b89 100644 --- a/src/render.ts +++ b/src/render.ts @@ -2,7 +2,10 @@ import { compile as compileVegaLite } from "vega-lite"; import { parseDocument, type ChartSpec, type Frontmatter } from "./markdown.ts"; import { renderComponent } from "./components.ts"; import { runtimeBundle, type RuntimeName } from "./runtime.ts"; -import { escapeHtmlText, slugify } from "./text.ts"; +import { escapeHtmlText, headingSlugify } from "./text.ts"; +import { resolvePortableAssets, type AssetLimits, type PortableAssets } from "./assets.ts"; +import { resolveDesignTokens, type ResolvedDesignTokens } from "./design-tokens.ts"; +import { resolveLocaleContext, type LocaleContext } from "./locale.ts"; export { escapeHtmlText } from "./text.ts"; @@ -22,6 +25,12 @@ export class ArtifactTooLargeError extends Error { export interface RenderOptions { maxBytes?: number; + assets?: PortableAssets; + designTokens?: ResolvedDesignTokens; +} + +export interface PortableRenderOptions extends AssetLimits { + maxBytes?: number; } export interface RenderedArtifact { @@ -36,6 +45,7 @@ interface ResolvedChart { kind: ResolvedKind; spec?: unknown; error?: string; + summary?: string; } export const CSP = [ @@ -43,36 +53,44 @@ export const CSP = [ "script-src 'unsafe-inline'", "style-src 'unsafe-inline'", "img-src data:", + "font-src data:", "connect-src 'none'", ].join("; "); export const ARTIFACT_CSS = `:root{color-scheme:light; --page-bg:#e9edf2;--card-bg:#ffffff;--ink:#111827;--ink-2:#4b5563;--ink-3:#9ca3af;--line:#e5e7eb; ---accent:#6d6bd6;--good:#2f9e6e;--good-bg:#e4f4ec;--bad:#d64550;--bad-bg:#fdeeee; ---warn:#b45309;--warn-bg:#fdf0dc;--info:#33526e;--info-bg:#dce6f2; +--accent:#5f5dbf;--accent-ink:#ffffff;--good:#237a52;--good-bg:#e4f4ec;--bad:#b42335;--bad-bg:#fdeeee; +--warn:#92400e;--warn-bg:#fdf0dc;--info:#33526e;--info-bg:#dce6f2; --card-info-bg:#e3eaf4;--card-warn-bg:#fdeccd;--code-bg:#f3f4f6; ---radius:16px;--shadow:0 1px 3px rgb(15 23 42/.06)} +--radius:16px;--shadow:0 1px 3px rgb(15 23 42/.06); +--artifact-font:system-ui,-apple-system,"Segoe UI",sans-serif;--artifact-heading-font:var(--artifact-font); +--body-pad:1.5rem;--body-pad-bottom:3rem;--section-gap:1.25rem;--section-pad-y:1.5rem;--section-pad-x:1.75rem;--table-font-size:.86rem} @media (prefers-color-scheme: dark){:root:not([data-theme="light"]){color-scheme:dark; --page-bg:#151a21;--card-bg:#1f2630;--ink:#e5e7eb;--ink-2:#9ca3af;--ink-3:#6b7280;--line:#333d4d; +--accent:#a8a6ff;--accent-ink:#111827; --good:#4ade80;--good-bg:#14312a;--bad:#f87171;--bad-bg:#3a2226;--warn:#fbbf24;--warn-bg:#3a2f16; --info:#7ea4c7;--info-bg:#1e2c3d;--card-info-bg:#1e2c3d;--card-warn-bg:#3a2f16;--code-bg:#262e3a; --shadow:0 1px 3px rgb(0 0 0/.4)}} :root[data-theme="dark"]{color-scheme:dark; --page-bg:#151a21;--card-bg:#1f2630;--ink:#e5e7eb;--ink-2:#9ca3af;--ink-3:#6b7280;--line:#333d4d; +--accent:#a8a6ff;--accent-ink:#111827; --good:#4ade80;--good-bg:#14312a;--bad:#f87171;--bad-bg:#3a2226;--warn:#fbbf24;--warn-bg:#3a2f16; --info:#7ea4c7;--info-bg:#1e2c3d;--card-info-bg:#1e2c3d;--card-warn-bg:#3a2f16;--code-bg:#262e3a; --shadow:0 1px 3px rgb(0 0 0/.4)} -body{margin:0;background:var(--page-bg);color:var(--ink);font-family:system-ui,-apple-system,"Segoe UI",sans-serif;line-height:1.6} +html{overflow-wrap:anywhere}body{margin:0;background:var(--page-bg);color:var(--ink);font-family:var(--artifact-font);line-height:1.6} +.sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important} +.skip-link{position:fixed;z-index:100;inset-block-start:.5rem;inset-inline-start:.5rem;padding:.55rem .8rem;background:var(--card-bg);color:var(--ink);border:2px solid var(--accent);border-radius:8px;transform:translateY(-160%)} +.skip-link:focus{transform:none} .artifact-header{display:flex;align-items:center;gap:.6rem;padding:.9rem 1.5rem;background:var(--card-bg);border-bottom:1px solid var(--line)} -.theme-toggle{margin-left:auto;background:none;border:1px solid var(--line);border-radius:999px;padding:.25rem .8rem;font-size:.75rem;font-weight:600;color:var(--ink-2);cursor:pointer} +.theme-toggle{margin-inline-start:auto;min-height:2rem;background:none;border:1px solid var(--line);border-radius:999px;padding:.25rem .8rem;font-size:.75rem;font-weight:600;color:var(--ink-2);cursor:pointer} .theme-toggle:hover{border-color:var(--accent);color:var(--accent)} .artifact-header h1{font-size:1.1rem;margin:0;letter-spacing:-.01em} .artifact-icon{font-size:1.25rem} -.artifact-body{max-width:1080px;margin:0 auto;padding:1.5rem 1.5rem 3rem} +.artifact-body{max-width:1080px;margin:0 auto;padding:var(--body-pad) var(--body-pad) var(--body-pad-bottom)} .artifact-body>*:first-child{margin-top:0} .artifact-footer{max-width:1080px;margin:0 auto;padding:1rem 1.5rem 2rem;font-size:.8rem;color:var(--ink-3)} .artifact-footer a{color:inherit} -.section-card{background:var(--card-bg);border-radius:var(--radius);box-shadow:var(--shadow);padding:1.5rem 1.75rem;margin:1.25rem 0} +.section-card{background:var(--card-bg);border-radius:var(--radius);box-shadow:var(--shadow);padding:var(--section-pad-y) var(--section-pad-x);margin:var(--section-gap) 0} .section-card> :first-child{margin-top:0} .section-card p,.section-card li{max-width:68ch} td,.stat-value,.tl-time,.progress-label,.delta{font-variant-numeric:tabular-nums} @@ -82,14 +100,15 @@ p{margin:.6rem 0} pre{background:var(--code-bg);padding:.75rem 1rem;overflow:auto;border-radius:10px} code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9em} p code,li code,td code{background:var(--code-bg);padding:.1em .35em;border-radius:5px} -.chart{margin:1rem 0;min-height:320px} +.chart-frame,.diagram-frame{margin:1rem 0}.chart{margin:0;min-height:320px;max-width:100%;overflow:hidden} +.chart-summary,.diagram-summary{margin:.5rem 0 0;color:var(--ink-2);font-size:.88rem} .chart-error{padding:.75rem 1rem;border:1px solid var(--bad);border-radius:10px;color:var(--bad);background:var(--bad-bg);margin:1rem 0} table{border-collapse:collapse;width:100%;margin:1rem 0;font-size:.92rem} -th{text-align:left;background:var(--code-bg);font-weight:600} +th{text-align:start;background:var(--code-bg);font-weight:600} td,th{border:1px solid var(--line);padding:.45rem .7rem} img{max-width:100%} a{color:var(--accent)} -blockquote{margin:1rem 0;padding:.25rem 1rem;border-left:3px solid var(--line);color:var(--ink-2)} +blockquote{margin:1rem 0;padding:.25rem 1rem;border-inline-start:3px solid var(--line);color:var(--ink-2)} .stat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem;margin:1.25rem 0} .stat{background:var(--card-bg);border-radius:var(--radius);box-shadow:var(--shadow);padding:1.1rem 1.25rem} .stat-emphasis.tone-bad{background:var(--bad-bg)} @@ -102,7 +121,7 @@ blockquote{margin:1rem 0;padding:.25rem 1rem;border-left:3px solid var(--line);c .delta-warn{background:var(--warn-bg);color:var(--warn)} .timeline{list-style:none;margin:1rem 0;padding:0} .tl-item{display:flex;gap:.9rem;position:relative;padding:0 0 1.1rem .25rem} -.tl-item::before{content:"";position:absolute;left:.68rem;top:1.1rem;bottom:-.1rem;width:2px;background:var(--line)} +.tl-item::before{content:"";position:absolute;inset-inline-start:.68rem;top:1.1rem;bottom:-.1rem;width:2px;background:var(--line)} .tl-item:last-child::before{display:none} .tl-dot{flex:none;width:.85rem;height:.85rem;border-radius:50%;background:var(--ink-3);margin-top:.35rem;z-index:1} .dot-bad{background:var(--bad)}.dot-good{background:var(--good)}.dot-warn{background:var(--warn)}.dot-info{background:var(--info)} @@ -129,8 +148,8 @@ blockquote{margin:1rem 0;padding:.25rem 1rem;border-left:3px solid var(--line);c .pill-warn{background:var(--warn-bg);color:var(--warn)} .pill-good{background:var(--good-bg);color:var(--good)} .annotations{margin:.9rem 0 0;padding:0;list-style:none;counter-reset:note} -.annotations li{counter-increment:note;position:relative;padding-left:1.6rem;margin:.4rem 0;font-size:.9rem} -.annotations li::before{content:counter(note);position:absolute;left:0;top:.1rem;width:1.05rem;height:1.05rem;border-radius:50%;background:var(--ink);color:var(--card-bg);font-size:.65rem;font-weight:700;display:flex;align-items:center;justify-content:center} +.annotations li{counter-increment:note;position:relative;padding-inline-start:1.6rem;margin:.4rem 0;font-size:.9rem} +.annotations li::before{content:counter(note);position:absolute;inset-inline-start:0;top:.1rem;width:1.05rem;height:1.05rem;border-radius:50%;background:var(--ink);color:var(--card-bg);font-size:.65rem;font-weight:700;display:flex;align-items:center;justify-content:center} .tradeoff{margin:.9rem 0 0;font-size:.85rem;font-style:italic;color:var(--ink-2);border-top:1px solid var(--line);padding-top:.6rem} .callout{border-radius:var(--radius);padding:1.25rem 1.5rem;margin:1.25rem 0;background:var(--card-info-bg)} .callout-warn{background:var(--card-warn-bg)} @@ -147,9 +166,9 @@ blockquote{margin:1rem 0;padding:.25rem 1rem;border-left:3px solid var(--line);c .alert-warn{border-color:var(--warn);background:var(--warn-bg)} .alert-bad{border-color:var(--bad);background:var(--bad-bg)} li.task{list-style:none} -li.task input{margin-right:.45rem;accent-color:var(--accent)} +li.task input{margin-inline-end:.45rem;accent-color:var(--accent)} .copy-wrap{display:inline-flex;align-items:center;gap:.55rem;margin:.25rem 0} -.copy-btn{background:var(--accent);color:#fff;border:none;border-radius:8px;padding:.45rem 1rem;font-size:.85rem;font-weight:600;cursor:pointer} +.copy-btn{background:var(--accent);color:var(--accent-ink);border:none;border-radius:8px;padding:.45rem 1rem;font-size:.85rem;font-weight:600;cursor:pointer} .copy-btn:hover{filter:brightness(1.08)} .copy-note{font-size:.8rem;color:var(--good)} pre.mermaid{background:var(--card-bg);border:1px solid var(--line);border-radius:10px;padding:1rem;text-align:center} @@ -159,7 +178,7 @@ pre.mermaid{background:var(--card-bg);border:1px solid var(--line);border-radius .decision{margin:0 0 1rem} .decision-question{font-weight:600;margin-bottom:.5rem} .decision-options{display:flex;flex-direction:column;gap:.5rem} -.decision-opt{display:flex;flex-direction:column;gap:.15rem;text-align:left;background:var(--card-bg);border:1px solid var(--line);border-radius:10px;padding:.7rem 1rem;font-size:.92rem;color:var(--ink);cursor:pointer} +.decision-opt{display:flex;flex-direction:column;gap:.15rem;text-align:start;min-height:2.75rem;background:var(--card-bg);border:1px solid var(--line);border-radius:10px;padding:.7rem 1rem;font-size:.92rem;color:var(--ink);cursor:pointer} .section-card .decision-opt{background:var(--page-bg)} .decision-opt:hover{border-color:var(--accent)} .decision-opt.selected{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)} @@ -172,28 +191,30 @@ pre.mermaid{background:var(--card-bg);border:1px solid var(--line);border-radius .table-wrap{margin:1rem 0} .table-filter{width:100%;max-width:320px;padding:.45rem .8rem;border:1px solid var(--line);border-radius:8px;background:var(--card-bg);color:var(--ink);font:inherit;font-size:.88rem;margin-bottom:.5rem} .table-scroll{overflow-x:auto;border:1px solid var(--line);border-radius:10px} -.data-table{margin:0;font-size:.86rem} +.data-table caption{text-align:start;padding:.55rem;font-weight:650;color:var(--ink);background:var(--code-bg)} +.data-table{margin:0;font-size:var(--table-font-size)} .data-table th{padding:.35rem .55rem;white-space:nowrap} .data-table td{padding:.3rem .55rem} .th-sort{background:none;border:none;padding:0;font:inherit;font-weight:600;color:inherit;cursor:pointer} -.th-sort::after{content:"↕";margin-left:.35rem;opacity:.35;font-size:.75em} +.th-sort::after{content:"↕";margin-inline-start:.35rem;opacity:.35;font-size:.75em} th[data-dir="asc"] .th-sort::after{content:"↑";opacity:1;color:var(--accent)} th[data-dir="desc"] .th-sort::after{content:"↓";opacity:1;color:var(--accent)} .data-table .num{text-align:right} .table-meta{display:flex;justify-content:space-between;font-size:.78rem;color:var(--ink-3);margin-top:.35rem} -.comments-dock{position:fixed;right:1rem;bottom:1rem;width:300px;max-height:45vh;overflow:auto;background:var(--card-bg);border:1px solid var(--line);border-radius:12px;box-shadow:0 6px 24px rgb(15 23 42/.14);padding:.75rem .9rem;z-index:10;font-size:.85rem} +.comments-dock{position:fixed;inset-inline-end:1rem;bottom:4rem;width:300px;max-width:calc(100vw - 2rem);max-height:45vh;overflow:auto;background:var(--card-bg);border:1px solid var(--line);border-radius:12px;box-shadow:0 6px 24px rgb(15 23 42/.14);padding:.75rem .9rem;z-index:10;font-size:.85rem} .comments-title{font-weight:700;margin-bottom:.4rem} .comment{border-top:1px solid var(--line);padding:.45rem 0} -.comment-quote{font-size:.75rem;color:var(--ink-3);border-left:2px solid var(--accent);padding-left:.45rem;margin-bottom:.2rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.comment-quote{font-size:.75rem;color:var(--ink-3);border-inline-start:2px solid var(--accent);padding-inline-start:.45rem;margin-bottom:.2rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .comment-text{white-space:pre-wrap} .comment-empty{color:var(--ink-3);font-size:.78rem} -.comment-resolve{margin-top:.3rem;background:none;border:1px solid var(--line);border-radius:6px;padding:.15rem .55rem;font-size:.75rem;cursor:pointer;color:var(--ink-2)} +.comment-resolve{margin-top:.3rem;min-height:2rem;background:none;border:1px solid var(--line);border-radius:6px;padding:.15rem .55rem;font-size:.75rem;cursor:pointer;color:var(--ink-2)} .comment-resolve:hover{border-color:var(--good);color:var(--good)} -.comment-pop{z-index:20;background:var(--accent);color:#fff;border:none;border-radius:999px;padding:.3rem .8rem;font-size:.78rem;font-weight:600;cursor:pointer;box-shadow:0 2px 8px rgb(15 23 42/.2)} -.comment-form{position:fixed;right:1rem;bottom:1rem;width:320px;background:var(--card-bg);border:1px solid var(--line);border-radius:12px;box-shadow:0 6px 24px rgb(15 23 42/.14);padding:.9rem;z-index:21} +.comment-pop{z-index:20;background:var(--accent);color:var(--accent-ink);border:none;border-radius:999px;padding:.3rem .8rem;font-size:.78rem;font-weight:600;cursor:pointer;box-shadow:0 2px 8px rgb(15 23 42/.2)} +.comment-launcher{position:fixed;inset-inline-end:1rem;bottom:1rem;z-index:19;min-height:2.75rem;background:var(--accent);color:var(--accent-ink);border:2px solid var(--card-bg);border-radius:999px;padding:.5rem 1rem;font:inherit;font-size:.82rem;font-weight:650;cursor:pointer;box-shadow:0 2px 8px rgb(15 23 42/.2)} +.comment-form{position:fixed;inset-inline-end:1rem;bottom:1rem;width:320px;max-width:calc(100vw - 2rem);background:var(--card-bg);border:1px solid var(--line);border-radius:12px;box-shadow:0 6px 24px rgb(15 23 42/.14);padding:.9rem;z-index:21} .comment-input{width:100%;min-height:4.5rem;margin:.5rem 0;border:1px solid var(--line);border-radius:8px;padding:.45rem;font:inherit;background:var(--page-bg);color:var(--ink)} -.comment-save{background:var(--accent);color:#fff;border:none;border-radius:8px;padding:.35rem .9rem;font-weight:600;cursor:pointer} -.comment-cancel{background:none;border:1px solid var(--line);border-radius:8px;padding:.35rem .9rem;margin-left:.4rem;cursor:pointer;color:var(--ink-2)} +.comment-save{min-height:2.75rem;background:var(--accent);color:var(--accent-ink);border:none;border-radius:8px;padding:.35rem .9rem;font-weight:600;cursor:pointer} +.comment-cancel{min-height:2.75rem;background:none;border:1px solid var(--line);border-radius:8px;padding:.35rem .9rem;margin-inline-start:.4rem;cursor:pointer;color:var(--ink-2)} .progress{margin:1.25rem 0} .progress-label{font-size:.85rem;font-weight:600;margin-bottom:.4rem} .progress-track{height:.55rem;border-radius:999px;background:var(--code-bg);overflow:hidden} @@ -212,18 +233,21 @@ th[data-dir="desc"] .th-sort::after{content:"↓";opacity:1;color:var(--accent)} .card .desc{font-size:.85rem;color:var(--ink-2);margin:.1rem 0 .4rem} .card .icon{font-size:1.6rem} .gallery-empty{color:var(--ink-3);text-align:center;padding:3rem 0} -:focus-visible{outline:2px solid var(--accent);outline-offset:2px} -@media print{body{background:#fff}.section-card,.stat,.variant,.card{box-shadow:none;border:1px solid #ddd}}`; +:focus-visible{outline:3px solid var(--accent);outline-offset:3px} +@media (max-width:600px){.artifact-header{padding:.75rem 1rem}.artifact-body{--body-pad:1rem;--body-pad-bottom:2rem}.section-card{--section-pad-y:1rem;--section-pad-x:1rem}.tl-item{gap:.55rem}.tl-time{width:3.6rem}.comments-dock,.comment-form{inset-inline:1rem;width:auto}.stat-grid,.compare-grid{grid-template-columns:1fr}} +@media (max-width:700px){.comments-dock{position:static;margin:1rem;max-width:none;width:auto}} +@media (prefers-reduced-motion:reduce){*,*::before,*::after{scroll-behavior:auto!important;animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}} +@page{margin:15mm}@media print{body{background:#fff;color:#000}.skip-link,.theme-toggle,.copy-btn,.copy-note,.table-filter,.comments-dock,.comment-pop,.comment-launcher,.comment-form{display:none!important}.section-card,.stat,.variant,.card,.finding,.callout{box-shadow:none!important;break-inside:avoid;border:1px solid #bbb}.chart-frame,.diagram-frame,.table-wrap{break-inside:avoid}.table-scroll{overflow:visible}.data-table{font-size:9pt}a{color:inherit;text-decoration:underline}}`; const THEME_CSS: Record = { - report: `:root{color-scheme:light;--page-bg:#f6f0e4;--card-bg:#fffdf7;--ink:#2b251a;--ink-2:#6b5f49;--ink-3:#a29378;--line:#e3d9c4;--accent:#b4541e;--code-bg:#f1e9d8} -h2,.callout-title,.stat-value{font-family:Georgia,Charter,"Times New Roman",serif}`, - ops: `:root{color-scheme:dark;--page-bg:#0f140f;--card-bg:#171f17;--ink:#d5e5cf;--ink-2:#8fa389;--ink-3:#5c6b57;--line:#263026;--accent:#4ade80;--code-bg:#131c13;--good:#4ade80;--good-bg:#14311f;--bad:#f87171;--bad-bg:#3a1d1d;--warn:#fbbf24;--warn-bg:#3a2f16;--info:#7ea4c7;--info-bg:#1c2a38;--card-info-bg:#1c2a38;--card-warn-bg:#33290f} -h2,.callout-title{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:0}`, - editorial: `:root{color-scheme:light;--page-bg:#fafafa;--card-bg:#ffffff;--ink:#141414;--ink-2:#525252;--ink-3:#a3a3a3;--line:#e5e5e5;--accent:#141414;--code-bg:#f5f5f5;--radius:4px;--shadow:none} -h2{font-family:Georgia,Charter,"Times New Roman",serif;font-size:1.6rem;font-weight:500} + report: `:root{color-scheme:light;--page-bg:#f6f0e4;--card-bg:#fffdf7;--ink:#2b251a;--ink-2:#6b5f49;--ink-3:#a29378;--line:#e3d9c4;--accent:#8f3f13;--code-bg:#f1e9d8;--artifact-heading-font:Georgia,Charter,"Times New Roman",serif} +h2,.callout-title,.stat-value{font-family:var(--artifact-heading-font)}`, + ops: `:root{color-scheme:dark;--page-bg:#0f140f;--card-bg:#171f17;--ink:#d5e5cf;--ink-2:#8fa389;--ink-3:#5c6b57;--line:#263026;--accent:#4ade80;--accent-ink:#0f140f;--code-bg:#131c13;--good:#4ade80;--good-bg:#14311f;--bad:#f87171;--bad-bg:#3a1d1d;--warn:#fbbf24;--warn-bg:#3a2f16;--info:#7ea4c7;--info-bg:#1c2a38;--card-info-bg:#1c2a38;--card-warn-bg:#33290f;--artifact-heading-font:ui-monospace,SFMono-Regular,Menlo,monospace} +h2,.callout-title{font-family:var(--artifact-heading-font);letter-spacing:0}`, + editorial: `:root{color-scheme:light;--page-bg:#fafafa;--card-bg:#ffffff;--ink:#141414;--ink-2:#525252;--ink-3:#a3a3a3;--line:#e5e5e5;--accent:#141414;--code-bg:#f5f5f5;--radius:4px;--shadow:none;--artifact-heading-font:Georgia,Charter,"Times New Roman",serif} +h2{font-family:var(--artifact-heading-font);font-size:1.6rem;font-weight:500} .section-card,.stat,.variant,.card{border:1px solid var(--line)} -.artifact-header h1{font-family:Georgia,Charter,"Times New Roman",serif;font-size:1.35rem;font-weight:500}`, +.artifact-header h1{font-family:var(--artifact-heading-font);font-size:1.35rem;font-weight:500}`, }; const BOOT = `(function () { @@ -233,6 +257,7 @@ const BOOT = `(function () { : "00000000-0000-4000-8000-" + Math.random().toString(16).slice(2).padEnd(12, "0").slice(0, 12); } var root = document.documentElement; + var reducedMotion = !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches); if (!root.hasAttribute("data-page-theme")) { var header = document.querySelector(".artifact-header"); if (header) { @@ -272,6 +297,7 @@ const BOOT = `(function () { el.textContent = ""; var box = document.createElement("div"); box.className = "chart-error"; + box.setAttribute("role", "alert"); box.textContent = "Chart failed to render: " + message; el.appendChild(box); } @@ -283,6 +309,7 @@ const BOOT = `(function () { }); } else if (entry.kind === "echarts") { var chart = window.echarts.init(el); + if (reducedMotion && entry.spec && typeof entry.spec === "object") entry.spec.animation = false; chart.setOption(entry.spec); window.addEventListener("resize", function () { chart.resize(); }); } @@ -300,6 +327,7 @@ const BOOT = `(function () { el.textContent = ""; var box = document.createElement("div"); box.className = "chart-error"; + box.setAttribute("role", "alert"); box.textContent = "Diagram failed to render: " + (err && err.message ? err.message : String(err)); el.appendChild(box); }); @@ -335,6 +363,32 @@ const BOOT = `(function () { return label + " were not saved: " + reason + "." + next; } var decisionStateMeta = { revision: 0, contentHash: null }; + function selectDecision(opt, moveFocus) { + var group = opt ? opt.parentNode : null; + if (!group) return; + var peers = group.querySelectorAll(".decision-opt"); + for (var i = 0; i < peers.length; i++) { + peers[i].classList.remove("selected"); + peers[i].setAttribute("aria-checked", "false"); + peers[i].setAttribute("tabindex", "-1"); + } + opt.classList.add("selected"); + opt.setAttribute("aria-checked", "true"); + opt.setAttribute("tabindex", "0"); + if (moveFocus) opt.focus(); + } + document.addEventListener("keydown", function (ev) { + var opt = ev.target && ev.target.closest ? ev.target.closest(".decision-opt") : null; + if (!opt || !["ArrowDown", "ArrowRight", "ArrowUp", "ArrowLeft", "Home", "End"].includes(ev.key)) return; + var peers = Array.prototype.slice.call(opt.parentNode.querySelectorAll(".decision-opt")); + var at = peers.indexOf(opt); + var next = ev.key === "Home" ? 0 : ev.key === "End" ? peers.length - 1 + : ev.key === "ArrowDown" || ev.key === "ArrowRight" ? (at + 1) % peers.length + : (at - 1 + peers.length) % peers.length; + ev.preventDefault(); + peers[next].click(); + peers[next].focus(); + }); document.addEventListener("click", function (ev) { var btn = ev.target && ev.target.closest ? ev.target.closest(".copy-btn") : null; if (btn) { @@ -357,12 +411,7 @@ const BOOT = `(function () { if (!opt) return; var q = opt.getAttribute("data-question"); var o = opt.getAttribute("data-option"); - var group = opt.parentNode; - if (group) { - var peers = group.querySelectorAll(".decision-opt"); - for (var i = 0; i < peers.length; i++) peers[i].classList.remove("selected"); - } - opt.classList.add("selected"); + selectDecision(opt, false); var stateKey = "artifact-decisions:" + location.pathname; var state = {}; try { state = JSON.parse(localStorage.getItem(stateKey) || "{}"); } catch (e) {} @@ -396,7 +445,7 @@ const BOOT = `(function () { var saved = JSON.parse(localStorage.getItem("artifact-decisions:" + location.pathname) || "{}"); Object.keys(saved).forEach(function (q) { var el = document.querySelector('.decision-opt[data-question="' + q + '"][data-option="' + saved[q] + '"]'); - if (el) el.classList.add("selected"); + if (el) selectDecision(el, false); }); } catch (e) {} if (window.__ARTIFACT_STATE_URL__) { @@ -418,6 +467,8 @@ const BOOT = `(function () { var dock = null; var popBtn = null; var form = null; + var commentLauncher = null; + var formReturnFocus = null; function slugFromPath() { return decodeURIComponent(location.pathname.split("/").pop() || "").replace(/\.html$/, ""); @@ -460,18 +511,24 @@ const BOOT = `(function () { } function renderDock() { if (!dock) { - dock = make("div", "comments-dock"); + dock = make("aside", "comments-dock"); + dock.setAttribute("aria-label", "Page comments"); document.body.appendChild(dock); } dock.textContent = ""; var open = threads.filter(function (t) { return !t.resolved; }); - dock.appendChild(make("div", "comments-title", "Comments (" + open.length + ")")); + var title = make("div", "comments-title", "Comments (" + open.length + ")"); + title.setAttribute("role", "heading"); + title.setAttribute("aria-level", "2"); + dock.appendChild(title); open.forEach(function (t) { var item = make("div", "comment"); item.appendChild(make("div", "comment-quote", t.quote)); item.appendChild(make("div", "comment-text", t.text)); var resolveBtn = make("button", "comment-resolve", "Resolve"); + resolveBtn.type = "button"; resolveBtn.setAttribute("data-id", t.id); + resolveBtn.setAttribute("aria-label", "Resolve comment: " + String(t.quote || t.text).slice(0, 80)); item.appendChild(resolveBtn); dock.appendChild(item); }); @@ -481,9 +538,36 @@ const BOOT = `(function () { if (popBtn && popBtn.parentNode) popBtn.parentNode.removeChild(popBtn); popBtn = null; } - function closeForm() { + function closeForm(restoreFocus) { if (form && form.parentNode) form.parentNode.removeChild(form); form = null; + if (restoreFocus && formReturnFocus && formReturnFocus.focus) formReturnFocus.focus(); + formReturnFocus = null; + } + function openCommentForm(quote, trigger) { + closeForm(false); + formReturnFocus = trigger || commentLauncher; + form = make("div", "comment-form"); + form.setAttribute("role", "dialog"); + form.setAttribute("aria-labelledby", "artifact-comment-form-title"); + var title = make("div", "comments-title", "Add comment"); + title.id = "artifact-comment-form-title"; + form.appendChild(title); + form.appendChild(make("div", "comment-quote", quote || "Page comment")); + var label = make("label", "comment-label", "Comment"); + label.setAttribute("for", "artifact-comment-input"); + form.appendChild(label); + var textarea = make("textarea", "comment-input"); + textarea.id = "artifact-comment-input"; + form.appendChild(textarea); + var save = make("button", "comment-save", "Save"); + save.type = "button"; + form.appendChild(save); + var cancel = make("button", "comment-cancel", "Cancel"); + cancel.type = "button"; + form.appendChild(cancel); + document.body.appendChild(form); + textarea.focus(); } document.addEventListener("mouseup", function () { setTimeout(function () { @@ -493,6 +577,7 @@ const BOOT = `(function () { var rect = sel.getRangeAt(0).getBoundingClientRect(); hidePop(); popBtn = make("button", "comment-pop", "Comment"); + popBtn.type = "button"; popBtn.style.position = "fixed"; popBtn.style.left = Math.min(rect.left, window.innerWidth - 90) + "px"; popBtn.style.top = rect.bottom + 6 + "px"; @@ -503,16 +588,12 @@ const BOOT = `(function () { document.addEventListener("click", function (ev) { var t = ev.target; if (!t || !t.classList) return; + if (t.classList.contains("comment-launcher")) { + openCommentForm("Page comment", t); + return; + } if (t.classList.contains("comment-pop")) { - closeForm(); - form = make("div", "comment-form"); - form.appendChild(make("div", "comment-quote", t.getAttribute("data-quote") || "")); - form.appendChild(make("textarea", "comment-input")); - form.appendChild(make("button", "comment-save", "Save")); - form.appendChild(make("button", "comment-cancel", "Cancel")); - document.body.appendChild(form); - var input = form.querySelector(".comment-input"); - if (input) input.focus(); + openCommentForm(t.getAttribute("data-quote") || "", commentLauncher || document.querySelector("#artifact-main")); hidePop(); return; } @@ -531,10 +612,10 @@ const BOOT = `(function () { persist(); renderDock(); } - closeForm(); + closeForm(true); return; } - if (t.classList.contains("comment-cancel")) { closeForm(); return; } + if (t.classList.contains("comment-cancel")) { closeForm(true); return; } if (t.classList.contains("comment-resolve")) { var id = t.getAttribute("data-id"); threads.forEach(function (th) { if (th.id === id) th.resolved = true; }); @@ -552,20 +633,30 @@ const BOOT = `(function () { var numeric = th.getAttribute("data-type") === "num"; var asc = th.getAttribute("data-dir") !== "asc"; var heads = table.querySelectorAll("th"); - for (var hi = 0; hi < heads.length; hi++) heads[hi].removeAttribute("data-dir"); + for (var hi = 0; hi < heads.length; hi++) { + heads[hi].removeAttribute("data-dir"); + heads[hi].setAttribute("aria-sort", "none"); + } th.setAttribute("data-dir", asc ? "asc" : "desc"); + th.setAttribute("aria-sort", asc ? "ascending" : "descending"); var rows = Array.prototype.slice.call(tbody.querySelectorAll("tr")); rows.sort(function (a, b) { var av = a.children[colIndex].getAttribute("data-v") || ""; var bv = b.children[colIndex].getAttribute("data-v") || ""; var cmp = numeric ? (parseFloat(av) || 0) - (parseFloat(bv) || 0) - : av.localeCompare(bv); + : av.localeCompare(bv, root.getAttribute("data-locale") || "en-US"); return asc ? cmp : -cmp; }); rows.forEach(function (row) { tbody.appendChild(row); }); } }); + document.addEventListener("keydown", function (ev) { + if (ev.key === "Escape" && form) { + ev.preventDefault(); + closeForm(true); + } + }); document.addEventListener("input", function (ev) { var input = ev.target && ev.target.closest ? ev.target.closest(".table-filter") : null; if (!input) return; @@ -585,6 +676,9 @@ const BOOT = `(function () { }); var initialCommentsUrl = commentsUrl(); if (initialCommentsUrl) { + commentLauncher = make("button", "comment-launcher", "Add comment"); + commentLauncher.type = "button"; + document.body.appendChild(commentLauncher); fetch(initialCommentsUrl + "/" + encodeURIComponent(slugFromPath())) .then(function (r) { return r.json(); }) .then(function (data) { @@ -614,7 +708,7 @@ const ALERT_TONES: Record = { function alertDiv(kind: string, bodyHtml: string): string { const tone = ALERT_TONES[kind] ?? "info"; const label = kind.charAt(0) + kind.slice(1).toLowerCase(); - return `

${label}

${bodyHtml}
`; + return ``; } function enhanceBodyHtml(html: string): string { @@ -633,15 +727,15 @@ function enhanceBodyHtml(html: string): string { (_match, kind: string, body: string) => alertDiv(kind, `

${body}`), ); - out = out.replace( - /

  • \[x\]\s*/gi, - '
  • ', - ); - out = out.replace(/
  • \[ \]\s*/g, '
  • '); + out = out.replace(/
  • \[([ xX])\]\s*([\s\S]*?)<\/li>/g, (_match, marker: string, body: string) => { + const label = body.replace(/<[^>]+>/g, "").trim(); + const checked = marker.toLowerCase() === "x" ? " checked" : ""; + return `
  • ${body}
  • `; + }); out = out.replace(/([\s\S]*?)<\/h\1>/g, (_match, level: string, inner: string) => { const text = inner.replace(/<[^>]+>/g, ""); - return `${inner}`; + return `${inner}`; }); return out; @@ -660,20 +754,32 @@ export function emojiFaviconDataUri(icon: string): string { return `data:image/svg+xml,${encodeURIComponent(svg)}`; } +export function validateChartSpec(chart: ChartSpec): ResolvedChart & { code?: string } { + const kind: ResolvedKind = chart.kind === "echarts" ? "echarts" : "vega"; + try { + const parsed: unknown = JSON.parse(chart.json); + const record = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) + ? parsed as Record + : undefined; + const description = record?.["description"]; + if (typeof description !== "string" || description.trim().length < 8) { + return { kind, code: "chart-summary-missing", error: "chart needs a meaningful text description" }; + } + const summary = description.trim(); + if (kind === "echarts") return { kind, spec: parsed, summary }; + if (chart.kind === "vega-lite") { + const compiled = compileVegaLite(parsed as Parameters[0]); + return { kind, spec: compiled.spec, summary }; + } + return { kind, spec: parsed, summary }; + } catch (err) { + return { kind, code: `${chart.kind}-invalid`, error: err instanceof Error ? err.message : String(err) }; + } +} + function resolveCharts(charts: ChartSpec[]): ResolvedChart[] { return charts.map((chart) => { - const kind: ResolvedKind = chart.kind === "echarts" ? "echarts" : "vega"; - try { - const parsed: unknown = JSON.parse(chart.json); - if (kind === "echarts") return { kind, spec: parsed }; - if (chart.kind === "vega-lite") { - const compiled = compileVegaLite(parsed as Parameters[0]); - return { kind, spec: compiled.spec }; - } - return { kind, spec: parsed }; - } catch (err) { - return { kind, error: err instanceof Error ? err.message : String(err) }; - } + return validateChartSpec(chart); }); } @@ -687,6 +793,9 @@ interface AssembleInput { needsBoot: boolean; needsMermaid: boolean; maxBytes: number; + assetCss?: string; + designTokens?: ResolvedDesignTokens; + locale: LocaleContext; } function assemblePage(input: AssembleInput): string { @@ -702,11 +811,18 @@ function assemblePage(input: AssembleInput): string { const themeAttr = input.theme !== undefined && THEME_CSS[input.theme] !== undefined ? ` data-page-theme="${input.theme}"` + : input.designTokens?.fixesColorMode + ? ' data-page-theme="tokens"' : ""; + const designAttr = input.designTokens?.active ? " data-design-tokens" : ""; + const designMetadata = input.designTokens?.active + ? `` + : ""; + const localeMetadata = escapeHtmlText(JSON.stringify({ locale: input.locale.locale, timeZone: input.locale.timeZone })); const parts: string[] = [ "", - ``, + ``, "", '', ``, @@ -715,12 +831,15 @@ function assemblePage(input: AssembleInput): string { input.description !== undefined ? `` : "", + designMetadata, + ``, `${escapeHtmlText(input.title)}`, - ``, + ``, "", "", - `
    ${escapeHtmlText(input.icon)}

    ${escapeHtmlText(input.title)}

    `, - `
    ${input.bodyHtml}
    `, + '', + `

    ${escapeHtmlText(input.title)}

    `, + `
    ${input.bodyHtml}
    `, FOOTER_PLACEHOLDER, ]; @@ -746,17 +865,36 @@ function assemblePage(input: AssembleInput): string { } export function renderArtifact(markdown: string, options: RenderOptions = {}): RenderedArtifact { - const doc = parseDocument(markdown); + const doc = parseDocument(markdown, { assets: options.assets?.bySource }); + const locale = resolveLocaleContext(doc.meta); + const inlineDesign = options.designTokens === undefined + ? resolveDesignTokens(doc.meta.theme, undefined, doc.designTokens.map((block) => block.json)) + : undefined; + const designTokens = options.designTokens ?? inlineDesign?.designTokens; let bodyHtml = doc.bodyHtml; + if (inlineDesign !== undefined && inlineDesign.issues.length > 0) { + const errors = inlineDesign.issues + .map((item) => `
    ${escapeHtmlText(`Design tokens failed validation: ${item.reason}. ${item.nextAction}`)}
    `) + .join(""); + bodyHtml = `${errors}${bodyHtml}`; + } let needsBoot = false; let needsMermaid = false; doc.components.forEach((block, index) => { const placeholder = `
    `; - bodyHtml = bodyHtml.replace(placeholder, renderComponent(block.kind, block.json, `component-${index}`)); + bodyHtml = bodyHtml.replace(placeholder, renderComponent(block.kind, block.json, `component-${index}`, { locale })); if (block.kind === "copy" || block.kind === "decisions") needsBoot = true; if (block.kind === "mermaid") needsMermaid = true; }); + const resolved = resolveCharts(doc.charts); + resolved.forEach((chart, index) => { + if (!chart.summary) return; + const placeholder = `
    `; + const summaryId = `chart-${index}-summary`; + const accessible = `
    ${escapeHtmlText(chart.summary)}
    `; + bodyHtml = bodyHtml.replace(placeholder, accessible); + }); bodyHtml = wrapSections(enhanceBodyHtml(bodyHtml)); const html = assemblePage({ @@ -765,19 +903,41 @@ export function renderArtifact(markdown: string, options: RenderOptions = {}): R description: doc.meta.description, theme: doc.meta.theme, bodyHtml, - resolved: resolveCharts(doc.charts), + resolved, needsBoot, needsMermaid, maxBytes: options.maxBytes ?? DEFAULT_MAX_BYTES, + assetCss: options.assets?.font === undefined + ? undefined + : `@font-face{font-family:"Artifact Project";src:url(${options.assets.font.dataUri}) format("${fontFormat(options.assets.font.mime)}");font-display:swap}:root{--artifact-font:"Artifact Project",system-ui,-apple-system,"Segoe UI",sans-serif;--artifact-heading-font:var(--artifact-font)}`, + designTokens, + locale, }); return { html, meta: doc.meta, chartCount: doc.charts.length }; } +function fontFormat(mime: string): string { + if (mime === "font/woff2") return "woff2"; + if (mime === "font/woff") return "woff"; + if (mime === "font/ttf") return "truetype"; + return "opentype"; +} + +export async function renderPortableArtifact( + markdown: string, + worktreeRoot: string, + options: PortableRenderOptions = {}, +): Promise { + const assets = await resolvePortableAssets(markdown, worktreeRoot, options); + return renderArtifact(markdown, { maxBytes: options.maxBytes, assets }); +} + export function renderRawHtml( bodyHtml: string, meta: Frontmatter = {}, options: RenderOptions = {}, ): RenderedArtifact { + const locale = resolveLocaleContext(meta); const html = assemblePage({ title: meta.title ?? "Artifact", icon: meta.icon ?? "📄", @@ -786,6 +946,7 @@ export function renderRawHtml( needsBoot: false, needsMermaid: false, maxBytes: options.maxBytes ?? DEFAULT_MAX_BYTES, + locale, }); return { html, meta, chartCount: 0 }; } diff --git a/src/served-html.ts b/src/served-html.ts index 311dd68..699e842 100644 --- a/src/served-html.ts +++ b/src/served-html.ts @@ -10,8 +10,15 @@ export const NAME_RE = /^[a-z0-9-]+$/; */ export function prepareServedHtml(text: string, options: { liveReload?: boolean } = {}): string { const relaxed = text.replace("connect-src 'none'", "connect-src 'self'"); - const snippet = - options.liveReload === false ? BRIDGE_SNIPPET : BRIDGE_SNIPPET + LIVE_RELOAD_SNIPPET; - const at = relaxed.lastIndexOf(""); - return at === -1 ? relaxed + snippet : relaxed.slice(0, at) + snippet + relaxed.slice(at); + const headEnd = relaxed.indexOf(""); + const bodyStart = relaxed.indexOf("", bodyStart); + const bridged = bodyOpenEnd === -1 + ? BRIDGE_SNIPPET + relaxed + : relaxed.slice(0, bodyOpenEnd + 1) + BRIDGE_SNIPPET + relaxed.slice(bodyOpenEnd + 1); + if (options.liveReload === false) return bridged; + const bodyEnd = bridged.lastIndexOf(""); + return bodyEnd === -1 + ? bridged + LIVE_RELOAD_SNIPPET + : bridged.slice(0, bodyEnd) + LIVE_RELOAD_SNIPPET + bridged.slice(bodyEnd); } diff --git a/src/text.ts b/src/text.ts index 981ae37..6c95702 100644 --- a/src/text.ts +++ b/src/text.ts @@ -14,3 +14,13 @@ export function slugify(title: string): string { .replace(/-{2,}/g, "-"); return slug.length > 0 ? slug : "artifact"; } + +export function headingSlugify(title: string): string { + const slug = title + .normalize("NFKC") + .toLocaleLowerCase("und") + .replace(/[^\p{L}\p{N}]+/gu, "-") + .replace(/^-+|-+$/g, "") + .replace(/-{2,}/g, "-"); + return slug.length > 0 ? slug : "section"; +} diff --git a/test/accessibility.test.ts b/test/accessibility.test.ts new file mode 100644 index 0000000..e6e3286 --- /dev/null +++ b/test/accessibility.test.ts @@ -0,0 +1,110 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { renderArtifact } from "../src/render.ts"; +import { preflightDocument } from "../src/preflight.ts"; +import { validateComponent } from "../src/components.ts"; +import { contrastRatio } from "../src/design-tokens.ts"; +import { formatZonedTimestamp, resolveLocaleContext } from "../src/locale.ts"; + +const ACCESSIBLE_DOCUMENT = [ + "---", + "title: مراجعة الإشارات", + "lang: ar", + "dir: rtl", + "locale: ar-EG", + "timezone: Asia/Riyadh", + "---", + "# مراجعة الإشارات", + "", + "> [!NOTE] ملخص واضح لا يعتمد على اللون.", + "", + "- [x] اكتملت المراجعة", + "", + "```progress", + '{"label":"التقدم","done":3,"total":4}', + "```", + "", + "```echarts", + '{"description":"ترتفع الإشارة من ثلاث نقاط إلى خمس نقاط خلال يومين.","xAxis":{"type":"category","data":["الاثنين","الثلاثاء"]},"yAxis":{"type":"value"},"series":[{"type":"line","data":[3,5]}]}', + "```", + "", + "```table", + '{"caption":"سجل الإشارات","columns":[{"key":"count","label":"العدد","type":"num"},{"key":"captured","label":"وقت الالتقاط","type":"datetime"}],"rows":[{"count":1234.5,"captured":"2026-08-17T15:00:00Z"}]}', + "```", + "", + "```decisions", + '{"title":"قرار","questions":[{"id":"next","question":"ما الخطوة التالية؟","options":[{"id":"ship","label":"نشر"},{"id":"hold","label":"انتظار"}]}]}', + "```", +].join("\n"); + +test("built-in output exposes landmarks, equivalents, labels, state, and RTL metadata", async () => { + const preflight = await preflightDocument(ACCESSIBLE_DOCUMENT); + assert.deepEqual(preflight.diagnostics.filter((item) => item.severity === "error"), []); + const html = renderArtifact(ACCESSIBLE_DOCUMENT).html; + assert.match(html, //); + assert.match(html, /href="#artifact-main">Skip to main content/); + assert.match(html, /
    /); + assert.match(html, /

    /); + assert.match(html, /
    ]+role="img"[^>]+aria-labelledby="chart-0-summary"/); + assert.match(html, /
    ترتفع الإشارة/); + assert.match(html, /]*>
    سجل الإشارات<\/caption>/); + assert.match(html, /