@litfamily/lithermes 1.0.26 → 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 (96) 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 +8 -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/deliverable_hedge_guard.py +171 -48
  13. package/assets/lithermes-plugin/diagnostics.py +5 -3
  14. package/assets/lithermes-plugin/humanizer_detector.py +117 -5
  15. package/assets/lithermes-plugin/lit_reentry.py +233 -0
  16. package/assets/lithermes-plugin/payload-version.json +184 -52
  17. package/assets/lithermes-plugin/plugin.yaml +1 -1
  18. package/assets/lithermes-plugin/rules/__init__.py +2 -0
  19. package/assets/lithermes-plugin/rules/bundled-rules/lit-humanizer.md +2 -0
  20. package/assets/lithermes-plugin/rules/engine.py +26 -11
  21. package/assets/lithermes-plugin/skills/deep-interview/SKILL.md +2 -1
  22. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +3 -3
  23. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/hostexec.mjs +118 -0
  24. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/probe.mjs +6 -5
  25. package/assets/lithermes-plugin/skills/lit-brainstorm/SKILL.md +408 -0
  26. package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/doctor.mjs +3 -3
  27. package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/export.mjs +5 -5
  28. package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/hostexec.mjs +118 -0
  29. package/assets/lithermes-plugin/skills/lit-docx/scripts/convert_md_to_pdf.py +38 -4
  30. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/acs/template.tex +3 -0
  31. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/elsevier/template.tex +3 -0
  32. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/ieee/template.tex +3 -0
  33. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/korean-generic/template.tex +3 -0
  34. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/nature/template.tex +3 -0
  35. package/assets/lithermes-plugin/skills/lit-humanizer/SKILL.md +2 -0
  36. package/assets/lithermes-plugin/skills/lit-pptx/bin/hostexec.mjs +118 -0
  37. package/assets/lithermes-plugin/skills/lit-pptx/bin/office.mjs +16 -9
  38. package/assets/lithermes-plugin/skills/lit-typographic-motion/SKILL.md +13 -12
  39. package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/motion.mjs +19 -6
  40. package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/runtime.mjs +25 -19
  41. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/THIRD_PARTY_NOTICES +2 -0
  42. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/browser.mjs +11 -4
  43. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/constants.mjs +29 -0
  44. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-assets.mjs +87 -0
  45. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-capture.mjs +189 -0
  46. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-gate.mjs +263 -0
  47. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-image.mjs +103 -0
  48. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-init.js +36 -0
  49. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-png.mjs +77 -0
  50. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-sheet.mjs +70 -0
  51. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-treatment.mjs +104 -0
  52. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-webp.mjs +112 -0
  53. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-worker.mjs +19 -0
  54. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover.mjs +338 -0
  55. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/encode.mjs +1 -1
  56. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/hostexec.mjs +118 -0
  57. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/look.mjs +103 -7
  58. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/loop-kit.js +80 -0
  59. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/sound.mjs +1 -1
  60. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/stage.mjs +8 -2
  61. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/treatment.mjs +1 -0
  62. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/card-scenes/expected.json +49 -0
  63. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/card-scenes/index.html +37 -0
  64. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/reset-sweep/expected.json +49 -0
  65. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/reset-sweep/index.html +27 -0
  66. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/still-glow/expected.json +51 -0
  67. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/still-glow/index.html +29 -0
  68. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/title-slam/expected.json +51 -0
  69. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/title-slam/index.html +37 -0
  70. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +68 -0
  71. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/orbit-loop/index.html +112 -0
  72. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/painted-light/expected.json +96 -0
  73. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/painted-light/index.html +208 -0
  74. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/weight-tide/expected.json +68 -0
  75. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/weight-tide/index.html +81 -0
  76. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/cover.md +458 -0
  77. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/craft-loop.md +4 -1
  78. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/runtime.md +13 -5
  79. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/stage.md +4 -2
  80. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/treatment.md +6 -0
  81. package/assets/lithermes-plugin/skills/lit-typographic-motion/runtime/fonts.json +11 -0
  82. package/assets/lithermes-plugin/skills/lit-typographic-motion/runtime/requirements.lock +72 -0
  83. package/assets/lithermes-plugin/skills/litgoal/SKILL.md +19 -1
  84. package/assets/lithermes-plugin/skills/lsp-setup/scripts/verify-lsp.ts +16 -3
  85. package/assets/lithermes-plugin/skills/review-work/SKILL.md +38 -16
  86. package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +3 -3
  87. package/package.json +2 -1
  88. package/readme-assets/badge-version.svg +1 -1
  89. package/readme-assets/cover-motion-still.webp +0 -0
  90. package/readme-assets/cover-motion.webp +0 -0
  91. package/src/cli.js +2 -2
  92. package/src/lib/check.js +9 -6
  93. package/src/lib/hermesDiscovery.js +2 -2
  94. package/src/lib/hostExec.js +81 -0
  95. package/src/lib/skillPayload.js +1 -1
  96. package/src/lib/updateNotifier.js +14 -4
@@ -0,0 +1,51 @@
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
+ "G3.floor"
7
+ ],
8
+ "observed": {
9
+ "medianD": 0,
10
+ "movingFrac": 0.015625,
11
+ "longestStillSec": 7.056,
12
+ "spike": 2.2010107564043944,
13
+ "seamLocal": 2.003721874364625,
14
+ "bytes": 38536,
15
+ "failed": [
16
+ "G2",
17
+ "G3"
18
+ ]
19
+ },
20
+ "treatment": {
21
+ "mode": "cover",
22
+ "request": "A negative check page for the cover gate.",
23
+ "idea": "A still with a faint breathing glow: relighting without moving edges.",
24
+ "recipe": "own",
25
+ "recipeReason": "A shipped negative that must keep failing its named gates.",
26
+ "brandWord": "LITHERMES",
27
+ "aspect": "2:1",
28
+ "loopSec": 8,
29
+ "fps": 16,
30
+ "layers": {
31
+ "slow": "None.",
32
+ "mid": "None.",
33
+ "beat": "None."
34
+ },
35
+ "system": {
36
+ "rule": "None.",
37
+ "copies": "None.",
38
+ "offset": "None."
39
+ },
40
+ "title": {
41
+ "box": [
42
+ 0.1,
43
+ 0.25,
44
+ 0.8,
45
+ 0.25
46
+ ],
47
+ "kind": "live"
48
+ },
49
+ "ambition": "It fails the gates it is named for, every time."
50
+ }
51
+ }
@@ -0,0 +1,29 @@
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 still with a faint breathing glow, which is what relighting a
16
+ picture becomes without moving edges. The encoder drops the slow fade, so the decoded loop
17
+ barely moves. It must keep failing G2 and G3. -->
18
+ <div class="mid" id="glow" style="top:0;height:600px;background:radial-gradient(ellipse at 50% 45%, #ff633722, transparent 60%)"></div>
19
+ <div class="mid title" style="top:210px;font-size:110px;font-weight:800;letter-spacing:6px">LITHERMES</div>
20
+ <div class="mid" style="top:360px;font-size:28px;font-weight:500">KEEP THE WORK <span style="color:#ff6337">LIT.</span></div>
21
+ <script>
22
+ window.renderFrame = (t, options = {}) => {
23
+ document.body.className = options.mask ? 'mask' : '';
24
+ document.getElementById('glow').style.opacity = String(0.75 + 0.25 * Math.sin(LitLoop.TAU * t));
25
+ };
26
+ document.fonts.load('800 110px "JetBrains Mono"').then(() => document.fonts.ready).then(() => { window.renderFrame(0); window.ready = true; });
27
+ </script>
28
+ </body>
29
+ </html>
@@ -0,0 +1,51 @@
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
+ "G3.floor"
7
+ ],
8
+ "observed": {
9
+ "medianD": 0,
10
+ "movingFrac": 0.2421875,
11
+ "longestStillSec": 2.457,
12
+ "spike": 38.4729355359628,
13
+ "seamLocal": 0.2502592054987107,
14
+ "bytes": 137532,
15
+ "failed": [
16
+ "G2",
17
+ "G3"
18
+ ]
19
+ },
20
+ "treatment": {
21
+ "mode": "cover",
22
+ "request": "A negative check page for the cover gate.",
23
+ "idea": "Title slam, three lines that appear and hold, and an end card: the old type path as a cover.",
24
+ "recipe": "own",
25
+ "recipeReason": "A shipped negative that must keep failing its named gates.",
26
+ "brandWord": "LITHERMES",
27
+ "aspect": "2:1",
28
+ "loopSec": 8,
29
+ "fps": 16,
30
+ "layers": {
31
+ "slow": "None.",
32
+ "mid": "None.",
33
+ "beat": "None."
34
+ },
35
+ "system": {
36
+ "rule": "None.",
37
+ "copies": "None.",
38
+ "offset": "None."
39
+ },
40
+ "title": {
41
+ "box": [
42
+ 0.1,
43
+ 0.25,
44
+ 0.8,
45
+ 0.25
46
+ ],
47
+ "kind": "live"
48
+ },
49
+ "ambition": "It fails the gates it is named for, every time."
50
+ }
51
+ }
@@ -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
+ </head>
14
+ <body>
15
+ <!-- NEGATIVE (never a recipe): the old type path as a cover. The title slams in, three lines
16
+ appear one by one and hold, then everything crossfades to a settled end card. It must keep
17
+ failing G2 (most of the loop is still) and G3 (the holds). -->
18
+ <div class="mid title" id="title" style="top:150px;font-size:110px;font-weight:800;letter-spacing:6px">LITHERMES</div>
19
+ <div class="mid" id="l1" style="top:330px;font-size:34px;font-weight:500">Plans before code.</div>
20
+ <div class="mid" id="l2" style="top:380px;font-size:34px;font-weight:500">Loops until green.</div>
21
+ <div class="mid" id="l3" style="top:430px;font-size:34px;font-weight:500;color:#ff6337">Evidence, not claims.</div>
22
+ <div class="mid" id="end" style="top:250px;font-size:64px;font-weight:700;opacity:0">KEEP THE WORK <span style="color:#ff6337">LIT.</span></div>
23
+ <script>
24
+ const { clamp, ease } = LitLoop, LENGTH = 10;
25
+ const rise = (s, at, dur) => ease.outCubic(clamp((s - at) / dur));
26
+ window.renderFrame = (t, options = {}) => {
27
+ document.body.className = options.mask ? 'mask' : '';
28
+ const s = t * LENGTH, gone = 1 - rise(s, 5.6, 0.4), title = document.getElementById('title');
29
+ title.style.opacity = String(rise(s, 0.2, 0.5) * gone);
30
+ title.style.transform = `scale(${1.25 - 0.25 * rise(s, 0.2, 0.5)})`;
31
+ [2, 3, 4].forEach((at, i) => { const el = document.getElementById(`l${i + 1}`); el.style.opacity = String(rise(s, at, 0.3) * gone); el.style.transform = `translateY(${12 * (1 - rise(s, at, 0.3))}px)`; });
32
+ document.getElementById('end').style.opacity = String(rise(s, 6.1, 0.5) * (1 - rise(s, 9.6, 0.4)));
33
+ };
34
+ document.fonts.load('800 110px "JetBrains Mono"').then(() => document.fonts.ready).then(() => { window.renderFrame(0); window.ready = true; });
35
+ </script>
36
+ </body>
37
+ </html>
@@ -0,0 +1,68 @@
1
+ {
2
+ "schema": "lithermes.cover-recipe/v1",
3
+ "note": "The decoded gate row this recipe reached when it shipped, through this renderer (qa/cover-recipes.js re-renders it and fails when median d or the moving share leave +-15 % of the row, seamLocal leaves TARGET or the file passes 2 MiB).",
4
+ "row": {
5
+ "medianD": 0.002682811781258311,
6
+ "movingFrac": 1,
7
+ "longestStillSec": 0,
8
+ "spike": 1.6632692481475795,
9
+ "seamLocal": 1.6221165835071816,
10
+ "bytes": 1433792
11
+ },
12
+ "settings": {
13
+ "size": [
14
+ 1000,
15
+ 500
16
+ ],
17
+ "fps": 25,
18
+ "frames": 200,
19
+ "encode": {
20
+ "q": 78,
21
+ "edgeQ": 90,
22
+ "edgeFrames": 25,
23
+ "method": 4
24
+ }
25
+ },
26
+ "treatment": {
27
+ "mode": "cover",
28
+ "request": "An animated README cover for LitHermes that shows a working system.",
29
+ "idea": "Dots lap three rings, flash a gate at twelve o'clock and light a ledger that empties as the loop returns.",
30
+ "recipe": "orbit-loop",
31
+ "recipeReason": "A system with crossings reads as work being checked, beside a still name.",
32
+ "brandWord": "LITHERMES",
33
+ "motto": {
34
+ "text": "KEEP THE WORK LIT.",
35
+ "source": "project",
36
+ "from": "the motto painted on the repository artwork"
37
+ },
38
+ "aspect": "2:1",
39
+ "loopSec": 8,
40
+ "fps": 25,
41
+ "layers": {
42
+ "slow": "Dashed ring shadows turn a quarter lap per loop.",
43
+ "mid": "Dots lap at three, two and one laps per loop and the core breathes once.",
44
+ "beat": "Gates flash on each crossing, the core rings on four bars, ledger cells light."
45
+ },
46
+ "system": {
47
+ "rule": "One orbit rule with integer laps per loop.",
48
+ "copies": "Eight dots on three rings and sixteen ledger cells.",
49
+ "offset": "Each dot's start angle and its ring's lap count."
50
+ },
51
+ "title": {
52
+ "box": [
53
+ 0.04,
54
+ 0.33,
55
+ 0.46,
56
+ 0.18
57
+ ],
58
+ "kind": "live"
59
+ },
60
+ "encode": {
61
+ "q": 78,
62
+ "edgeQ": 90,
63
+ "edgeFrames": 25
64
+ },
65
+ "ambition": "Every crossing is visible on the beat and the ledger loops without a reset."
66
+ },
67
+ "verdict": "PASS+TARGET"
68
+ }
@@ -0,0 +1,112 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <!-- Cover recipe: orbit loop (a system). Copy to DIR/cover/index.html and replace WORD and the
6
+ motto. Dots ride three rings at 3, 2 and 1 laps per loop (whole numbers close the loop) in
7
+ alternating directions. A gate at twelve o'clock flashes on every crossing; the crossing times
8
+ are computed, not detected, and each one lights the next cell of a ledger under the word.
9
+ There are as many crossings per loop as cells, so the ledger loops too. The word is still. -->
10
+ <link rel="stylesheet" href="/lit/fonts.css">
11
+ <script src="/lit/loop-kit.js"></script>
12
+ <style>
13
+ html, body { margin: 0; width: 1200px; height: 600px; overflow: hidden; background: #080d14; }
14
+ canvas { display: block; width: 1200px; height: 600px; }
15
+ </style>
16
+ </head>
17
+ <body>
18
+ <canvas id="c" width="2400" height="1200"></canvas>
19
+ <script>
20
+ const WORD = 'LITHERMES';
21
+ const { TAU, clamp, fract, mix, ease, cubicPulse, swell } = LitLoop;
22
+ const INK = { ground: '#080d14', ring: '#1f2a37', dash: '#2c3a4b', word: '#f2efdf', accent: '#ff6337', quiet: '#8b8f8f' };
23
+ const ctx = document.getElementById('c').getContext('2d');
24
+ ctx.scale(2, 2);
25
+ const CX = 870, CY = 300, GATE = -Math.PI / 2;
26
+ const RINGS = [{ r: 104, laps: 3, dots: 3, dir: 1, size: 5.5 }, { r: 170, laps: 2, dots: 2, dir: -1, size: 6.5 }, { r: 238, laps: 1, dots: 3, dir: 1, size: 7.5 }];
27
+ // Dot k on a ring sits at GATE + dir * TAU * (laps * t + k / dots); it crosses the gate whenever
28
+ // laps * t + k / dots is a whole number. Sorted, those times are the ledger's cells.
29
+ const crossings = RINGS.flatMap((g) => Array.from({ length: g.dots * g.laps }, (_, n) => fract((Math.floor(n / g.dots) + 1 - (n % g.dots) / g.dots) / g.laps))).sort((a, b) => a - b);
30
+ const angle = (g, k, t) => GATE + g.dir * TAU * (g.laps * t + k / g.dots);
31
+
32
+ function hexagon(x, y, r) {
33
+ ctx.beginPath();
34
+ for (let i = 0; i < 6; i++) { const a = (i * TAU) / 6 + Math.PI / 6; ctx[i ? 'lineTo' : 'moveTo'](x + r * Math.cos(a), y + r * Math.sin(a)); }
35
+ ctx.closePath();
36
+ }
37
+
38
+ function word(mask) {
39
+ ctx.fillStyle = mask ? '#fff' : INK.word;
40
+ ctx.font = '800 92px "JetBrains Mono"';
41
+ ctx.fillText(WORD, 70, 292);
42
+ }
43
+
44
+ window.renderFrame = (t, options = {}) => {
45
+ ctx.fillStyle = options.mask ? '#000' : INK.ground;
46
+ ctx.fillRect(0, 0, 1200, 600);
47
+ if (options.mask) { word(true); return; }
48
+ // Slow: each ring's dashed shadow turns a quarter lap per loop, alternate rings the other way.
49
+ RINGS.forEach((g, i) => {
50
+ ctx.lineWidth = 1.5; ctx.strokeStyle = INK.ring;
51
+ ctx.beginPath(); ctx.arc(CX, CY, g.r, 0, TAU); ctx.stroke();
52
+ ctx.save();
53
+ ctx.setLineDash([2, 10]); ctx.lineDashOffset = (i % 2 ? 1 : -1) * 0.25 * t * TAU * g.r;
54
+ ctx.strokeStyle = INK.dash; ctx.lineWidth = 3;
55
+ ctx.beginPath(); ctx.arc(CX, CY, g.r + 7, 0, TAU); ctx.stroke();
56
+ ctx.restore();
57
+ });
58
+ // Mid and beat: the core breathes once per loop and rings on each of four bars.
59
+ const breath = swell(fract(t + 0.15)), bar = fract(4 * t);
60
+ const glow = ctx.createRadialGradient(CX, CY, 0, CX, CY, 90);
61
+ glow.addColorStop(0, `rgba(255,99,55,${0.18 + 0.14 * breath})`); glow.addColorStop(1, 'rgba(255,99,55,0)');
62
+ ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(CX, CY, 90, 0, TAU); ctx.fill();
63
+ ctx.strokeStyle = `rgba(255,99,55,${0.55 * (1 - ease.outCubic(bar))})`; ctx.lineWidth = 2;
64
+ hexagon(CX, CY, mix(34, 98, ease.outExpo(bar))); ctx.stroke();
65
+ hexagon(CX, CY, 34); ctx.fillStyle = '#0d141d'; ctx.fill(); ctx.lineWidth = 3; ctx.strokeStyle = INK.accent; ctx.stroke();
66
+ hexagon(CX, CY, 17 + 3 * breath); ctx.fillStyle = `rgba(255,140,90,${0.55 + 0.35 * breath})`; ctx.fill();
67
+ // Gates flash with a symmetric pulse around each crossing (an exponential tail would be a slow
68
+ // fade the encoder drops).
69
+ RINGS.forEach((g) => {
70
+ let hit = 0;
71
+ for (let k = 0; k < g.dots; k++) { const p = fract(g.laps * t + k / g.dots); hit = Math.max(hit, cubicPulse(0, 0.06, Math.min(p, 1 - p))); }
72
+ ctx.globalAlpha = 0.45 + 0.55 * hit; ctx.strokeStyle = INK.accent; ctx.lineWidth = 3;
73
+ ctx.beginPath(); ctx.moveTo(CX, CY - g.r - 12 - 10 * hit); ctx.lineTo(CX, CY - g.r - 4); ctx.stroke();
74
+ ctx.globalAlpha = 1;
75
+ });
76
+ RINGS.forEach((g) => {
77
+ for (let k = 0; k < g.dots; k++) {
78
+ const a = angle(g, k, t), p = fract(g.laps * t + k / g.dots), pulse = cubicPulse(0, 0.035, Math.min(p, 1 - p));
79
+ const trail = 0.55 / g.laps + 0.25;
80
+ for (let s = 0; s < 14; s++) {
81
+ const a0 = a - g.dir * trail * (s / 14), a1 = a - g.dir * trail * ((s + 1) / 14);
82
+ ctx.strokeStyle = `rgba(242,239,223,${0.32 * (1 - s / 14) ** 1.6})`; ctx.lineWidth = g.size * 0.9 * (1 - s / 16);
83
+ ctx.beginPath(); ctx.arc(CX, CY, g.r, Math.min(a0, a1), Math.max(a0, a1)); ctx.stroke();
84
+ }
85
+ const x = CX + g.r * Math.cos(a), y = CY + g.r * Math.sin(a);
86
+ if (pulse > 0.01) {
87
+ const h = ctx.createRadialGradient(x, y, 0, x, y, 28);
88
+ h.addColorStop(0, `rgba(255,140,90,${0.7 * pulse})`); h.addColorStop(1, 'rgba(255,99,55,0)');
89
+ ctx.fillStyle = h; ctx.beginPath(); ctx.arc(x, y, 28, 0, TAU); ctx.fill();
90
+ }
91
+ ctx.fillStyle = pulse > 0.5 ? INK.accent : INK.word;
92
+ ctx.beginPath(); ctx.arc(x, y, g.size * (1 + 0.6 * pulse), 0, TAU); ctx.fill();
93
+ }
94
+ });
95
+ word(false);
96
+ ctx.font = '500 18px "JetBrains Mono"'; ctx.fillStyle = INK.quiet;
97
+ let mx = 74;
98
+ for (const ch of 'KEEP THE WORK ') { ctx.fillText(ch, mx, 338); mx += 19.6; }
99
+ ctx.font = '700 18px "JetBrains Mono"'; ctx.fillStyle = INK.accent;
100
+ for (const ch of 'LIT.') { ctx.fillText(ch, mx, 338); mx += 19.6; }
101
+ // The ledger: cell i lights at crossing i and steps down in flat levels until the loop returns.
102
+ crossings.forEach((c, i) => {
103
+ const since = fract(t - c), level = since < 0.03 ? 1 : since < 0.35 ? 0.6 : since < 0.7 ? 0.3 : 0.12;
104
+ ctx.fillStyle = INK.ring; ctx.fillRect(74 + 29 * i, 378, 22, 8);
105
+ ctx.fillStyle = `rgba(255,99,55,${clamp(level)})`; ctx.fillRect(74 + 29 * i, 378, 22, 8);
106
+ });
107
+ };
108
+ Promise.all(['800 92px "JetBrains Mono"', '500 18px "JetBrains Mono"', '700 18px "JetBrains Mono"'].map((f) => document.fonts.load(f)))
109
+ .then(() => { window.renderFrame(0); window.ready = true; });
110
+ </script>
111
+ </body>
112
+ </html>
@@ -0,0 +1,96 @@
1
+ {
2
+ "schema": "lithermes.cover-recipe/v1",
3
+ "note": "The decoded gate row this recipe reached when it shipped, through this renderer (qa/cover-recipes.js re-renders it and fails when median d or the moving share leave +-15 % of the row, seamLocal leaves TARGET or the file passes 2 MiB).",
4
+ "row": {
5
+ "medianD": 0.0020774968966598946,
6
+ "movingFrac": 1,
7
+ "longestStillSec": 0,
8
+ "spike": 1.7006962618859784,
9
+ "seamLocal": 1.9645422524745952,
10
+ "bytes": 2063990
11
+ },
12
+ "settings": {
13
+ "size": [
14
+ 1000,
15
+ 500
16
+ ],
17
+ "fps": 16,
18
+ "frames": 128,
19
+ "encode": {
20
+ "q": 83,
21
+ "edgeQ": 97,
22
+ "edgeFrames": 18,
23
+ "method": 4
24
+ }
25
+ },
26
+ "base": "docs/assets/recipes/painted-light-base.png in the LitHermes repository (1536x1024, the five-robot line-up this recipe was measured on); any other painting needs its own PAINTING block",
27
+ "assets": [
28
+ {
29
+ "path": "cover/assets/base.png",
30
+ "role": "base-art",
31
+ "source": "user",
32
+ "box": [
33
+ 0.125,
34
+ 0,
35
+ 0.75,
36
+ 1
37
+ ],
38
+ "checkBox": [
39
+ 0.131,
40
+ 0.1445,
41
+ 0.1416,
42
+ 0.778
43
+ ],
44
+ "title": true
45
+ }
46
+ ],
47
+ "treatment": {
48
+ "mode": "cover",
49
+ "request": "Make the LitFamily README motion cover from the painted robot line-up.",
50
+ "idea": "A window of work light walks the sleeping bays while the lead robot breathes and a comet laps its frame.",
51
+ "recipe": "painted-light",
52
+ "recipeReason": "The painting already carries the brand, so the loop relights it instead of drawing over it.",
53
+ "brandWord": "LITFAMILY",
54
+ "aspect": "2:1",
55
+ "loopSec": 8,
56
+ "fps": 16,
57
+ "layers": {
58
+ "slow": "A comet laps the lead frame and motes rise one screen per loop.",
59
+ "mid": "A sharp-edged light window walks the bays once per loop, with scan bands at two passes.",
60
+ "beat": "Eyes breathe twice, one blink, and the LIT. flare pulses at t = 0 and 0.5."
61
+ },
62
+ "system": {
63
+ "rule": "One light window and one scan band rule.",
64
+ "copies": "Five panels and fifty motes.",
65
+ "offset": "Each panel's phase comes from its x position."
66
+ },
67
+ "title": {
68
+ "box": [
69
+ 0.347,
70
+ 0.031,
71
+ 0.31,
72
+ 0.086
73
+ ],
74
+ "kind": "painted"
75
+ },
76
+ "encode": {
77
+ "q": 83,
78
+ "edgeQ": 97,
79
+ "edgeFrames": 18
80
+ },
81
+ "ambition": "Light reads as moving edges that survive the encoder, and the seam disappears under the flare."
82
+ },
83
+ "regressions": {
84
+ "feather-150": {
85
+ "patch": {
86
+ "find": "feather: 24,",
87
+ "replace": "feather: 150,"
88
+ },
89
+ "mustFail": [
90
+ "G1.target"
91
+ ],
92
+ "defect": "Light drawn as a soft wash instead of a travelling edge. At this recipe's encode (q83, edge q97 for 18 frames) the encoder keeps the 150 px soft edge, so no motion gate sees it and only the 2 MiB size TARGET trips; the wash itself is caught by look review (question 6)."
93
+ }
94
+ },
95
+ "verdict": "PASS+TARGET"
96
+ }
@@ -0,0 +1,208 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <!-- Cover recipe: painted base + travelling light (the default). The painting is the whole
6
+ picture and the page only relights it. Copy this file to DIR/cover/index.html, put the
7
+ painting at DIR/cover/assets/base.png, list it in DIR/assets.json, and re-measure every
8
+ number in PAINTING on your own art: those numbers belong to one painting (here, a 1536x1024
9
+ line-up of five robots in panels under a painted LITFAMILY title and a painted motto). Any
10
+ panel can be the lead: set PAINTING.lead, give that machine its own lamps, and point the base
11
+ art's checkBox in assets.json at the same panel. -->
12
+ <link rel="stylesheet" href="/lit/fonts.css">
13
+ <script src="/lit/loop-kit.js"></script>
14
+ <style>
15
+ html, body { margin: 0; width: 1200px; height: 600px; overflow: hidden; background: #05080d; }
16
+ /* The painting keeps its own pixel coordinates and is scaled, never cropped: at 2:1 a 3:2
17
+ painting is pillarboxed, and the wings stay quiet. */
18
+ #art { position: absolute; left: 150px; top: 0; width: 1536px; height: 1024px; transform: scale(0.5859375); transform-origin: 0 0; }
19
+ #art > * { position: absolute; left: 0; top: 0; width: 1536px; height: 1024px; }
20
+ #wings, #motes { position: absolute; left: 0; top: 0; width: 1200px; height: 600px; }
21
+ #motes { mix-blend-mode: screen; }
22
+ #glow { mix-blend-mode: screen; }
23
+ #glint { mix-blend-mode: overlay; }
24
+ #mask { display: none; }
25
+ body.mask, body.mask * { background: #000 !important; }
26
+ body.mask canvas, body.mask svg { visibility: hidden; }
27
+ body.mask #mask { display: block; visibility: visible; }
28
+ </style>
29
+ </head>
30
+ <body>
31
+ <canvas id="wings" width="2400" height="1200"></canvas>
32
+ <div id="art">
33
+ <canvas id="paint" width="1536" height="1024"></canvas>
34
+ <canvas id="mask" width="1536" height="1024"></canvas>
35
+ <svg id="dim" viewBox="0 0 1536 1024"><defs><linearGradient id="pool" gradientUnits="userSpaceOnUse" y1="0" y2="0"></linearGradient><clipPath id="poolClip"></clipPath></defs><rect id="poolRect" fill="url(#pool)" clip-path="url(#poolClip)"/></svg>
36
+ <svg id="glow" viewBox="0 0 1536 1024">
37
+ <defs id="defs">
38
+ <filter id="blur5" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="5"/></filter>
39
+ <radialGradient id="ember"><stop offset="0" stop-color="#fff0d8" stop-opacity="0.85"/><stop offset="0.3" stop-color="#ff6337" stop-opacity="0.5"/><stop offset="1" stop-color="#ff6337" stop-opacity="0"/></radialGradient>
40
+ <radialGradient id="head"><stop offset="0" stop-color="#fff1e0"/><stop offset="0.4" stop-color="#ffb27a" stop-opacity="0.5"/><stop offset="1" stop-color="#ff7a46" stop-opacity="0"/></radialGradient>
41
+ </defs>
42
+ <g id="scanLayer"></g><g id="haloLayer"></g><g id="cometLayer" fill="none" stroke-linecap="round"></g>
43
+ <ellipse id="flare" fill="url(#ember)" opacity="0"/>
44
+ </svg>
45
+ <svg id="glint" viewBox="0 0 1536 1024"><defs><filter id="blur6"><feGaussianBlur stdDeviation="6"/></filter></defs><polygon id="sweep" fill="#fff" opacity="0" filter="url(#blur6)"/></svg>
46
+ </div>
47
+ <canvas id="motes" width="2400" height="1200"></canvas>
48
+ <script>
49
+ // PAINTING: every coordinate here is in the painting's own pixels. Measure them on your art.
50
+ const PAINTING = {
51
+ src: 'assets/base.png', w: 1536, h: 1024, left: 150, scale: 0.5859375,
52
+ panels: [[13, 303], [320, 607], [623, 906], [921, 1208], [1224, 1526]], // x ranges, left to right
53
+ top: 148, bottom: 945,
54
+ lead: 0, // index into panels: the machine that stays lit, carries the lamps and the comet
55
+ title: { box: [455, 32, 635, 88], lift: 1, maskLevel: 60 }, // painted letters; lift > 1 raises dark ones
56
+ // Lamp anchors per panel index, measured on that machine: two eyes and one core. Only the lead's
57
+ // entry glows. A lead with no entry stops the page; [] runs it with no beat lights.
58
+ lamps: { 0: [{ x: 88, y: 378, rx: 20, ry: 6, eye: true }, { x: 122, y: 374, rx: 16, ry: 7, eye: true }, { x: 85, y: 686, rx: 32, ry: 32, eye: false }] },
59
+ accent: { cx: 902, cy: 987, rx: 60, ry: 22 }, // the accent word of the painted motto
60
+ };
61
+
62
+ // Three speed layers, one rule each. Slow and perpetual: a comet laps the lead frame, motes rise.
63
+ // Mid: a window of work light walks the dim panels once per loop, with a sharp 24 px edge; a scan
64
+ // band sweeps each panel twice per loop, offset by the panel's position; one fast glint crosses
65
+ // the title. Beat: the eyes breathe twice and blink once, the core breathes once, the accent word
66
+ // flares at t = 0 and t = 0.5. Light is always a moving edge: a lossy animated WebP drops pixels
67
+ // that change by only a few levels per frame, so an in-place fade goes stale and pops at the seam.
68
+ const { TAU, clamp, fract, mix, ease, swell, cubicPulse, cdist, rng, makeNoise, loopNoise } = LitLoop;
69
+ const SVG = 'http://www.w3.org/2000/svg';
70
+ const byId = (id) => document.getElementById(id);
71
+ const add = (parent, tag, attrs) => { const el = document.createElementNS(SVG, tag); for (const k in attrs) el.setAttribute(k, String(attrs[k])); parent.appendChild(el); return el; };
72
+ const set = (el, attrs) => { for (const k in attrs) el.setAttribute(k, String(attrs[k])); };
73
+
74
+ if (!PAINTING.panels[PAINTING.lead]) throw new Error(`PAINTING.lead ${PAINTING.lead} is not a panel index (0 to ${PAINTING.panels.length - 1})`);
75
+ const leadLamps = PAINTING.lamps[PAINTING.lead];
76
+ if (!Array.isArray(leadLamps)) throw new Error(`PAINTING.lamps has no entry for the lead panel ${PAINTING.lead}: measure that machine's eyes and core, or set lamps[${PAINTING.lead}] = [] to run with no beat lights`);
77
+ const [lx0, lx1] = PAINTING.panels[PAINTING.lead];
78
+
79
+ // The dim pool spans the first to the last dim panel and is clipped around the lead, so a lead in
80
+ // the middle stays lit; the lit window is a flat span with a feather that walks every panel.
81
+ const dimmed = PAINTING.panels.filter((_, i) => i !== PAINTING.lead);
82
+ const POOL = { from: dimmed[0][0], to: dimmed[dimmed.length - 1][1], half: 230, feather: 24, dim: 0.68, lit: 0.04 };
83
+ set(byId('poolRect'), { x: POOL.from, y: PAINTING.top, width: POOL.to - POOL.from, height: PAINTING.bottom - PAINTING.top });
84
+ set(byId('pool'), { x1: POOL.from, x2: POOL.to });
85
+ if (lx0 > POOL.from) add(byId('poolClip'), 'rect', { x: POOL.from, y: PAINTING.top, width: lx0 - POOL.from, height: PAINTING.bottom - PAINTING.top });
86
+ if (lx1 < POOL.to) add(byId('poolClip'), 'rect', { x: lx1, y: PAINTING.top, width: POOL.to - lx1, height: PAINTING.bottom - PAINTING.top });
87
+ const stops = Array.from({ length: 6 }, () => add(byId('pool'), 'stop', { 'stop-color': '#02050a' }));
88
+ const windowAt = (t) => mix(POOL.from - 330, POOL.to + 330, fract(t + 0.18));
89
+ const lightAt = (t, x) => 1 - ease.smooth(clamp((Math.abs(x - windowAt(t)) - POOL.half) / POOL.feather));
90
+
91
+ // Scan bands: a 3 px leading line and two flat afterglow steps, clipped to the panel. Flat steps
92
+ // change pixels only at their edges, and by more than the encoder's threshold.
93
+ const BAND = 64, AFTERGLOW = [0.10, 0.05];
94
+ const bands = PAINTING.panels.map(([x0, x1], i) => {
95
+ add(add(byId('defs'), 'clipPath', { id: `panel${i}` }), 'rect', { x: x0 + 2, y: PAINTING.top, width: x1 - x0 - 4, height: PAINTING.bottom - PAINTING.top });
96
+ const group = add(byId('scanLayer'), 'g', { 'clip-path': `url(#panel${i})`, opacity: 0 });
97
+ const steps = AFTERGLOW.map((a) => add(group, 'rect', { x: x0 + 2, y: 0, width: x1 - x0 - 4, height: BAND / AFTERGLOW.length, fill: '#ffb27a', opacity: a }));
98
+ const edge = add(group, 'rect', { x: x0 + 2, y: 0, width: x1 - x0 - 4, height: 3, fill: '#ffc9a0', opacity: 0.32 });
99
+ return { group, steps, edge };
100
+ });
101
+ const halos = leadLamps.map((l) => add(byId('haloLayer'), 'ellipse', { cx: l.x, cy: l.y, rx: l.rx + 30, ry: l.ry + 30, fill: 'url(#ember)', opacity: 0 }));
102
+ set(byId('flare'), PAINTING.accent);
103
+
104
+ // The lead panel's frame as one chamfered path; the comet tail is stacked dashes of falling opacity.
105
+ const fl = lx0 + 0.5, fr = lx1 + 0.5, ft = PAINTING.top + 0.5, fb = PAINTING.bottom + 0.5, cut = 10;
106
+ const frame = `M${fl} ${ft + cut}L${fl + cut} ${ft}H${fr - cut}L${fr} ${ft + cut}V${fb - cut}L${fr - cut} ${fb}H${fl + cut}L${fl} ${fb - cut}Z`;
107
+ const tail = Array.from({ length: 6 }, (_, s) => add(byId('cometLayer'), 'path', { d: frame, stroke: '#ff7a46', 'stroke-width': 9 - s, opacity: 0.55 * (1 - s / 6), filter: s < 2 ? 'url(#blur5)' : '' }));
108
+ const core = add(byId('cometLayer'), 'path', { d: frame, stroke: '#ffd2b0', 'stroke-width': 3 });
109
+ const spark = add(byId('cometLayer'), 'circle', { r: 22, fill: 'url(#head)' });
110
+ const PERIM = core.getTotalLength();
111
+ const [tx, ty, tw, th] = PAINTING.title.box;
112
+ set(byId('sweep'), { points: `0,${ty - 8} 60,${ty - 8} 25,${ty + th + 8} -35,${ty + th + 8}` });
113
+
114
+ // Motes: few, mostly over the plain wings (each mote over the painting makes the encoder re-send
115
+ // the textured blocks around it).
116
+ const seeded = rng(11), field = makeNoise(5);
117
+ const motes = Array.from({ length: 50 }, (_, i) => ({ x: seeded() * 1200, y: seeded() * 600, laps: seeded() < 0.3 ? 2 : 1, r: 0.6 + seeded() * 1.4, a: 0.25 + seeded() * 0.45, i }));
118
+ const moteCtx = byId('motes').getContext('2d'); moteCtx.scale(2, 2);
119
+ const wingCtx = byId('wings').getContext('2d'); wingCtx.scale(2, 2);
120
+
121
+ function drawPool(t) {
122
+ const at = windowAt(t), share = (x) => clamp((x - POOL.from) / (POOL.to - POOL.from));
123
+ const edges = [[POOL.from, POOL.dim], [at - POOL.half - POOL.feather, POOL.dim], [at - POOL.half, POOL.lit], [at + POOL.half, POOL.lit], [at + POOL.half + POOL.feather, POOL.dim], [POOL.to, POOL.dim]];
124
+ edges.forEach(([x, a], k) => set(stops[k], { offset: k === 0 ? 0 : share(x), 'stop-opacity': a }));
125
+ }
126
+
127
+ function drawBands(t) {
128
+ PAINTING.panels.forEach(([x0, x1], i) => {
129
+ const y = mix(PAINTING.top - BAND, PAINTING.bottom, fract(2 * t - 0.11 * i));
130
+ bands[i].steps.forEach((r, k) => r.setAttribute('y', String(y + BAND - (k + 1) * (BAND / AFTERGLOW.length))));
131
+ bands[i].edge.setAttribute('y', String(y + BAND - 3));
132
+ bands[i].group.setAttribute('opacity', String(i === PAINTING.lead ? 0.8 : 0.35 + 0.65 * lightAt(t, (x0 + x1) / 2)));
133
+ });
134
+ }
135
+
136
+ function drawBeat(t) {
137
+ const blink = 1 - cubicPulse(0.62, 0.018, t);
138
+ leadLamps.forEach((l, k) => {
139
+ const b = l.eye ? swell(fract(2 * t - 0.06 * k)) : swell(fract(t - 0.35));
140
+ halos[k].setAttribute('opacity', String(l.eye ? (0.55 + 0.45 * b) * blink : 0.45 + 0.4 * b));
141
+ });
142
+ // A symmetric pulse at t = 0 and t = 0.5, so the reset at frame 0 lands under motion.
143
+ const pulse = Math.max(cubicPulse(0, 0.03, cdist(t, 0)), cubicPulse(0.5, 0.03, t));
144
+ byId('flare').setAttribute('opacity', String(0.18 + 0.5 * pulse));
145
+ }
146
+
147
+ function drawComet(t) {
148
+ const head = fract(t) * PERIM, length = 0.16 * PERIM;
149
+ tail.forEach((p, s) => { const l = length * (1 - s / 7); set(p, { 'stroke-dasharray': `${l} ${PERIM - l}`, 'stroke-dashoffset': -(head - l) }); });
150
+ set(core, { 'stroke-dasharray': `${0.35 * length} ${PERIM - 0.35 * length}`, 'stroke-dashoffset': -(head - 0.35 * length) });
151
+ const p = core.getPointAtLength(head);
152
+ set(spark, { cx: p.x, cy: p.y });
153
+ }
154
+
155
+ // One fast skewed sweep across the title in overlay blend: the dark plate stays dark and only the
156
+ // letters brighten (screen lights up a visible box; colour-dodge turns coloured letters red).
157
+ function drawGlint(t) {
158
+ const k = LitLoop.window(t, 0.08, 0.11);
159
+ set(byId('sweep'), { transform: `translate(${mix(tx - 40, tx + tw + 40, k < 0 ? 0 : ease.smooth(k))} 0)`, opacity: k < 0 ? 0 : 0.9 * Math.sin(Math.PI * k) });
160
+ }
161
+
162
+ function drawAir(t) {
163
+ wingCtx.clearRect(0, 0, 1200, 600);
164
+ const spill = (x, w, a) => { const g = wingCtx.createLinearGradient(x, 0, x + w, 0); g.addColorStop(0, `rgba(255,120,70,${a})`); g.addColorStop(1, 'rgba(255,120,70,0)'); wingCtx.fillStyle = g; wingCtx.fillRect(Math.min(x, x + w), 0, Math.abs(w), 600); };
165
+ spill(PAINTING.left, -PAINTING.left, 0.08);
166
+ spill(1200 - PAINTING.left, PAINTING.left, 0.03);
167
+ moteCtx.clearRect(0, 0, 1200, 600);
168
+ for (const m of motes) {
169
+ const y = 620 - fract(m.y / 600 + m.laps * t) * 640;
170
+ const x = m.x + 14 * loopNoise(field, t, 1.7 * m.i, 1.1);
171
+ const over = x >= PAINTING.left && x <= 1200 - PAINTING.left;
172
+ moteCtx.fillStyle = `rgba(255,214,170,${m.a * (over ? 0.3 + 0.7 * lightAt(t, (x - PAINTING.left) / PAINTING.scale) : 0.35)})`;
173
+ moteCtx.beginPath(); moteCtx.arc(x, y, m.r, 0, TAU); moteCtx.fill();
174
+ }
175
+ }
176
+
177
+ window.renderFrame = (t, options = {}) => {
178
+ document.body.className = options.mask ? 'mask' : '';
179
+ if (options.mask) return;
180
+ drawPool(t); drawBands(t); drawBeat(t); drawComet(t); drawGlint(t); drawAir(t);
181
+ };
182
+
183
+ // The painting is drawn once. Dark painted letters can be lifted in place with a luminance-keyed
184
+ // gain (the plate below them stays put), and the mask pass shows the same letters white on black
185
+ // for the contrast and cap-height gates.
186
+ const art = new Image();
187
+ art.src = PAINTING.src;
188
+ art.decode().then(() => {
189
+ const paint = byId('paint').getContext('2d', { willReadFrequently: true });
190
+ paint.drawImage(art, 0, 0, PAINTING.w, PAINTING.h);
191
+ const letters = paint.getImageData(tx, ty, tw, th), white = new ImageData(tw, th);
192
+ for (let p = 0; p < tw * th; p++) {
193
+ const o = 4 * p, Y = 0.2126 * letters.data[o] + 0.7152 * letters.data[o + 1] + 0.0722 * letters.data[o + 2];
194
+ const k = clamp((Y - 8) / 22), gain = 1 + (PAINTING.title.lift - 1) * k * k * (3 - 2 * k);
195
+ for (let c = 0; c < 3; c++) letters.data[o + c] = Math.min(255, Math.round(letters.data[o + c] * gain));
196
+ white.data[o] = white.data[o + 1] = white.data[o + 2] = Y > PAINTING.title.maskLevel ? 255 : 0;
197
+ white.data[o + 3] = 255;
198
+ }
199
+ paint.putImageData(letters, tx, ty);
200
+ const mask = byId('mask').getContext('2d');
201
+ mask.fillStyle = '#000'; mask.fillRect(0, 0, PAINTING.w, PAINTING.h);
202
+ mask.putImageData(white, tx, ty);
203
+ window.renderFrame(0);
204
+ window.ready = true;
205
+ });
206
+ </script>
207
+ </body>
208
+ </html>