konpeki 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/AGENTS.md +21 -28
  2. package/AUTHORING.md +68 -229
  3. package/CONTRIBUTING.md +10 -23
  4. package/README.md +96 -87
  5. package/SETUP.md +50 -114
  6. package/docs/development.md +43 -248
  7. package/docs/workflow.md +6 -108
  8. package/html/README.md +140 -0
  9. package/html/browser.ts +103 -0
  10. package/html/document.ts +38 -0
  11. package/html/floor.ts +34 -0
  12. package/html/index.html +5 -0
  13. package/html/inspect.ts +313 -0
  14. package/html/preview.css +62 -0
  15. package/html/preview.tsx +463 -0
  16. package/html/review-hints.ts +63 -0
  17. package/html/server.ts +99 -0
  18. package/html/source.ts +104 -0
  19. package/html/starter.ts +6 -0
  20. package/html/theme-authoring.md +176 -0
  21. package/html/theme.md +61 -0
  22. package/index.html +9 -9
  23. package/package.json +21 -35
  24. package/plugin.json +1 -1
  25. package/public/og.png +0 -0
  26. package/runtime/browser-B-29TH1a.mjs +595 -0
  27. package/runtime/floor-Cmk7G3pU.mjs +41 -0
  28. package/runtime/konpeki.mjs +90 -1410
  29. package/runtime/server-BAqC_5WD.mjs +2 -0
  30. package/runtime/server-DfRpfcY9.mjs +205 -0
  31. package/runtime/source-B_Ui9tMp.mjs +141 -0
  32. package/runtime/source-CZQq9GUO.mjs +2 -0
  33. package/skills/konpeki/SKILL.md +88 -172
  34. package/skills/konpeki/assets/blank.html +17 -0
  35. package/skills/konpeki/floor.md +76 -0
  36. package/skills/konpeki/references/cover.md +27 -0
  37. package/skills/konpeki/references/long-document.md +40 -0
  38. package/skills/konpeki/references/one-pager.md +27 -0
  39. package/skills/konpeki/references/patterns.md +118 -0
  40. package/skills/konpeki/references/resume.md +28 -0
  41. package/skills/konpeki/references/slides.md +28 -0
  42. package/skills/konpeki/scripts/ensure-runtime.mjs +10 -31
  43. package/skills/konpeki/scripts/prepare-document.mjs +25 -14
  44. package/src/components/PageBoard.tsx +156 -0
  45. package/src/lib/alignment.ts +21 -0
  46. package/src/lib/page-board.ts +25 -0
  47. package/src/lib/review-position.ts +19 -0
  48. package/src/styles/base.css +4 -6
  49. package/src/styles/feedback.css +97 -61
  50. package/src/styles/shell.css +95 -323
  51. package/theme-base.css +90 -0
  52. package/theme.css +56 -0
  53. package/vite.config.ts +2 -5
  54. package/composition/README.md +0 -156
  55. package/composition/compile.ts +0 -227
  56. package/composition/document.ts +0 -600
  57. package/composition/schema.json +0 -3001
  58. package/composition/schema.ts +0 -437
  59. package/composition/theme-tokens.ts +0 -16
  60. package/composition/types.ts +0 -269
  61. package/composition/validate.ts +0 -226
  62. package/composition/vector.ts +0 -143
  63. package/composition/visualizations.ts +0 -319
  64. package/design/README.md +0 -17
  65. package/design/palettes/README.md +0 -14
  66. package/design/palettes/base.ts +0 -14
  67. package/design/palettes/candidates.ts +0 -19
  68. package/design/palettes/index.ts +0 -78
  69. package/design/review/color-theme.md +0 -44
  70. package/design/review/layout.md +0 -16
  71. package/design/review/text.md +0 -18
  72. package/design/review/typography.md +0 -15
  73. package/design/review/visuals.md +0 -31
  74. package/design/semantic-patterns.md +0 -43
  75. package/design/themes/README.md +0 -40
  76. package/design/themes/index.ts +0 -24
  77. package/design/visual-languages/technical-product.md +0 -17
  78. package/design/visual-review.md +0 -88
  79. package/lib/assets.d.ts +0 -8
  80. package/lib/charts.ts +0 -18
  81. package/lib/contrast.ts +0 -16
  82. package/lib/layouts.ts +0 -50
  83. package/lib/slide.tsx +0 -42
  84. package/lib/taste.ts +0 -17
  85. package/lib/text.tsx +0 -89
  86. package/lib/typeface.ts +0 -44
  87. package/scripts/migrate-react-page.ts +0 -120
  88. package/skills/konpeki/assets/blank.json +0 -23
  89. package/slides/README.md +0 -153
  90. package/slides/architecture/PROMPT.md +0 -31
  91. package/slides/architecture/index.tsx +0 -102
  92. package/slides/article-brief/PROMPT.md +0 -35
  93. package/slides/article-brief/index.tsx +0 -71
  94. package/slides/bar-chart/PROMPT.md +0 -39
  95. package/slides/bar-chart/index.tsx +0 -97
  96. package/slides/comparison/PROMPT.md +0 -29
  97. package/slides/comparison/index.tsx +0 -95
  98. package/slides/decision-memo/PROMPT.md +0 -34
  99. package/slides/decision-memo/index.tsx +0 -85
  100. package/slides/delivery-plan/PROMPT.md +0 -45
  101. package/slides/delivery-plan/index.tsx +0 -105
  102. package/slides/experiment/PROMPT.md +0 -44
  103. package/slides/experiment/index.tsx +0 -127
  104. package/slides/incident-workflow/PROMPT.md +0 -57
  105. package/slides/incident-workflow/index.tsx +0 -78
  106. package/slides/introducing-konpeki/PROMPT.md +0 -40
  107. package/slides/introducing-konpeki/README.md +0 -76
  108. package/slides/introducing-konpeki/SOURCE.md +0 -26
  109. package/slides/introducing-konpeki/author.ts +0 -165
  110. package/slides/introducing-konpeki/composition.json +0 -3270
  111. package/slides/line-chart/PROMPT.md +0 -40
  112. package/slides/line-chart/index.tsx +0 -72
  113. package/slides/migration/PROMPT.md +0 -38
  114. package/slides/migration/index.tsx +0 -89
  115. package/slides/og-images/PROMPT.md +0 -21
  116. package/slides/og-images/index.tsx +0 -76
  117. package/slides/product-introduction/PROMPT.md +0 -24
  118. package/slides/product-introduction/index.tsx +0 -105
  119. package/slides/research-brief/PROMPT.md +0 -40
  120. package/slides/research-brief/index.tsx +0 -104
  121. package/slides/results-explanation/PROMPT.md +0 -32
  122. package/slides/results-explanation/index.tsx +0 -96
  123. package/slides/retrospective/PROMPT.md +0 -43
  124. package/slides/retrospective/index.tsx +0 -105
  125. package/slides/sankey/PROMPT.md +0 -11
  126. package/slides/sankey/index.tsx +0 -93
  127. package/slides/teaching/PROMPT.md +0 -45
  128. package/slides/teaching/index.tsx +0 -124
  129. package/slides/vertical-bar-charts/PROMPT.md +0 -13
  130. package/slides/vertical-bar-charts/index.tsx +0 -97
  131. package/src/app/App.tsx +0 -820
  132. package/src/components/BuildOrb.tsx +0 -40
  133. package/src/components/Canvas.tsx +0 -1185
  134. package/src/components/DiagramTypeIcon.tsx +0 -78
  135. package/src/components/InspectorPanel.tsx +0 -773
  136. package/src/components/LeftPanel.tsx +0 -120
  137. package/src/components/PageSizePicker.tsx +0 -30
  138. package/src/components/Presentation.tsx +0 -105
  139. package/src/components/RevisionNotes.tsx +0 -56
  140. package/src/components/RightPanel.tsx +0 -201
  141. package/src/components/VectorOverflowWarning.tsx +0 -46
  142. package/src/components/WorkspaceChrome.tsx +0 -288
  143. package/src/components/ui.tsx +0 -53
  144. package/src/lib/examples/react-page-migration.json +0 -1295
  145. package/src/lib/examples.ts +0 -42
  146. package/src/lib/export-png.ts +0 -104
  147. package/src/lib/file-session.ts +0 -87
  148. package/src/lib/history.ts +0 -53
  149. package/src/lib/model.ts +0 -188
  150. package/src/lib/page-size.ts +0 -24
  151. package/src/lib/presentation.ts +0 -17
  152. package/src/lib/review.ts +0 -26
  153. package/src/lib/storage.ts +0 -71
  154. package/src/lib/theme.ts +0 -25
  155. package/src/lib/use-file-session.ts +0 -227
  156. package/src/main.tsx +0 -29
  157. package/src/styles/canvas.css +0 -299
  158. package/src/styles/chrome.css +0 -384
  159. package/src/styles/component-previews.css +0 -386
  160. package/src/styles/left-panel.css +0 -166
  161. package/src/styles/presentation.css +0 -72
  162. package/src/styles/right-panel.css +0 -1215
package/html/README.md ADDED
@@ -0,0 +1,140 @@
1
+ # HTML, files, theme, and CLI
2
+
3
+ This is the reference for Konpeki's source contract and runtime behavior. The
4
+ [skill](../skills/konpeki/SKILL.md) owns workflow, its [floor](../skills/konpeki/floor.md)
5
+ owns bans and defaults, and [AUTHORING.md](../AUTHORING.md) owns editorial and
6
+ visual judgment.
7
+
8
+ ## Document and file contract
9
+
10
+ - Author static HTML/CSS and inline SVG. Do not author JavaScript, applications,
11
+ interactive controls, or remote resources.
12
+ - Each page is an explicit direct child of `<body>` with `data-page` and a
13
+ globally unique, stable `id`. Targetable HTML and SVG elements also need
14
+ globally unique, stable IDs.
15
+ - CSS defines fixed page width and height, at most 8192 CSS px per side. Pages
16
+ may differ in size. Page breaks are explicit; overflow is not paginated.
17
+ - Keep images, stylesheets, and licensed fonts beside the HTML with relative
18
+ paths, or embed them. The only remote exception is Google Fonts from
19
+ `fonts.googleapis.com/css2` and `fonts.gstatic.com`; it requires network access.
20
+
21
+ ```html
22
+ <!doctype html>
23
+ <html lang="en">
24
+ <head>
25
+ <meta charset="utf-8">
26
+ <link rel="stylesheet" href="theme.css">
27
+ </head>
28
+ <body>
29
+ <main id="page-1" data-page data-size="link">
30
+ <h1 id="title">A clear headline</h1>
31
+ </main>
32
+ </body>
33
+ </html>
34
+ ```
35
+
36
+ ## Themes
37
+
38
+ Link `theme.css` for the default light-blue theme. Read [Use a theme](theme.md)
39
+ for its `--kp-`-prefixed public tokens, complete type roles, page presets, and asset
40
+ requirements. A compatible entry point sets `--kp-theme: 1`; the base alone does
41
+ not. Keep the imported `theme-base.css` and referenced fonts beside the entry
42
+ point. Adapt a copy of the default when a brief brings its own look.
43
+
44
+ For deliberately unthemed content, set `data-theme="custom"` on its ancestor.
45
+ This skips theme-value checks in that subtree and definition checks on an
46
+ opted-out page, but keeps layout and resource checks. An alternative implementing
47
+ the contract does not need this opt-out.
48
+
49
+ ## Diagnostics
50
+
51
+ `inspect` detects page and text overflow, clipped text, missing resources,
52
+ inter-element text line-box overlap, and opaque sRGB HTML text contrast against
53
+ solid ancestor backgrounds. Contrast thresholds are 3:1 for large text and
54
+ 4.5:1 otherwise. Contrast inspection skips SVG text, gradients, images,
55
+ transparency, and compositing it cannot evaluate reliably.
56
+
57
+ Floor warnings use the rule ID from the [authoring floor](../skills/konpeki/floor.md)
58
+ as their code. `label-above-headline` flags a short, smaller line directly above
59
+ a title, display or section heading in the same column; running heads repeated
60
+ across A4 pages, full sentences and metric figures are not flagged, and
61
+ `data-kp-allow="label-above-headline"` on the label records a deliberate
62
+ exception. `stranded-word` flags a headline line that holds one word after a fuller
63
+ line, within each `<br>`-separated part; a line a `<br>` isolates is deliberate. The base balances headline roles and
64
+ sets `text-wrap: pretty` on pages to avoid most stranded words.
65
+
66
+ `small-text` warns below 24 CSS px on screen presets and 11 CSS px on A4,
67
+ including unthemed HTML and SVG text. The default preset is presentation.
68
+ These are computed font-size floors, not a guarantee of legibility after SVG
69
+ viewBox scaling, CSS transforms, or reduction to the final viewing size. Unknown
70
+ custom presets have no assumed floor. Review screen graphics at their intended
71
+ size and print pages at actual size; warnings do not block export.
72
+
73
+ For opted-in themes, it also checks the active definition and compares used
74
+ size/leading pairs, colors and shape treatments to its tokens. See the [theme diagnostics](theme-authoring.md#verify-a-theme)
75
+ for required roles and contrast pairs. `inspect` includes each page's resolved
76
+ type treatments under `theme.type`, so authors can plan with the actual sizes.
77
+
78
+ CLI inspection checks which fonts actually drew the text. Installed-font fallback
79
+ is an error, including on unthemed pages. Both CLI and preview check declared
80
+ weight/style coverage; only CLI can audit actual glyph fallback. Load the required
81
+ licensed faces and subsets from Google Fonts, local files, or embedded data
82
+ instead of relying on the machine. The default theme uses Google Fonts and needs
83
+ network access during preview and export; finished PNGs and PDFs do not.
84
+
85
+ HTML overlap uses estimated line-height boxes rather than raw font rectangles;
86
+ SVG, normal leading and transformed text retain range bounds. These are not glyph
87
+ ink measurements, and automated checks cannot establish factual
88
+ correctness or visual quality. Inspect rendered output. A render with diagnostic
89
+ errors exits nonzero and writes no output.
90
+
91
+ ## CLI
92
+
93
+ ```text
94
+ konpeki validate document.html
95
+ konpeki inspect document.html [--page N] [--details]
96
+ konpeki check document.html
97
+ konpeki render document.html [--page N] [--format png|pdf] [--scale 2] [--output file]
98
+ konpeki preview document.html [--host host] [--port port] [--json]
99
+ konpeki browser install
100
+ ```
101
+
102
+ Pages are one-based. HTML export supports PNG and PDF only. PNG defaults to page
103
+ 1; PDF includes all pages unless `--page N` is supplied and preserves mixed page
104
+ sizes. `--scale` affects PNG only. Outputs are exclusive and never overwritten.
105
+
106
+ Inspection and export require the pinned Chromium installed by `browser install`;
107
+ preview does not. Preview chooses another available port when its default is
108
+ busy, while an explicit `--port` is strict.
109
+
110
+ Preview comments remain browser-local. **Copy & clear** copies the full local
111
+ document path, comments, and target IDs for pasting into an agent conversation.
112
+ Small moves edit translation only; they do not reorder source. Deletion may
113
+ reflow content. Reread current source and preserve unrelated edits when applying
114
+ feedback.
115
+
116
+ ## Keyboard review
117
+
118
+ Open **Keyboard shortcuts** (`?`) and enable **Vim review mode**. The preference
119
+ is remembered in this browser. With it off, the existing mouse and comment
120
+ shortcuts are unchanged.
121
+
122
+ | Context | Keys | Action |
123
+ | --- | --- | --- |
124
+ | Canvas | `f`, then hint letters | Comment on a visible element of the current page |
125
+ | Canvas | `c` / `Shift+C` | Comment on the selection / entire current page |
126
+ | Canvas | `h j k l` | Pan left, down, up, right |
127
+ | Canvas | `[` / `]`, `gg` / `G` | Previous / next page, first / last page; keep zoom |
128
+ | Canvas | `+` or `=`, `-`, `0` | Zoom in, zoom out, fit and center current page |
129
+ | Canvas | `q` | Open all pending comments |
130
+ | Comment | `⌘/Ctrl+Enter` | Add and return to the canvas without changing the viewport |
131
+ | Pending comments | `j` / `k`, `Enter` | Select a comment, reveal its target and notes |
132
+ | Pending comments | `⌘/Ctrl+Enter` | Copy & clear; comments stay intact if copying fails |
133
+ | Review | `Esc` | Back one level, preserving unfinished drafts until reload |
134
+
135
+ The current page follows navigation and panning. Hints use stable element IDs,
136
+ exclude hidden/offscreen targets, and keep their codes while filtering.
137
+ `Backspace` corrects a hint prefix; `Esc` cancels. Moving the viewport or updating
138
+ the source cancels hints rather than reusing stale geometry. Letter shortcuts are
139
+ suspended inside text inputs and during IME composition. Review mode does not
140
+ add Vim text editing or bare-letter source-deletion commands.
@@ -0,0 +1,103 @@
1
+ import { chromium } from "playwright";
2
+ import { PDFDocument } from "pdf-lib";
3
+ import { documentServer } from "./server.ts";
4
+ import { inspectHTMLPage, type Diagnostic } from "./inspect.ts";
5
+ import { isGoogleFontResource } from "./document.ts";
6
+
7
+ export async function browserDocument(input: string, options: { page?: number; format?: string; scale?: number; details?: boolean } = {}) {
8
+ if (options.format !== undefined && !["png", "pdf"].includes(options.format)) throw new Error("HTML exports support PNG and PDF. Whole-page SVG export is not supported.");
9
+ const scale = options.scale ?? 2;
10
+ if (!Number.isFinite(scale) || scale <= 0 || scale > 8) throw new Error("Scale must be greater than 0 and at most 8.");
11
+ const server = await documentServer(input);
12
+ let browser;
13
+ try {
14
+ const snapshot = await server.store.read();
15
+ const selected = options.page;
16
+ if (selected !== undefined && (!Number.isInteger(selected) || selected < 1 || selected > snapshot.pages.length))
17
+ throw new Error(`Page must be an integer from 1 to ${snapshot.pages.length}.`);
18
+ try { browser = await chromium.launch({ headless: true }); }
19
+ catch { throw new Error("Chromium could not start. Run `konpeki browser install` to install the pinned browser; Linux also needs Chromium's system libraries."); }
20
+ const context = await browser.newContext({ viewport: { width: 794, height: 1123 }, deviceScaleFactor: scale, serviceWorkers: "block" });
21
+ // Local document assets plus Google Fonts; other external requests, including
22
+ // those hidden in CSS, remain blocked.
23
+ await context.route("**/*", route => {
24
+ const request = route.request();
25
+ return request.url().startsWith(`${server.origin}${server.prefix}/`) || isGoogleFontResource(request.url(), request.resourceType()) ? route.continue() : route.abort();
26
+ });
27
+ const tab = await context.newPage(), pages = [], diagnostics: Diagnostic[] = [];
28
+ const cdp = await context.newCDPSession(tab);
29
+ await cdp.send("DOM.enable"); await cdp.send("CSS.enable");
30
+ const failed = new Set<string>();
31
+ tab.on("requestfailed", request => failed.add(request.url()));
32
+ tab.on("response", response => { if (!response.ok()) failed.add(response.url()); });
33
+ const pdf = options.format === "pdf" ? await PDFDocument.create() : undefined;
34
+ let bytes: Uint8Array | undefined;
35
+ for (let index = 0; index < snapshot.pages.length; index++) {
36
+ if (selected !== undefined && selected !== index + 1) continue;
37
+ if (options.format === "png" && selected === undefined && index > 0) break;
38
+ const id = snapshot.pages[index];
39
+ await tab.goto("about:blank");
40
+ await tab.setViewportSize({ width: 794, height: 1123 });
41
+ failed.clear();
42
+ const response = await tab.goto(`${server.origin}${server.prefix}/document/?page=${encodeURIComponent(id)}&revision=${snapshot.revision}`);
43
+ if (!response?.ok()) throw new Error("The HTML changed while rendering. Inspect and retry the latest source.");
44
+ await tab.evaluate(async () => {
45
+ await document.fonts.ready;
46
+ await Promise.all(Array.from(document.images).map(image => image.decode().catch(() => {})));
47
+ });
48
+ const element = tab.locator("[data-konpeki-current]");
49
+ const initial = await element.boundingBox();
50
+ if (!initial || initial.width <= 0 || initial.height <= 0 || initial.width > 8192 || initial.height > 8192) throw new Error("Invalid page dimensions. Use an explicit width and height up to 8192 CSS px.");
51
+ await tab.setViewportSize({ width: Math.ceil(initial.width), height: Math.ceil(initial.height) });
52
+ await tab.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); });
53
+ await tab.waitForLoadState("networkidle");
54
+ const report = await element.evaluate(inspectHTMLPage);
55
+ // CSS font-family and FontFaceSet cannot tell which font supplied a glyph.
56
+ // Chromium can: inspect direct text children, retaining their nearest ID
57
+ // even when an inline span has no ID. Include generated text as well.
58
+ const { root } = await cdp.send("DOM.getDocument", { depth: -1, pierce: true });
59
+ const { nodeId } = await cdp.send("DOM.querySelector", { nodeId: root.nodeId, selector: "[data-konpeki-current]" });
60
+ const { node } = await cdp.send("DOM.describeNode", { nodeId, depth: -1, pierce: true });
61
+ const fallbacks = new Map<string, Set<string>>();
62
+ async function auditFonts(node: typeof root, target: string) {
63
+ if (node.nodeType !== 1) return;
64
+ const attrs = node.attributes ?? [], idIndex = attrs.indexOf("id");
65
+ if (idIndex >= 0) target = attrs[idIndex + 1];
66
+ const { computedStyle } = await cdp.send("CSS.getComputedStyleForNode", { nodeId: node.nodeId });
67
+ const style = Object.fromEntries(computedStyle.map(s => [s.name, s.value]));
68
+ if (style.display === "none" || Number(style.opacity) === 0) return;
69
+ if (style.visibility === "visible" && (node.pseudoType || node.children?.some(child => child.nodeType === 3 && child.nodeValue.trim()))) {
70
+ const { fonts } = await cdp.send("CSS.getPlatformFontsForNode", { nodeId: node.nodeId });
71
+ for (const font of fonts) if (font.glyphCount > 0 && !font.isCustomFont) {
72
+ if (!fallbacks.has(target)) fallbacks.set(target, new Set());
73
+ fallbacks.get(target)!.add(font.familyName);
74
+ }
75
+ }
76
+ for (const child of [...node.children ?? [], ...node.pseudoElements ?? []]) await auditFonts(child, target);
77
+ }
78
+ await auditFonts(node, id);
79
+ for (const [target, fonts] of fallbacks) report.diagnostics.push({ code: "font-fallback", severity: "error", page: id, target,
80
+ message: `Text was drawn with installed fonts: ${[...fonts].join(", ")}. Load webfonts covering its families, styles, weights and glyphs from Google Fonts, local files or embedded data instead of relying on this machine.` });
81
+ if (Math.abs(report.width - initial.width) > .75 || Math.abs(report.height - initial.height) > .75)
82
+ throw new Error("Page dimensions depend on the viewport. Give pages a fixed CSS width and height; their content may use responsive layout.");
83
+ for (const resource of failed) report.diagnostics.push({ code: "missing-resource", severity: "error", page: id, target: id, message: `Resource did not load: ${resource.startsWith(`${server.origin}${server.prefix}/document/`) ? resource.slice(`${server.origin}${server.prefix}/document/`.length) : new URL(resource).origin + new URL(resource).pathname}. Use local assets or check access to Google Fonts.` });
84
+ diagnostics.push(...report.diagnostics);
85
+ pages.push({ pageNumber: index + 1, ...report, ...(options.details ? {} : { text: undefined }) });
86
+ if (options.format === "png") {
87
+ if (report.width * report.height * scale * scale > 64_000_000) throw new Error("PNG exceeds 64 million pixels; reduce --scale.");
88
+ bytes = await element.screenshot({ type: "png", animations: "disabled" });
89
+ } else if (pdf) {
90
+ // Print the screen layout on exactly one authored page, then merge pages
91
+ // to retain mixed paper sizes. Never invent document pagination.
92
+ await tab.emulateMedia({ media: "screen" });
93
+ await tab.addStyleTag({ content: `@page { size: ${report.width}px ${report.height}px; margin: 0; } body { print-color-adjust: exact; }` });
94
+ const part = await PDFDocument.load(await tab.pdf({ preferCSSPageSize: true, printBackground: true, margin: { top: 0, left: 0, right: 0, bottom: 0 } }));
95
+ if (part.getPageCount() !== 1) throw new Error(`Page ${index + 1} printed onto multiple sheets. Fix its overflow before exporting PDF.`);
96
+ for (const page of await pdf.copyPages(part, [0])) pdf.addPage(page);
97
+ }
98
+ }
99
+ if ((await server.store.read()).revision !== snapshot.revision) throw new Error("The source changed during rendering. Retry the latest revision.");
100
+ if (pdf) bytes = await pdf.save();
101
+ return { bytes, report: { schema: "konpeki-html-inspection/v1", revision: snapshot.revision, units: "css-pixels", ok: !diagnostics.some(d => d.severity === "error"), pages, diagnostics } };
102
+ } finally { await browser?.close(); await server.close(); }
103
+ }
@@ -0,0 +1,38 @@
1
+ import { parse, parseFragment, serialize, type DefaultTreeAdapterMap } from "parse5";
2
+
3
+ export const documentPolicy = "default-src 'none'; script-src 'none'; style-src 'unsafe-inline' 'self' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data:; base-uri 'none'; form-action 'none'";
4
+
5
+ /** The only external resources permitted by CLI inspection/export. */
6
+ export function isGoogleFontResource(url: string, resourceType: string) {
7
+ const resource = new URL(url);
8
+ if (resource.protocol !== "https:" || resource.port || resource.username || resource.password) return false;
9
+ return resourceType === "stylesheet" && resource.hostname === "fonts.googleapis.com" && ["/css", "/css2"].includes(resource.pathname)
10
+ || resourceType === "font" && resource.hostname === "fonts.gstatic.com" && resource.pathname.startsWith("/s/");
11
+ }
12
+
13
+ /** A view of the source, never a serialization written back to it. Keep every
14
+ * page in the DOM so nth-child selectors and shared SVG definitions stay stable. */
15
+ export function documentHTML(source: string, pageId: string) {
16
+ const tree = parse(source);
17
+ const html = tree.childNodes.find(n => n.nodeName === "html") as DefaultTreeAdapterMap["element"];
18
+ const head = html.childNodes.find(n => n.nodeName === "head") as DefaultTreeAdapterMap["element"];
19
+ const body = html.childNodes.find(n => n.nodeName === "body") as DefaultTreeAdapterMap["element"];
20
+ for (const child of body.childNodes) {
21
+ if (!("attrs" in child)) continue;
22
+ child.attrs = child.attrs.filter(a => a.name !== "data-konpeki-current");
23
+ if (child.attrs.some(a => a.name === "data-page") && child.attrs.some(a => a.name === "id" && a.value === pageId))
24
+ child.attrs.push({ name: "data-konpeki-current", value: "" });
25
+ }
26
+ // Serve at a directory URL: relative assets and SVG #fragments then retain
27
+ // their native meaning without a <base> that redirects fragment references.
28
+ head.childNodes.unshift(...parseFragment(`<meta http-equiv="Content-Security-Policy" content="${documentPolicy}">`).childNodes);
29
+ head.childNodes.push(...parseFragment(`<style>
30
+ html, body { margin: 0 !important; padding: 0 !important; }
31
+ body { display: flex !important; flex-direction: column !important; align-items: flex-start !important; }
32
+ body > [data-page] { margin: 0 !important; flex: none !important; }
33
+ body > [data-page]:not([data-konpeki-current]) { display: none !important; }
34
+ *, *::before, *::after { animation: none !important; transition: none !important; }
35
+ @media print { body > [data-page] { break-before: auto !important; break-after: auto !important; } }
36
+ </style>`).childNodes);
37
+ return serialize(tree);
38
+ }
package/html/floor.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ export type FloorRule = { ids: string[]; tier: "ban" | "default" | "verify"; check: "inspect" | "review"; text: string };
4
+
5
+ const tiers: Record<string, FloorRule["tier"]> = { Bans: "ban", Defaults: "default", Verify: "verify" };
6
+
7
+ /** The rules in skills/konpeki/floor.md, the single source for the review checklist. */
8
+ export function floorRules(source = readFileSync(new URL("../skills/konpeki/floor.md", import.meta.url), "utf8")) {
9
+ const rules: FloorRule[] = [];
10
+ let tier: FloorRule["tier"] | undefined;
11
+ for (const line of source.split("\n")) {
12
+ const heading = /^## (\w+)/.exec(line);
13
+ if (heading) { tier = tiers[heading[1]]; continue; }
14
+ const rule = /^- ((?:`[a-z0-9-]+`(?:, )?)+) \((inspect|review)\):(?: (.*))?$/.exec(line);
15
+ if (rule && tier) rules.push({ ids: rule[1].match(/[a-z0-9-]+/g)!, tier, check: rule[2] as FloorRule["check"], text: rule[3] ?? "" });
16
+ else if (/^ {2}\S/.test(line) && rules.length) rules.at(-1)!.text = `${rules.at(-1)!.text} ${line.trim()}`.trim();
17
+ }
18
+ return rules;
19
+ }
20
+
21
+ /** The first sentence, ignoring full stops inside quoted examples. */
22
+ function firstSentence(text: string) {
23
+ let quoted = false;
24
+ for (let i = 0; i < text.length; i++) {
25
+ if (text[i] === '"') quoted = !quoted;
26
+ else if (!quoted && text[i] === "." && (i + 1 === text.length || text[i + 1] === " ")) return text.slice(0, i + 1);
27
+ }
28
+ return text;
29
+ }
30
+
31
+ /** The first sentence of each rule that only a person looking at the page can check. */
32
+ export function reviewChecklist(rules = floorRules()) {
33
+ return rules.filter(rule => rule.check === "review").map(rule => `${rule.ids[0]}: ${firstSentence(rule.text)}`);
34
+ }
@@ -0,0 +1,5 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Konpeki</title></head>
4
+ <body><div id="root"></div><script type="module" src="./preview.tsx"></script></body>
5
+ </html>