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.
- package/AGENTS.md +21 -28
- package/AUTHORING.md +68 -229
- package/CONTRIBUTING.md +10 -23
- package/README.md +96 -87
- package/SETUP.md +50 -114
- package/docs/development.md +43 -248
- package/docs/workflow.md +6 -108
- package/html/README.md +140 -0
- package/html/browser.ts +103 -0
- package/html/document.ts +38 -0
- package/html/floor.ts +34 -0
- package/html/index.html +5 -0
- package/html/inspect.ts +313 -0
- package/html/preview.css +62 -0
- package/html/preview.tsx +463 -0
- package/html/review-hints.ts +63 -0
- package/html/server.ts +99 -0
- package/html/source.ts +104 -0
- package/html/starter.ts +6 -0
- package/html/theme-authoring.md +176 -0
- package/html/theme.md +61 -0
- package/index.html +9 -9
- package/package.json +21 -35
- package/plugin.json +1 -1
- package/public/og.png +0 -0
- package/runtime/browser-B-29TH1a.mjs +595 -0
- package/runtime/floor-Cmk7G3pU.mjs +41 -0
- package/runtime/konpeki.mjs +90 -1410
- package/runtime/server-BAqC_5WD.mjs +2 -0
- package/runtime/server-DfRpfcY9.mjs +205 -0
- package/runtime/source-B_Ui9tMp.mjs +141 -0
- package/runtime/source-CZQq9GUO.mjs +2 -0
- package/skills/konpeki/SKILL.md +88 -172
- package/skills/konpeki/assets/blank.html +17 -0
- package/skills/konpeki/floor.md +76 -0
- package/skills/konpeki/references/cover.md +27 -0
- package/skills/konpeki/references/long-document.md +40 -0
- package/skills/konpeki/references/one-pager.md +27 -0
- package/skills/konpeki/references/patterns.md +118 -0
- package/skills/konpeki/references/resume.md +28 -0
- package/skills/konpeki/references/slides.md +28 -0
- package/skills/konpeki/scripts/ensure-runtime.mjs +10 -31
- package/skills/konpeki/scripts/prepare-document.mjs +25 -14
- package/src/components/PageBoard.tsx +156 -0
- package/src/lib/alignment.ts +21 -0
- package/src/lib/page-board.ts +25 -0
- package/src/lib/review-position.ts +19 -0
- package/src/styles/base.css +4 -6
- package/src/styles/feedback.css +97 -61
- package/src/styles/shell.css +95 -323
- package/theme-base.css +90 -0
- package/theme.css +56 -0
- package/vite.config.ts +2 -5
- package/composition/README.md +0 -156
- package/composition/compile.ts +0 -227
- package/composition/document.ts +0 -600
- package/composition/schema.json +0 -3001
- package/composition/schema.ts +0 -437
- package/composition/theme-tokens.ts +0 -16
- package/composition/types.ts +0 -269
- package/composition/validate.ts +0 -226
- package/composition/vector.ts +0 -143
- package/composition/visualizations.ts +0 -319
- package/design/README.md +0 -17
- package/design/palettes/README.md +0 -14
- package/design/palettes/base.ts +0 -14
- package/design/palettes/candidates.ts +0 -19
- package/design/palettes/index.ts +0 -78
- package/design/review/color-theme.md +0 -44
- package/design/review/layout.md +0 -16
- package/design/review/text.md +0 -18
- package/design/review/typography.md +0 -15
- package/design/review/visuals.md +0 -31
- package/design/semantic-patterns.md +0 -43
- package/design/themes/README.md +0 -40
- package/design/themes/index.ts +0 -24
- package/design/visual-languages/technical-product.md +0 -17
- package/design/visual-review.md +0 -88
- package/lib/assets.d.ts +0 -8
- package/lib/charts.ts +0 -18
- package/lib/contrast.ts +0 -16
- package/lib/layouts.ts +0 -50
- package/lib/slide.tsx +0 -42
- package/lib/taste.ts +0 -17
- package/lib/text.tsx +0 -89
- package/lib/typeface.ts +0 -44
- package/scripts/migrate-react-page.ts +0 -120
- package/skills/konpeki/assets/blank.json +0 -23
- package/slides/README.md +0 -153
- package/slides/architecture/PROMPT.md +0 -31
- package/slides/architecture/index.tsx +0 -102
- package/slides/article-brief/PROMPT.md +0 -35
- package/slides/article-brief/index.tsx +0 -71
- package/slides/bar-chart/PROMPT.md +0 -39
- package/slides/bar-chart/index.tsx +0 -97
- package/slides/comparison/PROMPT.md +0 -29
- package/slides/comparison/index.tsx +0 -95
- package/slides/decision-memo/PROMPT.md +0 -34
- package/slides/decision-memo/index.tsx +0 -85
- package/slides/delivery-plan/PROMPT.md +0 -45
- package/slides/delivery-plan/index.tsx +0 -105
- package/slides/experiment/PROMPT.md +0 -44
- package/slides/experiment/index.tsx +0 -127
- package/slides/incident-workflow/PROMPT.md +0 -57
- package/slides/incident-workflow/index.tsx +0 -78
- package/slides/introducing-konpeki/PROMPT.md +0 -40
- package/slides/introducing-konpeki/README.md +0 -76
- package/slides/introducing-konpeki/SOURCE.md +0 -26
- package/slides/introducing-konpeki/author.ts +0 -165
- package/slides/introducing-konpeki/composition.json +0 -3270
- package/slides/line-chart/PROMPT.md +0 -40
- package/slides/line-chart/index.tsx +0 -72
- package/slides/migration/PROMPT.md +0 -38
- package/slides/migration/index.tsx +0 -89
- package/slides/og-images/PROMPT.md +0 -21
- package/slides/og-images/index.tsx +0 -76
- package/slides/product-introduction/PROMPT.md +0 -24
- package/slides/product-introduction/index.tsx +0 -105
- package/slides/research-brief/PROMPT.md +0 -40
- package/slides/research-brief/index.tsx +0 -104
- package/slides/results-explanation/PROMPT.md +0 -32
- package/slides/results-explanation/index.tsx +0 -96
- package/slides/retrospective/PROMPT.md +0 -43
- package/slides/retrospective/index.tsx +0 -105
- package/slides/sankey/PROMPT.md +0 -11
- package/slides/sankey/index.tsx +0 -93
- package/slides/teaching/PROMPT.md +0 -45
- package/slides/teaching/index.tsx +0 -124
- package/slides/vertical-bar-charts/PROMPT.md +0 -13
- package/slides/vertical-bar-charts/index.tsx +0 -97
- package/src/app/App.tsx +0 -820
- package/src/components/BuildOrb.tsx +0 -40
- package/src/components/Canvas.tsx +0 -1185
- package/src/components/DiagramTypeIcon.tsx +0 -78
- package/src/components/InspectorPanel.tsx +0 -773
- package/src/components/LeftPanel.tsx +0 -120
- package/src/components/PageSizePicker.tsx +0 -30
- package/src/components/Presentation.tsx +0 -105
- package/src/components/RevisionNotes.tsx +0 -56
- package/src/components/RightPanel.tsx +0 -201
- package/src/components/VectorOverflowWarning.tsx +0 -46
- package/src/components/WorkspaceChrome.tsx +0 -288
- package/src/components/ui.tsx +0 -53
- package/src/lib/examples/react-page-migration.json +0 -1295
- package/src/lib/examples.ts +0 -42
- package/src/lib/export-png.ts +0 -104
- package/src/lib/file-session.ts +0 -87
- package/src/lib/history.ts +0 -53
- package/src/lib/model.ts +0 -188
- package/src/lib/page-size.ts +0 -24
- package/src/lib/presentation.ts +0 -17
- package/src/lib/review.ts +0 -26
- package/src/lib/storage.ts +0 -71
- package/src/lib/theme.ts +0 -25
- package/src/lib/use-file-session.ts +0 -227
- package/src/main.tsx +0 -29
- package/src/styles/canvas.css +0 -299
- package/src/styles/chrome.css +0 -384
- package/src/styles/component-previews.css +0 -386
- package/src/styles/left-panel.css +0 -166
- package/src/styles/presentation.css +0 -72
- package/src/styles/right-panel.css +0 -1215
package/html/inspect.ts
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
export type Bounds = { x: number; y: number; width: number; height: number };
|
|
2
|
+
export type Diagnostic = { code: string; severity: "error" | "warning"; page: string; target: string; message: string };
|
|
3
|
+
|
|
4
|
+
/** Self-contained so the same measurement runs in the review iframe and CLI's
|
|
5
|
+
* Chromium page. Range bounds describe font metrics, not glyph ink or leading. */
|
|
6
|
+
export function inspectHTMLPage(page: HTMLElement) {
|
|
7
|
+
const doc = page.ownerDocument, win = doc.defaultView!;
|
|
8
|
+
const origin = page.getBoundingClientRect();
|
|
9
|
+
const diagnostics: Diagnostic[] = [], seen = new Set<string>();
|
|
10
|
+
const bounds = (r: DOMRect): Bounds => ({ x: r.x - origin.x, y: r.y - origin.y, width: r.width, height: r.height });
|
|
11
|
+
const outside = (r: DOMRect, box: DOMRect, x = true, y = true) =>
|
|
12
|
+
x && (r.left < box.left - .75 || r.right > box.right + .75) || y && (r.top < box.top - .75 || r.bottom > box.bottom + .75);
|
|
13
|
+
function report(code: string, element: Element, message: string, severity: "error" | "warning" = "error") {
|
|
14
|
+
const target = element.closest("[id]")?.id ?? page.id, key = `${code}/${target}`;
|
|
15
|
+
if (!seen.has(key)) { seen.add(key); diagnostics.push({ code, severity, page: page.id, target, message }); }
|
|
16
|
+
}
|
|
17
|
+
function visible(element: Element) {
|
|
18
|
+
if (element.closest("defs,clipPath,mask,symbol,marker,pattern,linearGradient,radialGradient")) return false;
|
|
19
|
+
const style = win.getComputedStyle(element);
|
|
20
|
+
for (let ancestor: Element | null = element; ancestor; ancestor = ancestor.parentElement)
|
|
21
|
+
if (Number(win.getComputedStyle(ancestor).opacity) === 0) return false;
|
|
22
|
+
return style.visibility === "visible" && style.display !== "none" && element.getClientRects().length > 0;
|
|
23
|
+
}
|
|
24
|
+
if (origin.width <= 0 || origin.height <= 0 || origin.width > 8192 || origin.height > 8192)
|
|
25
|
+
report("page-size", page, "Use explicit positive page dimensions no larger than 8192 CSS px per side.");
|
|
26
|
+
const elements = Array.from(page.querySelectorAll("*"));
|
|
27
|
+
const blocks = elements.filter(el => el.id && visible(el)).map(el => ({ id: el.id, tag: el.tagName.toLowerCase(), bounds: bounds(el.getBoundingClientRect()) }));
|
|
28
|
+
for (const element of elements) {
|
|
29
|
+
if (!visible(element)) continue;
|
|
30
|
+
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.");
|
|
31
|
+
if (element.tagName === "IMG" && !(element as HTMLImageElement).naturalWidth)
|
|
32
|
+
report("missing-image", element, "An image did not load. Use a local asset beside the HTML or an embedded data URL.");
|
|
33
|
+
}
|
|
34
|
+
const text: { target: string; content: string; bounds: Bounds[] }[] = [];
|
|
35
|
+
const lines: { parent: Element; rect: DOMRect }[] = [];
|
|
36
|
+
const textElements = new Set<Element>();
|
|
37
|
+
const minimumSizes: Record<string, number> = { presentation: 24, portrait: 24, square: 24, link: 24, article: 24, a4: 11, explainer: 24, gallery: 24 };
|
|
38
|
+
const preset = page.getAttribute("data-size") ?? "presentation", minimumSize = minimumSizes[preset];
|
|
39
|
+
const walker = doc.createTreeWalker(page, 4); // NodeFilter.SHOW_TEXT across iframe realms
|
|
40
|
+
let node: Node | null;
|
|
41
|
+
while ((node = walker.nextNode())) {
|
|
42
|
+
const parent = node.parentElement;
|
|
43
|
+
if (!node.textContent?.trim() || !parent || !visible(parent) || parent.closest("style,defs,title,desc")) continue;
|
|
44
|
+
const range = doc.createRange(); range.selectNodeContents(node);
|
|
45
|
+
const rects = Array.from(range.getClientRects()).filter(r => r.width > 0 && r.height > 0);
|
|
46
|
+
if (rects.length) text.push({ target: parent.closest("[id]")?.id ?? page.id, content: node.textContent, bounds: rects.map(bounds) });
|
|
47
|
+
if (rects.length) textElements.add(parent);
|
|
48
|
+
const style = win.getComputedStyle(parent);
|
|
49
|
+
if (rects.length && parseFloat(style.fontSize) < minimumSize)
|
|
50
|
+
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");
|
|
51
|
+
let leading = parseFloat(style.lineHeight);
|
|
52
|
+
// Center an estimated HTML line box on the font-metric rectangle. SVG uses
|
|
53
|
+
// coordinate placement. Keep raw bounds for normal leading or non-horizontal
|
|
54
|
+
// text, and for transforms where an axis-aligned estimate is misleading.
|
|
55
|
+
for (let ancestor: Element | null = parent; ancestor; ancestor = ancestor.parentElement) {
|
|
56
|
+
const s = win.getComputedStyle(ancestor);
|
|
57
|
+
if (s.transform !== "none" || s.scale !== "none" || s.rotate !== "none" || Number(s.zoom) !== 1) leading = NaN;
|
|
58
|
+
}
|
|
59
|
+
for (const rect of rects) {
|
|
60
|
+
const line = parent.namespaceURI === "http://www.w3.org/1999/xhtml" && style.writingMode === "horizontal-tb" && leading > 0
|
|
61
|
+
? new DOMRect(rect.x, rect.y + (rect.height - leading) / 2, rect.width, leading) : rect;
|
|
62
|
+
for (const previous of lines) {
|
|
63
|
+
if (previous.parent === parent) continue;
|
|
64
|
+
const other = previous.rect;
|
|
65
|
+
if (Math.min(line.right, other.right) - Math.max(line.left, other.left) > 1 &&
|
|
66
|
+
Math.min(line.bottom, other.bottom) - Math.max(line.top, other.top) > 1)
|
|
67
|
+
report("text-overlap", parent, `Text overlaps text in #${previous.parent.closest("[id]")?.id ?? page.id}. Separate their line boxes.`);
|
|
68
|
+
}
|
|
69
|
+
lines.push({ parent, rect: line });
|
|
70
|
+
if (outside(rect, origin)) report("text-overflow", parent, "Text extends beyond the page. Do not hide it or shrink required copy to fit.");
|
|
71
|
+
for (let ancestor: Element | null = parent; ancestor; ancestor = ancestor.parentElement) {
|
|
72
|
+
const style = win.getComputedStyle(ancestor), box = ancestor.getBoundingClientRect();
|
|
73
|
+
if (outside(rect, box, style.overflowX !== "visible", style.overflowY !== "visible"))
|
|
74
|
+
report("clipped-text", parent, "Text crosses a clipping or scrolling boundary. Increase space or revise the layout.");
|
|
75
|
+
if (ancestor === page) break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Composition rules with IDs from skills/konpeki/floor.md. Headlines are the
|
|
80
|
+
// title, display and section-heading roles, explicit or semantic.
|
|
81
|
+
const xhtml = "http://www.w3.org/1999/xhtml";
|
|
82
|
+
const roleOf = (el: Element) => el.getAttribute("data-type") ?? (el.tagName === "H1" ? "title" : "heading");
|
|
83
|
+
const leading = (style: CSSStyleDeclaration) => parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2;
|
|
84
|
+
// A figure set in a headline role ("2,000") is a metric, not a headline.
|
|
85
|
+
const headlines = Array.from(page.querySelectorAll('h1:not([data-type]), h2:not([data-type]), [data-type="title"], [data-type="display"], [data-type="heading"]'))
|
|
86
|
+
.filter(el => el.namespaceURI === xhtml && visible(el) && (el.textContent?.match(/\p{L}/gu)?.length ?? 0) > (el.textContent?.match(/\d/g)?.length ?? 0));
|
|
87
|
+
function block(element: Element) {
|
|
88
|
+
let current = element;
|
|
89
|
+
while (current !== page && current.parentElement && win.getComputedStyle(current).display.startsWith("inline")) current = current.parentElement;
|
|
90
|
+
return current;
|
|
91
|
+
}
|
|
92
|
+
const textBlocks = [...new Set(Array.from(textElements, block))].filter(el => el.namespaceURI === xhtml);
|
|
93
|
+
// Horizontal extent of the text itself: a block box spans its whole column.
|
|
94
|
+
function extent(element: Element) {
|
|
95
|
+
const range = doc.createRange(); range.selectNodeContents(element);
|
|
96
|
+
const rects = Array.from(range.getClientRects()).filter(r => r.width > 0 && r.height > 0);
|
|
97
|
+
return rects.length ? { left: Math.min(...rects.map(r => r.left)), right: Math.max(...rects.map(r => r.right)) } : element.getBoundingClientRect();
|
|
98
|
+
}
|
|
99
|
+
// Running heads repeat on other print pages, apart from their numbers.
|
|
100
|
+
const normalize = (text: string) => text.replace(/\s+/g, " ").replace(/\d+/g, "#").trim();
|
|
101
|
+
const printPages = page.getAttribute("data-size") === "a4"
|
|
102
|
+
? Array.from(doc.querySelectorAll("body > [data-page]")).filter(p => p !== page && p.getAttribute("data-size") === "a4").map(p => normalize(p.textContent ?? ""))
|
|
103
|
+
: [];
|
|
104
|
+
function labels(heading: Element) {
|
|
105
|
+
const box = heading.getBoundingClientRect(), style = win.getComputedStyle(heading), headingInk = extent(heading);
|
|
106
|
+
// compareDocumentPosition bit 2: the block precedes the heading. The nearest
|
|
107
|
+
// preceding block and its row peers (a kicker left, a date right) are candidates.
|
|
108
|
+
const before = textBlocks.filter(b => !b.contains(heading) && !heading.contains(b) && heading.compareDocumentPosition(b) & 2);
|
|
109
|
+
const nearest = before.at(-1)?.getBoundingClientRect();
|
|
110
|
+
if (!nearest) return [];
|
|
111
|
+
return before.filter(label => {
|
|
112
|
+
const rect = label.getBoundingClientRect(), labelStyle = win.getComputedStyle(label), labelInk = extent(label);
|
|
113
|
+
const text = (label.textContent ?? "").replace(/\s+/g, " ").trim(), gap = box.top - rect.bottom;
|
|
114
|
+
if (rect.top >= nearest.bottom || rect.bottom <= nearest.top) return false;
|
|
115
|
+
if (gap < -1 || labelInk.right <= headingInk.left || labelInk.left >= headingInk.right || text.length > 48 || /[.!?]$/.test(text) || rect.height > leading(labelStyle) * 1.5) return false;
|
|
116
|
+
if (parseFloat(labelStyle.fontSize) >= parseFloat(style.fontSize) * .8 || label.closest('[data-kp-allow~="label-above-headline"]')) return false;
|
|
117
|
+
// A section heading's label hugs it; a page headline's label may sit anywhere above.
|
|
118
|
+
if (roleOf(heading) === "heading" && gap > leading(style)) return false;
|
|
119
|
+
return !printPages.some(other => other.includes(normalize(text)));
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
for (const heading of headlines)
|
|
123
|
+
for (const label of labels(heading))
|
|
124
|
+
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");
|
|
125
|
+
for (const heading of headlines) {
|
|
126
|
+
const words: { node: Node; top: number; text: string }[] = [];
|
|
127
|
+
const walker = doc.createTreeWalker(heading, 4);
|
|
128
|
+
for (let node: Node | null; (node = walker.nextNode());) {
|
|
129
|
+
for (const match of (node.textContent ?? "").matchAll(/\S+/g)) {
|
|
130
|
+
const range = doc.createRange();
|
|
131
|
+
range.setStart(node, match.index); range.setEnd(node, match.index + match[0].length);
|
|
132
|
+
const rect = range.getClientRects()[0];
|
|
133
|
+
if (rect) words.push({ node, top: rect.top, text: match[0] });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Each <br> starts a deliberate segment; a segment that wraps must not end
|
|
137
|
+
// on one word after a fuller line. compareDocumentPosition bit 4: follows.
|
|
138
|
+
const size = parseFloat(win.getComputedStyle(heading).fontSize), breaks = Array.from(heading.querySelectorAll("br"));
|
|
139
|
+
const segments = new Map<number, (typeof words)[]>();
|
|
140
|
+
for (const word of words) {
|
|
141
|
+
const segment = breaks.filter(br => br.compareDocumentPosition(word.node) & 4).length;
|
|
142
|
+
const lines = segments.get(segment) ?? [], line = lines.at(-1);
|
|
143
|
+
if (line && word.top <= line[0].top + size / 2) line.push(word); else lines.push([word]);
|
|
144
|
+
segments.set(segment, lines);
|
|
145
|
+
}
|
|
146
|
+
// One word per line is a deliberate stack, not a stranded word.
|
|
147
|
+
const stranded = [...segments.values()].find(lines => lines.length > 1 && lines.at(-1)!.length === 1 && lines.at(-2)!.length > 1);
|
|
148
|
+
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");
|
|
149
|
+
}
|
|
150
|
+
// Contrast is deliberately limited to opaque sRGB text on solid ancestor
|
|
151
|
+
// backgrounds. Images, gradients, compositing and SVG paint need visual review.
|
|
152
|
+
const rgb = (value: string) => /^rgba?\(/.test(value) ? value.match(/[\d.]+/g)!.map(Number) : undefined;
|
|
153
|
+
const opaque = (color: number[] | undefined) => color && (color.length === 3 || color[3] === 1);
|
|
154
|
+
const luminance = (color: number[]) => color.slice(0, 3).reduce((sum, channel, i) => {
|
|
155
|
+
const c = channel / 255;
|
|
156
|
+
return sum + (c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4) * [.2126, .7152, .0722][i];
|
|
157
|
+
}, 0);
|
|
158
|
+
for (const element of textElements) {
|
|
159
|
+
if (element.namespaceURI !== "http://www.w3.org/1999/xhtml") continue;
|
|
160
|
+
const style = win.getComputedStyle(element), foreground = rgb(style.color);
|
|
161
|
+
if (!opaque(foreground)) continue;
|
|
162
|
+
let background: number[] | undefined, uncertain = false;
|
|
163
|
+
for (let ancestor: Element | null = element; ancestor; ancestor = ancestor.parentElement) {
|
|
164
|
+
const s = win.getComputedStyle(ancestor), color = rgb(s.backgroundColor);
|
|
165
|
+
if (Number(s.opacity) !== 1 || s.filter !== "none" || s.backdropFilter !== "none" || s.mixBlendMode !== "normal") uncertain = true;
|
|
166
|
+
if (!background) {
|
|
167
|
+
if (!color || s.backgroundImage !== "none" || color.length === 4 && color[3] > 0 && color[3] < 1) uncertain = true;
|
|
168
|
+
if (opaque(color)) background = color;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// With no authored background, a light-scheme browser canvas is white.
|
|
172
|
+
if (!background && !win.getComputedStyle(doc.documentElement).colorScheme.includes("dark")) background = [255, 255, 255];
|
|
173
|
+
if (uncertain || !background) continue;
|
|
174
|
+
const a = luminance(foreground!), b = luminance(background);
|
|
175
|
+
const ratio = (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
|
|
176
|
+
const size = parseFloat(style.fontSize), large = size >= 24 || size >= 18.6667 && Number(style.fontWeight) >= 700;
|
|
177
|
+
const minimum = large ? 3 : 4.5;
|
|
178
|
+
if (ratio < minimum) report("low-contrast", element, `Text contrast is ${ratio.toFixed(2)}:1; use at least ${minimum}:1 on this solid background.`);
|
|
179
|
+
}
|
|
180
|
+
// Compare used values, including inherited styles, to the page's tokens. CSSOM
|
|
181
|
+
// cannot distinguish a literal equal to a token from a var() reference.
|
|
182
|
+
const probe = doc.createElement("span");
|
|
183
|
+
probe.style.display = "none"; doc.body.append(probe);
|
|
184
|
+
const roles = ["fine", "caption", "body", "lead", "heading", "title", "display"];
|
|
185
|
+
const faces = Array.from(doc.fonts);
|
|
186
|
+
const familyName = (family: string) => family.trim().replace(/^(["'])(.*)\1$/, "$2").toLowerCase();
|
|
187
|
+
const primaryFamily = (family: string) => familyName(family.match(/^\s*("[^"]*"|'[^']*'|[^,]+)/)?.[0] ?? "");
|
|
188
|
+
function hasFace(family: string, weight: number, style = "normal") {
|
|
189
|
+
return faces.some(face => {
|
|
190
|
+
if (familyName(face.family) !== primaryFamily(family)) return false;
|
|
191
|
+
const weights = face.weight.replace("normal", "400").replace("bold", "700").split(/\s+/).map(Number);
|
|
192
|
+
const angles = (value: string) => value.match(/-?[\d.]+(?=deg)/g)?.map(Number) ?? [14];
|
|
193
|
+
const requested = angles(style)[0], available = angles(face.style);
|
|
194
|
+
const sameStyle = face.style === style || style.startsWith("oblique") && face.style.startsWith("oblique") && requested >= available[0] && requested <= (available[1] ?? available[0]);
|
|
195
|
+
return sameStyle && weight >= weights[0] && weight <= (weights[1] ?? weights[0]);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
for (const element of textElements) {
|
|
199
|
+
const s = win.getComputedStyle(element);
|
|
200
|
+
// An undeclared family may use installed fonts; only the CLI can audit the
|
|
201
|
+
// actual glyph source. Declared faces must cover the requested treatment.
|
|
202
|
+
if (faces.some(face => familyName(face.family) === primaryFamily(s.fontFamily)) && !hasFace(s.fontFamily, Number(s.fontWeight), s.fontStyle))
|
|
203
|
+
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.`);
|
|
204
|
+
}
|
|
205
|
+
const colorTokens = ["bg", "fg", "muted", "line", "line-subtle", "line-strong", "inverse", "accent", "wash", "surface", "contrast", "on-contrast", "on-contrast-muted", "emphasis", "emphasis-wash", "complete", "attention", "blocked", ...Array.from({ length: 6 }, (_, i) => `category-${i + 1}`), ...Array.from({ length: 5 }, (_, i) => `sequence-${i + 1}`)];
|
|
206
|
+
function themeColors(style: CSSStyleDeclaration) {
|
|
207
|
+
probe.style.colorScheme = style.colorScheme;
|
|
208
|
+
return Object.fromEntries(colorTokens.map(token => {
|
|
209
|
+
const value = style.getPropertyValue(`--kp-${token}`);
|
|
210
|
+
probe.style.color = ""; probe.style.color = value;
|
|
211
|
+
return [token, value && probe.style.color ? win.getComputedStyle(probe).color : undefined];
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
const pageStyle = win.getComputedStyle(page), version = pageStyle.getPropertyValue("--kp-theme").trim();
|
|
215
|
+
const type = Object.fromEntries(roles.map(role => [role, {
|
|
216
|
+
size: parseFloat(pageStyle.getPropertyValue(`--kp-font-${role}`)),
|
|
217
|
+
lineHeight: parseFloat(pageStyle.getPropertyValue(`--kp-leading-${role}`)),
|
|
218
|
+
weight: Number(pageStyle.getPropertyValue(`--kp-weight-${role}`)),
|
|
219
|
+
tracking: pageStyle.getPropertyValue(`--kp-tracking-${role}`).trim(),
|
|
220
|
+
family: pageStyle.getPropertyValue(role === "display" ? "--kp-font-family-display" : role === "heading" || role === "title" ? "--kp-font-family-heading" : "--kp-font-family").trim(),
|
|
221
|
+
}]));
|
|
222
|
+
const theme = version === "1" ? { version: 1, mode: pageStyle.colorScheme, unit: parseFloat(pageStyle.getPropertyValue("--kp-unit")), type } : undefined;
|
|
223
|
+
if (version && version !== "1") report("theme-version", page, `Unsupported --kp-theme version ${version}; theme checks were skipped.`, "warning");
|
|
224
|
+
if (theme && !page.closest('[data-theme="custom"]')) {
|
|
225
|
+
// Validate the active definition even when a token is not used by this page.
|
|
226
|
+
const required = [...colorTokens, "font-family", "font-family-heading", "font-family-display", "font-family-mono", "weight-strong", "weight-mono", "unit", "rule-width", "stroke", "stroke-heavy", "radius", "radius-small", "page-width", "page-height", "page-margin", ...[1, 2, 3, 4, 5, 6, 8, 10, 12, 16].map(n => `space-${n}`), ...roles.flatMap(role => ["font", "leading", "weight", "tracking"].map(property => `${property}-${role}`))];
|
|
227
|
+
const invalid = required.filter(token => !pageStyle.getPropertyValue(`--kp-${token}`).trim()).map(token => `missing --kp-${token}`);
|
|
228
|
+
const rootStyle = win.getComputedStyle(doc.documentElement);
|
|
229
|
+
const shadowed = [...roles.flatMap(role => [`font-${role}`, `leading-${role}`]), "page-width", "page-height", "page-margin"]
|
|
230
|
+
.filter(token => rootStyle.getPropertyValue(`--kp-${token}`).trim() && rootStyle.getPropertyValue(`--kp-${token}`).trim() !== pageStyle.getPropertyValue(`--kp-${token}`).trim());
|
|
231
|
+
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");
|
|
232
|
+
let previous = 0;
|
|
233
|
+
for (const role of roles) {
|
|
234
|
+
const t = type[role];
|
|
235
|
+
const pixels = ["font", "leading"].every(property => /^\d+(?:\.\d+)?px$/.test(pageStyle.getPropertyValue(`--kp-${property}-${role}`).trim()));
|
|
236
|
+
if (!pixels || !(t.size > previous && t.lineHeight >= t.size && t.weight >= 1 && t.weight <= 1000) || !win.CSS.supports("letter-spacing", t.tracking)) invalid.push(`invalid ${role} type treatment`);
|
|
237
|
+
if (/^\d+(?:\.\d+)?px$/.test(pageStyle.getPropertyValue(`--kp-font-${role}`).trim()) && t.size > 0) previous = t.size;
|
|
238
|
+
}
|
|
239
|
+
const treatments = [...roles.map(role => ({ role, ...type[role] })),
|
|
240
|
+
{ role: "strong", family: pageStyle.getPropertyValue("--kp-font-family"), weight: Number(pageStyle.getPropertyValue("--kp-weight-strong")) },
|
|
241
|
+
{ role: "mono", family: pageStyle.getPropertyValue("--kp-font-family-mono"), weight: Number(pageStyle.getPropertyValue("--kp-weight-mono")) }];
|
|
242
|
+
const missingFaces = treatments.filter(t => t.family && t.weight > 0 && !hasFace(t.family, t.weight));
|
|
243
|
+
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.`);
|
|
244
|
+
const colors = themeColors(pageStyle);
|
|
245
|
+
for (const token of colorTokens) if (!colors[token]) invalid.push(`invalid --kp-${token} color`);
|
|
246
|
+
if (invalid.length) report("theme-definition", page, invalid.join("; "));
|
|
247
|
+
const textContrast: string[] = [], chartContrast: string[] = [];
|
|
248
|
+
const contrast = (foreground: string, background: string, minimum: number, failures: string[]) => {
|
|
249
|
+
const a = rgb(colors[foreground] ?? ""), b = rgb(colors[background] ?? "");
|
|
250
|
+
if (!opaque(a) || !opaque(b)) return;
|
|
251
|
+
const x = luminance(a!), y = luminance(b!), ratio = (Math.max(x, y) + .05) / (Math.min(x, y) + .05);
|
|
252
|
+
if (ratio < minimum) failures.push(`${foreground} on ${background}: ${ratio.toFixed(2)}:1; needs ${minimum}:1`);
|
|
253
|
+
};
|
|
254
|
+
for (const surface of ["bg", "surface", "wash"]) for (const ink of ["fg", "muted"]) contrast(ink, surface, 4.5, textContrast);
|
|
255
|
+
for (const ink of ["on-contrast", "on-contrast-muted"]) contrast(ink, "contrast", 4.5, textContrast);
|
|
256
|
+
contrast("accent", "bg", 4.5, textContrast); contrast("inverse", "accent", 4.5, textContrast);
|
|
257
|
+
const categories = Array.from({ length: 6 }, (_, i) => `category-${i + 1}`);
|
|
258
|
+
for (const token of categories) contrast(token, "bg", 3, chartContrast);
|
|
259
|
+
if (textContrast.length) report("theme-contrast", page, textContrast.join("; "));
|
|
260
|
+
if (chartContrast.length) report("theme-chart-contrast", page, chartContrast.join("; "), "warning");
|
|
261
|
+
if (new Set(categories.map(token => colors[token])).size !== categories.length)
|
|
262
|
+
report("theme-categories", page, "Category colors contain exact duplicates. Use distinct identities or supply non-color encodings.", "warning");
|
|
263
|
+
}
|
|
264
|
+
for (const element of [page, ...elements]) {
|
|
265
|
+
if (!visible(element) || element.closest('[data-theme="custom"]')) continue;
|
|
266
|
+
const style = win.getComputedStyle(element);
|
|
267
|
+
if (style.getPropertyValue("--kp-theme").trim() !== "1") continue;
|
|
268
|
+
if (element.hasAttribute("data-type") && !roles.includes(element.getAttribute("data-type")!))
|
|
269
|
+
report("theme-role", element, `Unknown data-type="${element.getAttribute("data-type")}". Use ${roles.join(", ")}.`, "warning");
|
|
270
|
+
const deviations: string[] = [];
|
|
271
|
+
// SVG text uses explicit coordinates, not CSS line-height, for line spacing.
|
|
272
|
+
if (textElements.has(element) && !roles.some(role =>
|
|
273
|
+
Math.abs(parseFloat(style.getPropertyValue(`--kp-font-${role}`)) - parseFloat(style.fontSize)) < .1 &&
|
|
274
|
+
(element.namespaceURI !== "http://www.w3.org/1999/xhtml" || Math.abs(parseFloat(style.getPropertyValue(`--kp-leading-${role}`)) - parseFloat(style.lineHeight)) < .1)))
|
|
275
|
+
deviations.push(`type pair ${style.fontSize}/${style.lineHeight}`);
|
|
276
|
+
const colors = Object.values(themeColors(style));
|
|
277
|
+
const values = [style.backgroundColor];
|
|
278
|
+
if (textElements.has(element)) values.push(element.namespaceURI === "http://www.w3.org/2000/svg" ? style.fill : style.color);
|
|
279
|
+
probe.style.fontSize = style.fontSize;
|
|
280
|
+
function themedLength(property: "borderTopWidth" | "borderTopLeftRadius" | "strokeWidth", value: string, tokens: string[]) {
|
|
281
|
+
return tokens.some(token => {
|
|
282
|
+
probe.style[property] = ""; probe.style[property] = style.getPropertyValue(`--kp-${token}`);
|
|
283
|
+
return probe.style[property] && win.getComputedStyle(probe)[property] === value;
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
probe.style.borderTopStyle = "solid";
|
|
287
|
+
const widths = ["rule-width", "stroke", "stroke-heavy"];
|
|
288
|
+
for (const side of ["Top", "Right", "Bottom", "Left"] as const) {
|
|
289
|
+
const width = style[`border${side}Width`];
|
|
290
|
+
if (parseFloat(width) > 0) {
|
|
291
|
+
values.push(style[`border${side}Color`]);
|
|
292
|
+
if (!themedLength("borderTopWidth", width, widths)) deviations.push(`border width ${width}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
for (const corner of ["TopLeft", "TopRight", "BottomLeft", "BottomRight"] as const) {
|
|
296
|
+
const radius = style[`border${corner}Radius`];
|
|
297
|
+
if (radius !== "0px" && !themedLength("borderTopLeftRadius", radius, ["radius", "radius-small"])) deviations.push(`corner radius ${radius}`);
|
|
298
|
+
}
|
|
299
|
+
if (element.matches("path,rect,circle,ellipse,polygon,polyline,line")) {
|
|
300
|
+
if (!element.matches("line")) values.push(style.fill);
|
|
301
|
+
values.push(style.stroke);
|
|
302
|
+
if (style.stroke !== "none" && parseFloat(style.strokeWidth) > 0 && !themedLength("strokeWidth", style.strokeWidth, widths)) deviations.push(`stroke width ${style.strokeWidth}`);
|
|
303
|
+
}
|
|
304
|
+
for (const value of new Set(values))
|
|
305
|
+
if (value !== "none" && value !== "rgba(0, 0, 0, 0)" && !colors.includes(value)) deviations.push(`color ${value}`);
|
|
306
|
+
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");
|
|
307
|
+
}
|
|
308
|
+
probe.remove();
|
|
309
|
+
for (const link of doc.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]'))
|
|
310
|
+
if (!link.sheet && !link.disabled) report("missing-stylesheet", page, "A stylesheet did not load. Keep stylesheets beside the HTML or inline them.");
|
|
311
|
+
for (const font of doc.fonts) if (font.status === "error") report("missing-font", page, `Font failed to load: ${font.family}.`);
|
|
312
|
+
return { id: page.id, width: origin.width, height: origin.height, blocks, text, theme, diagnostics };
|
|
313
|
+
}
|
package/html/preview.css
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
.html-heading { display: flex; flex-wrap: wrap; align-items: center; gap: 20px; }
|
|
2
|
+
.html-heading a { color: var(--ui-muted); font-size: 12px; text-underline-offset: 3px; }
|
|
3
|
+
.html-page { position: relative; width: 100%; background: white; box-shadow: 0 1px 3px #00000016, 0 12px 40px #0000000a; touch-action: none; -webkit-user-select: none; user-select: none; }
|
|
4
|
+
/* Component outlines indicate focus without making the page look selected too. */
|
|
5
|
+
.html-page:has(.html-outline):focus-visible,
|
|
6
|
+
.workspace:has(.review-hints) .html-page:focus-visible { outline: none; }
|
|
7
|
+
.html-page iframe { position: absolute; inset: 0; border: 0; transform-origin: top left; pointer-events: none; }
|
|
8
|
+
.html-outline { position: absolute; border: 1.5px solid var(--accent); background: #007bbb06; pointer-events: none; }
|
|
9
|
+
.html-pin { position: absolute; z-index: 3; display: grid; place-items: center; border: 2px solid white; background: #007bbb; color: white; border-radius: 50%; min-width: 22px; height: 22px; padding: 0 4px; font-size: 11px; line-height: 1; }
|
|
10
|
+
.html-pin:hover:not(:disabled) { border-color: white; background: #006da7; color: white; }
|
|
11
|
+
.revision-note-list li { align-items: center; }
|
|
12
|
+
.revision-note-label { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
|
13
|
+
.toast-dismiss { display: grid; place-items: center; }
|
|
14
|
+
.toast-dismiss svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.75; stroke-linecap: round; }
|
|
15
|
+
.revision-notes > form > textarea { margin-top: 10px; }
|
|
16
|
+
.html-guide { position: absolute; z-index: 2; pointer-events: none; border-color: #d43c8a; border-style: dashed; border-width: 0; }
|
|
17
|
+
.html-guide.x { top: 0; height: 100%; border-left-width: 1px; }
|
|
18
|
+
.html-guide.y { left: 0; width: 100%; border-top-width: 1px; }
|
|
19
|
+
.html-diagnostics { position: relative; margin-left: auto; flex: none; color: #92570c; font-size: 12px; }
|
|
20
|
+
.html-diagnostics summary { cursor: pointer; }
|
|
21
|
+
.html-diagnostics ul { position: absolute; right: 0; z-index: 10; width: min(340px, 70vw); max-height: 280px; overflow: auto; margin-top: 6px; padding: 14px 14px 14px 28px; border: 1px solid #efd5ab; border-radius: 10px; background: #fffcf7; box-shadow: 0 6px 20px #00000014; color: #4f402b; white-space: normal; }
|
|
22
|
+
.html-diagnostics li + li { margin-top: 8px; }
|
|
23
|
+
.shortcut-launcher { margin-left: auto; padding: 4px 9px; border-color: transparent; background: transparent; color: var(--ui-muted); }
|
|
24
|
+
kbd { font: 11px ui-monospace, monospace; }
|
|
25
|
+
.review-status { position: fixed; left: 24px; bottom: 24px; z-index: 35; display: flex; align-items: center; gap: 12px; max-width: calc(100vw - 112px); min-height: 34px; padding: 4px 12px 4px 4px; border: 1px solid var(--ui-border); border-radius: 8px; background: var(--ui-surface); box-shadow: 0 2px 8px #00000006; color: var(--ui-muted); font-size: 11px; }
|
|
26
|
+
.review-status button { padding: 5px 8px; border-color: transparent; background: transparent; color: var(--ui-text); font: 11px ui-monospace, monospace; white-space: nowrap; }
|
|
27
|
+
.review-status > kbd { color: var(--accent); font-weight: 600; }
|
|
28
|
+
.review-page-number { padding-left: 12px; border-left: 1px solid var(--ui-border); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
29
|
+
.review-hints { position: fixed; inset: 0; z-index: 36; pointer-events: none; }
|
|
30
|
+
.review-hints > svg { position: absolute; inset: 0; width: 100%; height: 100%; fill: none; stroke: var(--accent); stroke-width: 1; }
|
|
31
|
+
.review-hints rect { stroke-opacity: .18; }
|
|
32
|
+
.review-hints line { stroke-opacity: .7; }
|
|
33
|
+
.review-hint { position: absolute; height: 22px; padding: 2px 4px; border: 1px solid #fff; border-radius: 4px; background: #006da7; color: white; box-shadow: 0 1px 4px #00000024; font: 600 12px/16px ui-monospace, monospace; text-transform: uppercase; pointer-events: auto; }
|
|
34
|
+
.review-hint:hover:not(:disabled) { border-color: white; background: #005584; color: white; }
|
|
35
|
+
.review-hint > span { color: #afdbf2; }
|
|
36
|
+
.review-queue-heading { display: flex; flex-direction: column; gap: 4px; margin-bottom: 8px; }
|
|
37
|
+
.review-queue-heading > span { color: var(--ui-muted); font-size: 11px; }
|
|
38
|
+
.revision-note-list li.review-note-active { background: var(--ui-selected); box-shadow: -8px 0 var(--ui-selected), 8px 0 var(--ui-selected); }
|
|
39
|
+
.revision-note-target:focus-visible { outline-offset: 2px; }
|
|
40
|
+
.revision-notes .primary kbd { opacity: .85; font: inherit; font-weight: 400; }
|
|
41
|
+
.shortcut-help { width: 510px; max-width: calc(100vw - 32px); max-height: calc(100dvh - 48px); padding: 24px; border: 1px solid var(--ui-border); border-radius: 14px; background: var(--ui-surface); color: var(--ui-text); box-shadow: var(--ui-shadow); font-size: 13px; overscroll-behavior: contain; }
|
|
42
|
+
.shortcut-help::backdrop { background: #20202040; }
|
|
43
|
+
.shortcut-help header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
|
|
44
|
+
.shortcut-help h2 { font-size: 17px; }
|
|
45
|
+
.shortcut-help header button { display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border-color: transparent; background: transparent; font-size: 20px; color: var(--ui-muted); }
|
|
46
|
+
.vim-toggle { display: flex; align-items: center; gap: 10px; font-weight: 500; cursor: pointer; }
|
|
47
|
+
.vim-toggle input { width: 16px; height: 16px; margin: 0; accent-color: var(--accent); }
|
|
48
|
+
.shortcut-help > p { margin: 8px 0 20px; color: var(--ui-muted); font-size: 12px; }
|
|
49
|
+
.shortcut-help h3 { display: flex; align-items: baseline; justify-content: space-between; margin: 18px 0 10px; font-size: 13px; font-weight: 500; }
|
|
50
|
+
.shortcut-help h3 span { color: var(--ui-muted); font-size: 11px; font-weight: 400; }
|
|
51
|
+
.shortcut-help dl { display: grid; grid-template-columns: 128px 1fr; align-items: baseline; gap: 10px 12px; margin: 0; padding-top: 12px; border-top: 1px solid var(--ui-border); }
|
|
52
|
+
.shortcut-help dt { color: var(--ui-muted); }
|
|
53
|
+
.shortcut-help dd { margin: 0; }
|
|
54
|
+
.shortcut-help kbd { padding: 2px 4px; border: 1px solid var(--ui-border); border-radius: 4px; background: var(--ui-subtle); color: var(--ui-text); white-space: nowrap; }
|
|
55
|
+
@media (max-width: 600px) {
|
|
56
|
+
.review-status { left: 16px; bottom: 20px; gap: 6px; padding-right: 8px; max-width: calc(100vw - 96px); }
|
|
57
|
+
.review-status > span:not(.review-page-number) { display: none; }
|
|
58
|
+
.review-page-number { padding-left: 8px; }
|
|
59
|
+
.shortcut-help { padding: 18px; }
|
|
60
|
+
.shortcut-help dl { grid-template-columns: 110px 1fr; gap: 10px; font-size: 12px; }
|
|
61
|
+
.review-actions:has(kbd) { flex-wrap: wrap; }
|
|
62
|
+
}
|