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
@@ -0,0 +1,595 @@
1
+ import { a as isGoogleFontResource, t as documentServer } from "./server-DfRpfcY9.mjs";
2
+ import { chromium } from "playwright";
3
+ import { PDFDocument } from "pdf-lib";
4
+ //#region html/inspect.ts
5
+ /** Self-contained so the same measurement runs in the review iframe and CLI's
6
+ * Chromium page. Range bounds describe font metrics, not glyph ink or leading. */
7
+ function inspectHTMLPage(page) {
8
+ const doc = page.ownerDocument, win = doc.defaultView;
9
+ const origin = page.getBoundingClientRect();
10
+ const diagnostics = [], seen = /* @__PURE__ */ new Set();
11
+ const bounds = (r) => ({
12
+ x: r.x - origin.x,
13
+ y: r.y - origin.y,
14
+ width: r.width,
15
+ height: r.height
16
+ });
17
+ const outside = (r, box, x = true, y = true) => x && (r.left < box.left - .75 || r.right > box.right + .75) || y && (r.top < box.top - .75 || r.bottom > box.bottom + .75);
18
+ function report(code, element, message, severity = "error") {
19
+ const target = element.closest("[id]")?.id ?? page.id, key = `${code}/${target}`;
20
+ if (!seen.has(key)) {
21
+ seen.add(key);
22
+ diagnostics.push({
23
+ code,
24
+ severity,
25
+ page: page.id,
26
+ target,
27
+ message
28
+ });
29
+ }
30
+ }
31
+ function visible(element) {
32
+ if (element.closest("defs,clipPath,mask,symbol,marker,pattern,linearGradient,radialGradient")) return false;
33
+ const style = win.getComputedStyle(element);
34
+ for (let ancestor = element; ancestor; ancestor = ancestor.parentElement) if (Number(win.getComputedStyle(ancestor).opacity) === 0) return false;
35
+ return style.visibility === "visible" && style.display !== "none" && element.getClientRects().length > 0;
36
+ }
37
+ if (origin.width <= 0 || origin.height <= 0 || origin.width > 8192 || origin.height > 8192) report("page-size", page, "Use explicit positive page dimensions no larger than 8192 CSS px per side.");
38
+ const elements = Array.from(page.querySelectorAll("*"));
39
+ const blocks = elements.filter((el) => el.id && visible(el)).map((el) => ({
40
+ id: el.id,
41
+ tag: el.tagName.toLowerCase(),
42
+ bounds: bounds(el.getBoundingClientRect())
43
+ }));
44
+ for (const element of elements) {
45
+ if (!visible(element)) continue;
46
+ if (outside(element.getBoundingClientRect(), origin)) report("page-overflow", element, "This element extends beyond the page. Recompose it; content does not continue onto another page automatically.");
47
+ if (element.tagName === "IMG" && !element.naturalWidth) report("missing-image", element, "An image did not load. Use a local asset beside the HTML or an embedded data URL.");
48
+ }
49
+ const text = [];
50
+ const lines = [];
51
+ const textElements = /* @__PURE__ */ new Set();
52
+ const minimumSizes = {
53
+ presentation: 24,
54
+ portrait: 24,
55
+ square: 24,
56
+ link: 24,
57
+ article: 24,
58
+ a4: 11,
59
+ explainer: 24,
60
+ gallery: 24
61
+ };
62
+ const preset = page.getAttribute("data-size") ?? "presentation", minimumSize = minimumSizes[preset];
63
+ const walker = doc.createTreeWalker(page, 4);
64
+ let node;
65
+ while (node = walker.nextNode()) {
66
+ const parent = node.parentElement;
67
+ if (!node.textContent?.trim() || !parent || !visible(parent) || parent.closest("style,defs,title,desc")) continue;
68
+ const range = doc.createRange();
69
+ range.selectNodeContents(node);
70
+ const rects = Array.from(range.getClientRects()).filter((r) => r.width > 0 && r.height > 0);
71
+ if (rects.length) text.push({
72
+ target: parent.closest("[id]")?.id ?? page.id,
73
+ content: node.textContent,
74
+ bounds: rects.map(bounds)
75
+ });
76
+ if (rects.length) textElements.add(parent);
77
+ const style = win.getComputedStyle(parent);
78
+ if (rects.length && parseFloat(style.fontSize) < minimumSize) report("small-text", parent, `Text is ${style.fontSize}; use at least ${minimumSize}px for ${preset}. Reduce content or recompose for the viewing size, rather than shrinking it.`, "warning");
79
+ let leading = parseFloat(style.lineHeight);
80
+ for (let ancestor = parent; ancestor; ancestor = ancestor.parentElement) {
81
+ const s = win.getComputedStyle(ancestor);
82
+ if (s.transform !== "none" || s.scale !== "none" || s.rotate !== "none" || Number(s.zoom) !== 1) leading = NaN;
83
+ }
84
+ for (const rect of rects) {
85
+ const line = parent.namespaceURI === "http://www.w3.org/1999/xhtml" && style.writingMode === "horizontal-tb" && leading > 0 ? new DOMRect(rect.x, rect.y + (rect.height - leading) / 2, rect.width, leading) : rect;
86
+ for (const previous of lines) {
87
+ if (previous.parent === parent) continue;
88
+ const other = previous.rect;
89
+ if (Math.min(line.right, other.right) - Math.max(line.left, other.left) > 1 && Math.min(line.bottom, other.bottom) - Math.max(line.top, other.top) > 1) report("text-overlap", parent, `Text overlaps text in #${previous.parent.closest("[id]")?.id ?? page.id}. Separate their line boxes.`);
90
+ }
91
+ lines.push({
92
+ parent,
93
+ rect: line
94
+ });
95
+ if (outside(rect, origin)) report("text-overflow", parent, "Text extends beyond the page. Do not hide it or shrink required copy to fit.");
96
+ for (let ancestor = parent; ancestor; ancestor = ancestor.parentElement) {
97
+ const style = win.getComputedStyle(ancestor);
98
+ if (outside(rect, ancestor.getBoundingClientRect(), style.overflowX !== "visible", style.overflowY !== "visible")) report("clipped-text", parent, "Text crosses a clipping or scrolling boundary. Increase space or revise the layout.");
99
+ if (ancestor === page) break;
100
+ }
101
+ }
102
+ }
103
+ const xhtml = "http://www.w3.org/1999/xhtml";
104
+ const roleOf = (el) => el.getAttribute("data-type") ?? (el.tagName === "H1" ? "title" : "heading");
105
+ const leading = (style) => parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2;
106
+ const headlines = Array.from(page.querySelectorAll("h1:not([data-type]), h2:not([data-type]), [data-type=\"title\"], [data-type=\"display\"], [data-type=\"heading\"]")).filter((el) => el.namespaceURI === xhtml && visible(el) && (el.textContent?.match(/\p{L}/gu)?.length ?? 0) > (el.textContent?.match(/\d/g)?.length ?? 0));
107
+ function block(element) {
108
+ let current = element;
109
+ while (current !== page && current.parentElement && win.getComputedStyle(current).display.startsWith("inline")) current = current.parentElement;
110
+ return current;
111
+ }
112
+ const textBlocks = [...new Set(Array.from(textElements, block))].filter((el) => el.namespaceURI === xhtml);
113
+ function extent(element) {
114
+ const range = doc.createRange();
115
+ range.selectNodeContents(element);
116
+ const rects = Array.from(range.getClientRects()).filter((r) => r.width > 0 && r.height > 0);
117
+ return rects.length ? {
118
+ left: Math.min(...rects.map((r) => r.left)),
119
+ right: Math.max(...rects.map((r) => r.right))
120
+ } : element.getBoundingClientRect();
121
+ }
122
+ const normalize = (text) => text.replace(/\s+/g, " ").replace(/\d+/g, "#").trim();
123
+ const printPages = page.getAttribute("data-size") === "a4" ? Array.from(doc.querySelectorAll("body > [data-page]")).filter((p) => p !== page && p.getAttribute("data-size") === "a4").map((p) => normalize(p.textContent ?? "")) : [];
124
+ function labels(heading) {
125
+ const box = heading.getBoundingClientRect(), style = win.getComputedStyle(heading), headingInk = extent(heading);
126
+ const before = textBlocks.filter((b) => !b.contains(heading) && !heading.contains(b) && heading.compareDocumentPosition(b) & 2);
127
+ const nearest = before.at(-1)?.getBoundingClientRect();
128
+ if (!nearest) return [];
129
+ return before.filter((label) => {
130
+ const rect = label.getBoundingClientRect(), labelStyle = win.getComputedStyle(label), labelInk = extent(label);
131
+ const text = (label.textContent ?? "").replace(/\s+/g, " ").trim(), gap = box.top - rect.bottom;
132
+ if (rect.top >= nearest.bottom || rect.bottom <= nearest.top) return false;
133
+ if (gap < -1 || labelInk.right <= headingInk.left || labelInk.left >= headingInk.right || text.length > 48 || /[.!?]$/.test(text) || rect.height > leading(labelStyle) * 1.5) return false;
134
+ if (parseFloat(labelStyle.fontSize) >= parseFloat(style.fontSize) * .8 || label.closest("[data-kp-allow~=\"label-above-headline\"]")) return false;
135
+ if (roleOf(heading) === "heading" && gap > leading(style)) return false;
136
+ return !printPages.some((other) => other.includes(normalize(text)));
137
+ });
138
+ }
139
+ for (const heading of headlines) for (const label of labels(heading)) report("label-above-headline", label, `"${(label.textContent ?? "").replace(/\s+/g, " ").trim()}" labels the headline below it. Delete it, or move the product, date or source into the headline, supporting line, caption or footer.`, "warning");
140
+ for (const heading of headlines) {
141
+ const words = [];
142
+ const walker = doc.createTreeWalker(heading, 4);
143
+ for (let node; node = walker.nextNode();) for (const match of (node.textContent ?? "").matchAll(/\S+/g)) {
144
+ const range = doc.createRange();
145
+ range.setStart(node, match.index);
146
+ range.setEnd(node, match.index + match[0].length);
147
+ const rect = range.getClientRects()[0];
148
+ if (rect) words.push({
149
+ node,
150
+ top: rect.top,
151
+ text: match[0]
152
+ });
153
+ }
154
+ const size = parseFloat(win.getComputedStyle(heading).fontSize), breaks = Array.from(heading.querySelectorAll("br"));
155
+ const segments = /* @__PURE__ */ new Map();
156
+ for (const word of words) {
157
+ const segment = breaks.filter((br) => br.compareDocumentPosition(word.node) & 4).length;
158
+ const lines = segments.get(segment) ?? [], line = lines.at(-1);
159
+ if (line && word.top <= line[0].top + size / 2) line.push(word);
160
+ else lines.push([word]);
161
+ segments.set(segment, lines);
162
+ }
163
+ const stranded = [...segments.values()].find((lines) => lines.length > 1 && lines.at(-1).length === 1 && lines.at(-2).length > 1);
164
+ if (stranded) report("stranded-word", heading, `A headline line ends on one stranded word, "${stranded.at(-1)[0].text}". Reword it or change its measure rather than shrinking it.`, "warning");
165
+ }
166
+ const rgb = (value) => /^rgba?\(/.test(value) ? value.match(/[\d.]+/g).map(Number) : void 0;
167
+ const opaque = (color) => color && (color.length === 3 || color[3] === 1);
168
+ const luminance = (color) => color.slice(0, 3).reduce((sum, channel, i) => {
169
+ const c = channel / 255;
170
+ return sum + (c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4) * [
171
+ .2126,
172
+ .7152,
173
+ .0722
174
+ ][i];
175
+ }, 0);
176
+ for (const element of textElements) {
177
+ if (element.namespaceURI !== "http://www.w3.org/1999/xhtml") continue;
178
+ const style = win.getComputedStyle(element), foreground = rgb(style.color);
179
+ if (!opaque(foreground)) continue;
180
+ let background, uncertain = false;
181
+ for (let ancestor = element; ancestor; ancestor = ancestor.parentElement) {
182
+ const s = win.getComputedStyle(ancestor), color = rgb(s.backgroundColor);
183
+ if (Number(s.opacity) !== 1 || s.filter !== "none" || s.backdropFilter !== "none" || s.mixBlendMode !== "normal") uncertain = true;
184
+ if (!background) {
185
+ if (!color || s.backgroundImage !== "none" || color.length === 4 && color[3] > 0 && color[3] < 1) uncertain = true;
186
+ if (opaque(color)) background = color;
187
+ }
188
+ }
189
+ if (!background && !win.getComputedStyle(doc.documentElement).colorScheme.includes("dark")) background = [
190
+ 255,
191
+ 255,
192
+ 255
193
+ ];
194
+ if (uncertain || !background) continue;
195
+ const a = luminance(foreground), b = luminance(background);
196
+ const ratio = (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
197
+ const size = parseFloat(style.fontSize);
198
+ const minimum = size >= 24 || size >= 18.6667 && Number(style.fontWeight) >= 700 ? 3 : 4.5;
199
+ if (ratio < minimum) report("low-contrast", element, `Text contrast is ${ratio.toFixed(2)}:1; use at least ${minimum}:1 on this solid background.`);
200
+ }
201
+ const probe = doc.createElement("span");
202
+ probe.style.display = "none";
203
+ doc.body.append(probe);
204
+ const roles = [
205
+ "fine",
206
+ "caption",
207
+ "body",
208
+ "lead",
209
+ "heading",
210
+ "title",
211
+ "display"
212
+ ];
213
+ const faces = Array.from(doc.fonts);
214
+ const familyName = (family) => family.trim().replace(/^(["'])(.*)\1$/, "$2").toLowerCase();
215
+ const primaryFamily = (family) => familyName(family.match(/^\s*("[^"]*"|'[^']*'|[^,]+)/)?.[0] ?? "");
216
+ function hasFace(family, weight, style = "normal") {
217
+ return faces.some((face) => {
218
+ if (familyName(face.family) !== primaryFamily(family)) return false;
219
+ const weights = face.weight.replace("normal", "400").replace("bold", "700").split(/\s+/).map(Number);
220
+ const angles = (value) => value.match(/-?[\d.]+(?=deg)/g)?.map(Number) ?? [14];
221
+ const requested = angles(style)[0], available = angles(face.style);
222
+ return (face.style === style || style.startsWith("oblique") && face.style.startsWith("oblique") && requested >= available[0] && requested <= (available[1] ?? available[0])) && weight >= weights[0] && weight <= (weights[1] ?? weights[0]);
223
+ });
224
+ }
225
+ for (const element of textElements) {
226
+ const s = win.getComputedStyle(element);
227
+ if (faces.some((face) => familyName(face.family) === primaryFamily(s.fontFamily)) && !hasFace(s.fontFamily, Number(s.fontWeight), s.fontStyle)) report("font-face", element, `No declared face for ${s.fontFamily}, weight ${s.fontWeight}, style ${s.fontStyle}. Declare the matching webfont face; substitution or synthesis changes the intended text.`);
228
+ }
229
+ const colorTokens = [
230
+ "bg",
231
+ "fg",
232
+ "muted",
233
+ "line",
234
+ "line-subtle",
235
+ "line-strong",
236
+ "inverse",
237
+ "accent",
238
+ "wash",
239
+ "surface",
240
+ "contrast",
241
+ "on-contrast",
242
+ "on-contrast-muted",
243
+ "emphasis",
244
+ "emphasis-wash",
245
+ "complete",
246
+ "attention",
247
+ "blocked",
248
+ ...Array.from({ length: 6 }, (_, i) => `category-${i + 1}`),
249
+ ...Array.from({ length: 5 }, (_, i) => `sequence-${i + 1}`)
250
+ ];
251
+ function themeColors(style) {
252
+ probe.style.colorScheme = style.colorScheme;
253
+ return Object.fromEntries(colorTokens.map((token) => {
254
+ const value = style.getPropertyValue(`--kp-${token}`);
255
+ probe.style.color = "";
256
+ probe.style.color = value;
257
+ return [token, value && probe.style.color ? win.getComputedStyle(probe).color : void 0];
258
+ }));
259
+ }
260
+ const pageStyle = win.getComputedStyle(page), version = pageStyle.getPropertyValue("--kp-theme").trim();
261
+ const type = Object.fromEntries(roles.map((role) => [role, {
262
+ size: parseFloat(pageStyle.getPropertyValue(`--kp-font-${role}`)),
263
+ lineHeight: parseFloat(pageStyle.getPropertyValue(`--kp-leading-${role}`)),
264
+ weight: Number(pageStyle.getPropertyValue(`--kp-weight-${role}`)),
265
+ tracking: pageStyle.getPropertyValue(`--kp-tracking-${role}`).trim(),
266
+ family: pageStyle.getPropertyValue(role === "display" ? "--kp-font-family-display" : role === "heading" || role === "title" ? "--kp-font-family-heading" : "--kp-font-family").trim()
267
+ }]));
268
+ const theme = version === "1" ? {
269
+ version: 1,
270
+ mode: pageStyle.colorScheme,
271
+ unit: parseFloat(pageStyle.getPropertyValue("--kp-unit")),
272
+ type
273
+ } : void 0;
274
+ if (version && version !== "1") report("theme-version", page, `Unsupported --kp-theme version ${version}; theme checks were skipped.`, "warning");
275
+ if (theme && !page.closest("[data-theme=\"custom\"]")) {
276
+ const invalid = [
277
+ ...colorTokens,
278
+ "font-family",
279
+ "font-family-heading",
280
+ "font-family-display",
281
+ "font-family-mono",
282
+ "weight-strong",
283
+ "weight-mono",
284
+ "unit",
285
+ "rule-width",
286
+ "stroke",
287
+ "stroke-heavy",
288
+ "radius",
289
+ "radius-small",
290
+ "page-width",
291
+ "page-height",
292
+ "page-margin",
293
+ ...[
294
+ 1,
295
+ 2,
296
+ 3,
297
+ 4,
298
+ 5,
299
+ 6,
300
+ 8,
301
+ 10,
302
+ 12,
303
+ 16
304
+ ].map((n) => `space-${n}`),
305
+ ...roles.flatMap((role) => [
306
+ "font",
307
+ "leading",
308
+ "weight",
309
+ "tracking"
310
+ ].map((property) => `${property}-${role}`))
311
+ ].filter((token) => !pageStyle.getPropertyValue(`--kp-${token}`).trim()).map((token) => `missing --kp-${token}`);
312
+ const rootStyle = win.getComputedStyle(doc.documentElement);
313
+ const shadowed = [
314
+ ...roles.flatMap((role) => [`font-${role}`, `leading-${role}`]),
315
+ "page-width",
316
+ "page-height",
317
+ "page-margin"
318
+ ].filter((token) => rootStyle.getPropertyValue(`--kp-${token}`).trim() && rootStyle.getPropertyValue(`--kp-${token}`).trim() !== pageStyle.getPropertyValue(`--kp-${token}`).trim());
319
+ if (shadowed.length) report("theme-root", page, `Root values differ from this page: ${shadowed.map((token) => `--kp-${token}`).join(", ")}. Apply type and page overrides to [data-page] or [data-size], not :root.`, "warning");
320
+ let previous = 0;
321
+ for (const role of roles) {
322
+ const t = type[role];
323
+ if (!["font", "leading"].every((property) => /^\d+(?:\.\d+)?px$/.test(pageStyle.getPropertyValue(`--kp-${property}-${role}`).trim())) || !(t.size > previous && t.lineHeight >= t.size && t.weight >= 1 && t.weight <= 1e3) || !win.CSS.supports("letter-spacing", t.tracking)) invalid.push(`invalid ${role} type treatment`);
324
+ if (/^\d+(?:\.\d+)?px$/.test(pageStyle.getPropertyValue(`--kp-font-${role}`).trim()) && t.size > 0) previous = t.size;
325
+ }
326
+ const missingFaces = [
327
+ ...roles.map((role) => ({
328
+ role,
329
+ ...type[role]
330
+ })),
331
+ {
332
+ role: "strong",
333
+ family: pageStyle.getPropertyValue("--kp-font-family"),
334
+ weight: Number(pageStyle.getPropertyValue("--kp-weight-strong"))
335
+ },
336
+ {
337
+ role: "mono",
338
+ family: pageStyle.getPropertyValue("--kp-font-family-mono"),
339
+ weight: Number(pageStyle.getPropertyValue("--kp-weight-mono"))
340
+ }
341
+ ].filter((t) => t.family && t.weight > 0 && !hasFace(t.family, t.weight));
342
+ if (missingFaces.length) report("theme-font", page, `Missing declared normal faces: ${missingFaces.map((t) => `${t.role} (${t.family}, ${t.weight})`).join("; ")}. Declare webfonts with matching families and weights.`);
343
+ const colors = themeColors(pageStyle);
344
+ for (const token of colorTokens) if (!colors[token]) invalid.push(`invalid --kp-${token} color`);
345
+ if (invalid.length) report("theme-definition", page, invalid.join("; "));
346
+ const textContrast = [], chartContrast = [];
347
+ const contrast = (foreground, background, minimum, failures) => {
348
+ const a = rgb(colors[foreground] ?? ""), b = rgb(colors[background] ?? "");
349
+ if (!opaque(a) || !opaque(b)) return;
350
+ const x = luminance(a), y = luminance(b), ratio = (Math.max(x, y) + .05) / (Math.min(x, y) + .05);
351
+ if (ratio < minimum) failures.push(`${foreground} on ${background}: ${ratio.toFixed(2)}:1; needs ${minimum}:1`);
352
+ };
353
+ for (const surface of [
354
+ "bg",
355
+ "surface",
356
+ "wash"
357
+ ]) for (const ink of ["fg", "muted"]) contrast(ink, surface, 4.5, textContrast);
358
+ for (const ink of ["on-contrast", "on-contrast-muted"]) contrast(ink, "contrast", 4.5, textContrast);
359
+ contrast("accent", "bg", 4.5, textContrast);
360
+ contrast("inverse", "accent", 4.5, textContrast);
361
+ const categories = Array.from({ length: 6 }, (_, i) => `category-${i + 1}`);
362
+ for (const token of categories) contrast(token, "bg", 3, chartContrast);
363
+ if (textContrast.length) report("theme-contrast", page, textContrast.join("; "));
364
+ if (chartContrast.length) report("theme-chart-contrast", page, chartContrast.join("; "), "warning");
365
+ if (new Set(categories.map((token) => colors[token])).size !== categories.length) report("theme-categories", page, "Category colors contain exact duplicates. Use distinct identities or supply non-color encodings.", "warning");
366
+ }
367
+ for (const element of [page, ...elements]) {
368
+ if (!visible(element) || element.closest("[data-theme=\"custom\"]")) continue;
369
+ const style = win.getComputedStyle(element);
370
+ if (style.getPropertyValue("--kp-theme").trim() !== "1") continue;
371
+ if (element.hasAttribute("data-type") && !roles.includes(element.getAttribute("data-type"))) report("theme-role", element, `Unknown data-type="${element.getAttribute("data-type")}". Use ${roles.join(", ")}.`, "warning");
372
+ const deviations = [];
373
+ if (textElements.has(element) && !roles.some((role) => Math.abs(parseFloat(style.getPropertyValue(`--kp-font-${role}`)) - parseFloat(style.fontSize)) < .1 && (element.namespaceURI !== "http://www.w3.org/1999/xhtml" || Math.abs(parseFloat(style.getPropertyValue(`--kp-leading-${role}`)) - parseFloat(style.lineHeight)) < .1))) deviations.push(`type pair ${style.fontSize}/${style.lineHeight}`);
374
+ const colors = Object.values(themeColors(style));
375
+ const values = [style.backgroundColor];
376
+ if (textElements.has(element)) values.push(element.namespaceURI === "http://www.w3.org/2000/svg" ? style.fill : style.color);
377
+ probe.style.fontSize = style.fontSize;
378
+ function themedLength(property, value, tokens) {
379
+ return tokens.some((token) => {
380
+ probe.style[property] = "";
381
+ probe.style[property] = style.getPropertyValue(`--kp-${token}`);
382
+ return probe.style[property] && win.getComputedStyle(probe)[property] === value;
383
+ });
384
+ }
385
+ probe.style.borderTopStyle = "solid";
386
+ const widths = [
387
+ "rule-width",
388
+ "stroke",
389
+ "stroke-heavy"
390
+ ];
391
+ for (const side of [
392
+ "Top",
393
+ "Right",
394
+ "Bottom",
395
+ "Left"
396
+ ]) {
397
+ const width = style[`border${side}Width`];
398
+ if (parseFloat(width) > 0) {
399
+ values.push(style[`border${side}Color`]);
400
+ if (!themedLength("borderTopWidth", width, widths)) deviations.push(`border width ${width}`);
401
+ }
402
+ }
403
+ for (const corner of [
404
+ "TopLeft",
405
+ "TopRight",
406
+ "BottomLeft",
407
+ "BottomRight"
408
+ ]) {
409
+ const radius = style[`border${corner}Radius`];
410
+ if (radius !== "0px" && !themedLength("borderTopLeftRadius", radius, ["radius", "radius-small"])) deviations.push(`corner radius ${radius}`);
411
+ }
412
+ if (element.matches("path,rect,circle,ellipse,polygon,polyline,line")) {
413
+ if (!element.matches("line")) values.push(style.fill);
414
+ values.push(style.stroke);
415
+ if (style.stroke !== "none" && parseFloat(style.strokeWidth) > 0 && !themedLength("strokeWidth", style.strokeWidth, widths)) deviations.push(`stroke width ${style.strokeWidth}`);
416
+ }
417
+ for (const value of new Set(values)) if (value !== "none" && value !== "rgba(0, 0, 0, 0)" && !colors.includes(value)) deviations.push(`color ${value}`);
418
+ if (deviations.length) report("theme-value", element, `Outside the active theme: ${[...new Set(deviations)].join(", ")}. Use complete type roles and theme color/shape tokens, or data-theme="custom" for deliberately unthemed content.`, "warning");
419
+ }
420
+ probe.remove();
421
+ for (const link of doc.querySelectorAll("link[rel=\"stylesheet\"]")) if (!link.sheet && !link.disabled) report("missing-stylesheet", page, "A stylesheet did not load. Keep stylesheets beside the HTML or inline them.");
422
+ for (const font of doc.fonts) if (font.status === "error") report("missing-font", page, `Font failed to load: ${font.family}.`);
423
+ return {
424
+ id: page.id,
425
+ width: origin.width,
426
+ height: origin.height,
427
+ blocks,
428
+ text,
429
+ theme,
430
+ diagnostics
431
+ };
432
+ }
433
+ //#endregion
434
+ //#region html/browser.ts
435
+ async function browserDocument(input, options = {}) {
436
+ if (options.format !== void 0 && !["png", "pdf"].includes(options.format)) throw new Error("HTML exports support PNG and PDF. Whole-page SVG export is not supported.");
437
+ const scale = options.scale ?? 2;
438
+ if (!Number.isFinite(scale) || scale <= 0 || scale > 8) throw new Error("Scale must be greater than 0 and at most 8.");
439
+ const server = await documentServer(input);
440
+ let browser;
441
+ try {
442
+ const snapshot = await server.store.read();
443
+ const selected = options.page;
444
+ if (selected !== void 0 && (!Number.isInteger(selected) || selected < 1 || selected > snapshot.pages.length)) throw new Error(`Page must be an integer from 1 to ${snapshot.pages.length}.`);
445
+ try {
446
+ browser = await chromium.launch({ headless: true });
447
+ } catch {
448
+ throw new Error("Chromium could not start. Run `konpeki browser install` to install the pinned browser; Linux also needs Chromium's system libraries.");
449
+ }
450
+ const context = await browser.newContext({
451
+ viewport: {
452
+ width: 794,
453
+ height: 1123
454
+ },
455
+ deviceScaleFactor: scale,
456
+ serviceWorkers: "block"
457
+ });
458
+ await context.route("**/*", (route) => {
459
+ const request = route.request();
460
+ return request.url().startsWith(`${server.origin}${server.prefix}/`) || isGoogleFontResource(request.url(), request.resourceType()) ? route.continue() : route.abort();
461
+ });
462
+ const tab = await context.newPage(), pages = [], diagnostics = [];
463
+ const cdp = await context.newCDPSession(tab);
464
+ await cdp.send("DOM.enable");
465
+ await cdp.send("CSS.enable");
466
+ const failed = /* @__PURE__ */ new Set();
467
+ tab.on("requestfailed", (request) => failed.add(request.url()));
468
+ tab.on("response", (response) => {
469
+ if (!response.ok()) failed.add(response.url());
470
+ });
471
+ const pdf = options.format === "pdf" ? await PDFDocument.create() : void 0;
472
+ let bytes;
473
+ for (let index = 0; index < snapshot.pages.length; index++) {
474
+ if (selected !== void 0 && selected !== index + 1) continue;
475
+ if (options.format === "png" && selected === void 0 && index > 0) break;
476
+ const id = snapshot.pages[index];
477
+ await tab.goto("about:blank");
478
+ await tab.setViewportSize({
479
+ width: 794,
480
+ height: 1123
481
+ });
482
+ failed.clear();
483
+ if (!(await tab.goto(`${server.origin}${server.prefix}/document/?page=${encodeURIComponent(id)}&revision=${snapshot.revision}`))?.ok()) throw new Error("The HTML changed while rendering. Inspect and retry the latest source.");
484
+ await tab.evaluate(async () => {
485
+ await document.fonts.ready;
486
+ await Promise.all(Array.from(document.images).map((image) => image.decode().catch(() => {})));
487
+ });
488
+ const element = tab.locator("[data-konpeki-current]");
489
+ const initial = await element.boundingBox();
490
+ 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.");
491
+ await tab.setViewportSize({
492
+ width: Math.ceil(initial.width),
493
+ height: Math.ceil(initial.height)
494
+ });
495
+ await tab.evaluate(async () => {
496
+ await document.fonts.ready;
497
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
498
+ });
499
+ await tab.waitForLoadState("networkidle");
500
+ const report = await element.evaluate(inspectHTMLPage);
501
+ const { root } = await cdp.send("DOM.getDocument", {
502
+ depth: -1,
503
+ pierce: true
504
+ });
505
+ const { nodeId } = await cdp.send("DOM.querySelector", {
506
+ nodeId: root.nodeId,
507
+ selector: "[data-konpeki-current]"
508
+ });
509
+ const { node } = await cdp.send("DOM.describeNode", {
510
+ nodeId,
511
+ depth: -1,
512
+ pierce: true
513
+ });
514
+ const fallbacks = /* @__PURE__ */ new Map();
515
+ async function auditFonts(node, target) {
516
+ if (node.nodeType !== 1) return;
517
+ const attrs = node.attributes ?? [], idIndex = attrs.indexOf("id");
518
+ if (idIndex >= 0) target = attrs[idIndex + 1];
519
+ const { computedStyle } = await cdp.send("CSS.getComputedStyleForNode", { nodeId: node.nodeId });
520
+ const style = Object.fromEntries(computedStyle.map((s) => [s.name, s.value]));
521
+ if (style.display === "none" || Number(style.opacity) === 0) return;
522
+ if (style.visibility === "visible" && (node.pseudoType || node.children?.some((child) => child.nodeType === 3 && child.nodeValue.trim()))) {
523
+ const { fonts } = await cdp.send("CSS.getPlatformFontsForNode", { nodeId: node.nodeId });
524
+ for (const font of fonts) if (font.glyphCount > 0 && !font.isCustomFont) {
525
+ if (!fallbacks.has(target)) fallbacks.set(target, /* @__PURE__ */ new Set());
526
+ fallbacks.get(target).add(font.familyName);
527
+ }
528
+ }
529
+ for (const child of [...node.children ?? [], ...node.pseudoElements ?? []]) await auditFonts(child, target);
530
+ }
531
+ await auditFonts(node, id);
532
+ for (const [target, fonts] of fallbacks) report.diagnostics.push({
533
+ code: "font-fallback",
534
+ severity: "error",
535
+ page: id,
536
+ target,
537
+ 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.`
538
+ });
539
+ if (Math.abs(report.width - initial.width) > .75 || Math.abs(report.height - initial.height) > .75) throw new Error("Page dimensions depend on the viewport. Give pages a fixed CSS width and height; their content may use responsive layout.");
540
+ for (const resource of failed) report.diagnostics.push({
541
+ code: "missing-resource",
542
+ severity: "error",
543
+ page: id,
544
+ target: id,
545
+ 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.`
546
+ });
547
+ diagnostics.push(...report.diagnostics);
548
+ pages.push({
549
+ pageNumber: index + 1,
550
+ ...report,
551
+ ...options.details ? {} : { text: void 0 }
552
+ });
553
+ if (options.format === "png") {
554
+ if (report.width * report.height * scale * scale > 64e6) throw new Error("PNG exceeds 64 million pixels; reduce --scale.");
555
+ bytes = await element.screenshot({
556
+ type: "png",
557
+ animations: "disabled"
558
+ });
559
+ } else if (pdf) {
560
+ await tab.emulateMedia({ media: "screen" });
561
+ await tab.addStyleTag({ content: `@page { size: ${report.width}px ${report.height}px; margin: 0; } body { print-color-adjust: exact; }` });
562
+ const part = await PDFDocument.load(await tab.pdf({
563
+ preferCSSPageSize: true,
564
+ printBackground: true,
565
+ margin: {
566
+ top: 0,
567
+ left: 0,
568
+ right: 0,
569
+ bottom: 0
570
+ }
571
+ }));
572
+ if (part.getPageCount() !== 1) throw new Error(`Page ${index + 1} printed onto multiple sheets. Fix its overflow before exporting PDF.`);
573
+ for (const page of await pdf.copyPages(part, [0])) pdf.addPage(page);
574
+ }
575
+ }
576
+ if ((await server.store.read()).revision !== snapshot.revision) throw new Error("The source changed during rendering. Retry the latest revision.");
577
+ if (pdf) bytes = await pdf.save();
578
+ return {
579
+ bytes,
580
+ report: {
581
+ schema: "konpeki-html-inspection/v1",
582
+ revision: snapshot.revision,
583
+ units: "css-pixels",
584
+ ok: !diagnostics.some((d) => d.severity === "error"),
585
+ pages,
586
+ diagnostics
587
+ }
588
+ };
589
+ } finally {
590
+ await browser?.close();
591
+ await server.close();
592
+ }
593
+ }
594
+ //#endregion
595
+ export { browserDocument };
@@ -0,0 +1,41 @@
1
+ import { readFileSync } from "node:fs";
2
+ //#region html/floor.ts
3
+ var tiers = {
4
+ Bans: "ban",
5
+ Defaults: "default",
6
+ Verify: "verify"
7
+ };
8
+ /** The rules in skills/konpeki/floor.md, the single source for the review checklist. */
9
+ function floorRules(source = readFileSync(new URL("../skills/konpeki/floor.md", import.meta.url), "utf8")) {
10
+ const rules = [];
11
+ let tier;
12
+ for (const line of source.split("\n")) {
13
+ const heading = /^## (\w+)/.exec(line);
14
+ if (heading) {
15
+ tier = tiers[heading[1]];
16
+ continue;
17
+ }
18
+ const rule = /^- ((?:`[a-z0-9-]+`(?:, )?)+) \((inspect|review)\):(?: (.*))?$/.exec(line);
19
+ if (rule && tier) rules.push({
20
+ ids: rule[1].match(/[a-z0-9-]+/g),
21
+ tier,
22
+ check: rule[2],
23
+ text: rule[3] ?? ""
24
+ });
25
+ else if (/^ {2}\S/.test(line) && rules.length) rules.at(-1).text = `${rules.at(-1).text} ${line.trim()}`.trim();
26
+ }
27
+ return rules;
28
+ }
29
+ /** The first sentence, ignoring full stops inside quoted examples. */
30
+ function firstSentence(text) {
31
+ let quoted = false;
32
+ for (let i = 0; i < text.length; i++) if (text[i] === "\"") quoted = !quoted;
33
+ else if (!quoted && text[i] === "." && (i + 1 === text.length || text[i + 1] === " ")) return text.slice(0, i + 1);
34
+ return text;
35
+ }
36
+ /** The first sentence of each rule that only a person looking at the page can check. */
37
+ function reviewChecklist(rules = floorRules()) {
38
+ return rules.filter((rule) => rule.check === "review").map((rule) => `${rule.ids[0]}: ${firstSentence(rule.text)}`);
39
+ }
40
+ //#endregion
41
+ export { reviewChecklist };