@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.
Files changed (187) hide show
  1. package/LICENSE +21 -0
  2. package/README.ja.md +75 -0
  3. package/README.md +75 -0
  4. package/package.json +65 -0
  5. package/src/cli/address.ts +53 -0
  6. package/src/cli/build.ts +85 -0
  7. package/src/cli/check.ts +97 -0
  8. package/src/cli/commands.ts +851 -0
  9. package/src/cli/contract.ts +444 -0
  10. package/src/cli/cues.ts +18 -0
  11. package/src/cli/files.ts +309 -0
  12. package/src/cli/flags.ts +295 -0
  13. package/src/cli/format.ts +156 -0
  14. package/src/cli/goto.ts +96 -0
  15. package/src/cli/init.ts +136 -0
  16. package/src/cli/keep-alive.ts +72 -0
  17. package/src/cli/lint.ts +84 -0
  18. package/src/cli/ls.ts +119 -0
  19. package/src/cli/main.ts +231 -0
  20. package/src/cli/marks.ts +16 -0
  21. package/src/cli/mv.ts +37 -0
  22. package/src/cli/new.ts +64 -0
  23. package/src/cli/pdf.ts +16 -0
  24. package/src/cli/qr.ts +466 -0
  25. package/src/cli/ref.ts +165 -0
  26. package/src/cli/rehearse.ts +42 -0
  27. package/src/cli/result.ts +161 -0
  28. package/src/cli/scope.ts +308 -0
  29. package/src/cli/serve.ts +125 -0
  30. package/src/cli/shot.ts +134 -0
  31. package/src/cli/show.ts +103 -0
  32. package/src/cli/sync.ts +37 -0
  33. package/src/cli/text.ts +479 -0
  34. package/src/cli/theme.ts +56 -0
  35. package/src/cli/tty.ts +81 -0
  36. package/src/cli/usage.ts +198 -0
  37. package/src/cli/video.ts +17 -0
  38. package/src/cli/voice.ts +113 -0
  39. package/src/cli.ts +4 -0
  40. package/src/core/assets.ts +239 -0
  41. package/src/core/build.ts +77 -0
  42. package/src/core/bundled-theme.ts +15 -0
  43. package/src/core/capture-go.ts +72 -0
  44. package/src/core/chrome.ts +128 -0
  45. package/src/core/config-keys.ts +82 -0
  46. package/src/core/config.ts +95 -0
  47. package/src/core/css-scan.ts +158 -0
  48. package/src/core/css-transform.ts +140 -0
  49. package/src/core/css-value.ts +127 -0
  50. package/src/core/css.ts +545 -0
  51. package/src/core/cue.ts +187 -0
  52. package/src/core/deck-paths.ts +34 -0
  53. package/src/core/diagnostic.ts +114 -0
  54. package/src/core/document.ts +218 -0
  55. package/src/core/error.ts +31 -0
  56. package/src/core/escape.ts +16 -0
  57. package/src/core/fill.ts +100 -0
  58. package/src/core/finish-beat.ts +30 -0
  59. package/src/core/github.ts +234 -0
  60. package/src/core/html-scan.ts +253 -0
  61. package/src/core/html.ts +234 -0
  62. package/src/core/in-page-go.ts +128 -0
  63. package/src/core/lines.ts +43 -0
  64. package/src/core/lint/asset-refs.ts +109 -0
  65. package/src/core/lint/context.ts +116 -0
  66. package/src/core/lint/files.ts +117 -0
  67. package/src/core/lint/keys.ts +48 -0
  68. package/src/core/lint/morph.ts +91 -0
  69. package/src/core/lint/script.ts +102 -0
  70. package/src/core/lint/shared-styles.ts +165 -0
  71. package/src/core/lint/slide-html.ts +466 -0
  72. package/src/core/lint/slide-script.ts +179 -0
  73. package/src/core/lint/theme-css.ts +295 -0
  74. package/src/core/lint/timing.ts +66 -0
  75. package/src/core/lint/tokens.ts +520 -0
  76. package/src/core/lint/voice.ts +76 -0
  77. package/src/core/lint.ts +137 -0
  78. package/src/core/live-protocol.ts +130 -0
  79. package/src/core/marks.ts +198 -0
  80. package/src/core/mv.ts +293 -0
  81. package/src/core/ogp.ts +99 -0
  82. package/src/core/optional.ts +24 -0
  83. package/src/core/overflow.ts +220 -0
  84. package/src/core/page-pool.ts +53 -0
  85. package/src/core/page.ts +66 -0
  86. package/src/core/parse.ts +307 -0
  87. package/src/core/path.ts +58 -0
  88. package/src/core/pdf.ts +71 -0
  89. package/src/core/playwright-module.d.ts +43 -0
  90. package/src/core/playwright-motion.ts +42 -0
  91. package/src/core/playwright-sheet.ts +32 -0
  92. package/src/core/playwright-visual.ts +391 -0
  93. package/src/core/playwright-worker.ts +5 -0
  94. package/src/core/playwright.ts +431 -0
  95. package/src/core/position.ts +146 -0
  96. package/src/core/presenter-state.ts +43 -0
  97. package/src/core/presenter.ts +131 -0
  98. package/src/core/prose.ts +16 -0
  99. package/src/core/pseudo-text.ts +67 -0
  100. package/src/core/rail-width.ts +4 -0
  101. package/src/core/ref-name.ts +110 -0
  102. package/src/core/ref.ts +238 -0
  103. package/src/core/resolve.ts +228 -0
  104. package/src/core/rumdl.ts +99 -0
  105. package/src/core/safe-fs.ts +196 -0
  106. package/src/core/sarif.ts +135 -0
  107. package/src/core/schema.ts +89 -0
  108. package/src/core/script-lines.ts +53 -0
  109. package/src/core/sheet.ts +223 -0
  110. package/src/core/shot/morph.ts +80 -0
  111. package/src/core/shot/motion.ts +187 -0
  112. package/src/core/shot/still.ts +216 -0
  113. package/src/core/shot-cache.ts +140 -0
  114. package/src/core/size.ts +11 -0
  115. package/src/core/skeleton-record.ts +51 -0
  116. package/src/core/skeleton.ts +89 -0
  117. package/src/core/slide-draw.ts +165 -0
  118. package/src/core/slide-eval-worker.ts +58 -0
  119. package/src/core/slide-measure.ts +272 -0
  120. package/src/core/slide-script-eval.ts +258 -0
  121. package/src/core/slide-script.ts +252 -0
  122. package/src/core/spawn.ts +128 -0
  123. package/src/core/step.ts +94 -0
  124. package/src/core/still-page.ts +70 -0
  125. package/src/core/suggest.ts +33 -0
  126. package/src/core/sync.ts +316 -0
  127. package/src/core/text-contrast.ts +466 -0
  128. package/src/core/theme-excerpt.ts +187 -0
  129. package/src/core/theme-facts.ts +51 -0
  130. package/src/core/timeline.ts +216 -0
  131. package/src/core/timing.ts +88 -0
  132. package/src/core/toml-keys.ts +349 -0
  133. package/src/core/url-attributes.ts +103 -0
  134. package/src/core/video-document.ts +15 -0
  135. package/src/core/visual-cache.ts +100 -0
  136. package/src/core/visual.ts +558 -0
  137. package/src/core/voice.ts +321 -0
  138. package/src/core/zod.ts +79 -0
  139. package/src/runtime/browser.ts +203 -0
  140. package/src/runtime/clone.ts +52 -0
  141. package/src/runtime/deck-control.ts +43 -0
  142. package/src/runtime/fit.ts +32 -0
  143. package/src/runtime/go.ts +56 -0
  144. package/src/runtime/input.ts +128 -0
  145. package/src/runtime/laser-view.ts +110 -0
  146. package/src/runtime/laser.ts +145 -0
  147. package/src/runtime/live-host.ts +33 -0
  148. package/src/runtime/live-reload.ts +29 -0
  149. package/src/runtime/live.ts +88 -0
  150. package/src/runtime/marks-view.ts +74 -0
  151. package/src/runtime/motion.ts +113 -0
  152. package/src/runtime/navigator.ts +189 -0
  153. package/src/runtime/player.ts +28 -0
  154. package/src/runtime/presenter-view.ts +289 -0
  155. package/src/runtime/presenter.ts +52 -0
  156. package/src/runtime/rail-view.ts +229 -0
  157. package/src/runtime/rail.ts +52 -0
  158. package/src/runtime/rehearse.ts +264 -0
  159. package/src/runtime/routes.ts +142 -0
  160. package/src/runtime/settle.ts +45 -0
  161. package/src/runtime/slide.d.ts +28 -0
  162. package/src/runtime/socket.ts +143 -0
  163. package/src/runtime/stage.ts +259 -0
  164. package/src/runtime/step.ts +139 -0
  165. package/src/runtime/window.ts +38 -0
  166. package/src/server/auth.ts +210 -0
  167. package/src/server/deck-snapshot.ts +146 -0
  168. package/src/server/dev.ts +251 -0
  169. package/src/server/hub.ts +104 -0
  170. package/src/server/lan.ts +51 -0
  171. package/src/server/lock.ts +97 -0
  172. package/src/server/pages.ts +71 -0
  173. package/src/server/pairing.ts +40 -0
  174. package/src/server/project-watch.ts +142 -0
  175. package/src/server/rooms.ts +133 -0
  176. package/src/server/routes.ts +286 -0
  177. package/src/server/serial.ts +25 -0
  178. package/src/server/watch.ts +308 -0
  179. package/src/theme/default.css +175 -0
  180. package/src/video/bake.ts +152 -0
  181. package/src/video/mux.ts +103 -0
  182. package/src/video/recorder.ts +115 -0
  183. package/src/video/sidecar.ts +60 -0
  184. package/src/video/worker.ts +51 -0
  185. package/src/voice/engine.ts +177 -0
  186. package/src/voice/synth.ts +127 -0
  187. package/src/voice/wav.ts +115 -0
@@ -0,0 +1,295 @@
1
+ import { bundledTokenValue } from "../bundled-theme.ts";
2
+ import {
3
+ type CssToken,
4
+ cssAtRules,
5
+ cssStyleSelectors,
6
+ cssUrls,
7
+ isScopedThemeSelector,
8
+ outermostSelectors,
9
+ publishedTokens,
10
+ type Stylesheet,
11
+ splitSelectorList,
12
+ } from "../css.ts";
13
+ import { deckPaths } from "../deck-paths.ts";
14
+ import { type Diagnostic, diag } from "../diagnostic.ts";
15
+ import { assetRefDiagnostics } from "./asset-refs.ts";
16
+ import type { LintContext } from "./context.ts";
17
+ import { isRawThemeValue, isRawTokenValue, REQUIRED_TOKENS, tokenSuggestion } from "./tokens.ts";
18
+
19
+ /**
20
+ * DEK018: no theme.css beside script.md. The deck would show unstyled, and
21
+ * every theme rule (DEK010, DEK012 to DEK015) is silent until one exists.
22
+ */
23
+ function missingThemeDiagnostic(ctx: LintContext): Diagnostic {
24
+ return diag("DEK018", {
25
+ message: "theme.css not found",
26
+ path: deckPaths(ctx.deck.dir).theme,
27
+ hint: "copy theme.css from the project root or another deck into the deck directory",
28
+ });
29
+ }
30
+
31
+ /** DEK012, DEK013, DEK015, DEK014, and what its `url()`s load (DEK020 to DEK023) for theme.css. */
32
+ function lintTheme(
33
+ { path, sheet, classes, tokens }: NonNullable<LintContext["theme"]>,
34
+ { maxClasses, deckDir }: { maxClasses: number; deckDir: string },
35
+ ): Diagnostic[] {
36
+ const diagnostics: Diagnostic[] = [];
37
+ // A rule inside `@media` reaches the page as surely as one outside it.
38
+ for (const { selector, line } of outermostSelectors(sheet)) {
39
+ if (isScopedThemeSelector(selector)) {
40
+ continue;
41
+ }
42
+ diagnostics.push(
43
+ diag("DEK012", {
44
+ message: `theme selector "${selector}" must be scoped under .slide`,
45
+ path,
46
+ line,
47
+ hint: themeScopeHint(selector),
48
+ data: { selector },
49
+ }),
50
+ );
51
+ }
52
+
53
+ const classCount = classes.size;
54
+ if (classCount > maxClasses) {
55
+ diagnostics.push(
56
+ diag("DEK013", {
57
+ message: `theme.css has ${classCount} classes; limit is ${maxClasses}`,
58
+ path,
59
+ data: { classes: classCount, limit: maxClasses },
60
+ }),
61
+ );
62
+ }
63
+
64
+ for (const name of REQUIRED_TOKENS) {
65
+ if (tokens.some((token) => token.name === name)) {
66
+ continue;
67
+ }
68
+ diagnostics.push(
69
+ diag("DEK015", {
70
+ message: `theme.css is missing required token "${name}"`,
71
+ path,
72
+ hint: missingTokenHint(name),
73
+ data: { token: name },
74
+ }),
75
+ );
76
+ }
77
+
78
+ // The theme is in the deck like its slides: what it loads stays inside it, or the build links out.
79
+ for (const url of cssUrls(sheet)) {
80
+ diagnostics.push(
81
+ ...assetRefDiagnostics(
82
+ { value: url.value, use: "resource" },
83
+ { path, line: url.line, deckDir },
84
+ ),
85
+ );
86
+ }
87
+
88
+ // Only a published token applies wherever the raw value sits.
89
+ diagnostics.push(...rawValueDiagnostics(sheet, path, tokens));
90
+ return diagnostics;
91
+ }
92
+
93
+ /** A slide's own stylesheet: tokens only, and nothing that reaches past the slide. */
94
+ export function lintSlideStyle(
95
+ slug: string,
96
+ path: string,
97
+ sheet: Stylesheet,
98
+ tokens: CssToken[],
99
+ deckDir: string,
100
+ ): Diagnostic[] {
101
+ const diagnostics: Diagnostic[] = [];
102
+ const reach = (
103
+ message: string,
104
+ line: number,
105
+ hint: string,
106
+ data: Record<string, string>,
107
+ ): void => {
108
+ diagnostics.push(diag("DEK012", { message, path, line, slug, hint, data }));
109
+ };
110
+ for (const { selector, line } of cssStyleSelectors(sheet)) {
111
+ const parts = splitSelectorList(selector).map((part) => part.trim());
112
+ if (parts.some((part) => part.startsWith("::view-transition"))) {
113
+ reach(
114
+ `"${selector}" applies to every slide`,
115
+ line,
116
+ "move it to theme.css, where view transitions belong",
117
+ { selector },
118
+ );
119
+ } else if (parts.some((part) => PAGE_SELECTOR_RE.test(part))) {
120
+ reach(
121
+ `"${selector}" never matches inside a slide`,
122
+ line,
123
+ "move it to theme.css, or start it at .slide",
124
+ { selector },
125
+ );
126
+ } else if (parts.some((part) => SIBLING_OF_SLIDE_RE.test(part))) {
127
+ reach(
128
+ `"${selector}" reaches the slides after this one`,
129
+ line,
130
+ "start the selector at .slide and stay inside it; a rule for more than one slide belongs in theme.css",
131
+ { selector },
132
+ );
133
+ }
134
+ }
135
+ for (const { name, line } of cssAtRules(sheet)) {
136
+ if (DECK_AT_RULES.has(name.toLowerCase())) {
137
+ reach(`@${name} applies to the whole deck`, line, "move it to theme.css", { atRule: name });
138
+ }
139
+ }
140
+ // In the order the file writes them, selectors and at-rules alike.
141
+ diagnostics.sort((a, b) => (a.line ?? 0) - (b.line ?? 0));
142
+ for (const url of cssUrls(sheet)) {
143
+ diagnostics.push(
144
+ ...assetRefDiagnostics(
145
+ { value: url.value, use: "resource" },
146
+ { path, line: url.line, slug, deckDir },
147
+ ),
148
+ );
149
+ }
150
+ diagnostics.push(
151
+ ...rawValueDiagnostics(sheet, path, [...tokens, ...publishedTokens(sheet)], slug),
152
+ );
153
+ return diagnostics;
154
+ }
155
+
156
+ /**
157
+ * Whether a rule is where tokens are set: on the slide itself, as `.slide` or a compound of it such
158
+ * as `.slide[data-layout="split"]`, or on a view transition, which the theme draws. A custom
159
+ * property set anywhere else is a raw value handed to whatever reads it.
160
+ */
161
+ function setsTokens(selector: string): boolean {
162
+ return splitSelectorList(selector).every((part) => {
163
+ const item = part.trim();
164
+ return item.startsWith("::view-transition") || /^\.slide(?![-\w])[^\s>+~]*$/.test(item);
165
+ });
166
+ }
167
+
168
+ /** DEK014 for theme.css and slide stylesheets alike: design values come from tokens. */
169
+ function rawValueDiagnostics(
170
+ sheet: Stylesheet,
171
+ path: string,
172
+ tokens: CssToken[],
173
+ slug?: string,
174
+ ): Diagnostic[] {
175
+ return sheet.decls
176
+ .filter((decl) =>
177
+ decl.property.startsWith("--")
178
+ ? !setsTokens(decl.selector) && isRawTokenValue(decl.value)
179
+ : isRawThemeValue(decl.property, decl.value),
180
+ )
181
+ .map((decl) => {
182
+ // A part that changes a token the theme or the sheet's own .slide sets keeps its change
183
+ // through a name of its own; moving the value to .slide would change the whole slide.
184
+ const changes =
185
+ decl.property.startsWith("--") &&
186
+ (tokens.some((token) => token.name === decl.property) ||
187
+ sheet.decls.some(
188
+ (other) => other.property === decl.property && setsTokens(other.selector),
189
+ ));
190
+ const token = changes ? `${decl.property}-${partName(decl.selector)}` : undefined;
191
+ return diag("DEK014", {
192
+ message: `raw value in "${decl.property}: ${decl.value}"; use a theme token`,
193
+ path,
194
+ line: decl.line,
195
+ hint: token
196
+ ? partTokenHint(decl, token, slug)
197
+ : decl.property.startsWith("--")
198
+ ? tokenPlaceHint(decl.property, slug)
199
+ : rawValueHint(tokenSuggestion(decl.property, decl.value, tokens), decl.value, slug),
200
+ data: { property: decl.property, value: decl.value, ...(token ? { token } : {}) },
201
+ ...(slug === undefined ? {} : { slug }),
202
+ });
203
+ });
204
+ }
205
+
206
+ /**
207
+ * What a rule is called by: the last class or id of its first selector's last compound, else its
208
+ * tag, so `.slide .card > .title` is "title" and `.slide li` is "li".
209
+ */
210
+ function partName(selector: string): string {
211
+ const last =
212
+ (selector.split(",")[0] ?? "")
213
+ .trim()
214
+ .split(/\s*[\s>+~]\s*/)
215
+ .at(-1) ?? "";
216
+ const named = [...last.matchAll(/[.#]([\w-]+)/g)].at(-1)?.[1];
217
+ return named ?? last.match(/^[a-z][\w-]*/i)?.[0] ?? "part";
218
+ }
219
+
220
+ /** A token a part changes: the value named on .slide, and the part pointing its token at it. */
221
+ function partTokenHint(
222
+ decl: { property: string; value: string; selector: string },
223
+ token: string,
224
+ slug?: string,
225
+ ): string {
226
+ const where = slug === undefined ? "the .slide rule in theme.css" : "this file's .slide rule";
227
+ return `name the value on ${where}, as ${token}: ${decl.value}, and set ${decl.property}: var(${token}) here, so ${decl.selector} still changes ${decl.property} for what it holds`;
228
+ }
229
+
230
+ /**
231
+ * DEK014's hint. theme.css gains a token; a slide stylesheet may name the value on its own
232
+ * `.slide` instead, since a value one slide uses need not join the theme.
233
+ */
234
+ /** The line to add, with the value dek's own theme gives the token. */
235
+ function missingTokenHint(name: string): string {
236
+ const value = bundledTokenValue(name);
237
+ return value === undefined
238
+ ? `add ${name} to the .slide rule in theme.css`
239
+ : `add ${name}: ${value}; to the .slide rule in theme.css, as dek's own theme sets it`;
240
+ }
241
+
242
+ /** A raw custom property set off the slide belongs on it, where a token is set and published. */
243
+ function tokenPlaceHint(property: string, slug?: string): string {
244
+ return slug === undefined
245
+ ? `set ${property} on the .slide rule, where the theme's tokens go, and use var(${property}) below it`
246
+ : `set ${property} on this file's .slide rule, where the slide's own tokens go, and use var(${property}) below it`;
247
+ }
248
+
249
+ function rawValueHint(suggestion: string | undefined, value: string, slug?: string): string {
250
+ if (slug === undefined) {
251
+ return suggestion ?? "add a token for it to .slide and use var() here";
252
+ }
253
+ const local = `name it in this file: .slide { --<name>: ${value}; }`;
254
+ return suggestion ? `${suggestion}; or ${local}` : `${local}, then use var(--<name>)`;
255
+ }
256
+
257
+ /** Selectors that name the page, which a scoped slide rule can never reach. */
258
+ const PAGE_SELECTOR_RE = /^(:root|html|body)(?=$|[\s[.:#>+~])/;
259
+
260
+ /**
261
+ * A slide rule that steps from the slide to a sibling: scoped, `.slide ~ .slide` still matches
262
+ * every slide after this one.
263
+ */
264
+ const SIBLING_OF_SLIDE_RE = /^\.slide(?![-\w])[^\s>+~]*\s*[+~]/;
265
+
266
+ /**
267
+ * At-rules that register something for the whole document, which no slide scope can confine: a
268
+ * font, an import, a custom property's type, a counter style, the printed page.
269
+ */
270
+ const DECK_AT_RULES = new Set([
271
+ "font-face",
272
+ "import",
273
+ "property",
274
+ "counter-style",
275
+ "page",
276
+ "font-palette-values",
277
+ "font-feature-values",
278
+ ]);
279
+
280
+ /** How to write an unscoped theme selector so it styles slides. */
281
+ function themeScopeHint(selector: string): string {
282
+ const parts = splitSelectorList(selector).map((part) => part.trim());
283
+ if (parts.some((part) => PAGE_SELECTOR_RE.test(part))) {
284
+ return "theme.css styles slides, not the page: put it on .slide, whose content inherits it";
285
+ }
286
+ const scoped = parts.map((part) => (isScopedThemeSelector(part) ? part : `.slide ${part}`));
287
+ return `write it as ${scoped.join(", ")}`;
288
+ }
289
+
290
+ /** The theme's own findings, or DEK018 when there is no theme. */
291
+ export function themeDiagnostics(ctx: LintContext): Diagnostic[] {
292
+ return ctx.theme
293
+ ? lintTheme(ctx.theme, { maxClasses: ctx.config.maxClasses, deckDir: ctx.deck.dir })
294
+ : [missingThemeDiagnostic(ctx)];
295
+ }
@@ -0,0 +1,66 @@
1
+ import type { DekConfig } from "../config.ts";
2
+ import { type Diagnostic, diag } from "../diagnostic.ts";
3
+ import type { ProjectDeck } from "../resolve.ts";
4
+ import type { Timeline } from "../timeline.ts";
5
+ import { formatClock, parseDurationSeconds, sectionTiming } from "../timing.ts";
6
+ import { tryLoadCachedTimeline } from "../voice.ts";
7
+ import type { LintContext } from "./context.ts";
8
+
9
+ const DURATION_DRIFT_RATIO = 0.2;
10
+ /** The reading-time estimate leaves out pauses and demos, so it gets more room than a Timeline. */
11
+ const ESTIMATE_DRIFT_RATIO = 0.35;
12
+
13
+ /** DEK041 against the voice timeline when there is one, else against the reading-time estimate. */
14
+ export function timingDiagnostics(ctx: LintContext): Diagnostic[] {
15
+ const timeline = tryLoadCachedTimeline(ctx.deck.dir);
16
+ return timeline ? lintDuration(ctx.deck, timeline) : lintEstimate(ctx.deck, ctx.config);
17
+ }
18
+
19
+ function lintDuration(deck: ProjectDeck, timeline: Timeline): Diagnostic[] {
20
+ const budget = parseDurationSeconds(deck.deck.duration);
21
+ if (budget === undefined || budget <= 0) {
22
+ return [];
23
+ }
24
+ const actual = timeline.durationMs / 1000;
25
+ const drift = Math.abs(actual - budget) / budget;
26
+ if (drift < DURATION_DRIFT_RATIO) {
27
+ return [];
28
+ }
29
+ return [
30
+ diag("DEK041", {
31
+ message: `video duration ${Math.round(actual)}s differs from budget ${deck.deck.duration} by more than ${Math.round(DURATION_DRIFT_RATIO * 100)}%`,
32
+ path: deck.scriptPath,
33
+ hint: durationHint(actual < budget),
34
+ data: { actualSeconds: Math.round(actual), budgetSeconds: budget, source: "timeline" },
35
+ }),
36
+ ];
37
+ }
38
+
39
+ /** DEK041 before any voice: the reading-time estimate `dekc ls` shows, against the budget. */
40
+ function lintEstimate(deck: ProjectDeck, config: DekConfig): Diagnostic[] {
41
+ const budget = parseDurationSeconds(deck.deck.duration);
42
+ if (budget === undefined || budget <= 0) {
43
+ return [];
44
+ }
45
+ const estimate = sectionTiming(deck.deck.sections, deck.deck.duration, config).reduce(
46
+ (sum, row) => sum + row.estimateSeconds,
47
+ 0,
48
+ );
49
+ if (Math.abs(estimate - budget) / budget < ESTIMATE_DRIFT_RATIO) {
50
+ return [];
51
+ }
52
+ return [
53
+ diag("DEK041", {
54
+ message: `the script reads in about ${formatClock(estimate)}, budget ${deck.deck.duration}; more than ${Math.round(ESTIMATE_DRIFT_RATIO * 100)}% apart`,
55
+ path: deck.scriptPath,
56
+ hint: durationHint(estimate < budget),
57
+ data: { actualSeconds: estimate, budgetSeconds: budget, source: "estimate" },
58
+ }),
59
+ ];
60
+ }
61
+
62
+ function durationHint(short: boolean): string {
63
+ return short
64
+ ? "write more for the slot, or shorten duration in the frontmatter"
65
+ : "cut the script, or lengthen duration in the frontmatter";
66
+ }