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 `` 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. + + + +## 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 `` 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("