konpeki 0.3.1 → 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 +98 -106
- package/SETUP.md +49 -129
- package/docs/development.md +43 -256
- 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 -37
- 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 +1 -10
- package/src/styles/feedback.css +2 -1
- package/src/styles/shell.css +94 -354
- 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 -56
- 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/drawing-references.md +0 -32
- package/slides/experiment/PROMPT.md +0 -44
- package/slides/experiment/index.tsx +0 -127
- package/slides/github-cover/PROMPT.md +0 -21
- package/slides/github-cover/README.md +0 -21
- package/slides/github-cover/author.ts +0 -67
- package/slides/github-cover/composition.json +0 -595
- package/slides/incident-workflow/PROMPT.md +0 -57
- package/slides/incident-workflow/index.tsx +0 -78
- package/slides/introducing-konpeki/PROMPT.md +0 -30
- package/slides/introducing-konpeki/README.md +0 -44
- package/slides/introducing-konpeki/SOURCE.md +0 -19
- 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 -845
- package/src/components/BuildOrb.tsx +0 -40
- package/src/components/Canvas.tsx +0 -1191
- package/src/components/DiagramTypeIcon.tsx +0 -78
- package/src/components/InspectorPanel.tsx +0 -757
- 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 -55
- package/src/components/RightPanel.tsx +0 -233
- package/src/components/VectorOverflowWarning.tsx +0 -73
- package/src/components/WorkspaceChrome.tsx +0 -316
- package/src/components/ui.tsx +0 -147
- 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 -249
- package/src/main.tsx +0 -29
- package/src/styles/canvas.css +0 -299
- package/src/styles/chrome.css +0 -475
- package/src/styles/component-previews.css +0 -386
- package/src/styles/left-panel.css +0 -187
- package/src/styles/presentation.css +0 -72
- package/src/styles/right-panel.css +0 -1298
package/html/browser.ts
ADDED
|
@@ -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
|
+
}
|
package/html/document.ts
ADDED
|
@@ -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
|
+
}
|
package/html/index.html
ADDED
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
|
+
}
|