@dixy-ui/themes 0.0.0-stage → 0.1.0-beta.1

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 (211) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -2
  3. package/dist/ansi/ornament.d.ts +3 -0
  4. package/dist/ansi/ornament.js +8 -0
  5. package/dist/ansi-panes.css +929 -0
  6. package/dist/art-deco.css +850 -0
  7. package/dist/atelier/ornament.d.ts +4 -0
  8. package/dist/atelier/ornament.js +9 -0
  9. package/dist/atomic/ornament.d.ts +3 -0
  10. package/dist/atomic/ornament.js +8 -0
  11. package/dist/atomic-lounge.css +946 -0
  12. package/dist/authoring.d.ts +142 -0
  13. package/dist/authoring.js +83 -0
  14. package/dist/azulejo/ornament.d.ts +3 -0
  15. package/dist/azulejo/ornament.js +8 -0
  16. package/dist/azulejo-tile.css +962 -0
  17. package/dist/blueprint/ornament.d.ts +3 -0
  18. package/dist/blueprint/ornament.js +8 -0
  19. package/dist/blueprint.css +897 -0
  20. package/dist/brutalist/ornament.d.ts +3 -0
  21. package/dist/brutalist/ornament.js +8 -0
  22. package/dist/brutalist-concrete.css +936 -0
  23. package/dist/bytes.json +162 -0
  24. package/dist/candy-clay.css +778 -0
  25. package/dist/catalog.d.ts +1346 -0
  26. package/dist/catalog.js +1379 -0
  27. package/dist/character.d.ts +103 -0
  28. package/dist/character.js +477 -0
  29. package/dist/chrome.d.ts +114 -0
  30. package/dist/chrome.js +247 -0
  31. package/dist/clay/ornament.d.ts +3 -0
  32. package/dist/clay/ornament.js +8 -0
  33. package/dist/compile-css.d.ts +3 -0
  34. package/dist/compile-css.js +133 -0
  35. package/dist/deco/ornament.d.ts +3 -0
  36. package/dist/deco/ornament.js +8 -0
  37. package/dist/diwali/ornament.d.ts +3 -0
  38. package/dist/diwali/ornament.js +8 -0
  39. package/dist/diwali-lights.css +894 -0
  40. package/dist/documents.d.ts +104 -0
  41. package/dist/documents.js +1411 -0
  42. package/dist/forest/ornament.d.ts +3 -0
  43. package/dist/forest/ornament.js +12 -0
  44. package/dist/foundations.d.ts +105 -0
  45. package/dist/foundations.js +140 -0
  46. package/dist/frame-corners.d.ts +5 -0
  47. package/dist/frame-corners.js +5 -0
  48. package/dist/frame-styles.d.ts +36 -0
  49. package/dist/frame-styles.js +146 -0
  50. package/dist/freeze.d.ts +2 -0
  51. package/dist/freeze.js +9 -0
  52. package/dist/freighter/ornament.d.ts +3 -0
  53. package/dist/freighter/ornament.js +8 -0
  54. package/dist/freighter.css +942 -0
  55. package/dist/glass/ornament.d.ts +3 -0
  56. package/dist/glass/ornament.js +8 -0
  57. package/dist/glass-ui.css +923 -0
  58. package/dist/hardware/ornament.d.ts +4 -0
  59. package/dist/hardware/ornament.js +10 -0
  60. package/dist/holi/ornament.d.ts +3 -0
  61. package/dist/holi/ornament.js +8 -0
  62. package/dist/holi-colours.css +887 -0
  63. package/dist/hud/ornament.d.ts +3 -0
  64. package/dist/hud/ornament.js +8 -0
  65. package/dist/light/ornament.d.ts +3 -0
  66. package/dist/light/ornament.js +10 -0
  67. package/dist/living-forest.css +917 -0
  68. package/dist/lunar/ornament.d.ts +3 -0
  69. package/dist/lunar/ornament.js +8 -0
  70. package/dist/lunar-new-year.css +811 -0
  71. package/dist/manifests.d.ts +2485 -0
  72. package/dist/manifests.js +264 -0
  73. package/dist/material-light.css +880 -0
  74. package/dist/neon/ornament.d.ts +3 -0
  75. package/dist/neon/ornament.js +8 -0
  76. package/dist/neon-night-market.css +944 -0
  77. package/dist/ocean/ornament.d.ts +3 -0
  78. package/dist/ocean/ornament.js +8 -0
  79. package/dist/ocean-depths.css +825 -0
  80. package/dist/orbital-hud.css +973 -0
  81. package/dist/organic-css.d.ts +3 -0
  82. package/dist/organic-css.js +23 -0
  83. package/dist/organic-geometry.d.ts +20 -0
  84. package/dist/organic-geometry.js +74 -0
  85. package/dist/organic-ornament.d.ts +9 -0
  86. package/dist/organic-ornament.js +28 -0
  87. package/dist/organic-paint-launch.d.ts +43 -0
  88. package/dist/organic-paint-launch.js +1156 -0
  89. package/dist/organic-paint-world.d.ts +24 -0
  90. package/dist/organic-paint-world.js +676 -0
  91. package/dist/organic-paint.d.ts +50 -0
  92. package/dist/organic-paint.js +695 -0
  93. package/dist/organic-runtime.d.ts +4 -0
  94. package/dist/organic-runtime.js +231 -0
  95. package/dist/organic-settings.d.ts +680 -0
  96. package/dist/organic-settings.js +433 -0
  97. package/dist/ornament-parts.d.ts +20 -0
  98. package/dist/ornament-parts.js +15 -0
  99. package/dist/papel-picado.css +904 -0
  100. package/dist/phosphor/ornament.d.ts +3 -0
  101. package/dist/phosphor/ornament.js +8 -0
  102. package/dist/phosphor-terminal.css +1011 -0
  103. package/dist/picado/ornament.d.ts +3 -0
  104. package/dist/picado/ornament.js +8 -0
  105. package/dist/prism/ornament.d.ts +4 -0
  106. package/dist/prism/ornament.js +9 -0
  107. package/dist/prism-glass.css +970 -0
  108. package/dist/recipes.d.ts +938 -0
  109. package/dist/recipes.js +939 -0
  110. package/dist/resolve.d.ts +40 -0
  111. package/dist/resolve.js +24 -0
  112. package/dist/retro/ornament.d.ts +5 -0
  113. package/dist/retro/ornament.js +10 -0
  114. package/dist/retro-os.css +867 -0
  115. package/dist/riso/ornament.d.ts +3 -0
  116. package/dist/riso/ornament.js +8 -0
  117. package/dist/riso-zine.css +786 -0
  118. package/dist/sail/ornament.d.ts +3 -0
  119. package/dist/sail/ornament.js +8 -0
  120. package/dist/scrapbook.css +685 -0
  121. package/dist/signal/ornament.d.ts +4 -0
  122. package/dist/signal/ornament.js +11 -0
  123. package/dist/skins/ansi-panes.d.ts +68 -0
  124. package/dist/skins/ansi-panes.js +68 -0
  125. package/dist/skins/art-deco.d.ts +68 -0
  126. package/dist/skins/art-deco.js +68 -0
  127. package/dist/skins/atomic-lounge.d.ts +68 -0
  128. package/dist/skins/atomic-lounge.js +68 -0
  129. package/dist/skins/azulejo-tile.d.ts +68 -0
  130. package/dist/skins/azulejo-tile.js +68 -0
  131. package/dist/skins/blueprint.d.ts +68 -0
  132. package/dist/skins/blueprint.js +68 -0
  133. package/dist/skins/brutalist-concrete.d.ts +68 -0
  134. package/dist/skins/brutalist-concrete.js +68 -0
  135. package/dist/skins/candy-clay.d.ts +57 -0
  136. package/dist/skins/candy-clay.js +57 -0
  137. package/dist/skins/diwali-lights.d.ts +68 -0
  138. package/dist/skins/diwali-lights.js +68 -0
  139. package/dist/skins/freighter.d.ts +68 -0
  140. package/dist/skins/freighter.js +68 -0
  141. package/dist/skins/glass-ui.d.ts +68 -0
  142. package/dist/skins/glass-ui.js +68 -0
  143. package/dist/skins/holi-colours.d.ts +68 -0
  144. package/dist/skins/holi-colours.js +68 -0
  145. package/dist/skins/living-forest.d.ts +68 -0
  146. package/dist/skins/living-forest.js +68 -0
  147. package/dist/skins/lunar-new-year.d.ts +68 -0
  148. package/dist/skins/lunar-new-year.js +68 -0
  149. package/dist/skins/material-light.d.ts +57 -0
  150. package/dist/skins/material-light.js +57 -0
  151. package/dist/skins/neon-night-market.d.ts +68 -0
  152. package/dist/skins/neon-night-market.js +68 -0
  153. package/dist/skins/ocean-depths.d.ts +57 -0
  154. package/dist/skins/ocean-depths.js +57 -0
  155. package/dist/skins/orbital-hud.d.ts +68 -0
  156. package/dist/skins/orbital-hud.js +68 -0
  157. package/dist/skins/papel-picado.d.ts +68 -0
  158. package/dist/skins/papel-picado.js +68 -0
  159. package/dist/skins/phosphor-terminal.d.ts +68 -0
  160. package/dist/skins/phosphor-terminal.js +68 -0
  161. package/dist/skins/prism-glass.d.ts +57 -0
  162. package/dist/skins/prism-glass.js +57 -0
  163. package/dist/skins/retro-os.d.ts +57 -0
  164. package/dist/skins/retro-os.js +57 -0
  165. package/dist/skins/riso-zine.d.ts +68 -0
  166. package/dist/skins/riso-zine.js +68 -0
  167. package/dist/skins/scrapbook.d.ts +57 -0
  168. package/dist/skins/scrapbook.js +57 -0
  169. package/dist/skins/solar-sail.d.ts +68 -0
  170. package/dist/skins/solar-sail.js +68 -0
  171. package/dist/skins/studio-hardware.d.ts +57 -0
  172. package/dist/skins/studio-hardware.js +57 -0
  173. package/dist/skins/sumi-ink.d.ts +68 -0
  174. package/dist/skins/sumi-ink.js +68 -0
  175. package/dist/skins/swiss-poster.d.ts +68 -0
  176. package/dist/skins/swiss-poster.js +68 -0
  177. package/dist/skins/tactile-atelier.d.ts +57 -0
  178. package/dist/skins/tactile-atelier.js +57 -0
  179. package/dist/skins/tokyo-transit.d.ts +68 -0
  180. package/dist/skins/tokyo-transit.js +68 -0
  181. package/dist/skins/winamp-signal.d.ts +57 -0
  182. package/dist/skins/winamp-signal.js +57 -0
  183. package/dist/skins/y2k-titanium.d.ts +57 -0
  184. package/dist/skins/y2k-titanium.js +57 -0
  185. package/dist/skins/yule-knit.d.ts +68 -0
  186. package/dist/skins/yule-knit.js +68 -0
  187. package/dist/solar-sail.css +901 -0
  188. package/dist/starters-launch.d.ts +361 -0
  189. package/dist/starters-launch.js +808 -0
  190. package/dist/studio-hardware.css +867 -0
  191. package/dist/sumi/ornament.d.ts +3 -0
  192. package/dist/sumi/ornament.js +8 -0
  193. package/dist/sumi-ink.css +733 -0
  194. package/dist/swiss/ornament.d.ts +3 -0
  195. package/dist/swiss/ornament.js +8 -0
  196. package/dist/swiss-poster.css +727 -0
  197. package/dist/tactile-atelier.css +922 -0
  198. package/dist/theme-document.schema.json +790 -0
  199. package/dist/theme-schema.d.ts +453 -0
  200. package/dist/theme-schema.js +416 -0
  201. package/dist/titanium/ornament.d.ts +3 -0
  202. package/dist/titanium/ornament.js +11 -0
  203. package/dist/tokyo-transit.css +756 -0
  204. package/dist/transit/ornament.d.ts +3 -0
  205. package/dist/transit/ornament.js +8 -0
  206. package/dist/winamp-signal.css +1048 -0
  207. package/dist/y2k-titanium.css +979 -0
  208. package/dist/yule/ornament.d.ts +3 -0
  209. package/dist/yule/ornament.js +8 -0
  210. package/dist/yule-knit.css +850 -0
  211. package/package.json +351 -3
@@ -0,0 +1,1156 @@
1
+ /**
2
+ * Painters for the 2026-12 launch frames: terminal (crt, panes), extras
3
+ * (swiss, azulejo, formwork, atomic), sci-fi (hud, hazard, orbit), festival
4
+ * (rangoli, papercut, picado, gulal, knit) and Glass UI (liquid).
5
+ *
6
+ * Same contract as organic-paint-world.ts: card space spans 0..w × 0..h with a
7
+ * 32px gutter; every painter clips itself away from the content box (`limits`),
8
+ * outlines only follow the card's own rounded rectangle via `trace()`, and
9
+ * motion runs only while `playing`, so reduced motion gets a still frame.
10
+ */
11
+ import { paintState, rand, tau, trace, } from "./organic-paint.js";
12
+ const cornersOf = (w, h) => [
13
+ [0, 0, 1, 1],
14
+ [w, 0, -1, 1],
15
+ [w, h, -1, -1],
16
+ [0, h, 1, -1],
17
+ ];
18
+ /** Keep every mark out of the content box, whatever the painter draws. */
19
+ function clipFrame(ctx, w, h, l) {
20
+ ctx.beginPath();
21
+ ctx.rect(-32, -32, w + 64, h + 64);
22
+ ctx.rect(l.inline, l.top, Math.max(0, w - l.inline * 2), Math.max(0, h - l.top - l.bottom));
23
+ ctx.clip("evenodd");
24
+ }
25
+ /** Light or dark surface, read from the computed paper colour. */
26
+ function isDark(color) {
27
+ const n = (color.match(/-?[\d.]+/g) ?? []).map(Number);
28
+ if (color.startsWith("color("))
29
+ return luma(n[0] * 255, n[1] * 255, n[2] * 255);
30
+ if (color.startsWith("oklch") || color.startsWith("oklab"))
31
+ return n[0] < (color.includes("%") ? 50 : 0.5);
32
+ if (n.length >= 4 && n[3] === 0)
33
+ return false;
34
+ return n.length >= 3 ? luma(n[0], n[1], n[2]) : false;
35
+ }
36
+ const luma = (r, g, b) => 0.2126 * r + 0.7152 * g + 0.0722 * b < 110;
37
+ function outline(ctx, p, color, width = 1, alpha = 1, offset = 0) {
38
+ trace(ctx, p, offset);
39
+ ctx.closePath();
40
+ ctx.strokeStyle = color;
41
+ ctx.lineWidth = width;
42
+ ctx.globalAlpha = alpha;
43
+ ctx.stroke();
44
+ ctx.globalAlpha = 1;
45
+ }
46
+ function glow(ctx, color, blur) {
47
+ ctx.shadowColor = color;
48
+ ctx.shadowBlur = blur;
49
+ }
50
+ function dot(ctx, x, y, r, color) {
51
+ ctx.beginPath();
52
+ ctx.arc(x, y, Math.max(0.2, r), 0, tau);
53
+ ctx.fillStyle = color;
54
+ ctx.fill();
55
+ }
56
+ function label(ctx, text, x, y, font, color, align = "left") {
57
+ ctx.font = font;
58
+ ctx.textAlign = align;
59
+ ctx.textBaseline = "middle";
60
+ ctx.fillStyle = color;
61
+ ctx.fillText(text, x, y);
62
+ return ctx.measureText(text).width;
63
+ }
64
+ /** An n-point star with alternating radii, used for bursts and sparkles. */
65
+ function star(ctx, x, y, outer, inner, points, turn = 0) {
66
+ ctx.beginPath();
67
+ for (let i = 0; i < points * 2; i++) {
68
+ const r = i % 2 ? inner : outer, a = turn + (i * Math.PI) / points - Math.PI / 2;
69
+ if (i)
70
+ ctx.lineTo(x + Math.cos(a) * r, y + Math.sin(a) * r);
71
+ else
72
+ ctx.moveTo(x + Math.cos(a) * r, y + Math.sin(a) * r);
73
+ }
74
+ ctx.closePath();
75
+ }
76
+ /** Run a painter body inside a saved, frame-clipped context. */
77
+ function framed(ctx, w, h, l, body) {
78
+ ctx.save();
79
+ clipFrame(ctx, w, h, l);
80
+ body();
81
+ ctx.restore();
82
+ }
83
+ function tierOf(w, h) {
84
+ if (w >= 440 && h >= 160)
85
+ return "hero";
86
+ if (w < 300 || h < 140)
87
+ return "small";
88
+ return "card";
89
+ }
90
+ /** A deterministic per-instance choice in 0..n-1. */
91
+ function choose(index, seed, salt, n) {
92
+ return Math.floor(rand(seed * 0.173 + index * 7.31 + salt * 3.7) * n) % n;
93
+ }
94
+ /** Distance from a point to the content box: soft marks are sized to stop short of it, never clipped by it. */
95
+ function room(x, y, w, h, l) {
96
+ const dx = Math.max(l.inline - x, 0, x - (w - l.inline)), dy = Math.max(l.top - y, 0, y - (h - l.bottom));
97
+ return Math.hypot(dx, dy);
98
+ }
99
+ const vt = (size) => `${size}px "VT323", "Share Tech Mono", ui-monospace, monospace`;
100
+ export const paintPhosphor = (ctx, p, w, h, settings, colors, clock, playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
101
+ const s = paintState(settings), dark = isDark(colors.paper), tier = tierOf(w, h), quiet = s.style === "Quiet cursor";
102
+ if (dark)
103
+ glow(ctx, colors.a, 7);
104
+ // A double rule just outside the edge, like ╔═╗ drawn in box characters.
105
+ outline(ctx, p, colors.a, 1, dark ? 0.85 : 0.55, 3);
106
+ if (!quiet)
107
+ outline(ctx, p, colors.a, 1, dark ? 0.55 : 0.35, 6);
108
+ if (!quiet) {
109
+ const len = 8 + s.scale * 0.45 * (0.5 + s.corners / 100);
110
+ ctx.strokeStyle = colors.a;
111
+ ctx.lineWidth = 2;
112
+ for (const [x, y, sx, sy] of cornersOf(w, h)) {
113
+ const ox = x - sx * 6, oy = y - sy * 6;
114
+ ctx.beginPath();
115
+ ctx.moveTo(ox + sx * len, oy);
116
+ ctx.lineTo(ox, oy);
117
+ ctx.lineTo(ox, oy + sy * len);
118
+ ctx.stroke();
119
+ }
120
+ }
121
+ if (s.style === "Raster ticks" && tier === "hero") {
122
+ ctx.strokeStyle = colors.b;
123
+ ctx.lineWidth = 1;
124
+ for (let x = 12, i = 0; x < w - 12; x += 8, i++) {
125
+ ctx.beginPath();
126
+ ctx.moveTo(x, -10);
127
+ ctx.lineTo(x, i % 8 ? -13 : -17);
128
+ ctx.stroke();
129
+ }
130
+ }
131
+ // Status line inside the card's bottom margin; small cards keep only the cursor.
132
+ const y = h - Math.min(11, limits.bottom / 2);
133
+ let x = Math.max(8, limits.inline - 2);
134
+ if (tier !== "small" &&
135
+ (s.style === "Status line" || s.style === "Box corners")) {
136
+ x += label(ctx, "READY.", x, y, vt(16), colors.a) + 4;
137
+ if (shown > 35 && tier === "hero")
138
+ label(ctx, `MEM ${Math.round(64 * (shown / 100))}K OK`, w - Math.max(8, limits.inline - 2), y, vt(15), colors.c, "right");
139
+ }
140
+ if (!playing || clock % 1 < 0.55) {
141
+ ctx.fillStyle = colors.a;
142
+ ctx.fillRect(x, y - 6, 7, 12);
143
+ }
144
+ if (playing && s.motion > 0 && choose(index, s.seed, 1, 2) === 0) {
145
+ const q = p.at((((clock * s.motion) / 300) % 1) * p.total);
146
+ ctx.shadowBlur = dark ? 12 : 0;
147
+ dot(ctx, q.x + Math.cos(q.t - Math.PI / 2) * 3, q.y + Math.sin(q.t - Math.PI / 2) * 3, 1.8, colors.d);
148
+ }
149
+ });
150
+ /* ───────────────────────── ANSI Panes ───────────────────────── */
151
+ const jb = (size, weight = 700) => `${weight} ${size}px "JetBrains Mono", ui-monospace, Menlo, monospace`;
152
+ const paneTitles = [
153
+ "main",
154
+ "logs",
155
+ "files",
156
+ "branches",
157
+ "status",
158
+ "jobs",
159
+ "diff",
160
+ ];
161
+ const spinner = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
162
+ export const paintPanes = (ctx, p, w, h, settings, colors, clock, playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
163
+ const s = paintState(settings), seed = s.seed + index * 37, bare = s.style === "Bare panes";
164
+ // A single-line pane box just outside the card, like ┌─┐.
165
+ outline(ctx, p, colors.d, 1, 0.7, 5);
166
+ if (bare)
167
+ return;
168
+ // Title cut into the top rule: "─ branches ─".
169
+ const title = paneTitles[Math.floor(rand(seed) * paneTitles.length)];
170
+ ctx.font = jb(11);
171
+ const tw = ctx.measureText(title).width + 12, tx = Math.max(14, p.r + 8);
172
+ ctx.save();
173
+ ctx.globalCompositeOperation = "destination-out";
174
+ ctx.fillRect(tx - 2, -9, tw + 4, 8);
175
+ ctx.restore();
176
+ label(ctx, title, tx + 6, -5, jb(11), colors.a);
177
+ // Pane numbers on the right of the rule; the focused one is reverse video.
178
+ const panes = 1 + Math.round((shown / 100) * 3), focus = Math.floor(rand(seed + 3) * panes);
179
+ for (let i = 0; i < panes; i++) {
180
+ const bx = w - 14 - (panes - i) * 18;
181
+ ctx.fillStyle = i === focus ? colors.b : "transparent";
182
+ if (i === focus)
183
+ ctx.fillRect(bx - 1, -11, 14, 12);
184
+ label(ctx, String(i + 1), bx + 6, -5, jb(10), i === focus ? colors.paper || "#000" : colors.d, "center");
185
+ }
186
+ // Activity spinner at the top-right corner.
187
+ const frame = playing && s.motion > 0 ? Math.floor(clock * 10) % spinner.length : 0;
188
+ label(ctx, playing ? spinner[frame] : "●", w - 6, -5, jb(10, 400), colors.c, "center");
189
+ if (s.style === "Split panes") {
190
+ // Tee junctions where a split would meet the frame: ├ and ┤.
191
+ ctx.strokeStyle = colors.d;
192
+ ctx.lineWidth = 1;
193
+ for (const x of [-5, w + 5]) {
194
+ ctx.beginPath();
195
+ ctx.moveTo(x, h / 2);
196
+ ctx.lineTo(x + (x < 0 ? 8 : -8), h / 2);
197
+ ctx.stroke();
198
+ }
199
+ }
200
+ if ((s.style === "Key hints" || s.style === "Pane titles") &&
201
+ w > 240 &&
202
+ limits.bottom >= 14) {
203
+ // Keyboard hints sit in the card's own bottom margin, so dense grids
204
+ // never collide with the next card; narrow cards skip them.
205
+ const hints = [
206
+ ["↑↓", "move"],
207
+ ["⏎", "open"],
208
+ ["/", "find"],
209
+ ["?", "help"],
210
+ ["q", "quit"],
211
+ ];
212
+ let x = Math.max(8, limits.inline - 2);
213
+ const y = h - Math.min(11, limits.bottom / 2);
214
+ for (const [key, text] of hints) {
215
+ ctx.font = jb(10);
216
+ const kw = ctx.measureText(key).width + 6;
217
+ ctx.font = jb(10, 400);
218
+ const lw = ctx.measureText(text).width;
219
+ if (x + kw + lw + 4 > w)
220
+ break;
221
+ ctx.fillStyle = colors.d;
222
+ ctx.fillRect(x, y - 6, kw, 12);
223
+ ctx.save();
224
+ ctx.globalCompositeOperation = "destination-out";
225
+ label(ctx, key, x + kw / 2, y, jb(10), "#000", "center");
226
+ ctx.restore();
227
+ label(ctx, text, x + kw + 4, y, jb(10, 400), colors.d);
228
+ x += kw + lw + 14;
229
+ }
230
+ }
231
+ });
232
+ /* ───────────────────────── Swiss Poster ───────────────────────── */
233
+ export const paintSwiss = (ctx, _p, w, h, settings, colors, _clock, _playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
234
+ const s = paintState(settings), tier = tierOf(w, h), len = 8 + s.scale * 0.25;
235
+ // Crop marks are the system: every sheet gets them.
236
+ ctx.strokeStyle = colors.d;
237
+ ctx.lineWidth = 1;
238
+ for (const [x, y, sx, sy] of cornersOf(w, h)) {
239
+ ctx.beginPath();
240
+ ctx.moveTo(x, y - sy * 6);
241
+ ctx.lineTo(x, y - sy * (6 + len));
242
+ ctx.moveTo(x - sx * 6, y);
243
+ ctx.lineTo(x - sx * (6 + len), y);
244
+ ctx.stroke();
245
+ }
246
+ if (s.style === "Bare grid")
247
+ return;
248
+ // The red block is an event, not a pattern: the hero always, other cards rarely.
249
+ const block = s.style === "Red block" ||
250
+ tier === "hero" ||
251
+ (tier === "card" && choose(index, s.seed, 2, 3) === 0);
252
+ if (block) {
253
+ const bh = Math.min(limits.top - 4, 6 + s.weight * 0.14), bw = w * (0.18 + (shown / 100) * 0.22), left = choose(index, s.seed, 3, 2) === 0 && tier !== "hero";
254
+ if (bh > 2) {
255
+ ctx.fillStyle = colors.a;
256
+ ctx.fillRect(left ? 0 : w - bw, 0, bw, bh);
257
+ }
258
+ }
259
+ if ((s.style === "Grid ticks" || s.style === "Crop marks") &&
260
+ tier === "hero") {
261
+ for (let i = 0; i <= 6; i++) {
262
+ const x = (w * i) / 6;
263
+ ctx.beginPath();
264
+ ctx.moveTo(x, h + 6);
265
+ ctx.lineTo(x, h + 11);
266
+ ctx.stroke();
267
+ if (i < 6)
268
+ label(ctx, String(i + 1).padStart(2, "0"), x + 3, h + 16, '600 8px "Inter Variable", Inter, sans-serif', colors.c);
269
+ }
270
+ const x = w + 12, y = h + 12, r = 5;
271
+ ctx.beginPath();
272
+ ctx.arc(x, y, r, 0, tau);
273
+ ctx.moveTo(x - r - 3, y);
274
+ ctx.lineTo(x + r + 3, y);
275
+ ctx.moveTo(x, y - r - 3);
276
+ ctx.lineTo(x, y + r + 3);
277
+ ctx.stroke();
278
+ }
279
+ });
280
+ /* ───────────────────────── Azulejo Tile ───────────────────────── */
281
+ function tile(ctx, x, y, size, colors, rosette) {
282
+ const r = size / 2;
283
+ ctx.save();
284
+ ctx.translate(x, y);
285
+ ctx.fillStyle = colors.d;
286
+ ctx.strokeStyle = colors.a;
287
+ ctx.lineWidth = 1;
288
+ ctx.beginPath();
289
+ ctx.rect(-r, -r, size, size);
290
+ ctx.fill();
291
+ ctx.stroke();
292
+ ctx.save();
293
+ ctx.clip();
294
+ // Quarter circles in each corner join into rings when tiles meet.
295
+ ctx.lineWidth = Math.max(1, size * 0.07);
296
+ for (const [cx, cy] of [
297
+ [-r, -r],
298
+ [r, -r],
299
+ [r, r],
300
+ [-r, r],
301
+ ]) {
302
+ ctx.beginPath();
303
+ ctx.arc(cx, cy, size * 0.3, 0, tau);
304
+ ctx.stroke();
305
+ }
306
+ // Four-petal flower (eight on a rosette).
307
+ const petals = rosette ? 8 : 4;
308
+ ctx.fillStyle = colors.b;
309
+ for (let i = 0; i < petals; i++) {
310
+ ctx.save();
311
+ ctx.rotate((i * tau) / petals);
312
+ ctx.beginPath();
313
+ ctx.ellipse(0, -r * 0.42, r * 0.16, r * 0.34, 0, 0, tau);
314
+ ctx.fill();
315
+ ctx.restore();
316
+ }
317
+ dot(ctx, 0, 0, r * 0.18, colors.c);
318
+ // Glaze highlight.
319
+ ctx.globalAlpha = 0.45;
320
+ ctx.strokeStyle = "#ffffff";
321
+ ctx.lineWidth = 1.2;
322
+ ctx.beginPath();
323
+ ctx.arc(-r * 0.2, -r * 0.2, r * 0.75, Math.PI * 1.05, Math.PI * 1.45);
324
+ ctx.stroke();
325
+ ctx.restore();
326
+ ctx.restore();
327
+ }
328
+ export const paintAzulejo = (ctx, p, w, h, settings, colors, clock, playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
329
+ const s = paintState(settings), tier = tierOf(w, h), size = 14 + s.scale * 0.5, corners = cornersOf(w, h), at = choose(index, s.seed, 1, 4), rosette = s.style === "Floral rosette";
330
+ outline(ctx, p, colors.a, 1, 0.5, 4);
331
+ const corner = (k, scale) => {
332
+ const [x, y, sx, sy] = corners[k], t = size * scale;
333
+ tile(ctx, x - sx * t * 0.15, y - sy * t * 0.15, t, colors, rosette);
334
+ };
335
+ if (tier === "hero" || s.style === "Border run") {
336
+ // A frieze: a run of tiles along the top edge, finished by a corner tile at each end.
337
+ const small = size * 0.62, step = small + 1;
338
+ const run = Math.max(0, Math.floor((w - size * 2) / step) * (shown / 100));
339
+ const start = (w - run * step) / 2 + step / 2;
340
+ for (let i = 0; i < Math.floor(run); i++)
341
+ tile(ctx, start + i * step, -small / 2 - 4, small, colors, false);
342
+ corner(0, 1);
343
+ corner(1, 1);
344
+ }
345
+ else if (tier === "card") {
346
+ // One corner tile, with two smaller tiles trailing along the edge.
347
+ corner(at, 1);
348
+ const [x, y, sx, sy] = corners[at], small = size * 0.55;
349
+ for (let i = 1; i <= 2; i++)
350
+ tile(ctx, x + sx * (size * 0.5 + i * (small + 1)), y - sy * (small / 2 + 4), small, colors, false);
351
+ }
352
+ else if (s.style !== "Single tile" || at % 2 === 0)
353
+ corner(at, 0.75);
354
+ if (playing && s.motion > 0 && tier === "hero") {
355
+ const x = ((clock * s.motion * 0.6) % (w + 80)) - 40;
356
+ const g = ctx.createLinearGradient(x - 20, 0, x + 20, 0);
357
+ g.addColorStop(0, "#ffffff00");
358
+ g.addColorStop(0.5, "#ffffff66");
359
+ g.addColorStop(1, "#ffffff00");
360
+ ctx.fillStyle = g;
361
+ ctx.fillRect(x - 20, -32, 40, 32);
362
+ }
363
+ });
364
+ /* ───────────────────────── Brutalist Concrete ───────────────────────── */
365
+ function tieHole(ctx, x, y, r, colors) {
366
+ dot(ctx, x, y + 0.8, r + 0.8, "rgba(255,255,255,.35)");
367
+ dot(ctx, x, y, r, colors.a);
368
+ dot(ctx, x - r * 0.25, y - r * 0.25, r * 0.45, "rgba(0,0,0,.35)");
369
+ }
370
+ export const paintFormwork = (ctx, p, w, h, settings, colors, _clock, _playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
371
+ const s = paintState(settings), tier = tierOf(w, h), r = 2 + s.scale * 0.06, inset = Math.max(6, Math.min(11, limits.top / 2, limits.inline / 2));
372
+ outline(ctx, p, colors.b, 1, 0.4, 3);
373
+ if (s.style === "Bare concrete")
374
+ return;
375
+ // Tie holes follow the formwork grid: corners always, a row on wider pours.
376
+ for (const [x, y, sx, sy] of cornersOf(w, h))
377
+ tieHole(ctx, x + sx * inset, y + sy * inset, r, colors);
378
+ if (tier !== "small") {
379
+ const bays = Math.max(1, Math.round((w - inset * 2) / (90 + (100 - shown))));
380
+ for (let i = 1; i < bays; i++) {
381
+ const x = inset + ((w - inset * 2) * i) / bays;
382
+ tieHole(ctx, x, inset, r, colors);
383
+ tieHole(ctx, x, h - inset, r, colors);
384
+ }
385
+ }
386
+ if (s.style === "Board seams" ||
387
+ (s.style === "Tie holes" && tier === "hero")) {
388
+ ctx.strokeStyle = colors.b;
389
+ ctx.globalAlpha = 0.55;
390
+ ctx.lineWidth = 1;
391
+ for (let y = 18; y < h - 6; y += 18) {
392
+ ctx.beginPath();
393
+ ctx.moveTo(-3, y);
394
+ ctx.lineTo(-7 - s.weight * 0.06, y);
395
+ ctx.stroke();
396
+ }
397
+ ctx.globalAlpha = 1;
398
+ }
399
+ if (tier !== "small" &&
400
+ (s.style === "Stencil marks" || s.style === "Tie holes")) {
401
+ // Each pour gets its own stencil code; only the hero carries a survey level.
402
+ const code = "ABCDEF"[choose(index, s.seed, 1, 6)] +
403
+ "-0" +
404
+ (1 + choose(index, s.seed, 2, 9));
405
+ label(ctx, code, Math.max(8, limits.inline - 2), h - Math.min(11, limits.bottom / 2), '400 11px "Archivo Black", "Arial Black", sans-serif', colors.d);
406
+ if (tier === "hero") {
407
+ const x = w - limits.inline - 46, y = h - Math.min(11, limits.bottom / 2);
408
+ ctx.fillStyle = colors.c;
409
+ ctx.beginPath();
410
+ ctx.moveTo(x, y + 5);
411
+ ctx.lineTo(x - 5, y - 4);
412
+ ctx.lineTo(x + 5, y - 4);
413
+ ctx.closePath();
414
+ ctx.fill();
415
+ label(ctx, "+4.20", x + 9, y, mono(11), colors.d);
416
+ }
417
+ }
418
+ });
419
+ /* ───────────────────────── Atomic Lounge ───────────────────────── */
420
+ export const paintAtomic = (ctx, _p, w, h, settings, colors, clock, playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
421
+ const s = paintState(settings), tier = tierOf(w, h), seed = s.seed + index * 53, spin = playing ? clock * (s.motion / 400) : 0, big = 9 + s.scale * 0.5, corners = cornersOf(w, h), at = choose(index, s.seed, 1, 4), inks = [colors.a, colors.c, colors.b];
422
+ const burst = (x, y, r, color, turn) => {
423
+ star(ctx, x, y, r, r * 0.28, 8, turn);
424
+ ctx.fillStyle = color;
425
+ ctx.fill();
426
+ star(ctx, x, y, r * 0.55, r * 0.2, 4, turn + Math.PI / 8);
427
+ ctx.fill();
428
+ };
429
+ const boomerang = (x, y, size, turn, color) => {
430
+ ctx.save();
431
+ ctx.translate(x, y);
432
+ ctx.rotate(turn);
433
+ ctx.beginPath();
434
+ ctx.moveTo(-size, 0);
435
+ ctx.quadraticCurveTo(0, -size * 0.9, size, 0);
436
+ ctx.quadraticCurveTo(0, -size * 0.35, -size, 0);
437
+ ctx.fillStyle = color;
438
+ ctx.globalAlpha = 0.85;
439
+ ctx.fill();
440
+ ctx.restore();
441
+ };
442
+ const atom = (x, y, r) => {
443
+ ctx.strokeStyle = colors.b;
444
+ ctx.lineWidth = 1.2;
445
+ for (let k = 0; k < 3; k++) {
446
+ const rot = (k * Math.PI) / 3, a = spin * 6 + k * 2, ex = Math.cos(a) * r, ey = Math.sin(a) * r * 0.35;
447
+ ctx.beginPath();
448
+ ctx.ellipse(x, y, r, r * 0.35, rot, 0, tau);
449
+ ctx.stroke();
450
+ dot(ctx, x + ex * Math.cos(rot) - ey * Math.sin(rot), y + ex * Math.sin(rot) + ey * Math.cos(rot), 1.8, colors.a);
451
+ }
452
+ dot(ctx, x, y, 3, colors.c);
453
+ };
454
+ const [cx, cy, sx, sy] = corners[at];
455
+ if (tier === "hero") {
456
+ // A little constellation: burst on one top corner, an atom on the other,
457
+ // a boomerang swinging under the opposite bottom corner, sparkles between.
458
+ const top = at % 2;
459
+ burst(top ? w - 4 : 4, -4, big, colors.a, spin);
460
+ if (s.style !== "Lone star") {
461
+ atom(top ? 10 : w - 10, -8, big);
462
+ boomerang(top ? 18 : w - 18, h + 8, big * 1.2, top ? 0.2 : Math.PI - 0.2, colors.b);
463
+ const count = Math.round((shown / 100) * 7);
464
+ for (let i = 0; i < count; i++) {
465
+ const x = 40 + rand(seed + i) * (w - 80), y = -8 - rand(seed + i + 9) * 12;
466
+ star(ctx, x, y, 2.5 + rand(seed + i + 2) * 3, 1, 4, spin * 2);
467
+ ctx.fillStyle = i % 2 ? colors.b : colors.a;
468
+ ctx.fill();
469
+ }
470
+ }
471
+ }
472
+ else if (tier === "card") {
473
+ const ink = inks[choose(index, s.seed, 2, 3)];
474
+ if (s.style === "Orbit atoms")
475
+ atom(cx - sx * 4, cy - sy * 4, big);
476
+ else if (s.style === "Boomerangs")
477
+ boomerang(cx + sx * 14, cy - sy * 6, big * 1.1, sy < 0 ? Math.PI : 0, colors.b);
478
+ else
479
+ burst(cx - sx * 4, cy - sy * 4, big * 0.8, ink, spin);
480
+ star(ctx, cx + sx * (big * 2.4), cy - sy * 9, 3, 1, 4, spin * 2);
481
+ ctx.fillStyle = colors.b;
482
+ ctx.fill();
483
+ }
484
+ else {
485
+ star(ctx, cx - sx * 4, cy - sy * 4, 5, 1.6, 4, spin * 2);
486
+ ctx.fillStyle = inks[choose(index, s.seed, 2, 3)];
487
+ ctx.fill();
488
+ }
489
+ });
490
+ /* ───────────────────────── Orbital HUD ───────────────────────── */
491
+ const mono = (size) => `${size}px "Share Tech Mono", ui-monospace, monospace`;
492
+ export const paintHud = (ctx, p, w, h, settings, colors, clock, playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
493
+ const s = paintState(settings), tier = tierOf(w, h), dark = isDark(colors.paper), seed = s.seed + index * 11, len = 10 + s.scale * 0.45 * (0.6 + s.corners / 100);
494
+ if (dark)
495
+ glow(ctx, colors.a, 6);
496
+ // Target brackets are the instrument's grammar: every panel has them.
497
+ ctx.strokeStyle = colors.a;
498
+ ctx.lineWidth = 2;
499
+ for (const [x, y, sx, sy] of cornersOf(w, h)) {
500
+ const ox = x - sx * 5, oy = y - sy * 5;
501
+ ctx.beginPath();
502
+ ctx.moveTo(ox + sx * len, oy);
503
+ ctx.lineTo(ox, oy);
504
+ ctx.lineTo(ox, oy + sy * len);
505
+ ctx.stroke();
506
+ }
507
+ ctx.shadowBlur = 0;
508
+ outline(ctx, p, colors.b, 1, 0.35, 2);
509
+ if (s.style === "Clean brackets")
510
+ return;
511
+ // Telemetry lives on the main display; other panels show one readout each.
512
+ const jitter = playing ? Math.floor(clock * 4) : 0, readouts = ["SYS", "PWR", "O₂", "LNK", "THR", "NAV"], name = readouts[choose(index, s.seed, 1, readouts.length)], value = (0.9 + rand(seed + jitter) * 0.09).toFixed(2);
513
+ if (tier !== "small")
514
+ label(ctx, `${name} ${value}`, len + 10, -10, mono(10), colors.a);
515
+ if (tier === "hero" &&
516
+ (s.style === "Telemetry" || s.style === "Target brackets")) {
517
+ ctx.strokeStyle = colors.b;
518
+ ctx.lineWidth = 1;
519
+ for (let y = len + 12, i = 0; y < h - len - 12; y += 6, i++) {
520
+ ctx.beginPath();
521
+ ctx.moveTo(-6, y);
522
+ ctx.lineTo(i % 5 ? -9 : -13, y);
523
+ ctx.stroke();
524
+ }
525
+ const dv = (3 + rand(seed + jitter + 1) * 0.4).toFixed(1);
526
+ if (shown > 30)
527
+ label(ctx, `Δv ${dv} KM/S`, w - len - 10, h + 11, mono(10), colors.a, "right");
528
+ }
529
+ // One reticle locks onto one panel at a time.
530
+ if (s.style === "Reticle" ||
531
+ tier === "hero" ||
532
+ choose(index, s.seed, 2, 4) === 0) {
533
+ const x = w + 6, y = -6, r = 6 + s.scale * 0.25, turn = playing ? clock * (s.motion / 60) : 0;
534
+ ctx.strokeStyle = colors.c;
535
+ ctx.lineWidth = 1.2;
536
+ ctx.beginPath();
537
+ ctx.arc(x, y, r * 0.45, 0, tau);
538
+ ctx.stroke();
539
+ for (let k = 0; k < 3; k++) {
540
+ ctx.beginPath();
541
+ ctx.arc(x, y, r, turn + (k * tau) / 3, turn + (k * tau) / 3 + 1.2);
542
+ ctx.stroke();
543
+ }
544
+ }
545
+ if (playing && s.motion > 0 && tier === "hero") {
546
+ const span = w - len * 2, x = len + ((clock * s.motion * 1.2) % span);
547
+ ctx.strokeStyle = colors.d;
548
+ ctx.lineWidth = 2;
549
+ ctx.globalAlpha = 0.8;
550
+ ctx.beginPath();
551
+ ctx.moveTo(x, -2);
552
+ ctx.lineTo(Math.min(w - len, x + 24), -2);
553
+ ctx.stroke();
554
+ ctx.globalAlpha = 1;
555
+ }
556
+ });
557
+ /* ───────────────────────── Freighter ───────────────────────── */
558
+ function stripes(ctx, x, y, bw, bh, a, b) {
559
+ ctx.save();
560
+ ctx.beginPath();
561
+ ctx.rect(x, y, bw, bh);
562
+ ctx.clip();
563
+ ctx.fillStyle = a;
564
+ ctx.fillRect(x, y, bw, bh);
565
+ ctx.fillStyle = b;
566
+ for (let i = -bh; i < bw + bh; i += 14) {
567
+ ctx.beginPath();
568
+ ctx.moveTo(x + i, y + bh);
569
+ ctx.lineTo(x + i + bh, y);
570
+ ctx.lineTo(x + i + bh + 7, y);
571
+ ctx.lineTo(x + i + 7, y + bh);
572
+ ctx.closePath();
573
+ ctx.fill();
574
+ }
575
+ ctx.restore();
576
+ }
577
+ function rivet(ctx, x, y, r, colors) {
578
+ dot(ctx, x, y + 0.7, r, "rgba(0,0,0,.35)");
579
+ dot(ctx, x, y, r, colors.d);
580
+ dot(ctx, x - r * 0.3, y - r * 0.3, r * 0.4, "rgba(255,255,255,.55)");
581
+ }
582
+ export const paintHazard = (ctx, _p, w, h, settings, colors, clock, playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
583
+ const s = paintState(settings), tier = tierOf(w, h), band = 6 + s.weight * 0.06, inset = Math.max(6, Math.min(8, limits.top / 2, limits.inline / 2)), r = 1.6 + s.scale * 0.04, left = choose(index, s.seed, 1, 2) === 0;
584
+ // Rivets hold every plate; seams only on the big hull panels.
585
+ for (const [x, y, sx, sy] of cornersOf(w, h))
586
+ rivet(ctx, x + sx * inset, y + sy * inset, r, colors);
587
+ if (tier === "hero" && s.style !== "Clean plate") {
588
+ const step = 22 + (100 - shown) * 0.4;
589
+ for (let x = inset + step; x < w - inset - step / 2; x += step) {
590
+ rivet(ctx, x, inset, r * 0.8, colors);
591
+ rivet(ctx, x, h - inset, r * 0.8, colors);
592
+ }
593
+ }
594
+ if (s.style === "Clean plate")
595
+ return;
596
+ if (tier === "hero") {
597
+ stripes(ctx, 0, -band - 3, w, band, colors.a, colors.b);
598
+ label(ctx, "BAY 02", w - 4, h + 14, '400 12px "Archivo Black", "Arial Black", sans-serif', colors.d, "right");
599
+ }
600
+ else if (tier === "card") {
601
+ // A strip of hazard tape wrapped round one top corner.
602
+ const tw = Math.min(90, w * 0.4);
603
+ stripes(ctx, left ? 0 : w - tw, -band - 3, tw, band, colors.a, colors.b);
604
+ label(ctx, "MAX " + (20 + choose(index, s.seed, 2, 5) * 10) + "T", left ? w : 0, -7, mono(10), colors.d, left ? "right" : "left");
605
+ }
606
+ // A warning beacon on the hero only.
607
+ if (tier === "hero" && (!playing || s.motion === 0 || clock % 1.2 < 0.6)) {
608
+ glow(ctx, colors.c, 8);
609
+ dot(ctx, w + 8, -8, 3, colors.c);
610
+ ctx.shadowBlur = 0;
611
+ }
612
+ });
613
+ /* ───────────────────────── Solar Sail ───────────────────────── */
614
+ export const paintOrbit = (ctx, p, w, h, settings, colors, clock, playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
615
+ const s = paintState(settings), tier = tierOf(w, h), seed = s.seed + index * 17, at = choose(index, s.seed, 1, 4), [cx, cy] = cornersOf(w, h)[at];
616
+ outline(ctx, p, colors.b, 1, 0.45, 3);
617
+ const stars = (count) => {
618
+ for (let i = 0; i < count; i++) {
619
+ const q = p.at(rand(seed + i) * p.total), o = 5 + rand(seed + i + 3) * 20, tw = playing ? 0.5 + 0.5 * Math.sin(clock * 2 + i) : 1;
620
+ ctx.globalAlpha = 0.35 + tw * 0.6;
621
+ dot(ctx, q.x + Math.cos(q.t - Math.PI / 2) * o, q.y + Math.sin(q.t - Math.PI / 2) * o, 0.6 + rand(seed + i + 5) * 0.9, colors.c);
622
+ }
623
+ ctx.globalAlpha = 1;
624
+ };
625
+ if (tier === "small")
626
+ return stars(Math.round((shown / 100) * 6));
627
+ const rings = tier === "hero" && s.style !== "Single orbit"
628
+ ? 2 + Math.round((s.drape / 100) * 2)
629
+ : 1, sun = tier === "hero" || s.style === "Gold foil";
630
+ if (sun) {
631
+ const r = 10 + s.scale * 0.35, g = ctx.createLinearGradient(cx - r, cy - r, cx + r, cy + r);
632
+ g.addColorStop(0, colors.b);
633
+ g.addColorStop(0.45, colors.a);
634
+ g.addColorStop(0.6, "#fff6d8");
635
+ g.addColorStop(1, colors.a);
636
+ ctx.fillStyle = g;
637
+ ctx.beginPath();
638
+ ctx.arc(cx, cy, r, 0, tau);
639
+ ctx.fill();
640
+ }
641
+ ctx.strokeStyle = colors.a;
642
+ ctx.lineWidth = 1;
643
+ for (let k = 0; k < rings; k++) {
644
+ // Rings stop short of the content box, so they read as orbits, not clipped arcs.
645
+ const r = Math.min(22 + k * (10 + s.scale * 0.3), room(cx, cy, w, h, limits) - 2);
646
+ if (r < 8)
647
+ break;
648
+ ctx.globalAlpha = 0.85 - k * 0.15;
649
+ ctx.beginPath();
650
+ ctx.arc(cx, cy, r, 0, tau);
651
+ ctx.stroke();
652
+ if (k === rings - 1) {
653
+ const a = Math.PI * 0.75 +
654
+ (playing ? clock * (s.motion / 300) : 0) +
655
+ rand(seed) * tau;
656
+ dot(ctx, cx + Math.cos(a) * r, cy + Math.sin(a) * r, 2.4, colors.d);
657
+ }
658
+ }
659
+ ctx.globalAlpha = 1;
660
+ if (tier === "hero" || s.style === "Star field")
661
+ stars(Math.round((shown / 100) * 22));
662
+ });
663
+ /* ───────────────────────── Diwali Lights ───────────────────────── */
664
+ function rangoli(ctx, x, y, r, colors, turn) {
665
+ const ring = (n, radius, len, wide, color, offset) => {
666
+ ctx.fillStyle = color;
667
+ for (let i = 0; i < n; i++) {
668
+ const a = turn + offset + (i * tau) / n;
669
+ ctx.save();
670
+ ctx.translate(x + Math.cos(a) * radius, y + Math.sin(a) * radius);
671
+ ctx.rotate(a + Math.PI / 2);
672
+ ctx.beginPath();
673
+ ctx.ellipse(0, 0, wide, len, 0, 0, tau);
674
+ ctx.fill();
675
+ ctx.restore();
676
+ }
677
+ };
678
+ ring(12, r * 0.78, r * 0.26, r * 0.1, colors.d, Math.PI / 12);
679
+ ring(8, r * 0.55, r * 0.3, r * 0.16, colors.a, 0);
680
+ ring(8, r * 0.3, r * 0.18, r * 0.1, colors.b, Math.PI / 8);
681
+ dot(ctx, x, y, r * 0.14, colors.c);
682
+ }
683
+ function diya(ctx, x, y, size, colors, flicker, dark) {
684
+ // Clay bowl.
685
+ ctx.fillStyle = "#b5562b";
686
+ ctx.beginPath();
687
+ ctx.ellipse(x, y, size, size * 0.45, 0, 0, Math.PI);
688
+ ctx.fill();
689
+ ctx.fillStyle = "#d9773f";
690
+ ctx.fillRect(x - size, y - 1, size * 2, 2);
691
+ // Flame.
692
+ const fh = size * (1.1 + flicker * 0.25);
693
+ const g = ctx.createRadialGradient(x, y - fh * 0.35, 0.5, x, y - fh * 0.35, fh * 0.7);
694
+ g.addColorStop(0, "#fffbe6");
695
+ g.addColorStop(0.4, colors.c);
696
+ g.addColorStop(1, colors.b);
697
+ if (dark)
698
+ glow(ctx, colors.b, 10);
699
+ ctx.fillStyle = g;
700
+ ctx.beginPath();
701
+ ctx.moveTo(x, y - fh);
702
+ ctx.quadraticCurveTo(x + size * 0.45, y - fh * 0.35, x, y - 1);
703
+ ctx.quadraticCurveTo(x - size * 0.45, y - fh * 0.35, x, y - fh);
704
+ ctx.fill();
705
+ ctx.shadowBlur = 0;
706
+ }
707
+ export const paintRangoli = (ctx, p, w, h, settings, colors, clock, playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
708
+ const s = paintState(settings), tier = tierOf(w, h), dark = isDark(colors.paper), r = 12 + s.scale * 0.55, turn = playing ? clock * (s.motion / 2000) : 0, at = choose(index, s.seed, 1, 4), corners = cornersOf(w, h), flicker = (i) => playing && s.motion > 0 ? Math.sin(clock * 8 + i * 1.7) : 0, dsize = 5.5 + s.scale * 0.12,
709
+ // Lamps stand on the card's own floor, inside its bottom padding.
710
+ floor = h - Math.max(6, limits.bottom * 0.45);
711
+ // Everything lives inside this card's rounded rectangle, decorating its
712
+ // padding like a doorway, so neighbouring cards can never collide.
713
+ trace(ctx, p, 0);
714
+ ctx.closePath();
715
+ ctx.clip();
716
+ const corner = (k, size) => {
717
+ // Centre the motif just inside the rounded corner so its heart stays visible.
718
+ const [x, y, sx, sy] = corners[k], cx = x + sx * 7, cy = y + sy * 7;
719
+ rangoli(ctx, cx, cy, Math.min(size, room(cx, cy, w, h, limits) * 1.3), colors, turn);
720
+ };
721
+ const toran = () => {
722
+ // A marigold toran hung from the top edge: swags with mango leaves between them.
723
+ const swags = Math.max(2, Math.round(w / 120)), span = (w - 24) / swags, sag = Math.max(3, Math.min(8, limits.top / 2 - 6));
724
+ for (let k = 0; k < swags; k++) {
725
+ for (let t = 0; t <= 1.001; t += 0.1) {
726
+ const x = 12 + k * span + t * span, y = 3 + Math.sin(t * Math.PI) * sag;
727
+ dot(ctx, x, y, 2.4, Math.round(t * 10) % 2 ? colors.b : colors.c);
728
+ }
729
+ if (k < swags - 1) {
730
+ ctx.fillStyle = colors.d;
731
+ ctx.beginPath();
732
+ ctx.ellipse(12 + (k + 1) * span, 9, 2.2, 5.5, 0, 0, tau);
733
+ ctx.fill();
734
+ }
735
+ }
736
+ };
737
+ if (tier === "hero") {
738
+ if (s.style !== "Single diya")
739
+ toran();
740
+ // One rangoli anchors the composition on a bottom corner, lamps along the floor.
741
+ const k = at % 2 ? 2 : 3, x = corners[k][0];
742
+ if (s.style !== "Single diya")
743
+ corner(k, r * 1.6);
744
+ if (s.style !== "Marigold string") {
745
+ const step = 34 + (100 - shown) * 0.5;
746
+ for (let x2 = limits.inline, i = 0; x2 < w - limits.inline / 2; x2 += step, i++) {
747
+ if (Math.abs(x2 - x) < r * 1.8)
748
+ continue;
749
+ diya(ctx, x2, floor, dsize, colors, flicker(i), dark);
750
+ }
751
+ }
752
+ }
753
+ else if (tier === "card") {
754
+ // A corner rangoli on this card's own corner; one lamp on the opposite side.
755
+ if (s.style !== "Single diya")
756
+ corner(at, r * 1.25);
757
+ const right = corners[at][0] === 0;
758
+ diya(ctx, right ? w - limits.inline / 2 - 4 : limits.inline / 2 + 4, floor, dsize, colors, flicker(index), dark);
759
+ }
760
+ else if (s.style === "Single diya") {
761
+ const right = at % 2 === 1;
762
+ diya(ctx, right ? w - limits.inline / 2 - 4 : limits.inline / 2 + 4, floor, dsize, colors, flicker(index), dark);
763
+ }
764
+ else
765
+ corner(at, r);
766
+ });
767
+ /* ───────────────────────── Lunar New Year ───────────────────────── */
768
+ function lantern(ctx, x, y, size, colors, sway) {
769
+ ctx.save();
770
+ ctx.translate(x, -22);
771
+ ctx.rotate(sway);
772
+ const cy = y + 22;
773
+ ctx.strokeStyle = colors.b;
774
+ ctx.lineWidth = 1;
775
+ ctx.beginPath();
776
+ ctx.moveTo(0, 0);
777
+ ctx.lineTo(0, cy - size * 1.1);
778
+ ctx.stroke();
779
+ ctx.fillStyle = colors.a;
780
+ ctx.beginPath();
781
+ ctx.ellipse(0, cy, size * 0.9, size, 0, 0, tau);
782
+ ctx.fill();
783
+ // Ribs and gold caps.
784
+ ctx.strokeStyle = "rgba(0,0,0,.18)";
785
+ for (const k of [-0.45, 0, 0.45]) {
786
+ ctx.beginPath();
787
+ ctx.ellipse(0, cy, size * 0.9 * Math.abs(k) + 0.1, size, 0, 0, tau);
788
+ ctx.stroke();
789
+ }
790
+ ctx.fillStyle = colors.b;
791
+ ctx.fillRect(-size * 0.45, cy - size - 2, size * 0.9, 3);
792
+ ctx.fillRect(-size * 0.45, cy + size - 1, size * 0.9, 3);
793
+ // Tassel.
794
+ ctx.strokeStyle = colors.b;
795
+ ctx.beginPath();
796
+ ctx.moveTo(0, cy + size + 2);
797
+ ctx.lineTo(0, cy + size + 8);
798
+ ctx.stroke();
799
+ ctx.restore();
800
+ }
801
+ export const paintPapercut = (ctx, _p, w, h, settings, colors, clock, playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
802
+ const s = paintState(settings), tier = tierOf(w, h), seed = s.seed + index * 23, at = choose(index, s.seed, 1, 4), corners = cornersOf(w, h), size = 8 + s.scale * 0.15, sway = (k) => playing && s.motion > 0
803
+ ? Math.sin(clock * 1.6 + k + rand(seed)) * 0.05 * (s.motion / 50)
804
+ : 0;
805
+ // A paper-cut fan opening outward from a corner: scalloped, with punched holes.
806
+ const fan = (k, R) => {
807
+ const [x, y, sx, sy] = corners[k], mid = Math.atan2(-sy, -sx), n = 8;
808
+ ctx.save();
809
+ ctx.translate(x + sx * 2, y + sy * 2);
810
+ ctx.fillStyle = colors.a;
811
+ ctx.beginPath();
812
+ ctx.moveTo(0, 0);
813
+ for (let i = 0; i <= n; i++) {
814
+ const a = mid - Math.PI / 4 + (i / n) * (Math.PI / 2), rr = i % 2 ? R * 0.84 : R;
815
+ ctx.lineTo(Math.cos(a) * rr, Math.sin(a) * rr);
816
+ }
817
+ ctx.closePath();
818
+ ctx.fill();
819
+ ctx.globalCompositeOperation = "destination-out";
820
+ for (let i = 1; i < 4; i++) {
821
+ const a = mid - Math.PI / 4 + (i / 4) * (Math.PI / 2);
822
+ dot(ctx, Math.cos(a) * R * 0.6, Math.sin(a) * R * 0.6, R * 0.09, "#000");
823
+ }
824
+ dot(ctx, Math.cos(mid) * R * 0.3, Math.sin(mid) * R * 0.3, R * 0.07, "#000");
825
+ ctx.restore();
826
+ };
827
+ if (tier === "hero") {
828
+ if (s.style !== "Single lantern") {
829
+ const R = 3 + s.scale * 0.1, step = R * 2.4;
830
+ ctx.fillStyle = colors.a;
831
+ for (let x = R; x < w - R / 2; x += step) {
832
+ ctx.beginPath();
833
+ ctx.arc(x, -2, R, Math.PI, tau);
834
+ ctx.fill();
835
+ }
836
+ }
837
+ const right = at % 2 === 1, count = s.style === "Single lantern" ? 1 : 1 + Math.round((shown / 100) * 2);
838
+ for (let k = 0; k < count; k++) {
839
+ const x = right ? w - 22 - k * 30 : 22 + k * 30;
840
+ lantern(ctx, x, -size + 4 + (k % 2) * 4, size * (k ? 0.82 : 1), colors, sway(k));
841
+ }
842
+ fan(right ? 3 : 2, 18 + s.scale * 0.3);
843
+ }
844
+ else if (tier === "card") {
845
+ const top = at < 2 ? at : at - 2;
846
+ lantern(ctx, top ? w - 22 : 22, -size + 4, size * 0.9, colors, sway(index));
847
+ fan(top ? 3 : 2, 14 + s.scale * 0.25);
848
+ }
849
+ else
850
+ fan(at, 12 + s.scale * 0.2);
851
+ });
852
+ /* ───────────────────────── Papel Picado ───────────────────────── */
853
+ export const paintPicado = (ctx, _p, w, h, settings, colors, clock, playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
854
+ const s = paintState(settings), tier = tierOf(w, h), seed = s.seed + index * 29, inks = [colors.a, colors.b, colors.c, colors.d], fw = 8 + s.scale * 0.2, fh = fw * 1.15, ink0 = choose(index, s.seed, 2, 4), right = choose(index, s.seed, 1, 2) === 1;
855
+ const flag = (x, y, i) => {
856
+ const flutter = playing && s.motion > 0
857
+ ? Math.sin(clock * 3 + i) * 0.06 * (s.motion / 50)
858
+ : 0;
859
+ ctx.save();
860
+ ctx.translate(x, y);
861
+ ctx.transform(1, 0, flutter, 1, 0, 0);
862
+ ctx.beginPath();
863
+ ctx.moveTo(-fw / 2, 0);
864
+ ctx.lineTo(fw / 2, 0);
865
+ ctx.lineTo(fw / 2, fh * 0.8);
866
+ for (let k = 4; k >= 0; k--)
867
+ ctx.lineTo(-fw / 2 + (k * fw) / 4, fh * (k % 2 ? 1 : 0.8));
868
+ ctx.closePath();
869
+ ctx.fillStyle = inks[(i + ink0) % 4];
870
+ ctx.fill();
871
+ ctx.globalCompositeOperation = "destination-out";
872
+ dot(ctx, 0, fh * 0.42, fw * 0.16, "#000");
873
+ for (const [dx, dy] of [
874
+ [-0.28, 0.2],
875
+ [0.28, 0.2],
876
+ [-0.28, 0.64],
877
+ [0.28, 0.64],
878
+ ])
879
+ dot(ctx, dx * fw, dy * fh, fw * 0.06, "#000");
880
+ ctx.restore();
881
+ };
882
+ // A string between two points with a natural sag, flags hung along it.
883
+ const string = (x0, x1, top, sag, count) => {
884
+ ctx.strokeStyle = colors.d;
885
+ ctx.lineWidth = 0.8;
886
+ ctx.beginPath();
887
+ ctx.moveTo(x0, top);
888
+ ctx.quadraticCurveTo((x0 + x1) / 2, top + sag * 2, x1, top);
889
+ ctx.stroke();
890
+ for (let i = 0; i < count; i++) {
891
+ const t = (i + 0.5) / count, x = x0 + (x1 - x0) * t, y = top + sag * 4 * t * (1 - t);
892
+ flag(x, y, i);
893
+ }
894
+ };
895
+ const sag = 4 + s.drape * 0.06;
896
+ if (tier === "hero") {
897
+ const step = fw + 4 + (100 - shown) * 0.12;
898
+ string(-8, w + 8, -19, sag, Math.floor((w + 16) / step));
899
+ if (s.style === "Paper confetti" || s.style === "Bunting") {
900
+ const count = Math.round((shown / 100) * 10);
901
+ for (let i = 0; i < count; i++) {
902
+ const x = rand(seed + i) * w, fall = playing
903
+ ? (clock * s.motion * 0.15 + rand(seed + i + 7) * 20) % 20
904
+ : rand(seed + i + 7) * 20;
905
+ ctx.save();
906
+ ctx.translate(x, h + 6 + fall * 0.6);
907
+ ctx.rotate(rand(seed + i + 3) * tau + (playing ? clock : 0));
908
+ ctx.fillStyle = inks[i % 4];
909
+ ctx.fillRect(-2.5, -1.5, 5, 3);
910
+ ctx.restore();
911
+ }
912
+ }
913
+ }
914
+ else if (tier === "card") {
915
+ // A short string tied off at one corner, drooping toward the middle.
916
+ const span = w * 0.55, x0 = right ? w + 6 : -6, x1 = right ? w - span : span;
917
+ string(Math.min(x0, x1), Math.max(x0, x1), -19, sag * 0.8, 4);
918
+ }
919
+ else {
920
+ // Two flags pinned to one corner.
921
+ const x = right ? w - 14 : 14;
922
+ flag(x - fw * 0.6, -16, 0);
923
+ flag(x + fw * 0.6, -15, 1);
924
+ }
925
+ });
926
+ /* ───────────────────────── Holi Colours ───────────────────────── */
927
+ export const paintGulal = (ctx, _p, w, h, settings, colors, clock, playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
928
+ const s = paintState(settings), tier = tierOf(w, h), seed = s.seed + index * 41, inks = [colors.a, colors.b, colors.c, colors.d], drift = playing ? clock * (s.motion / 200) : 0, haze = s.style === "Soft haze", corners = cornersOf(w, h), at = choose(index, s.seed, 1, 4), ink = choose(index, s.seed, 2, 4);
929
+ // A powder cloud pushed outward from a corner. Its radius stops before the
930
+ // content box, so the soft edge is never sliced by the frame clip.
931
+ const cloud = (k, color, scale) => {
932
+ const [x, y, sx, sy] = corners[k], base = (18 + s.scale * 0.9) * scale, ox = Math.sin(drift + k) * 2, oy = Math.cos(drift * 0.8 + k) * 2;
933
+ for (let j = 0; j < 5; j++) {
934
+ const jx = x -
935
+ sx * (6 + rand(seed + k * 7 + j) * 10) +
936
+ ox +
937
+ (rand(seed + k + j * 3) - 0.5) * base * 0.6, jy = y - sy * (6 + rand(seed + k * 7 + j + 3) * 10) + oy, r = Math.min(base * (0.6 + rand(seed + j) * 0.4), room(jx, jy, w, h, limits) - 1);
938
+ if (r < 4)
939
+ continue;
940
+ const g = ctx.createRadialGradient(jx, jy, 0, jx, jy, r);
941
+ g.addColorStop(0, color);
942
+ g.addColorStop(1, "transparent");
943
+ ctx.globalAlpha = haze ? 0.28 : 0.5;
944
+ ctx.fillStyle = g;
945
+ ctx.beginPath();
946
+ ctx.arc(jx, jy, r, 0, tau);
947
+ ctx.fill();
948
+ }
949
+ ctx.globalAlpha = 1;
950
+ };
951
+ // Dry powder thrown along an edge, thinning away from its corner.
952
+ const speckle = (k, color, count) => {
953
+ const [x, y, sx, sy] = corners[k];
954
+ for (let i = 0; i < count; i++) {
955
+ const along = rand(seed + i + k * 13) ** 1.6 * Math.min(w, h) * 0.6, off = 3 + rand(seed + i + 1) * 18, edge = i % 2, px = edge ? x + sx * along : x - sx * off, py = edge ? y - sy * off : y + sy * along;
956
+ if (room(px, py, w, h, limits) < 2)
957
+ continue;
958
+ ctx.globalAlpha = 0.5 + rand(seed + i + 2) * 0.45;
959
+ dot(ctx, px, py, 0.6 + rand(seed + i + 3) * (s.style === "Splashes" ? 2.2 : 1.3), color);
960
+ }
961
+ ctx.globalAlpha = 1;
962
+ };
963
+ const count = Math.round((shown / 100) * 40);
964
+ if (tier === "hero") {
965
+ if (s.style !== "Speckle dust") {
966
+ cloud(at, inks[ink], 1.1);
967
+ cloud((at + 2) % 4, inks[(ink + 2) % 4], 0.9);
968
+ }
969
+ if (!haze) {
970
+ speckle(at, inks[ink], count);
971
+ speckle((at + 2) % 4, inks[(ink + 2) % 4], count);
972
+ speckle((at + 1) % 4, inks[(ink + 1) % 4], count / 3);
973
+ }
974
+ }
975
+ else if (tier === "card") {
976
+ if (s.style !== "Speckle dust")
977
+ cloud(at, inks[ink], 0.85);
978
+ if (!haze)
979
+ speckle(at, inks[ink], count * 0.7);
980
+ }
981
+ else {
982
+ if (s.style !== "Speckle dust")
983
+ cloud(at, inks[ink], 0.6);
984
+ if (!haze)
985
+ speckle(at, inks[ink], count * 0.4);
986
+ }
987
+ });
988
+ /* ───────────────────────── Yule Knit ───────────────────────── */
989
+ function snowflake(ctx, x, y, r, color, turn) {
990
+ ctx.save();
991
+ ctx.translate(x, y);
992
+ ctx.rotate(turn);
993
+ ctx.strokeStyle = color;
994
+ ctx.lineWidth = 1;
995
+ for (let i = 0; i < 6; i++) {
996
+ ctx.rotate(tau / 6);
997
+ ctx.beginPath();
998
+ ctx.moveTo(0, 0);
999
+ ctx.lineTo(0, -r);
1000
+ ctx.moveTo(0, -r * 0.55);
1001
+ ctx.lineTo(r * 0.25, -r * 0.8);
1002
+ ctx.moveTo(0, -r * 0.55);
1003
+ ctx.lineTo(-r * 0.25, -r * 0.8);
1004
+ ctx.stroke();
1005
+ }
1006
+ ctx.restore();
1007
+ }
1008
+ export const paintKnit = (ctx, _p, w, h, settings, colors, clock, playing, index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
1009
+ const s = paintState(settings), tier = tierOf(w, h), seed = s.seed + index * 61, cell = 5 + s.scale * 0.08, at = choose(index, s.seed, 1, 4);
1010
+ const vStitch = (x, y, color) => {
1011
+ ctx.strokeStyle = color;
1012
+ ctx.lineWidth = cell * 0.36;
1013
+ ctx.lineCap = "round";
1014
+ ctx.beginPath();
1015
+ ctx.moveTo(x - cell * 0.28, y - cell * 0.3);
1016
+ ctx.lineTo(x, y + cell * 0.25);
1017
+ ctx.lineTo(x + cell * 0.28, y - cell * 0.3);
1018
+ ctx.stroke();
1019
+ };
1020
+ const flakes = (count, side) => {
1021
+ for (let i = 0; i < count; i++) {
1022
+ const fall = playing && s.motion > 0
1023
+ ? (clock * s.motion * 0.12 + rand(seed + i) * 30) % 30
1024
+ : rand(seed + i) * 30, x = side
1025
+ ? w + 6 + rand(seed + i + 2) * 16
1026
+ : -6 - rand(seed + i + 2) * 16, y = 8 + rand(seed + i + 4) * Math.min(h * 0.5, 120) + fall;
1027
+ snowflake(ctx, x, y, 3 + rand(seed + i + 5) * 3, colors.d, playing ? clock * 0.3 + i : i);
1028
+ }
1029
+ };
1030
+ if (tier === "hero") {
1031
+ if (s.style === "Fair Isle band" || s.style === "Snowflakes") {
1032
+ // The yoke: a Fair Isle band across the top of the main panel only.
1033
+ const rows = 3, top = -rows * cell - 4;
1034
+ ctx.fillStyle = colors.a;
1035
+ ctx.fillRect(0, top - cell * 0.5, w, rows * cell + cell * 0.5);
1036
+ for (let r = 0; r < rows; r++)
1037
+ for (let x = cell / 2, i = 0; x < w; x += cell, i++)
1038
+ if (Math.abs((i % 4) - 2) === r || (r === 1 && i % 4 === 0))
1039
+ vStitch(x, top + r * cell + cell / 2, colors.c);
1040
+ ctx.fillStyle = colors.b;
1041
+ ctx.fillRect(0, -4, w, 2.5);
1042
+ ctx.fillRect(0, top - cell * 0.5 - 2.5, w, 2.5);
1043
+ }
1044
+ if (s.style === "Cable stitch") {
1045
+ ctx.strokeStyle = colors.a;
1046
+ ctx.lineWidth = 2.4;
1047
+ for (const phase of [0, Math.PI]) {
1048
+ ctx.beginPath();
1049
+ for (let y = 0; y <= h; y += 2) {
1050
+ const xx = -9 + Math.sin(y / 9 + phase) * 4;
1051
+ if (y)
1052
+ ctx.lineTo(xx, y);
1053
+ else
1054
+ ctx.moveTo(xx, y);
1055
+ }
1056
+ ctx.stroke();
1057
+ }
1058
+ }
1059
+ flakes(Math.round((shown / 100) * 3), at % 2);
1060
+ }
1061
+ else if (tier === "card") {
1062
+ // A ribbed cuff at the hem, and a patch of snow on one side.
1063
+ ctx.fillStyle = colors.a;
1064
+ const cuff = 7 + s.weight * 0.05;
1065
+ ctx.fillRect(0, h + 3, w, cuff);
1066
+ ctx.strokeStyle = colors.c;
1067
+ ctx.globalAlpha = 0.35;
1068
+ ctx.lineWidth = 1;
1069
+ for (let x = 3; x < w; x += 4) {
1070
+ ctx.beginPath();
1071
+ ctx.moveTo(x, h + 4);
1072
+ ctx.lineTo(x, h + 2 + cuff);
1073
+ ctx.stroke();
1074
+ }
1075
+ ctx.globalAlpha = 1;
1076
+ flakes(2, at % 2);
1077
+ }
1078
+ else {
1079
+ // A tiny knitted label: three stitches and a flake.
1080
+ const x0 = at % 2 ? w - 4 - cell * 3 : 4;
1081
+ for (let i = 0; i < 3; i++)
1082
+ vStitch(x0 + cell / 2 + i * cell, -8, i === 1 ? colors.b : colors.a);
1083
+ snowflake(ctx, at % 2 ? w + 10 : -10, 14, 4, colors.d, playing ? clock * 0.3 : 0);
1084
+ }
1085
+ });
1086
+ /* ───────────────────────── Glass UI ───────────────────────── */
1087
+ export const paintLiquid = (ctx, p, w, h, settings, colors, clock, playing, _index, limits, shown = settings.density) => framed(ctx, w, h, limits, () => {
1088
+ const s = paintState(settings), dark = isDark(colors.paper),
1089
+ // The light source drifts slowly while animated, like tilting the device.
1090
+ angle = -0.9 +
1091
+ (playing && s.motion > 0
1092
+ ? Math.sin(clock * (s.motion / 120)) * 0.6
1093
+ : 0), lx = w / 2 + Math.cos(angle) * w, ly = h / 2 + Math.sin(angle) * h;
1094
+ if (s.style === "Clear") {
1095
+ outline(ctx, p, colors.a, 1, dark ? 0.25 : 0.6, 0.5);
1096
+ return;
1097
+ }
1098
+ // Specular rim: a gradient stroke that is brightest where it faces the light.
1099
+ const rim = ctx.createLinearGradient(lx, ly, w - lx, h - ly);
1100
+ rim.addColorStop(0, colors.a);
1101
+ rim.addColorStop(0.45, "rgba(255,255,255,0)");
1102
+ rim.addColorStop(0.75, "rgba(255,255,255,0)");
1103
+ rim.addColorStop(1, colors.a);
1104
+ trace(ctx, p, 0.5);
1105
+ ctx.closePath();
1106
+ ctx.strokeStyle = rim;
1107
+ ctx.lineWidth = 1.2 + s.weight * 0.012;
1108
+ ctx.globalAlpha = dark ? 0.7 : 0.95;
1109
+ ctx.stroke();
1110
+ ctx.globalAlpha = 1;
1111
+ if (s.style === "Refraction" || s.style === "Specular rim") {
1112
+ // Refraction: a thin chromatic fringe hugging the inner edge.
1113
+ for (const [color, off] of [
1114
+ [colors.b, -2.2],
1115
+ [colors.c, -3.6],
1116
+ ]) {
1117
+ ctx.globalAlpha = 0.18 + (shown / 100) * 0.25;
1118
+ outline(ctx, p, color, 1.2, ctx.globalAlpha, off);
1119
+ }
1120
+ ctx.globalAlpha = 1;
1121
+ }
1122
+ if (s.style === "Light catch" || s.style === "Specular rim") {
1123
+ // A soft caustic just below the top edge, where light enters the glass.
1124
+ const g = ctx.createRadialGradient(lx * 0.4 + w * 0.3, 0, 0, lx * 0.4 + w * 0.3, 0, w * 0.35);
1125
+ g.addColorStop(0, dark ? "rgba(255,255,255,.18)" : "rgba(255,255,255,.55)");
1126
+ g.addColorStop(1, "rgba(255,255,255,0)");
1127
+ ctx.fillStyle = g;
1128
+ ctx.fillRect(0, -6, w, Math.min(18, limits.top));
1129
+ }
1130
+ // A faint drop shadow line below, so the slab reads as lifted.
1131
+ ctx.strokeStyle = colors.d;
1132
+ ctx.globalAlpha = dark ? 0.35 : 0.12;
1133
+ ctx.lineWidth = 1;
1134
+ ctx.beginPath();
1135
+ ctx.moveTo(p.r, h + 3);
1136
+ ctx.lineTo(w - p.r, h + 3);
1137
+ ctx.stroke();
1138
+ ctx.globalAlpha = 1;
1139
+ });
1140
+ export const launchPainters = {
1141
+ crt: paintPhosphor,
1142
+ panes: paintPanes,
1143
+ swiss: paintSwiss,
1144
+ azulejo: paintAzulejo,
1145
+ formwork: paintFormwork,
1146
+ atomic: paintAtomic,
1147
+ hud: paintHud,
1148
+ hazard: paintHazard,
1149
+ orbit: paintOrbit,
1150
+ rangoli: paintRangoli,
1151
+ papercut: paintPapercut,
1152
+ picado: paintPicado,
1153
+ gulal: paintGulal,
1154
+ knit: paintKnit,
1155
+ liquid: paintLiquid,
1156
+ };