@hajimism/dek 0.1.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/LICENSE +21 -0
- package/README.ja.md +75 -0
- package/README.md +75 -0
- package/package.json +65 -0
- package/src/cli/address.ts +53 -0
- package/src/cli/build.ts +85 -0
- package/src/cli/check.ts +97 -0
- package/src/cli/commands.ts +851 -0
- package/src/cli/contract.ts +444 -0
- package/src/cli/cues.ts +18 -0
- package/src/cli/files.ts +309 -0
- package/src/cli/flags.ts +295 -0
- package/src/cli/format.ts +156 -0
- package/src/cli/goto.ts +96 -0
- package/src/cli/init.ts +136 -0
- package/src/cli/keep-alive.ts +72 -0
- package/src/cli/lint.ts +84 -0
- package/src/cli/ls.ts +119 -0
- package/src/cli/main.ts +231 -0
- package/src/cli/marks.ts +16 -0
- package/src/cli/mv.ts +37 -0
- package/src/cli/new.ts +64 -0
- package/src/cli/pdf.ts +16 -0
- package/src/cli/qr.ts +466 -0
- package/src/cli/ref.ts +165 -0
- package/src/cli/rehearse.ts +42 -0
- package/src/cli/result.ts +161 -0
- package/src/cli/scope.ts +308 -0
- package/src/cli/serve.ts +125 -0
- package/src/cli/shot.ts +134 -0
- package/src/cli/show.ts +103 -0
- package/src/cli/sync.ts +37 -0
- package/src/cli/text.ts +479 -0
- package/src/cli/theme.ts +56 -0
- package/src/cli/tty.ts +81 -0
- package/src/cli/usage.ts +198 -0
- package/src/cli/video.ts +17 -0
- package/src/cli/voice.ts +113 -0
- package/src/cli.ts +4 -0
- package/src/core/assets.ts +239 -0
- package/src/core/build.ts +77 -0
- package/src/core/bundled-theme.ts +15 -0
- package/src/core/capture-go.ts +72 -0
- package/src/core/chrome.ts +128 -0
- package/src/core/config-keys.ts +82 -0
- package/src/core/config.ts +95 -0
- package/src/core/css-scan.ts +158 -0
- package/src/core/css-transform.ts +140 -0
- package/src/core/css-value.ts +127 -0
- package/src/core/css.ts +545 -0
- package/src/core/cue.ts +187 -0
- package/src/core/deck-paths.ts +34 -0
- package/src/core/diagnostic.ts +114 -0
- package/src/core/document.ts +218 -0
- package/src/core/error.ts +31 -0
- package/src/core/escape.ts +16 -0
- package/src/core/fill.ts +100 -0
- package/src/core/finish-beat.ts +30 -0
- package/src/core/github.ts +234 -0
- package/src/core/html-scan.ts +253 -0
- package/src/core/html.ts +234 -0
- package/src/core/in-page-go.ts +128 -0
- package/src/core/lines.ts +43 -0
- package/src/core/lint/asset-refs.ts +109 -0
- package/src/core/lint/context.ts +116 -0
- package/src/core/lint/files.ts +117 -0
- package/src/core/lint/keys.ts +48 -0
- package/src/core/lint/morph.ts +91 -0
- package/src/core/lint/script.ts +102 -0
- package/src/core/lint/shared-styles.ts +165 -0
- package/src/core/lint/slide-html.ts +466 -0
- package/src/core/lint/slide-script.ts +179 -0
- package/src/core/lint/theme-css.ts +295 -0
- package/src/core/lint/timing.ts +66 -0
- package/src/core/lint/tokens.ts +520 -0
- package/src/core/lint/voice.ts +76 -0
- package/src/core/lint.ts +137 -0
- package/src/core/live-protocol.ts +130 -0
- package/src/core/marks.ts +198 -0
- package/src/core/mv.ts +293 -0
- package/src/core/ogp.ts +99 -0
- package/src/core/optional.ts +24 -0
- package/src/core/overflow.ts +220 -0
- package/src/core/page-pool.ts +53 -0
- package/src/core/page.ts +66 -0
- package/src/core/parse.ts +307 -0
- package/src/core/path.ts +58 -0
- package/src/core/pdf.ts +71 -0
- package/src/core/playwright-module.d.ts +43 -0
- package/src/core/playwright-motion.ts +42 -0
- package/src/core/playwright-sheet.ts +32 -0
- package/src/core/playwright-visual.ts +391 -0
- package/src/core/playwright-worker.ts +5 -0
- package/src/core/playwright.ts +431 -0
- package/src/core/position.ts +146 -0
- package/src/core/presenter-state.ts +43 -0
- package/src/core/presenter.ts +131 -0
- package/src/core/prose.ts +16 -0
- package/src/core/pseudo-text.ts +67 -0
- package/src/core/rail-width.ts +4 -0
- package/src/core/ref-name.ts +110 -0
- package/src/core/ref.ts +238 -0
- package/src/core/resolve.ts +228 -0
- package/src/core/rumdl.ts +99 -0
- package/src/core/safe-fs.ts +196 -0
- package/src/core/sarif.ts +135 -0
- package/src/core/schema.ts +89 -0
- package/src/core/script-lines.ts +53 -0
- package/src/core/sheet.ts +223 -0
- package/src/core/shot/morph.ts +80 -0
- package/src/core/shot/motion.ts +187 -0
- package/src/core/shot/still.ts +216 -0
- package/src/core/shot-cache.ts +140 -0
- package/src/core/size.ts +11 -0
- package/src/core/skeleton-record.ts +51 -0
- package/src/core/skeleton.ts +89 -0
- package/src/core/slide-draw.ts +165 -0
- package/src/core/slide-eval-worker.ts +58 -0
- package/src/core/slide-measure.ts +272 -0
- package/src/core/slide-script-eval.ts +258 -0
- package/src/core/slide-script.ts +252 -0
- package/src/core/spawn.ts +128 -0
- package/src/core/step.ts +94 -0
- package/src/core/still-page.ts +70 -0
- package/src/core/suggest.ts +33 -0
- package/src/core/sync.ts +316 -0
- package/src/core/text-contrast.ts +466 -0
- package/src/core/theme-excerpt.ts +187 -0
- package/src/core/theme-facts.ts +51 -0
- package/src/core/timeline.ts +216 -0
- package/src/core/timing.ts +88 -0
- package/src/core/toml-keys.ts +349 -0
- package/src/core/url-attributes.ts +103 -0
- package/src/core/video-document.ts +15 -0
- package/src/core/visual-cache.ts +100 -0
- package/src/core/visual.ts +558 -0
- package/src/core/voice.ts +321 -0
- package/src/core/zod.ts +79 -0
- package/src/runtime/browser.ts +203 -0
- package/src/runtime/clone.ts +52 -0
- package/src/runtime/deck-control.ts +43 -0
- package/src/runtime/fit.ts +32 -0
- package/src/runtime/go.ts +56 -0
- package/src/runtime/input.ts +128 -0
- package/src/runtime/laser-view.ts +110 -0
- package/src/runtime/laser.ts +145 -0
- package/src/runtime/live-host.ts +33 -0
- package/src/runtime/live-reload.ts +29 -0
- package/src/runtime/live.ts +88 -0
- package/src/runtime/marks-view.ts +74 -0
- package/src/runtime/motion.ts +113 -0
- package/src/runtime/navigator.ts +189 -0
- package/src/runtime/player.ts +28 -0
- package/src/runtime/presenter-view.ts +289 -0
- package/src/runtime/presenter.ts +52 -0
- package/src/runtime/rail-view.ts +229 -0
- package/src/runtime/rail.ts +52 -0
- package/src/runtime/rehearse.ts +264 -0
- package/src/runtime/routes.ts +142 -0
- package/src/runtime/settle.ts +45 -0
- package/src/runtime/slide.d.ts +28 -0
- package/src/runtime/socket.ts +143 -0
- package/src/runtime/stage.ts +259 -0
- package/src/runtime/step.ts +139 -0
- package/src/runtime/window.ts +38 -0
- package/src/server/auth.ts +210 -0
- package/src/server/deck-snapshot.ts +146 -0
- package/src/server/dev.ts +251 -0
- package/src/server/hub.ts +104 -0
- package/src/server/lan.ts +51 -0
- package/src/server/lock.ts +97 -0
- package/src/server/pages.ts +71 -0
- package/src/server/pairing.ts +40 -0
- package/src/server/project-watch.ts +142 -0
- package/src/server/rooms.ts +133 -0
- package/src/server/routes.ts +286 -0
- package/src/server/serial.ts +25 -0
- package/src/server/watch.ts +308 -0
- package/src/theme/default.css +175 -0
- package/src/video/bake.ts +152 -0
- package/src/video/mux.ts +103 -0
- package/src/video/recorder.ts +115 -0
- package/src/video/sidecar.ts +60 -0
- package/src/video/worker.ts +51 -0
- package/src/voice/engine.ts +177 -0
- package/src/voice/synth.ts +127 -0
- package/src/voice/wav.ts +115 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
import { type DeckPaths, deckPaths } from "./deck-paths.ts";
|
|
2
|
+
import { type Diagnostic, diag } from "./diagnostic.ts";
|
|
3
|
+
import { EDGES, type Edge } from "./overflow.ts";
|
|
4
|
+
import { renderPdfHtml } from "./pdf.ts";
|
|
5
|
+
import {
|
|
6
|
+
askPlaywright,
|
|
7
|
+
type ContrastOrigin,
|
|
8
|
+
type FillFinding,
|
|
9
|
+
type PageAction,
|
|
10
|
+
type PagesResponse,
|
|
11
|
+
type PlaywrightRunner,
|
|
12
|
+
playwrightReady,
|
|
13
|
+
type VisualPage,
|
|
14
|
+
} from "./playwright.ts";
|
|
15
|
+
import { asResolvedDeck, type ResolvedDeck } from "./resolve.ts";
|
|
16
|
+
import { type Still, still, stillPage } from "./shot/still.ts";
|
|
17
|
+
import { logicalSize } from "./size.ts";
|
|
18
|
+
import { lastStop, stepKey } from "./step.ts";
|
|
19
|
+
import { loadSlideSources, renderSlideHtml } from "./still-page.ts";
|
|
20
|
+
import { contrastThreshold, parseCssRgb } from "./text-contrast.ts";
|
|
21
|
+
import { type PageFindings, visualCache } from "./visual-cache.ts";
|
|
22
|
+
|
|
23
|
+
export type VisualDeckOptions = {
|
|
24
|
+
slug?: string;
|
|
25
|
+
runner?: PlaywrightRunner;
|
|
26
|
+
screenshot?: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type VisualDeckResult = {
|
|
30
|
+
diagnostics: Diagnostic[];
|
|
31
|
+
screenshotPath?: string;
|
|
32
|
+
/** How much of the frame each slide measured fills at its last beat, as its shot shows it. */
|
|
33
|
+
fills: FillFinding[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export async function lintVisualDeck(
|
|
37
|
+
dir: string,
|
|
38
|
+
options?: VisualDeckOptions,
|
|
39
|
+
): Promise<Diagnostic[] | null>;
|
|
40
|
+
export async function lintVisualDeck(
|
|
41
|
+
source: ResolvedDeck,
|
|
42
|
+
options?: VisualDeckOptions,
|
|
43
|
+
): Promise<Diagnostic[] | null>;
|
|
44
|
+
export async function lintVisualDeck(
|
|
45
|
+
input: string | ResolvedDeck,
|
|
46
|
+
options: VisualDeckOptions = {},
|
|
47
|
+
): Promise<Diagnostic[] | null> {
|
|
48
|
+
const result = await visualDeck(input, options);
|
|
49
|
+
return result ? result.diagnostics : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function runVisualDeck(
|
|
53
|
+
dir: string,
|
|
54
|
+
options?: VisualDeckOptions,
|
|
55
|
+
): Promise<VisualDeckResult | null>;
|
|
56
|
+
export async function runVisualDeck(
|
|
57
|
+
source: ResolvedDeck,
|
|
58
|
+
options?: VisualDeckOptions,
|
|
59
|
+
): Promise<VisualDeckResult | null>;
|
|
60
|
+
export async function runVisualDeck(
|
|
61
|
+
input: string | ResolvedDeck,
|
|
62
|
+
options: VisualDeckOptions = {},
|
|
63
|
+
): Promise<VisualDeckResult | null> {
|
|
64
|
+
return visualDeck(input, options);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Every beat of every slide measured in one browser run, and with `screenshot` each slide's
|
|
69
|
+
* still shot in the same run. Null when Playwright is not installed.
|
|
70
|
+
*/
|
|
71
|
+
async function visualDeck(
|
|
72
|
+
input: string | ResolvedDeck,
|
|
73
|
+
options: VisualDeckOptions,
|
|
74
|
+
): Promise<VisualDeckResult | null> {
|
|
75
|
+
// Every page is measured in a browser, so without one there is nothing to render or cache.
|
|
76
|
+
if (!playwrightReady(options.runner)) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const { deck } = asResolvedDeck(input);
|
|
80
|
+
const { pages, stills, scripted } = visualPages(deck, options);
|
|
81
|
+
|
|
82
|
+
if (pages.length === 0) {
|
|
83
|
+
return { diagnostics: [], fills: [] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const actions: PageAction[] = ["overflow", "contrast", "fill"];
|
|
87
|
+
const viewport = logicalSize(deck.deck.ratio);
|
|
88
|
+
const cache = visualCache(deck.dir, { viewport, actions });
|
|
89
|
+
const keyed = pages.map((page) => {
|
|
90
|
+
const key = cache.key(page);
|
|
91
|
+
return { page, key, found: cache.read(key) };
|
|
92
|
+
});
|
|
93
|
+
// A page is measured again only when its findings are not kept, or it still owes its shot.
|
|
94
|
+
const toMeasure = keyed.filter(({ page, found }) => !found || page.screenshotPath);
|
|
95
|
+
const response =
|
|
96
|
+
toMeasure.length === 0
|
|
97
|
+
? NOTHING_FOUND
|
|
98
|
+
: await askPlaywright(
|
|
99
|
+
{ kind: "pages", viewport, actions, pages: toMeasure.map(({ page }) => page) },
|
|
100
|
+
options.runner,
|
|
101
|
+
);
|
|
102
|
+
if (response === null) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const fresh = findingsByPage(response);
|
|
106
|
+
for (const { page, key } of toMeasure) {
|
|
107
|
+
cache.write(key, fresh.get(pageId(page)) ?? NOTHING_FOUND);
|
|
108
|
+
}
|
|
109
|
+
const whole = scripted ? await wholeDeckDraws(deck, viewport, options.runner) : undefined;
|
|
110
|
+
if (whole === null) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
if (!options.slug) {
|
|
114
|
+
cache.prune(new Set([...keyed.map(({ key }) => key), ...(whole ? [whole.key] : [])]));
|
|
115
|
+
}
|
|
116
|
+
for (const shot of stills) {
|
|
117
|
+
shot.entry.commit();
|
|
118
|
+
}
|
|
119
|
+
// In the pages' order; a finding that names no page asked for is reported all the same.
|
|
120
|
+
const asked = new Set(keyed.map(({ page }) => pageId(page)));
|
|
121
|
+
// Each slide's pages run from its arrival to its last beat, so the last one kept is its last.
|
|
122
|
+
const lastSteps = new Map(pages.map((page) => [page.slug, page.step]));
|
|
123
|
+
const all = [
|
|
124
|
+
...keyed.map(({ page, found }) => fresh.get(pageId(page)) ?? found ?? NOTHING_FOUND),
|
|
125
|
+
...[...fresh].flatMap(([id, found]) => (asked.has(id) ? [] : [found])),
|
|
126
|
+
{
|
|
127
|
+
...NOTHING_FOUND,
|
|
128
|
+
drawErrors: (whole?.drawErrors ?? []).filter(
|
|
129
|
+
(error) => !options.slug || error.slug === options.slug,
|
|
130
|
+
),
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
return {
|
|
134
|
+
diagnostics: visualDiagnostics(
|
|
135
|
+
{
|
|
136
|
+
overflows: all.flatMap((page) => page.overflows),
|
|
137
|
+
contrasts: all.flatMap((page) => page.contrasts),
|
|
138
|
+
drawErrors: all.flatMap((page) => page.drawErrors),
|
|
139
|
+
collisions: all.flatMap((page) => page.collisions ?? []),
|
|
140
|
+
},
|
|
141
|
+
deck.dir,
|
|
142
|
+
),
|
|
143
|
+
...(stills[0] ? { screenshotPath: stills[0].screenshotPath } : {}),
|
|
144
|
+
fills: all
|
|
145
|
+
.flatMap((page) => page.fills)
|
|
146
|
+
.filter((fill) => lastSteps.get(fill.slug) === fill.step),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const NOTHING_FOUND: PageFindings = {
|
|
151
|
+
overflows: [],
|
|
152
|
+
contrasts: [],
|
|
153
|
+
drawErrors: [],
|
|
154
|
+
collisions: [],
|
|
155
|
+
fills: [],
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/** A page's findings name its slide and step, which no two pages of one run share. */
|
|
159
|
+
function pageId(page: { slug: string; step: string }): string {
|
|
160
|
+
return `${page.slug}\0${page.step}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A response split back into its pages' findings. */
|
|
164
|
+
function findingsByPage(response: PagesResponse): Map<string, PageFindings> {
|
|
165
|
+
const pages = new Map<string, PageFindings>();
|
|
166
|
+
const of = (finding: { slug: string; step: string }): PageFindings => {
|
|
167
|
+
const id = pageId(finding);
|
|
168
|
+
const found = pages.get(id) ?? {
|
|
169
|
+
overflows: [],
|
|
170
|
+
contrasts: [],
|
|
171
|
+
drawErrors: [],
|
|
172
|
+
collisions: [],
|
|
173
|
+
fills: [],
|
|
174
|
+
};
|
|
175
|
+
pages.set(id, found);
|
|
176
|
+
return found;
|
|
177
|
+
};
|
|
178
|
+
for (const finding of response.overflows) {
|
|
179
|
+
of(finding).overflows.push(finding);
|
|
180
|
+
}
|
|
181
|
+
for (const finding of response.contrasts) {
|
|
182
|
+
of(finding).contrasts.push(finding);
|
|
183
|
+
}
|
|
184
|
+
for (const finding of response.drawErrors ?? []) {
|
|
185
|
+
of(finding).drawErrors.push(finding);
|
|
186
|
+
}
|
|
187
|
+
for (const finding of response.collisions ?? []) {
|
|
188
|
+
of(finding).collisions.push(finding);
|
|
189
|
+
}
|
|
190
|
+
for (const finding of response.fills ?? []) {
|
|
191
|
+
of(finding).fills.push(finding);
|
|
192
|
+
}
|
|
193
|
+
return pages;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Every beat of each section with a slide, the last one as a still shot with `screenshot`. */
|
|
197
|
+
function visualPages(
|
|
198
|
+
deck: ResolvedDeck["deck"],
|
|
199
|
+
options: VisualDeckOptions,
|
|
200
|
+
): { pages: VisualPage[]; stills: Still[]; scripted: boolean } {
|
|
201
|
+
// A broken slide script is DEK016's to report; keep checking the slide without it.
|
|
202
|
+
const sources = loadSlideSources(deck, { strict: false });
|
|
203
|
+
const pages: VisualPage[] = [];
|
|
204
|
+
const stills: Still[] = [];
|
|
205
|
+
// A missing file or section is lint's to report (DEK001, DEK007); there is nothing to render.
|
|
206
|
+
const sections = deck.deck.sections.filter(
|
|
207
|
+
(section) =>
|
|
208
|
+
(!options.slug || section.slug === options.slug) && sources.slides.has(section.slug),
|
|
209
|
+
);
|
|
210
|
+
for (const section of sections) {
|
|
211
|
+
const last = lastStop(section.beats);
|
|
212
|
+
for (let beatIndex = 0; beatIndex <= last; beatIndex++) {
|
|
213
|
+
if (options.screenshot && beatIndex === last) {
|
|
214
|
+
// The same still `dekc shot` takes of the slide, and taken only when it is not on disk.
|
|
215
|
+
const shot = still(deck, section, beatIndex, sources);
|
|
216
|
+
stills.push(shot);
|
|
217
|
+
pages.push(stillPage(shot));
|
|
218
|
+
} else {
|
|
219
|
+
pages.push({
|
|
220
|
+
html: renderSlideHtml(sources, section.slug, beatIndex),
|
|
221
|
+
slug: section.slug,
|
|
222
|
+
step: stepKey(section.beats, beatIndex),
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const scripted = sources.scripts.some((entry) => !("problem" in entry));
|
|
228
|
+
return { pages, stills, scripted };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* What the slides' draws do with every slide on one page, as in the built deck: only there does a
|
|
233
|
+
* draw that finds its elements through the document reach another slide. The page is drawn for
|
|
234
|
+
* its draws alone, and kept like any page by what it renders. Null when Playwright is missing.
|
|
235
|
+
*/
|
|
236
|
+
async function wholeDeckDraws(
|
|
237
|
+
deck: ResolvedDeck["deck"],
|
|
238
|
+
viewport: { width: number; height: number },
|
|
239
|
+
runner: PlaywrightRunner | undefined,
|
|
240
|
+
): Promise<{ key: string; drawErrors: PageFindings["drawErrors"] } | null> {
|
|
241
|
+
const cache = visualCache(deck.dir, { viewport, actions: [] });
|
|
242
|
+
const page: VisualPage = { html: renderPdfHtml(deck, { strict: false }), slug: "*", step: "*" };
|
|
243
|
+
const key = cache.key(page);
|
|
244
|
+
const kept = cache.read(key);
|
|
245
|
+
if (kept) {
|
|
246
|
+
return { key, drawErrors: kept.drawErrors };
|
|
247
|
+
}
|
|
248
|
+
const response = await askPlaywright(
|
|
249
|
+
{ kind: "pages", viewport, actions: [], pages: [page] },
|
|
250
|
+
runner,
|
|
251
|
+
);
|
|
252
|
+
if (response === null) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
const drawErrors = response.drawErrors ?? [];
|
|
256
|
+
cache.write(key, { ...NOTHING_FOUND, drawErrors });
|
|
257
|
+
return { key, drawErrors };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const SNIPPET_CHARS = 24;
|
|
261
|
+
const HORIZONTAL_HINT = "shorten it, or let it wrap with overflow-wrap: anywhere in";
|
|
262
|
+
// Changing a size token would move every slide; the fix belongs to this one.
|
|
263
|
+
const VERTICAL_HINT = "cut it, split it across beats or slides, or give it a smaller size in";
|
|
264
|
+
|
|
265
|
+
function describeTarget(box: string, text: string | undefined): string {
|
|
266
|
+
if (!text) {
|
|
267
|
+
return `${box} `;
|
|
268
|
+
}
|
|
269
|
+
const chars = [...text];
|
|
270
|
+
const snippet =
|
|
271
|
+
chars.length > SNIPPET_CHARS ? `${chars.slice(0, SNIPPET_CHARS).join("")}…` : text;
|
|
272
|
+
return `${box} "${snippet}" `;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function atSteps(steps: string[]): string {
|
|
276
|
+
return steps.length === 1 ? `at step ${steps[0]}` : `at steps ${steps.join(", ")}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function toHex(color: string): string | undefined {
|
|
280
|
+
const rgb = parseCssRgb(color);
|
|
281
|
+
return (
|
|
282
|
+
rgb && `#${rgb.map((channel) => Math.round(channel).toString(16).padStart(2, "0")).join("")}`
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
type StepGroup<T> = { first: T; items: T[]; steps: string[] };
|
|
287
|
+
|
|
288
|
+
/** Groups findings that differ only by step, keeping the first-seen order. */
|
|
289
|
+
function groupBySteps<T extends { step: string }>(items: T[], key: (item: T) => string) {
|
|
290
|
+
const groups = new Map<string, StepGroup<T>>();
|
|
291
|
+
for (const item of items) {
|
|
292
|
+
const id = key(item);
|
|
293
|
+
const group = groups.get(id);
|
|
294
|
+
if (!group) {
|
|
295
|
+
groups.set(id, { first: item, items: [item], steps: [item.step] });
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
group.items.push(item);
|
|
299
|
+
if (!group.steps.includes(item.step)) {
|
|
300
|
+
group.steps.push(item.step);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return [...groups.values()];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Where to fix low contrast. Text the theme alone draws too faint is fixed once in theme.css
|
|
308
|
+
* for every slide that uses the pair; a slide that fixed it in its own CSS would leave the rest.
|
|
309
|
+
*/
|
|
310
|
+
function contrastHint(origin: ContrastOrigin | undefined, threshold: number, slug: string): string {
|
|
311
|
+
const own = `slides/${slug}.css`;
|
|
312
|
+
switch (origin) {
|
|
313
|
+
case "theme":
|
|
314
|
+
return `theme.css alone draws it below ${threshold}:1: fix the pair in theme.css, where one change reaches every slide that uses it`;
|
|
315
|
+
case "slide":
|
|
316
|
+
return `${own} brings it below ${threshold}:1, which theme.css alone does not: raise its contrast in ${own}`;
|
|
317
|
+
case "script": {
|
|
318
|
+
// What draw sets inline wins over every stylesheet, so the fix is in the script.
|
|
319
|
+
const script = `slides/${slug}.ts`;
|
|
320
|
+
return `${script} draws it below ${threshold}:1, and a color draw sets inline wins over any stylesheet: raise the contrast of the color it sets in ${script}`;
|
|
321
|
+
}
|
|
322
|
+
case undefined:
|
|
323
|
+
return `raise the contrast of its color against the background to ${threshold}:1`;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The file to change: the one the origin names, or the slide itself when it is not known. */
|
|
328
|
+
function contrastPath(origin: ContrastOrigin | undefined, slug: string, paths: DeckPaths): string {
|
|
329
|
+
switch (origin) {
|
|
330
|
+
case "theme":
|
|
331
|
+
return paths.theme;
|
|
332
|
+
case "slide":
|
|
333
|
+
return paths.slide(slug, ".css");
|
|
334
|
+
case "script":
|
|
335
|
+
return paths.slide(slug, ".ts");
|
|
336
|
+
case undefined:
|
|
337
|
+
return paths.slide(slug, ".html");
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function visualDiagnostics(response: PagesResponse, deckDir: string): Diagnostic[] {
|
|
342
|
+
const paths = deckPaths(deckDir);
|
|
343
|
+
const failing = response.contrasts.filter((sample) => sample.ratio < contrastThreshold(sample));
|
|
344
|
+
return [
|
|
345
|
+
...groupBySteps(response.overflows, overflowKey).map((group) =>
|
|
346
|
+
overflowDiagnostic(group, paths),
|
|
347
|
+
),
|
|
348
|
+
...groupBySteps(failing, contrastKey).map((group) => contrastDiagnostic(group, paths)),
|
|
349
|
+
...groupBySteps(response.drawErrors ?? [], drawErrorKey).map((group) =>
|
|
350
|
+
drawErrorDiagnostic(group, deckDir),
|
|
351
|
+
),
|
|
352
|
+
...groupBySteps(response.collisions ?? [], collisionKey).map((group) =>
|
|
353
|
+
collisionDiagnostic(group, paths),
|
|
354
|
+
),
|
|
355
|
+
];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
type CollisionSample = NonNullable<PagesResponse["collisions"]>[number];
|
|
359
|
+
|
|
360
|
+
function collisionKey(c: CollisionSample): string {
|
|
361
|
+
return [c.slug, c.box, c.text ?? "", c.other, c.otherText ?? ""].join("\0");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Two texts drawn over each other, which neither reads through. */
|
|
365
|
+
function collisionDiagnostic(
|
|
366
|
+
{ first, steps }: StepGroup<CollisionSample>,
|
|
367
|
+
paths: DeckPaths,
|
|
368
|
+
): Diagnostic {
|
|
369
|
+
const css = `slides/${first.slug}.css`;
|
|
370
|
+
const drawn = [first.box, first.other].some((box) => box.includes("::"));
|
|
371
|
+
return diag("DEK033", {
|
|
372
|
+
message: `${describeTarget(first.box, first.text)}and ${describeTarget(first.other, first.otherText)}are drawn over each other ${atSteps(steps)}`,
|
|
373
|
+
path: paths.slide(first.slug, ".html"),
|
|
374
|
+
slug: first.slug,
|
|
375
|
+
hint: drawn
|
|
376
|
+
? `keep the slide's content clear of what theme.css draws there, a folio or a running head: move it, or give it room in ${css}`
|
|
377
|
+
: `move one of them, or give the layout room for both, in ${css}`,
|
|
378
|
+
data: {
|
|
379
|
+
box: first.box,
|
|
380
|
+
...(first.text ? { text: first.text } : {}),
|
|
381
|
+
other: first.other,
|
|
382
|
+
...(first.otherText ? { otherText: first.otherText } : {}),
|
|
383
|
+
steps,
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
type DrawErrorSample = NonNullable<PagesResponse["drawErrors"]>[number];
|
|
389
|
+
|
|
390
|
+
function drawErrorKey(error: DrawErrorSample): string {
|
|
391
|
+
return [error.slug, error.kind, error.message].join("\0");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* A draw that misbehaves on a still page, which every shot, the PDF, and every measurement is:
|
|
396
|
+
* one that throws leaves the slide as if it never ran, one that reaches outside its slide changes
|
|
397
|
+
* another in the built deck, and one that keeps state draws something a seek cannot replay.
|
|
398
|
+
*/
|
|
399
|
+
function drawErrorDiagnostic(
|
|
400
|
+
{ first, steps }: StepGroup<DrawErrorSample>,
|
|
401
|
+
deckDir: string,
|
|
402
|
+
): Diagnostic {
|
|
403
|
+
const script = `slides/${first.slug}.ts`;
|
|
404
|
+
const where = atSteps(steps);
|
|
405
|
+
const { message, hint } = {
|
|
406
|
+
throw: {
|
|
407
|
+
message: `draw threw ${first.message} at the end of ${where.replace(/^at /, "")}`,
|
|
408
|
+
hint: `make draw in ${script} draw the end of every beat without throwing; until then every still, shot, and PDF shows the slide as if draw never ran`,
|
|
409
|
+
},
|
|
410
|
+
reach: {
|
|
411
|
+
message: `draw ${first.message} at the end of ${where.replace(/^at /, "")}`,
|
|
412
|
+
hint: `find elements from the slide draw is given in ${script}, and change nothing else: the built deck holds every slide`,
|
|
413
|
+
},
|
|
414
|
+
seek: {
|
|
415
|
+
message: `draw ${first.message} ${where}`,
|
|
416
|
+
hint: `draw from t alone in ${script}: work every value out from t and set everything you touch on every call, with nothing kept between calls`,
|
|
417
|
+
},
|
|
418
|
+
}[first.kind ?? "throw"];
|
|
419
|
+
return diag("DEK032", {
|
|
420
|
+
message,
|
|
421
|
+
path: deckPaths(deckDir).slide(first.slug, ".ts"),
|
|
422
|
+
slug: first.slug,
|
|
423
|
+
hint,
|
|
424
|
+
data: { kind: first.kind ?? "throw", message: first.message, steps },
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
type OverflowSample = PagesResponse["overflows"][number];
|
|
429
|
+
type ContrastSample = PagesResponse["contrasts"][number];
|
|
430
|
+
|
|
431
|
+
function crossedEdges(overflow: OverflowSample): Edge[] {
|
|
432
|
+
return EDGES.filter((edge) => overflow.by[edge] !== undefined);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function overflowKey(o: OverflowSample): string {
|
|
436
|
+
return [o.slug, o.box, o.text ?? "", crossedEdges(o).join(), o.origin ?? "", o.clip ?? ""].join(
|
|
437
|
+
"\0",
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** The ratio as reported, to one decimal: samples that round alike are one finding. */
|
|
442
|
+
function shownRatio(sample: ContrastSample): number {
|
|
443
|
+
return Math.round(sample.ratio * 10) / 10;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function contrastKey(c: ContrastSample): string {
|
|
447
|
+
return [c.slug, c.box, c.text ?? "", shownRatio(c), contrastThreshold(c), c.origin ?? ""].join(
|
|
448
|
+
"\0",
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function overflowWhere(amounts: Partial<Record<Edge, number>>): string {
|
|
453
|
+
const edges = Object.entries(amounts);
|
|
454
|
+
if (edges.length === 0) {
|
|
455
|
+
return "overflows the slide";
|
|
456
|
+
}
|
|
457
|
+
return `overflows ${edges.map(([edge, px]) => `the ${edge} edge by ${px}px`).join(" and ")}`;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* What to do about an overflow, where its cause is. A script or a stylesheet that puts the
|
|
462
|
+
* element past the edge is fixed where it does; content too big for the slide is cut, split, or
|
|
463
|
+
* sized down on the slide. An element with no text may be decoration meant to bleed.
|
|
464
|
+
*/
|
|
465
|
+
function overflowHint(first: OverflowSample, edges: Edge[]): string | undefined {
|
|
466
|
+
const css = `slides/${first.slug}.css`;
|
|
467
|
+
if (first.clip !== undefined && first.origin !== "script") {
|
|
468
|
+
return `let ${first.clip} grow to fit it in ${css}, or cut the text: ${first.clip} hides what does not fit`;
|
|
469
|
+
}
|
|
470
|
+
const hints =
|
|
471
|
+
first.origin === "script"
|
|
472
|
+
? [
|
|
473
|
+
`draw in slides/${first.slug}.ts moves or sizes it past the edge: keep what it draws inside the slide at every beat`,
|
|
474
|
+
]
|
|
475
|
+
: first.origin === "slide"
|
|
476
|
+
? [
|
|
477
|
+
`${css} puts it past the edge, which the theme alone does not: fix its position or size in ${css}`,
|
|
478
|
+
]
|
|
479
|
+
: [
|
|
480
|
+
...(edges.some((edge) => edge === "left" || edge === "right")
|
|
481
|
+
? [`${HORIZONTAL_HINT} ${css}`]
|
|
482
|
+
: []),
|
|
483
|
+
...(edges.some((edge) => edge === "top" || edge === "bottom")
|
|
484
|
+
? [`${VERTICAL_HINT} ${css}`]
|
|
485
|
+
: []),
|
|
486
|
+
];
|
|
487
|
+
if (!first.text && hints.length > 0) {
|
|
488
|
+
hints.push('if it is decoration meant to bleed off the slide, mark it aria-hidden="true"');
|
|
489
|
+
}
|
|
490
|
+
return hints.length > 0 ? hints.join("; ") : undefined;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** The file to change for an overflow: the script or stylesheet that caused it, else the slide. */
|
|
494
|
+
function overflowPath(first: OverflowSample, paths: DeckPaths): string {
|
|
495
|
+
const ext = first.origin === "script" ? ".ts" : first.origin === "slide" ? ".css" : ".html";
|
|
496
|
+
return paths.slide(first.slug, ext);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function overflowDiagnostic(
|
|
500
|
+
{ first, items, steps }: StepGroup<OverflowSample>,
|
|
501
|
+
paths: DeckPaths,
|
|
502
|
+
): Diagnostic {
|
|
503
|
+
const edges = crossedEdges(first);
|
|
504
|
+
// Each edge by the most it overflows at any step, so the fix covers the worst beat.
|
|
505
|
+
const amounts = Object.fromEntries(
|
|
506
|
+
edges.map((edge) => [edge, Math.max(...items.map((item) => item.by[edge] ?? 0))]),
|
|
507
|
+
);
|
|
508
|
+
const target =
|
|
509
|
+
edges.length === 0 && !first.text ? "content " : describeTarget(first.box, first.text);
|
|
510
|
+
const hint = overflowHint(first, edges);
|
|
511
|
+
const where =
|
|
512
|
+
first.clip === undefined
|
|
513
|
+
? overflowWhere(amounts)
|
|
514
|
+
: `is cut off by ${first.clip} ${Object.entries(amounts)
|
|
515
|
+
.map(([edge, px]) => `past its ${edge} edge by ${px}px`)
|
|
516
|
+
.join(" and ")}`;
|
|
517
|
+
return diag("DEK030", {
|
|
518
|
+
message: `${target}${where} ${atSteps(steps)}`,
|
|
519
|
+
path: overflowPath(first, paths),
|
|
520
|
+
slug: first.slug,
|
|
521
|
+
...(hint ? { hint } : {}),
|
|
522
|
+
data: {
|
|
523
|
+
box: first.box,
|
|
524
|
+
...(first.text ? { text: first.text } : {}),
|
|
525
|
+
edges: amounts,
|
|
526
|
+
...(first.origin ? { origin: first.origin } : {}),
|
|
527
|
+
...(first.clip ? { clip: first.clip } : {}),
|
|
528
|
+
steps,
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function contrastDiagnostic(
|
|
534
|
+
{ first, steps }: StepGroup<ContrastSample>,
|
|
535
|
+
paths: DeckPaths,
|
|
536
|
+
): Diagnostic {
|
|
537
|
+
const threshold = contrastThreshold(first);
|
|
538
|
+
const ratio = shownRatio(first);
|
|
539
|
+
const size = threshold === 3 ? " (large text)" : "";
|
|
540
|
+
const fg = toHex(first.fg);
|
|
541
|
+
const bg = toHex(first.bg);
|
|
542
|
+
const colors = fg && bg ? ` (${fg} on ${bg})` : "";
|
|
543
|
+
return diag("DEK031", {
|
|
544
|
+
message: `${describeTarget(first.box, first.text)}has contrast ${ratio}${colors}, below ${threshold}:1${size} ${atSteps(steps)}`,
|
|
545
|
+
path: contrastPath(first.origin, first.slug, paths),
|
|
546
|
+
slug: first.slug,
|
|
547
|
+
hint: contrastHint(first.origin, threshold, first.slug),
|
|
548
|
+
data: {
|
|
549
|
+
box: first.box,
|
|
550
|
+
...(first.text ? { text: first.text } : {}),
|
|
551
|
+
ratio,
|
|
552
|
+
threshold,
|
|
553
|
+
...(fg && bg ? { fg, bg } : {}),
|
|
554
|
+
...(first.origin ? { origin: first.origin } : {}),
|
|
555
|
+
steps,
|
|
556
|
+
},
|
|
557
|
+
});
|
|
558
|
+
}
|