@litfamily/lithermes 1.0.27 → 1.0.28

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 (92) hide show
  1. package/README.md +10 -9
  2. package/README_Ko-KR.md +10 -9
  3. package/assets/lithermes-plugin/README.md +28 -9
  4. package/assets/lithermes-plugin/__init__.py +7 -3
  5. package/assets/lithermes-plugin/auto_update.py +17 -3
  6. package/assets/lithermes-plugin/core.py +41 -2
  7. package/assets/lithermes-plugin/core_contexts.py +27 -9
  8. package/assets/lithermes-plugin/core_contract.py +7 -2
  9. package/assets/lithermes-plugin/core_review.py +144 -3
  10. package/assets/lithermes-plugin/core_routing.py +10 -0
  11. package/assets/lithermes-plugin/core_workflow_commands.py +3 -2
  12. package/assets/lithermes-plugin/diagnostics.py +5 -3
  13. package/assets/lithermes-plugin/lit_reentry.py +233 -0
  14. package/assets/lithermes-plugin/payload-version.json +180 -48
  15. package/assets/lithermes-plugin/plugin.yaml +1 -1
  16. package/assets/lithermes-plugin/rules/__init__.py +2 -0
  17. package/assets/lithermes-plugin/rules/engine.py +26 -11
  18. package/assets/lithermes-plugin/skills/deep-interview/SKILL.md +2 -1
  19. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +3 -3
  20. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/hostexec.mjs +118 -0
  21. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/probe.mjs +6 -5
  22. package/assets/lithermes-plugin/skills/lit-brainstorm/SKILL.md +408 -0
  23. package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/doctor.mjs +3 -3
  24. package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/export.mjs +5 -5
  25. package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/hostexec.mjs +118 -0
  26. package/assets/lithermes-plugin/skills/lit-docx/scripts/convert_md_to_pdf.py +38 -4
  27. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/acs/template.tex +3 -0
  28. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/elsevier/template.tex +3 -0
  29. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/ieee/template.tex +3 -0
  30. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/korean-generic/template.tex +3 -0
  31. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/nature/template.tex +3 -0
  32. package/assets/lithermes-plugin/skills/lit-pptx/bin/hostexec.mjs +118 -0
  33. package/assets/lithermes-plugin/skills/lit-pptx/bin/office.mjs +16 -9
  34. package/assets/lithermes-plugin/skills/lit-typographic-motion/SKILL.md +13 -12
  35. package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/motion.mjs +19 -6
  36. package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/runtime.mjs +25 -19
  37. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/THIRD_PARTY_NOTICES +2 -0
  38. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/browser.mjs +11 -4
  39. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/constants.mjs +29 -0
  40. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-assets.mjs +87 -0
  41. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-capture.mjs +189 -0
  42. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-gate.mjs +263 -0
  43. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-image.mjs +103 -0
  44. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-init.js +36 -0
  45. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-png.mjs +77 -0
  46. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-sheet.mjs +70 -0
  47. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-treatment.mjs +104 -0
  48. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-webp.mjs +112 -0
  49. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-worker.mjs +19 -0
  50. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover.mjs +338 -0
  51. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/encode.mjs +1 -1
  52. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/hostexec.mjs +118 -0
  53. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/look.mjs +103 -7
  54. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/loop-kit.js +80 -0
  55. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/sound.mjs +1 -1
  56. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/stage.mjs +8 -2
  57. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/treatment.mjs +1 -0
  58. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/card-scenes/expected.json +49 -0
  59. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/card-scenes/index.html +37 -0
  60. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/reset-sweep/expected.json +49 -0
  61. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/reset-sweep/index.html +27 -0
  62. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/still-glow/expected.json +51 -0
  63. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/still-glow/index.html +29 -0
  64. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/title-slam/expected.json +51 -0
  65. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/title-slam/index.html +37 -0
  66. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +68 -0
  67. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/orbit-loop/index.html +112 -0
  68. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/painted-light/expected.json +96 -0
  69. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/painted-light/index.html +208 -0
  70. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/weight-tide/expected.json +68 -0
  71. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/weight-tide/index.html +81 -0
  72. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/cover.md +458 -0
  73. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/craft-loop.md +4 -1
  74. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/runtime.md +13 -5
  75. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/stage.md +4 -2
  76. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/treatment.md +6 -0
  77. package/assets/lithermes-plugin/skills/lit-typographic-motion/runtime/fonts.json +11 -0
  78. package/assets/lithermes-plugin/skills/lit-typographic-motion/runtime/requirements.lock +72 -0
  79. package/assets/lithermes-plugin/skills/litgoal/SKILL.md +19 -1
  80. package/assets/lithermes-plugin/skills/lsp-setup/scripts/verify-lsp.ts +16 -3
  81. package/assets/lithermes-plugin/skills/review-work/SKILL.md +38 -16
  82. package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +3 -3
  83. package/package.json +2 -1
  84. package/readme-assets/badge-version.svg +1 -1
  85. package/readme-assets/cover-motion-still.webp +0 -0
  86. package/readme-assets/cover-motion.webp +0 -0
  87. package/src/cli.js +2 -2
  88. package/src/lib/check.js +9 -6
  89. package/src/lib/hermesDiscovery.js +2 -2
  90. package/src/lib/hostExec.js +81 -0
  91. package/src/lib/skillPayload.js +1 -1
  92. package/src/lib/updateNotifier.js +14 -4
@@ -0,0 +1,118 @@
1
+ // Child processes that survive Windows. Since the CVE-2024-27980 fix, Node refuses to spawn a
2
+ // .cmd or .bat file without a shell (spawnSync npm.cmd EINVAL), and PATH lookups there depend on
3
+ // PATHEXT. Platform, env and fs probes are arguments so tests can simulate win32 without
4
+ // touching globals. Every skill that spawns tools carries a byte-identical copy of this file.
5
+ import { spawnSync as nodeSpawnSync } from 'node:child_process';
6
+ import { existsSync } from 'node:fs';
7
+ import { posix, win32 } from 'node:path';
8
+
9
+ const SHELL_SCRIPT = /\.(?:cmd|bat)$/i;
10
+ const SAFE_ARG = /^[\w@+=:,./\\-]+$/;
11
+
12
+ export function whichSync(name, { platform = process.platform, env = process.env, exists = existsSync } = {}) {
13
+ const win = platform === 'win32';
14
+ const p = win ? win32 : posix;
15
+ const exts = win ? String(env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').map((e) => e.trim().toLowerCase()).filter(Boolean) : [''];
16
+ const hasExt = win && exts.some((e) => name.toLowerCase().endsWith(e.toLowerCase()));
17
+ // A bare name never matches on Windows: npm leaves an extensionless sh script beside npm.cmd.
18
+ const names = win ? (hasExt ? [name] : exts.map((e) => name + e)) : [name];
19
+ const dirs = p.isAbsolute(name) || /[\\/]/.test(name) ? [''] : String(env.PATH ?? env.Path ?? '').split(win ? ';' : ':').map((d) => d.trim().replace(/^"|"$/g, '')).filter(Boolean);
20
+ for (const dir of dirs) {
21
+ for (const candidate of names) {
22
+ const full = dir ? p.join(dir, candidate) : candidate;
23
+ if (exists(full)) return full;
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+
29
+ // Quoted for cmd.exe, which still expands %VAR% inside quotes, so those are refused, never escaped.
30
+ export function quoteCmdArg(arg) {
31
+ const text = String(arg);
32
+ if (/["%\r\n\0]/.test(text)) throw new Error(`refusing an argument with a quote, percent sign or line break on a Windows shell: ${JSON.stringify(text.slice(0, 40))}`);
33
+ if (text !== '' && SAFE_ARG.test(text)) return text;
34
+ return `"${text.replace(/\\+$/, (run) => run + run)}"`;
35
+ }
36
+
37
+ // Where an npm or npx shim's JavaScript entry lives, so Windows can run it with node directly.
38
+ function npmCli(name, resolved, { env, execPath, exists }) {
39
+ const cli = `${name}-cli.js`;
40
+ const dirs = [];
41
+ if (typeof env.npm_execpath === 'string' && /\.js$/i.test(env.npm_execpath)) dirs.push(win32.dirname(env.npm_execpath));
42
+ dirs.push(win32.join(win32.dirname(resolved), 'node_modules', 'npm', 'bin'));
43
+ dirs.push(win32.join(win32.dirname(execPath), 'node_modules', 'npm', 'bin'));
44
+ return dirs.map((dir) => win32.join(dir, cli)).find((file) => exists(file)) || null;
45
+ }
46
+
47
+ export function prepareSpawn(command, args = [], options = {}, ctx = {}) {
48
+ const { platform = process.platform, execPath = process.execPath, exists = existsSync } = ctx;
49
+ if (platform !== 'win32') return { command, args, options };
50
+ const env = ctx.env || options.env || process.env;
51
+ const resolved = whichSync(command, { platform, env, exists });
52
+ if (!resolved || !SHELL_SCRIPT.test(resolved)) return { command: resolved || command, args, options };
53
+ const base = win32.basename(resolved).replace(SHELL_SCRIPT, '').toLowerCase();
54
+ if (base === 'npm' || base === 'npx') {
55
+ const cli = npmCli(base, resolved, { env, execPath, exists });
56
+ if (cli) return { command: execPath, args: [cli, ...args], options };
57
+ }
58
+ const line = [resolved, ...args].map(quoteCmdArg).join(' ');
59
+ return { command: line, args: [], options: { ...options, shell: true } };
60
+ }
61
+
62
+ export function spawnTool(command, args, options, ctx = {}) {
63
+ const run = ctx.spawnSync || nodeSpawnSync;
64
+ const prepared = prepareSpawn(command, args, options, ctx);
65
+ return run(prepared.command, prepared.args, prepared.options);
66
+ }
67
+
68
+ // One line naming the real cause: the spawn error, the exit status and the last stderr line.
69
+ export function describeSpawnFailure(label, result, { limit = 200 } = {}) {
70
+ if (!result) return `${label} returned no result`;
71
+ const tail = String(result.stderr || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean).at(-1) || '';
72
+ const clean = tail.replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, limit);
73
+ if (result.error) return `${label} could not run: ${result.error.code || 'error'} ${result.error.message}${clean ? ` (${clean})` : ''}`;
74
+ if (result.signal) return `${label} stopped by ${result.signal}${clean ? ` (${clean})` : ''}`;
75
+ return `${label} exited ${result.status}${clean ? `: ${clean}` : ''}`;
76
+ }
77
+
78
+ // Interpreter inside a venv: Scripts\python.exe on Windows, bin/python elsewhere.
79
+ export function venvPython(dir, platform = process.platform) {
80
+ return platform === 'win32' ? win32.join(dir, 'Scripts', 'python.exe') : posix.join(dir, 'bin', 'python');
81
+ }
82
+
83
+ // The newest CPython the Office wheels ship for (pip runs --only-binary); 3.15 has no PyYAML wheel,
84
+ // so a python3 that is 3.15 is passed over. A caller whose lock is narrower passes its own max.
85
+ export const PYTHON_MAX = [3, 14];
86
+
87
+ export function probePython(command, env = process.env, spawn = spawnTool) {
88
+ const result = spawn(command, ['-c', 'import sys; print("%d.%d.%d" % sys.version_info[:3])'], { encoding: 'utf8', env, timeout: 10000 });
89
+ if (!result || result.error || result.status !== 0) return null;
90
+ const version = String(result.stdout || '').trim();
91
+ const [major, minor] = version.split('.').map(Number);
92
+ return Number.isInteger(major) && Number.isInteger(minor) ? { version, major, minor } : null;
93
+ }
94
+
95
+ // The host Python a pinned venv is built from. An override variable, when set, is the only
96
+ // candidate and is not range-checked. Otherwise the newest python3.X from `max` down to `min`,
97
+ // then python3 and python, each kept only when it runs and falls inside min..max.
98
+ // Returns { python, version } or { python: null, detail } naming what was tried.
99
+ export function pickPython({ env = process.env, override, min = [3, 10], max = PYTHON_MAX, probe = probePython } = {}) {
100
+ if (override && env[override]) {
101
+ const found = probe(env[override], env);
102
+ return found ? { python: env[override], version: found.version } : { python: null, detail: `${override} is set to ${env[override]}, which does not run` };
103
+ }
104
+ const candidates = [];
105
+ for (let minor = max[1]; minor >= min[1]; minor -= 1) candidates.push(`python3.${minor}`);
106
+ candidates.push('python3', 'python');
107
+ const tried = [];
108
+ for (const command of candidates) {
109
+ const found = probe(command, env);
110
+ if (!found) continue;
111
+ const inRange = (found.major > min[0] || (found.major === min[0] && found.minor >= min[1]))
112
+ && (found.major < max[0] || (found.major === max[0] && found.minor <= max[1]));
113
+ if (inRange) return { python: command, version: found.version };
114
+ tried.push(`${command} ${found.version}`);
115
+ }
116
+ const range = `${min.join('.')} to ${max.join('.')}`;
117
+ return { python: null, detail: `no supported Python (${range}) on PATH${tried.length ? `; found only ${tried.join(', ')}` : ''}` };
118
+ }
@@ -9,6 +9,9 @@ import { join, resolve } from 'node:path';
9
9
  import { checkCompletion } from './completion.mjs';
10
10
  import { writeJson } from './director.mjs';
11
11
  import { validateTreatment } from './treatment.mjs';
12
+ import { COVER } from './constants.mjs';
13
+ import { generatedLabels } from './cover-assets.mjs';
14
+ import { isCoverTreatment, validateCoverTreatment } from './cover-treatment.mjs';
12
15
 
13
16
  const sha = (data) => createHash('sha256').update(data).digest('hex');
14
17
  const usage = (message) => Object.assign(new Error(message), { exitCode: 2 });
@@ -26,6 +29,21 @@ export const QUESTIONS = Object.freeze({
26
29
  });
27
30
  const NEED_YES = new Set([1, 2, 3, 5, 6]);
28
31
  const NEED_NO = new Set([4, 8, 9]);
32
+ // Cover mode asks its own eight (references/cover.md): motion in every half second, the seam,
33
+ // motion that survives the encoder, the brand word, the three layers, slide-likeness, damage,
34
+ // and what a skilled motion designer would change.
35
+ export const COVER_QUESTIONS = Object.freeze({
36
+ 1: 'Is something moving in every half-second window (the plot never lies flat on the red dashes)?',
37
+ 2: 'Does the seam disappear in the decoded file (seam.png, the right end of the plot)?',
38
+ 3: 'Does it still move after encoding, with no region that freezes and then pops?',
39
+ 4: 'Is the brand word present, legible and in place in every frame, never faded or swapped?',
40
+ 5: 'Are all three speed layers visible: a slow perpetual layer, a mid swell and beat events?',
41
+ 6: 'Does anything read as a slide, a card swap, a title fade or an end card, or does light wash in place instead of travelling as an edge?',
42
+ 7: 'Is any glyph, glint or glow damaged: a colour block, a dark disc, a visible box?',
43
+ 8: 'Name one thing a skilled motion designer would add or remove, or none.',
44
+ });
45
+ const COVER_NEED_YES = new Set([1, 2, 3, 4, 5]);
46
+ const COVER_NEED_NO = new Set([6, 7]);
29
47
  const BARE = /^(?:yes|no|y|n|ok|okay|none|n\/a|네|예|아니오|아니요|아니|응|없음|있음)[.!]?$/iu;
30
48
 
31
49
  export function verdictOf(value) {
@@ -78,6 +96,8 @@ export function recordLook(out, round, answers) {
78
96
  }
79
97
 
80
98
  const files = new Map(set.files.map((f) => [f.file, f]));
99
+ const cover = set.mode === 'cover';
100
+ if (!cover && answers.defects !== undefined) throw usage('defects are a cover-mode record; a film round names its weakest beat in "change"');
81
101
  const viewed = [...new Set(answers.viewed || [])];
82
102
  if (!viewed.length) throw usage('list every stills file you viewed in "viewed"');
83
103
  const answerList = Array.isArray(answers.answers) ? answers.answers : [];
@@ -88,16 +108,23 @@ export function recordLook(out, round, answers) {
88
108
  if (onDisk !== files.get(file).sha256) throw usage(`${file} changed after the stills set was written; render again`);
89
109
  if (viewed.includes(file)) frames.push({ file, sha256: onDisk });
90
110
  }
91
- for (const q of Object.keys(QUESTIONS).map(Number)) {
92
- if (!answerList.some((a) => Number(a.q) === q)) throw usage(`question ${q} is unanswered: ${QUESTIONS[q]}`);
111
+ const questions = cover ? COVER_QUESTIONS : QUESTIONS;
112
+ for (const q of Object.keys(questions).map(Number)) {
113
+ if (!answerList.some((a) => Number(a.q) === q)) throw usage(`question ${q} is unanswered: ${questions[q]}`);
93
114
  }
94
115
  const reasons = [];
95
116
  for (const a of answerList) {
96
117
  const q = Number(a.q);
97
- if (!QUESTIONS[q]) throw usage(`unknown question ${a.q}`);
118
+ if (!questions[q]) throw usage(`unknown question ${a.q}`);
98
119
  if (!a.frame) throw usage(`question ${q} names no frame`);
99
120
  if (!concrete(a.observed)) throw usage(`question ${q}: "observed" must be at least one sentence naming a concrete visible detail in ${a.frame}, not a bare yes or no`);
100
121
  const verdict = verdictOf(a.verdict);
122
+ if (cover) {
123
+ if (COVER_NEED_YES.has(q) && verdict === 'no') reasons.push(`Q${q}: no`);
124
+ if (COVER_NEED_NO.has(q) && verdict === 'yes') reasons.push(`Q${q}: yes`);
125
+ if (q === 8 && verdict !== 'none') reasons.push('Q8: a motion designer would change something');
126
+ continue;
127
+ }
101
128
  if (q === 1) {
102
129
  if (!['blind', 'self'].includes(a.by)) throw usage('question 1 needs "by": "blind" (a fresh delegate_task subagent) or "self"');
103
130
  if (a.by === 'blind' && !concrete(a.stranger)) throw usage('question 1 answered blind needs the subagent\'s sentence, verbatim, in "stranger"');
@@ -107,14 +134,38 @@ export function recordLook(out, round, answers) {
107
134
  if (q === 7 && verdict !== 'none') reasons.push('Q7: a designer would have shown something this film does not');
108
135
  }
109
136
  if (round === 1) {
110
- if (!stillsOnlyMode(set.mode)) throw usage('round 1 is a stills round: render with --stills-only --round 1 before the first full render');
111
- if (!concrete(answers.change)) throw usage('round 1 must name the weakest beat and the change you made in "change"');
137
+ if (!cover && !stillsOnlyMode(set.mode)) throw usage('round 1 is a stills round: render with --stills-only --round 1 before the first full render');
138
+ if (!concrete(answers.change)) throw usage(cover ? 'round 1 must name the change you make before round 2 in "change"' : 'round 1 must name the weakest beat and the change you made in "change"');
112
139
  }
113
- look.rounds.push({ ...base, frames, answers: answerList, change: answers.change || null, aids: answers.aids || [], another: reasons.length > 0, reasons });
140
+ const defects = cover ? checkDefects(answers.defects, set, files) : undefined;
141
+ look.rounds.push({ ...base, frames, answers: answerList, change: answers.change || null, aids: answers.aids || [], ...(cover ? { defects } : {}), another: reasons.length > 0, reasons });
114
142
  writeJson(join(out, 'look.json'), look);
115
143
  return look.rounds.at(-1);
116
144
  }
117
145
 
146
+ // Cover defects: at most three per round, worst first, each { t, region, frame, observed, cause,
147
+ // fix }: the loop time in seconds where it shows, the [x, y, w, h] it covers in cover pixels, and a
148
+ // file of the current stills set that shows it. `cover --window` checks the fix before the next
149
+ // full render.
150
+ function checkDefects(list, set, files) {
151
+ if (list === undefined) return [];
152
+ if (!Array.isArray(list)) throw usage('defects must be an array');
153
+ if (list.length > COVER.maxDefects) throw usage(`at most ${COVER.maxDefects} defects per round; fix the worst first`);
154
+ const [W, H] = set.size || [0, 0];
155
+ const loop = set.durationSec;
156
+ return list.map((d, i) => {
157
+ const at = `defect ${i + 1}`;
158
+ if (!d || typeof d !== 'object') throw usage(`${at} must be an object`);
159
+ if (typeof d.t !== 'number' || !(d.t >= 0) || (loop && d.t >= loop)) throw usage(`${at}: t must be the loop time in seconds where it shows (0 to ${loop})`);
160
+ const r = d.region;
161
+ if (!Array.isArray(r) || r.length !== 4 || !r.every(Number.isInteger) || r[0] < 0 || r[1] < 0 || r[2] <= 0 || r[3] <= 0 || r[0] + r[2] > W || r[1] + r[3] > H) throw usage(`${at}: region must be [x, y, w, h] inside the ${W}x${H} cover`);
162
+ if (!files.has(d.frame)) throw usage(`${at}: frame ${JSON.stringify(d.frame)} is not in the latest stills set`);
163
+ if (!concrete(d.observed)) throw usage(`${at}: observed must be at least one sentence naming what is visible there`);
164
+ if (![d.cause, d.fix].every((x) => typeof x === 'string' && x.trim().length >= 8 && !/<[^<>\n]{1,80}>/u.test(x))) throw usage(`${at}: cause and fix must each say something concrete`);
165
+ return { t: d.t, region: r, frame: d.frame, observed: d.observed.trim(), cause: d.cause.trim(), fix: d.fix.trim() };
166
+ });
167
+ }
168
+
118
169
  export async function lookCommand(flags) {
119
170
  const out = resolve(flags.out || 'motion-output');
120
171
  try {
@@ -130,7 +181,7 @@ export async function lookCommand(flags) {
130
181
  }
131
182
  console.log(`look round ${round.round} recorded: ${round.frames.length} frames viewed (manifest ${round.manifestSha256.slice(0, 12)})`);
132
183
  if (round.another) console.log(`another round is due (${round.reasons.join('; ')})${round.round >= 3 ? '; this was round 3 of 3: deliver with these open items stated plainly' : `; revise and render --round ${round.round + 1}`}`);
133
- else if (round.round === 1) console.log('round 1 done: make the change you named, then render the film with --round 2');
184
+ else if (round.round === 1) console.log(round.stillsMode === 'cover' ? 'round 1 done: make the change you named (check a defect fix with `cover --window t0,t1`), then render the cover with --round 2' : 'round 1 done: make the change you named, then render the film with --round 2');
134
185
  return 0;
135
186
  } catch (error) {
136
187
  console.error(error.exitCode ? error.message : `motion internal error: ${error.stack || error.message}`);
@@ -171,6 +222,7 @@ export function checkDone(out) {
171
222
  if (!existsSync(statePath)) return no('no run-state.json: no render finished in this directory');
172
223
  const state = readJson(statePath);
173
224
  if (!state.finished || state.exitCode === null || state.exitCode === undefined) return no('the render command started but did not finish');
225
+ if (state.path === 'cover' || state.mode === 'cover') return checkCoverDone(out, state, no);
174
226
  if (stillsOnlyMode(state.mode)) return no('the last render was a stills round; render the film');
175
227
  if (state.exitCode !== 0) return no(`the last render exited ${state.exitCode}${state.failed?.length ? ` (${state.failed.join(', ')})` : ''}; done needs a passing gate`);
176
228
  if (state.path !== 'stage') {
@@ -213,11 +265,55 @@ export function checkDone(out) {
213
265
  return { done: true, status: 'COMPLETE', reason: `gate passed; ${rounds.length} look rounds; the last viewed all ${set.files.length} stills of the final render${receipts ? ' (vision_analyze receipts matched)' : ''}`, openItems, ...extra };
214
266
  }
215
267
 
268
+ // Cover done (references/cover.md): a valid cover treatment unchanged since the last render, that
269
+ // render's decoded cover at every TARGET gate (exit 0), at least two look rounds (round 1 naming its
270
+ // change) and a last round on the final render that viewed the sheet, the poster and the seam pair,
271
+ // with vision_analyze receipts when the Hermes tool hook recorded them.
272
+ function checkCoverDone(out, state, no) {
273
+ const labels = generatedLabels(out);
274
+ const extra = { downgraded: [], labels };
275
+ const file = join(out, 'treatment.json');
276
+ if (!existsSync(file)) return no('treatment.json is missing', extra);
277
+ let treatment;
278
+ try { treatment = readJson(file); } catch { return no('treatment.json is not valid JSON', extra); }
279
+ if (!isCoverTreatment(treatment)) return no('the last render was a cover but treatment.json is now a film; render again', extra);
280
+ const valid = validateCoverTreatment(treatment);
281
+ if (!valid.ok) return no(`treatment.json no longer validates (field ${valid.field})`, extra);
282
+ if (!existsSync(join(out, 'manifest.json')) || !existsSync(join(out, 'gate-report.txt'))) return no('no cover render has finished (manifest.json or gate-report.txt is missing)', extra);
283
+ const manifest = readJson(join(out, 'manifest.json'));
284
+ if (manifest.treatment?.sha256 !== sha(readFileSync(file))) return no('treatment.json changed after the last cover render; render again', extra);
285
+ if (state.exitCode !== 0 || !/^COVER gate --strict: PASS\+TARGET$/mu.test(readFileSync(join(out, 'gate-report.txt'), 'utf8'))) {
286
+ return no(`the decoded cover did not reach every TARGET gate on the last render (exit ${state.exitCode}${state.failed?.length ? `; ${state.failed.join(', ')}` : ''})`, extra);
287
+ }
288
+ const set = readStillsSet(out);
289
+ if (set.mode !== 'cover') return no('the stills set is not a cover set; render the cover again', extra);
290
+ const rounds = readLook(out).rounds;
291
+ if (!rounds.length) return no('no look round is recorded; view stills/sheet.png, stills/poster.png and stills/seam.png and record them with `look`', extra);
292
+ const last = rounds.at(-1);
293
+ const manifestSha256 = sha(readFileSync(join(out, 'manifest.json')));
294
+ if (last.manifestSha256 !== manifestSha256) return no('the last look round was recorded before the final cover render; look again and record it', extra);
295
+ if (last.blocked === 'no-vision-tool') return { done: true, status: 'DONE_UNVIEWED', reason: 'the cover reached TARGET but no image tool could view it; say plainly that nobody viewed the frames', ...extra };
296
+ if (rounds.length < 2) return no('done needs two look rounds: round 1 with the change it named, then a round on the final render', extra);
297
+ if (!rounds[0].change) return no('round 1 must name the change made after it', extra);
298
+ const seen = new Map(last.frames.map((f) => [f.file, f.sha256]));
299
+ const required = set.files.filter((f) => set.required.includes(f.file));
300
+ const missing = required.filter((f) => seen.get(f.file) !== f.sha256).map((f) => f.file);
301
+ if (missing.length) return no(`the last round did not view ${missing.join(', ')}`, extra);
302
+ const receipts = hostReceipts(out);
303
+ if (receipts) {
304
+ const unviewed = required.filter((f) => !receipts.viewed.has(`${f.file}|${f.sha256}`)).map((f) => f.file);
305
+ if (unviewed.length) return no(`the tool hook saw no vision_analyze call on ${unviewed.join(', ')}`, extra);
306
+ }
307
+ if (last.another && last.round < 3) return no(`the last look round asks for another round (${last.reasons.join('; ')})`, extra);
308
+ return { done: true, status: 'COMPLETE', reason: `cover reached TARGET; ${rounds.length} look rounds; the last viewed the sheet, the poster and the seam pair${receipts ? ' (vision_analyze receipts matched)' : ''}`, openItems: last.another ? last.reasons : [], ...extra };
309
+ }
310
+
216
311
  export function completeCommand(out) {
217
312
  const result = checkDone(out);
218
313
  if (existsSync(out)) writeJson(join(out, 'done.json'), { ...result, at: new Date().toISOString() });
219
314
  console.log(`${result.status}: ${result.reason}`);
220
315
  for (const item of result.downgraded || []) console.log(`downgraded: ${item}`);
221
316
  for (const item of result.openItems || []) console.log(`open item: ${item}`);
317
+ for (const item of result.labels || []) console.log(`label: ${item}`);
222
318
  return result.done ? 0 : 1;
223
319
  }
@@ -0,0 +1,80 @@
1
+ // LitLoop: the helper kit a cover page loads from /lit/loop-kit.js (references/cover.md). A cover
2
+ // page is one picture drawn from a loop time t in [0, 1) by window.renderFrame(t); the renderer
3
+ // seeks frames in any order and draws some twice in fresh pages. So every helper below is either
4
+ // pure or periodic in t: frame N would equal frame 0, and no frame depends on the one before it.
5
+ // Clocks and unseeded entropy throw in a cover page, so layout randomness comes from rng(seed).
6
+ (function attachLitLoop(scope) {
7
+ 'use strict';
8
+ const TAU = 2 * Math.PI;
9
+ const clamp = (x, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, x));
10
+ const fract = (x) => x - Math.floor(x);
11
+ const mix = (a, b, k) => a + (b - a) * k;
12
+
13
+ // mulberry32: a seeded stream for layout (positions, sizes, which copy carries the accent).
14
+ function rng(seed) {
15
+ let s = seed >>> 0;
16
+ return function next() {
17
+ s = (s + 0x6d2b79f5) >>> 0;
18
+ let z = Math.imul(s ^ (s >>> 15), s | 1);
19
+ z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
20
+ return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
21
+ };
22
+ }
23
+
24
+ // Value noise on a seeded 3-D lattice. loopNoise samples it on a circle of `radius` around
25
+ // `offset`, so the value at t = 1 is the value at t = 0; different offsets decorrelate copies.
26
+ function makeNoise(seed) {
27
+ const next = rng(seed);
28
+ const table = new Float32Array(256);
29
+ const perm = new Uint8Array(512);
30
+ for (let i = 0; i < 256; i++) { table[i] = 2 * next() - 1; perm[i] = i; }
31
+ for (let i = 255; i > 0; i--) { const j = Math.floor(next() * (i + 1)); const k = perm[i]; perm[i] = perm[j]; perm[j] = k; }
32
+ for (let i = 0; i < 256; i++) perm[256 + i] = perm[i];
33
+ const fade = (u) => u * u * (3 - 2 * u);
34
+ const lattice = (x, y, z) => table[perm[perm[perm[x & 255] + (y & 255)] + (z & 255)]];
35
+ return function noise(x, y, z) {
36
+ const x0 = Math.floor(x), y0 = Math.floor(y), z0 = Math.floor(z);
37
+ const u = fade(x - x0), v = fade(y - y0), w = fade(z - z0);
38
+ const edge = (yy, zz) => mix(lattice(x0, yy, zz), lattice(x0 + 1, yy, zz), u);
39
+ const face = (zz) => mix(edge(y0, zz), edge(y0 + 1, zz), v);
40
+ return mix(face(z0), face(z0 + 1), w);
41
+ };
42
+ }
43
+ const loopNoise = (noise, t, offset = 0, radius = 1.3) =>
44
+ noise(offset + radius * Math.cos(TAU * t), radius * Math.sin(TAU * t), 0.37 * offset);
45
+
46
+ // Shapes for events. Linear time is only for motion that never stops (drift, laps).
47
+ const ease = Object.freeze({
48
+ smooth: (p) => p * p * (3 - 2 * p),
49
+ outCubic: (p) => 1 - (1 - p) ** 3,
50
+ inCubic: (p) => p * p * p,
51
+ outExpo: (p) => (p >= 1 ? 1 : 1 - 2 ** (-10 * p)),
52
+ outBack: (p, s = 1.6) => { const q = p - 1; return 1 + (s + 1) * q * q * q + s * q * q; },
53
+ gain: (p, k) => (p < 0.5 ? 0.5 * (2 * p) ** k : 1 - 0.5 * (2 - 2 * p) ** k),
54
+ });
55
+
56
+ // cubicPulse is a symmetric bump of half-width w around c, zero outside. Use it for micro events:
57
+ // the long tail of expImpulse is a slow fade, and a lossy animation encoder drops slow fades.
58
+ function cubicPulse(c, w, x) {
59
+ const d = Math.abs(x - c);
60
+ if (d >= w) return 0;
61
+ const k = d / w;
62
+ return 1 - k * k * (3 - 2 * k);
63
+ }
64
+ const expImpulse = (x, k) => { const h = k * x; return h * Math.exp(1 - h); };
65
+
66
+ // phase: where one copy of the rule is in its own cycle. cdist: distance around the loop, for an
67
+ // event that straddles t = 0. window: a one-shot event, -1 outside [start, start + dur) (wraps).
68
+ const phase = (t, offset = 0, cycles = 1) => fract(cycles * t - offset);
69
+ const cdist = (p, c) => { const d = fract(p - c); return Math.min(d, 1 - d); };
70
+ const once = (p, start, dur) => { const q = fract(p - start); return q < dur ? q / dur : -1; };
71
+ // steps: hold at `stops` poses per cycle, travelling during the last `travel` of each slot.
72
+ function steps(p, stops, travel = 0.35, shape = ease.smooth) {
73
+ const x = p * stops, i = Math.floor(x);
74
+ return (i + shape(clamp((x - i - (1 - travel)) / travel))) / stops;
75
+ }
76
+ // swell: 0 -> 1 -> 0 over one cycle, dwelling at both ends.
77
+ const swell = (p) => 0.5 - 0.5 * Math.cos(TAU * p);
78
+
79
+ scope.LitLoop = Object.freeze({ TAU, clamp, fract, mix, rng, makeNoise, loopNoise, ease, cubicPulse, expImpulse, phase, cdist, window: once, steps, swell });
80
+ })(typeof window === 'undefined' ? globalThis : window);
@@ -282,7 +282,7 @@ export function muxArgs({ video, track, output, durationSec }) {
282
282
 
283
283
  export function muxAudio({ video, track, output, durationSec }) {
284
284
  const result = spawnSync('ffmpeg', muxArgs({ video, track, output, durationSec }), { encoding: 'utf8' });
285
- if (result.status !== 0) throw fail(`the sound track could not be muxed: ${(result.stderr || result.error?.message || '').trim().split('\n').at(-1)}`);
285
+ if (result.status !== 0) throw fail(`the sound track could not be muxed: ${(result.stderr || result.error?.message || '').trim().split(/\r?\n/).at(-1)}`);
286
286
  }
287
287
 
288
288
  function streams(file) {
@@ -167,7 +167,7 @@ export function scanStage(stageDir) {
167
167
  const bySize = new Map();
168
168
  for (const r of rasters) if (r.size) { const key = r.size.join('x'); bySize.set(key, (bySize.get(key) || 0) + 1); }
169
169
  for (const [key, count] of bySize) if (count >= RASTER_LIMITS.sameSize) contract.push(`${count} rasters of the same size ${key} read as a flipbook; draw motion, do not play frames`);
170
- return { contract: [...new Set(contract)], network: [...new Set(network)], files: files.length, rasters: { count: rasters.length, bytes: total } };
170
+ return { contract: [...new Set(contract)], network: [...new Set(network)], files: files.length, rasters: { count: rasters.length, bytes: total, paths: rasters.map((r) => r.rel) } };
171
171
  }
172
172
 
173
173
  // ---------------------------------------------------------------------------
@@ -181,6 +181,8 @@ const FACE_ROUTES = [
181
181
  ['Silkscreen', 400, '100%', 'silkscreen-400'], ['Silkscreen', 700, '100%', 'silkscreen-700'],
182
182
  ];
183
183
 
184
+ const MONO_FILE = 'JetBrainsMono-wght-latin.woff2';
185
+
184
186
  export function fontRoutes(cache) {
185
187
  const routes = new Map();
186
188
  const faces = [];
@@ -192,6 +194,10 @@ export function fontRoutes(cache) {
192
194
  const format = name.endsWith('.otf') ? 'opentype' : 'truetype';
193
195
  faces.push(`@font-face { font-family: "${family}"; src: url("/lit/fonts/${name}") format("${format}"); font-weight: ${weight}; font-stretch: ${stretch}; font-style: normal; font-display: block; }`);
194
196
  }
197
+ // JetBrains Mono, pinned in the same cache: a variable face with a real wght axis (100-800)
198
+ // and a fixed advance, so weight motion on a cover never reflows a line.
199
+ routes.set(`/lit/fonts/${MONO_FILE}`, fontFile(cache, `fonts/${MONO_FILE}`));
200
+ faces.push(`@font-face { font-family: "JetBrains Mono"; src: url("/lit/fonts/${MONO_FILE}") format("woff2"); font-weight: 100 800; font-style: normal; font-display: block; }`);
195
201
  return { routes, css: `${faces.join('\n')}\n` };
196
202
  }
197
203
 
@@ -515,7 +521,7 @@ class StageSession {
515
521
  this.masterSec = Number(process.hrtime.bigint() - started) / 1e9;
516
522
  if (encoder) {
517
523
  const done = await encoder.finish();
518
- if (done.code !== 0) throw fail(1, `ffmpeg master encode failed: ${done.stderr.split('\n').filter(Boolean).at(-1) || done.code}`);
524
+ if (done.code !== 0) throw fail(1, `ffmpeg master encode failed: ${done.stderr.split(/\r?\n/).filter(Boolean).at(-1) || done.code}`);
519
525
  }
520
526
  this.pngs = pngs;
521
527
  } finally {
@@ -103,6 +103,7 @@ const need = (cond, field, message) => { if (!cond) throw new Invalid(field, mes
103
103
 
104
104
  function check(t, { outDir, examples }) {
105
105
  need(t && typeof t === 'object' && !Array.isArray(t), 'treatment', 'treatment.json must be a JSON object');
106
+ need(t.mode !== 'cover', 'mode', 'this is a cover treatment; render it with `cover --out DIR` (references/cover.md)');
106
107
  for (const field of ['request', 'genre', 'path', 'pathReason', 'idea', 'audience', 'channel', 'format', 'durationSec', 'beats', 'subject', 'visualDevices', 'typePlan', 'palette', 'sound', 'copy', 'ambition']) {
107
108
  need(t[field] !== undefined && t[field] !== null && t[field] !== '', field, `${field} is required`);
108
109
  }
@@ -0,0 +1,49 @@
1
+ {
2
+ "schema": "lithermes.cover-negative/v1",
3
+ "note": "Never a recipe. qa/cover-recipes.js and the test suite capture it, encode it and gate the decoded file; it counts as caught only when every gate in mustFail fails and no undeclared FLOOR gate among G1-G4 fails. Never change a threshold so it passes.",
4
+ "mustFail": [
5
+ "G2.floor"
6
+ ],
7
+ "observed": {
8
+ "medianD": 0,
9
+ "movingFrac": 0.3203125,
10
+ "longestStillSec": 1.449,
11
+ "spike": 22.180186084476993,
12
+ "seamLocal": 0.30624810170866057,
13
+ "bytes": 180632,
14
+ "failed": [
15
+ "G2"
16
+ ]
17
+ },
18
+ "treatment": {
19
+ "mode": "cover",
20
+ "request": "A negative check page for the cover gate.",
21
+ "idea": "Four card scenes that fade in, hold and fade out: the old stage path as a cover.",
22
+ "recipe": "own",
23
+ "recipeReason": "A shipped negative that must keep failing its named gates.",
24
+ "brandWord": "LITHERMES",
25
+ "aspect": "2:1",
26
+ "loopSec": 8,
27
+ "fps": 16,
28
+ "layers": {
29
+ "slow": "None.",
30
+ "mid": "None.",
31
+ "beat": "None."
32
+ },
33
+ "system": {
34
+ "rule": "None.",
35
+ "copies": "None.",
36
+ "offset": "None."
37
+ },
38
+ "title": {
39
+ "box": [
40
+ 0.1,
41
+ 0.25,
42
+ 0.8,
43
+ 0.25
44
+ ],
45
+ "kind": "live"
46
+ },
47
+ "ambition": "It fails the gates it is named for, every time."
48
+ }
49
+ }
@@ -0,0 +1,37 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <link rel="stylesheet" href="/lit/fonts.css">
6
+ <script src="/lit/loop-kit.js"></script>
7
+ <style>
8
+ html, body { margin: 0; width: 1200px; height: 600px; overflow: hidden; background: #080d14; color: #f2efdf; font-family: "JetBrains Mono"; }
9
+ .mid { position: absolute; left: 0; right: 0; text-align: center; white-space: nowrap; }
10
+ body.mask, body.mask * { background: #000 !important; color: #000 !important; border-color: #000 !important; }
11
+ body.mask .title { color: #fff !important; }
12
+ </style>
13
+ <style>.card { position: absolute; inset: 0; opacity: 0; } .box { position: absolute; border: 3px solid #ff6337; border-radius: 10px; }</style>
14
+ </head>
15
+ <body>
16
+ <!-- NEGATIVE (never a recipe): the old stage path as a cover. Four card scenes fade in, hold and
17
+ fade out, the last one an end card. It must keep failing G2: the holds leave most grid steps
18
+ still, whatever the fades do. -->
19
+ <div class="card" id="c0"><div class="mid title" style="top:200px;font-size:96px;font-weight:800">LITHERMES</div><div class="mid" style="top:340px;font-size:30px">A workflow family for Hermes Agent</div></div>
20
+ <div class="card" id="c1"><div class="box" style="left:160px;top:180px;width:260px;height:240px"></div><div style="position:absolute;left:480px;top:240px;font-size:44px;font-weight:700">1. Plan</div><div style="position:absolute;left:480px;top:310px;font-size:26px">Decision-complete steps.</div></div>
21
+ <div class="card" id="c2"><div class="box" style="left:160px;top:180px;width:260px;height:240px;border-color:#d7f75b"></div><div style="position:absolute;left:480px;top:240px;font-size:44px;font-weight:700">2. Loop</div><div style="position:absolute;left:480px;top:310px;font-size:26px">Test first, then green.</div></div>
22
+ <div class="card" id="c3"><div class="mid" style="top:250px;font-size:64px;font-weight:700">KEEP THE WORK <span style="color:#ff6337">LIT.</span></div></div>
23
+ <script>
24
+ const { clamp, ease } = LitLoop, LENGTH = 10, SCENES = [[0, 2.6], [2.6, 5.0], [5.0, 7.4], [7.4, 10]];
25
+ window.renderFrame = (t, options = {}) => {
26
+ document.body.className = options.mask ? 'mask' : '';
27
+ const s = t * LENGTH;
28
+ SCENES.forEach(([a, b], i) => {
29
+ const el = document.getElementById(`c${i}`);
30
+ el.style.opacity = String(ease.smooth(clamp((s - a) / 0.4)) * (1 - ease.smooth(clamp((s - (b - 0.4)) / 0.4))));
31
+ el.style.transform = `translateX(${20 * (1 - ease.outCubic(clamp((s - a) / 0.5)))}px)`;
32
+ });
33
+ };
34
+ document.fonts.load('800 96px "JetBrains Mono"').then(() => document.fonts.ready).then(() => { window.renderFrame(0); window.ready = true; });
35
+ </script>
36
+ </body>
37
+ </html>
@@ -0,0 +1,49 @@
1
+ {
2
+ "schema": "lithermes.cover-negative/v1",
3
+ "note": "Never a recipe. qa/cover-recipes.js and the test suite capture it, encode it and gate the decoded file; it counts as caught only when every gate in mustFail fails and no undeclared FLOOR gate among G1-G4 fails. Never change a threshold so it passes.",
4
+ "mustFail": [
5
+ "G4.floor"
6
+ ],
7
+ "observed": {
8
+ "medianD": 0.0007350640633021579,
9
+ "movingFrac": 1,
10
+ "longestStillSec": 0,
11
+ "spike": 124.92159799457926,
12
+ "seamLocal": 121.841924929272,
13
+ "bytes": 30944,
14
+ "failed": [
15
+ "G4"
16
+ ]
17
+ },
18
+ "treatment": {
19
+ "mode": "cover",
20
+ "request": "A negative check page for the cover gate.",
21
+ "idea": "A progress sweep that resets at the loop point: the seam is a cut.",
22
+ "recipe": "own",
23
+ "recipeReason": "A shipped negative that must keep failing its named gates.",
24
+ "brandWord": "LITHERMES",
25
+ "aspect": "2:1",
26
+ "loopSec": 8,
27
+ "fps": 16,
28
+ "layers": {
29
+ "slow": "None.",
30
+ "mid": "None.",
31
+ "beat": "None."
32
+ },
33
+ "system": {
34
+ "rule": "None.",
35
+ "copies": "None.",
36
+ "offset": "None."
37
+ },
38
+ "title": {
39
+ "box": [
40
+ 0.1,
41
+ 0.25,
42
+ 0.8,
43
+ 0.25
44
+ ],
45
+ "kind": "live"
46
+ },
47
+ "ambition": "It fails the gates it is named for, every time."
48
+ }
49
+ }
@@ -0,0 +1,27 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <link rel="stylesheet" href="/lit/fonts.css">
6
+ <script src="/lit/loop-kit.js"></script>
7
+ <style>
8
+ html, body { margin: 0; width: 1200px; height: 600px; overflow: hidden; background: #080d14; color: #f2efdf; font-family: "JetBrains Mono"; }
9
+ .mid { position: absolute; left: 0; right: 0; text-align: center; white-space: nowrap; }
10
+ body.mask, body.mask * { background: #000 !important; color: #000 !important; border-color: #000 !important; }
11
+ body.mask .title { color: #fff !important; }
12
+ </style>
13
+ </head>
14
+ <body>
15
+ <!-- NEGATIVE (never a recipe): a wall-clock habit, a progress sweep that fills once and resets at
16
+ the loop point, so the seam is a cut. It must keep failing G4. -->
17
+ <div id="sweep" style="position:absolute;left:0;top:0;height:600px;background:#1d2633"></div>
18
+ <div class="mid title" style="top:230px;font-size:110px;font-weight:800;letter-spacing:6px">LITHERMES</div>
19
+ <script>
20
+ window.renderFrame = (t, options = {}) => {
21
+ document.body.className = options.mask ? 'mask' : '';
22
+ document.getElementById('sweep').style.width = `${1200 * t}px`;
23
+ };
24
+ document.fonts.load('800 110px "JetBrains Mono"').then(() => document.fonts.ready).then(() => { window.renderFrame(0); window.ready = true; });
25
+ </script>
26
+ </body>
27
+ </html>