@deriv-com/dds-marketing 1.0.0 → 1.0.2

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 (131) hide show
  1. package/.llm/components/accordion.md +1 -1
  2. package/.llm/components/accordionitem.md +1 -1
  3. package/.llm/components/aspectratio.md +1 -1
  4. package/.llm/components/background.md +371 -0
  5. package/.llm/components/badge.md +1 -1
  6. package/.llm/components/bottomsheet.md +1 -1
  7. package/.llm/components/breadcrumb.md +1 -1
  8. package/.llm/components/breadcrumbitem.md +1 -1
  9. package/.llm/components/button.md +10 -6
  10. package/.llm/components/buttonicon.md +3 -3
  11. package/.llm/components/card.md +1 -1
  12. package/.llm/components/cardbody.md +1 -1
  13. package/.llm/components/cardbutton.md +1 -1
  14. package/.llm/components/checkbox.md +1 -1
  15. package/.llm/components/ctabanner.md +26 -1
  16. package/.llm/components/ctabannerslider.md +22 -1
  17. package/.llm/components/divider.md +1 -1
  18. package/.llm/components/dropdownmenu.md +2 -2
  19. package/.llm/components/faqaccordion.md +1 -1
  20. package/.llm/components/featurebullets.md +1 -1
  21. package/.llm/components/featurecards.md +1 -1
  22. package/.llm/components/featurescroll.md +1 -1
  23. package/.llm/components/featuresplit.md +1 -1
  24. package/.llm/components/filterchip.md +1 -1
  25. package/.llm/components/footer.md +1 -1
  26. package/.llm/components/footergroup.md +1 -1
  27. package/.llm/components/grid.md +1 -1
  28. package/.llm/components/icon.md +13 -3
  29. package/.llm/components/illustrationcard.md +236 -0
  30. package/.llm/components/illustrationscene.md +233 -0
  31. package/.llm/components/landinghero.md +25 -1
  32. package/.llm/components/link.md +4 -4
  33. package/.llm/components/logocolumns.md +1 -1
  34. package/.llm/components/logorows.md +1 -1
  35. package/.llm/components/menuitem.md +1 -1
  36. package/.llm/components/navbar.md +1 -1
  37. package/.llm/components/navbaritem.md +1 -1
  38. package/.llm/components/navbarmenu.md +1 -1
  39. package/.llm/components/navbarmenugroup.md +1 -1
  40. package/.llm/components/navbarpanel.md +1 -1
  41. package/.llm/components/navbarswitcher.md +1 -1
  42. package/.llm/components/navbarswitcheroption.md +1 -1
  43. package/.llm/components/option.md +1 -1
  44. package/.llm/components/pageheader.md +22 -1
  45. package/.llm/components/pagination.md +1 -1
  46. package/.llm/components/paginationdots.md +1 -1
  47. package/.llm/components/producthero.md +1 -1
  48. package/.llm/components/removablechip.md +1 -1
  49. package/.llm/components/row.md +1 -1
  50. package/.llm/components/scroller.md +1 -1
  51. package/.llm/components/searchinput.md +1 -1
  52. package/.llm/components/segmentedcontrol.md +1 -1
  53. package/.llm/components/segmentedcontrolitem.md +1 -1
  54. package/.llm/components/select.md +1 -1
  55. package/.llm/components/selectchip.md +1 -1
  56. package/.llm/components/spinner.md +1 -1
  57. package/.llm/components/stack.md +1 -1
  58. package/.llm/components/statusdot.md +1 -1
  59. package/.llm/components/subdomainnavbar.md +1 -1
  60. package/.llm/components/tab.md +3 -3
  61. package/.llm/components/table.md +1 -1
  62. package/.llm/components/tablebody.md +1 -1
  63. package/.llm/components/tablecell.md +1 -1
  64. package/.llm/components/tablehead.md +1 -1
  65. package/.llm/components/tableheadercell.md +1 -1
  66. package/.llm/components/tablerow.md +1 -1
  67. package/.llm/components/tablist.md +1 -1
  68. package/.llm/components/testimonialrow.md +1 -1
  69. package/.llm/components/testimonialslider.md +1 -1
  70. package/.llm/components/textarea.md +1 -1
  71. package/.llm/components/textinput.md +1 -1
  72. package/.llm/components/tooltip.md +1 -1
  73. package/.llm/components/typography.md +1 -1
  74. package/.llm/digest/background.md +52 -0
  75. package/.llm/digest/ctabanner.md +4 -0
  76. package/.llm/digest/ctabannerslider.md +3 -0
  77. package/.llm/digest/illustrationcard.md +46 -0
  78. package/.llm/digest/illustrationscene.md +51 -0
  79. package/.llm/digest/landinghero.md +7 -0
  80. package/.llm/digest/pageheader.md +3 -0
  81. package/.llm/index.md +7 -0
  82. package/.llm/rules.md +20 -10
  83. package/.llm/tokens.md +11 -0
  84. package/AGENTS.md +7 -5
  85. package/dist/components/blocks/cta-banner/CtaBanner.d.ts +1 -1
  86. package/dist/components/blocks/cta-banner/CtaBanner.js +3 -2
  87. package/dist/components/blocks/cta-banner/CtaBanner.types.d.ts +5 -0
  88. package/dist/components/blocks/cta-banner/cta-banner-impl.css +8 -0
  89. package/dist/components/blocks/cta-banner-slider/CtaBannerSlider.d.ts +1 -1
  90. package/dist/components/blocks/cta-banner-slider/CtaBannerSlider.js +5 -4
  91. package/dist/components/blocks/cta-banner-slider/CtaBannerSlider.types.d.ts +4 -0
  92. package/dist/components/blocks/cta-banner-slider/cta-banner-slider-impl.css +8 -0
  93. package/dist/components/blocks/landing-hero/LandingHero.d.ts +1 -1
  94. package/dist/components/blocks/landing-hero/LandingHero.js +4 -3
  95. package/dist/components/blocks/landing-hero/LandingHero.types.d.ts +7 -3
  96. package/dist/components/blocks/page-header/PageHeader.d.ts +1 -1
  97. package/dist/components/blocks/page-header/PageHeader.js +3 -2
  98. package/dist/components/blocks/page-header/PageHeader.types.d.ts +4 -0
  99. package/dist/components/blocks/page-header/page-header-impl.css +8 -0
  100. package/dist/components/core/background/Background.css +69 -0
  101. package/dist/components/core/background/Background.d.ts +21 -0
  102. package/dist/components/core/background/Background.js +36 -0
  103. package/dist/components/core/background/Background.types.d.ts +36 -0
  104. package/dist/components/core/background/Background.types.js +9 -0
  105. package/dist/components/core/background/background-impl.css +427 -0
  106. package/dist/components/core/background/halftone.d.ts +123 -0
  107. package/dist/components/core/background/halftone.js +682 -0
  108. package/dist/components/core/background/use-halftone.d.ts +15 -0
  109. package/dist/components/core/background/use-halftone.js +27 -0
  110. package/dist/components/core/background/use-offscreen.d.ts +10 -0
  111. package/dist/components/core/background/use-offscreen.js +30 -0
  112. package/dist/components/core/background/use-spotlight.d.ts +13 -0
  113. package/dist/components/core/background/use-spotlight.js +65 -0
  114. package/dist/components/core/illustration-card/IllustrationCard.css +40 -0
  115. package/dist/components/core/illustration-card/IllustrationCard.d.ts +22 -0
  116. package/dist/components/core/illustration-card/IllustrationCard.js +82 -0
  117. package/dist/components/core/illustration-card/IllustrationCard.types.d.ts +23 -0
  118. package/dist/components/core/illustration-card/IllustrationCard.types.js +8 -0
  119. package/dist/components/core/illustration-card/illustration-card-impl.css +27 -0
  120. package/dist/components/core/illustration-scene/IllustrationScene.css +44 -0
  121. package/dist/components/core/illustration-scene/IllustrationScene.d.ts +14 -0
  122. package/dist/components/core/illustration-scene/IllustrationScene.js +16 -0
  123. package/dist/components/core/illustration-scene/IllustrationScene.types.d.ts +33 -0
  124. package/dist/components/core/illustration-scene/IllustrationScene.types.js +8 -0
  125. package/dist/components/core/illustration-scene/illustration-scene-impl.css +40 -0
  126. package/dist/components/core/link/Link.js +5 -5
  127. package/dist/components/core/tab/Tab.types.d.ts +1 -1
  128. package/dist/components/index.d.ts +6 -0
  129. package/dist/components/index.js +6 -0
  130. package/dist/marketing.css +24 -0
  131. package/package.json +1 -1
@@ -0,0 +1,682 @@
1
+ /**
2
+ * The halftone, drawn on a canvas and only when it has to be.
3
+ *
4
+ * A grid of dots, each as large and as bright as the glow is where it sits: full at the glow's
5
+ * centre, falling away along a soft ellipse, and gone at the edges. The glow rests at the middle of
6
+ * the band's bottom edge and — where the band allows it — follows the pointer. The dots themselves
7
+ * answer the pointer too: a moving pointer shoves the ones near it aside and a click sends a ripple
8
+ * out from where it landed, and each dot springs back to its place in the grid.
9
+ *
10
+ * What it costs is the reason it is written this way rather than as CSS or as an animation loop:
11
+ *
12
+ * - It draws on change and nowhere else. A resize draws one frame; a pointer draws frames only
13
+ * while the glow is catching up or a dot is still moving. The resting drift is the one thing that
14
+ * keeps it drawing, at half the display's rate, and only while the band is on screen and motion
15
+ * is allowed — with the drift set to nothing, a band nobody is touching runs no code at all.
16
+ * - A band out of view draws nothing, and catches up with one frame when it returns.
17
+ * - The bitmap is held to twice the CSS pixel density, which is all a dot this small can use.
18
+ * - The dots are batched by brightness into a few dozen paths, so a frame is a few dozen fills
19
+ * rather than one per dot. Drawing visits only the rows below the pattern's edge, rejects a dot
20
+ * too small to be seen before working out its light — every dot, while the size floor keeps the
21
+ * screen across the whole band — and draws a dot under a pixel and a half as a square, which
22
+ * nobody can tell from a circle at that size.
23
+ * - Pointer events only record where the pointer is. The frame reads the band's position once and
24
+ * acts on it, so a fast mouse costs one layout read a frame rather than one per event; a shove or
25
+ * a ripple visits the grid cells it can reach, and the spring visits only the dots still moving.
26
+ *
27
+ * Every default comes from a token: the inks are the canvas's computed `color` (the glow's edge) and
28
+ * `outline-color` (its centre), the grid is
29
+ * size.halftone.pitch and size.halftone.dot, and the glow's movement is the canvas's transition
30
+ * duration and timing function. The rest are settings this file names, and `retune` overrides any
31
+ * of them while a pattern is being decided — the preview's Tune panel, and nothing a page uses.
32
+ */
33
+ /** The settings that are not read from a token. */
34
+ /**
35
+ * How strong the pattern is on each ground, where nothing has set it. The dark ground takes the
36
+ * least: white dots on near-black are the highest contrast the pattern has, and at full strength
37
+ * the glow's heart outshone the words standing on it. The light and the coral grounds keep most of
38
+ * theirs, where the dots are closer to the ground they sit on.
39
+ */
40
+ const OPACITY = { dark: 0.55, light: 0.85, brand: 0.85 };
41
+ const SETTINGS = {
42
+ // A third opaque at the faintest: the edge of the glow is a dim field of dots rather than a
43
+ // fade to nothing, so the pattern reaches the band's edges without competing with its heart.
44
+ floor: 0.37,
45
+ // Six tenths of the band's width and of its height at rest — a pool of light rather than a
46
+ // wash. An ellipse rather than a circle because a band is wide, and a round glow is either lost
47
+ // in it or cut off by it.
48
+ reachX: 0.6,
49
+ reachY: 0.6,
50
+ // A third of that under the pointer, so the light gathers where the pointer is rather than
51
+ // washing across the band — at half, it lit most of a narrow band.
52
+ hoverScale: 0.35,
53
+ // The whole band, fading in from the top to full at the foot — the way the aurora, the rule grid
54
+ // and the rings cover it, so the four patterns fill a band alike. Every dot keeps half of its
55
+ // widest wherever the glow is, so the screen reaches the band's edges as the grid's lines
56
+ // do, and the glow swells the dots it falls on rather than being the only place there are dots.
57
+ top: 0,
58
+ topFade: 1,
59
+ sizeFloor: 0.5,
60
+ // The glow follows the pointer no higher than a little under halfway: above that its brightest
61
+ // dots would sit where the fade has thinned them, and the heart of the light would read as dim.
62
+ ceiling: 0.47,
63
+ falloff: 1.2,
64
+ // Centred, on the band's foot, so the light rises from the bottom edge under the words.
65
+ restX: 0.5,
66
+ restY: 0.97,
67
+ // At rest the glow wanders along the band's foot: a fifth of the band either side, a little rise
68
+ // and fall, one sweep every sixteen seconds — slow enough to be felt rather than watched.
69
+ drift: 0.2,
70
+ driftRise: 0.04,
71
+ driftPeriod: 16,
72
+ // A moving pointer parts the dots near it, and they settle back in under a second and a half.
73
+ proximity: 145,
74
+ resistance: 1850,
75
+ returnDuration: 1.4,
76
+ // A click is a small, light tap rather than a shockwave.
77
+ shockRadius: 60,
78
+ shockStrength: 0.5,
79
+ };
80
+ /** Overrides set while a pattern is being decided. Empty on every real page. */
81
+ let overrides = {};
82
+ const live = new Set();
83
+ let resolved = null;
84
+ /**
85
+ * Override settings on every halftone on the page, live. For deciding a pattern — the preview's
86
+ * Tune panel calls it — and never for a page: what a page draws is what the tokens and the
87
+ * settings above say.
88
+ */
89
+ export function retune(next) {
90
+ overrides = { ...next };
91
+ for (const refresh of live)
92
+ refresh();
93
+ }
94
+ /** The settings the last halftone drew with, overrides included — for a panel to show. */
95
+ export function currentTuning() {
96
+ return resolved;
97
+ }
98
+ /** Below this radius a dot is not drawn — a fraction of a pixel is a smudge, not a dot. */
99
+ const SMALLEST = 0.4;
100
+ /**
101
+ * How many brightnesses the dots are batched into. Each step is one fill, so a frame costs this
102
+ * many fills rather than one per dot. Eight was too few: under the pointer the glow is half its
103
+ * resting size, brightness falls off twice as fast across the dots, and eight steps showed as rings
104
+ * of light rather than a fade. Thirty-two is finer than the eye can split at this dot size, and
105
+ * still a few dozen fills a frame.
106
+ */
107
+ const STEPS = 32;
108
+ /** Twice the CSS pixel density at most. */
109
+ const DENSITY_CAP = 2;
110
+ /** How springy a shoved dot is on its way back: below 1 it overshoots a little and settles. */
111
+ const DAMPING = 0.5;
112
+ /** A dimension token's value in CSS pixels, which is how the token build writes them. */
113
+ function pixels(value, fallback) {
114
+ const parsed = Number.parseFloat(value);
115
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
116
+ }
117
+ /** A computed duration — `0.4s` or `400ms` — in milliseconds. */
118
+ function milliseconds(value) {
119
+ const first = value.split(',')[0]?.trim() ?? '';
120
+ const parsed = Number.parseFloat(first);
121
+ if (!Number.isFinite(parsed))
122
+ return 0;
123
+ return first.endsWith('ms') ? parsed : parsed * 1000;
124
+ }
125
+ /**
126
+ * A computed timing function as a function of progress: `cubic-bezier(x1, y1, x2, y2)` solved for
127
+ * its x by bisection and read for its y — the curve the stylesheet would have run. Anything else
128
+ * falls back to decelerating, which is what motion.easing.enter is.
129
+ */
130
+ function easing(value) {
131
+ const match = /cubic-bezier\(([^)]+)\)/.exec(value);
132
+ const parts = match?.[1]?.split(',').map((each) => Number.parseFloat(each));
133
+ if (parts === undefined || parts.length !== 4 || parts.some((each) => !Number.isFinite(each))) {
134
+ return (t) => 1 - (1 - t) ** 3;
135
+ }
136
+ const [x1, y1, x2, y2] = parts;
137
+ const axis = (a, b, s) => 3 * a * s * (1 - s) ** 2 + 3 * b * s ** 2 * (1 - s) + s ** 3;
138
+ return (t) => {
139
+ if (t <= 0)
140
+ return 0;
141
+ if (t >= 1)
142
+ return 1;
143
+ let low = 0;
144
+ let high = 1;
145
+ let s = t;
146
+ for (let i = 0; i < 20; i += 1) {
147
+ s = (low + high) / 2;
148
+ if (axis(x1, x2, s) < t)
149
+ low = s;
150
+ else
151
+ high = s;
152
+ }
153
+ return axis(y1, y2, s);
154
+ };
155
+ }
156
+ /** Any CSS colour as red, green, blue and alpha, by letting a canvas parse it. */
157
+ function rgba(context, colour) {
158
+ context.fillStyle = '#000';
159
+ context.fillStyle = colour;
160
+ const parsed = context.fillStyle;
161
+ if (parsed.startsWith('#')) {
162
+ const hex = parsed.slice(1);
163
+ return [
164
+ Number.parseInt(hex.slice(0, 2), 16),
165
+ Number.parseInt(hex.slice(2, 4), 16),
166
+ Number.parseInt(hex.slice(4, 6), 16),
167
+ 1,
168
+ ];
169
+ }
170
+ const numbers = /rgba?\(([^)]+)\)/.exec(parsed)?.[1]?.split(',').map((each) => Number.parseFloat(each)) ?? [];
171
+ return [numbers[0] ?? 0, numbers[1] ?? 0, numbers[2] ?? 0, numbers[3] ?? 1];
172
+ }
173
+ export function halftone(root, canvas, options) {
174
+ // Somewhere with no layout to observe — a test's DOM, an old browser — the band keeps its flat
175
+ // ground, which is a finished band rather than a broken one.
176
+ if (typeof ResizeObserver === 'undefined' || typeof IntersectionObserver === 'undefined') {
177
+ return { destroy: () => { } };
178
+ }
179
+ const context = canvas.getContext('2d');
180
+ if (context === null)
181
+ return { destroy: () => { } };
182
+ const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)');
183
+ /** Whether the pattern answers the pointer: asked to, and motion not declined. */
184
+ const allowed = () => options.interactive && reduced?.matches !== true;
185
+ // The tokens, read once: they do not change while the band is on screen, and reading computed
186
+ // style inside a frame is a style recalculation on every draw.
187
+ const style = getComputedStyle(canvas);
188
+ const tokens = getComputedStyle(root);
189
+ const fromTokens = {
190
+ base: style.color,
191
+ active: style.outlineColor || style.color,
192
+ dot: pixels(tokens.getPropertyValue('--size-halftone-dot'), 6),
193
+ pitch: pixels(tokens.getPropertyValue('--size-halftone-pitch'), 20),
194
+ follow: milliseconds(style.transitionDuration),
195
+ opacity: OPACITY[root.dataset['tone'] ?? 'dark'] ?? 1,
196
+ };
197
+ const ease = easing(style.transitionTimingFunction);
198
+ let tuning = { ...fromTokens, ...SETTINGS, ...overrides };
199
+ // One colour per brightness step, from the base at the faintest to the active at the brightest.
200
+ let fills = [];
201
+ function settle() {
202
+ tuning = { ...fromTokens, ...SETTINGS, ...overrides };
203
+ resolved = tuning;
204
+ const from = rgba(context, tuning.base);
205
+ const to = rgba(context, tuning.active);
206
+ // A step is a brightness, from nothing to full. It decides the dot's opacity and how far its
207
+ // colour has moved from the base to the active ink — so a dot fading out at the pattern's edge
208
+ // dims and returns to the base colour together, and blends into the ground.
209
+ fills = Array.from({ length: STEPS }, (_, step) => {
210
+ const t = (step + 0.5) / STEPS;
211
+ const mix = (a, b) => Math.round(a + (b - a) * t);
212
+ const alpha = t * (from[3] + (to[3] - from[3]) * t) * Math.max(0, Math.min(1, tuning.opacity));
213
+ return `rgba(${mix(from[0], to[0])}, ${mix(from[1], to[1])}, ${mix(from[2], to[2])}, ${alpha})`;
214
+ });
215
+ }
216
+ let width = 0;
217
+ let height = 0;
218
+ let visible = true;
219
+ let frame = 0;
220
+ let last = 0;
221
+ // The grid: where it starts, how many columns and rows, and each dot's home. A regular grid, so a
222
+ // dot's index follows from its row and column — which is what lets every loop below visit only
223
+ // the rows and columns that can matter rather than every dot on the band.
224
+ let pitch = 20;
225
+ let startX = 0;
226
+ let startY = 0;
227
+ let columns = 0;
228
+ let rows = 0;
229
+ let homeX = new Float32Array(0);
230
+ let homeY = new Float32Array(0);
231
+ // How far, and how fast, each dot has been knocked from home.
232
+ let offX = new Float32Array(0);
233
+ let offY = new Float32Array(0);
234
+ let velX = new Float32Array(0);
235
+ let velY = new Float32Array(0);
236
+ // The dots currently knocked, so the spring visits those and nothing else. `knocked` marks a dot
237
+ // as listed, so a dot struck twice is listed once.
238
+ let moving = [];
239
+ let knocked = new Uint8Array(0);
240
+ function layout() {
241
+ pitch = Math.max(4, tuning.pitch);
242
+ // Centred on the band, so the grid sits symmetrically whatever its width.
243
+ startX = ((width % pitch) + pitch) / 2 - pitch;
244
+ startY = ((height % pitch) + pitch) / 2 - pitch;
245
+ columns = Math.ceil((width - startX) / pitch) + 1;
246
+ rows = Math.ceil((height - startY) / pitch) + 1;
247
+ const count = columns * rows;
248
+ homeX = new Float32Array(count);
249
+ homeY = new Float32Array(count);
250
+ offX = new Float32Array(count);
251
+ offY = new Float32Array(count);
252
+ velX = new Float32Array(count);
253
+ velY = new Float32Array(count);
254
+ knocked = new Uint8Array(count);
255
+ moving = [];
256
+ let at = 0;
257
+ for (let row = 0; row < rows; row += 1) {
258
+ for (let column = 0; column < columns; column += 1) {
259
+ homeX[at] = startX + column * pitch;
260
+ homeY[at] = startY + row * pitch;
261
+ at += 1;
262
+ }
263
+ }
264
+ }
265
+ // The glow's position in CSS pixels. While a pointer is over the band, or the glow is drifting,
266
+ // it chases `target`; with neither, it glides back to rest along the eased curve.
267
+ let glowX = 0;
268
+ let glowY = 0;
269
+ let target = null;
270
+ let glide = null;
271
+ let resting = true;
272
+ // How large the glow is right now, as a share of its resting size — eased towards hoverScale
273
+ // while a pointer is over the band and back to 1 when it leaves, so the size never jumps.
274
+ let scale = 1;
275
+ const rest = () => ({ x: width * tuning.restX, y: height * tuning.restY });
276
+ /**
277
+ * Whether the resting glow drifts: asked to animate, motion not declined, and a drift asked for.
278
+ * Apart from the pointer, so a band can drift without answering it or answer it without drifting.
279
+ */
280
+ const drifts = () => options.animated &&
281
+ reduced?.matches !== true &&
282
+ tuning.driftPeriod > 0 &&
283
+ (tuning.drift > 0 || tuning.driftRise > 0);
284
+ /**
285
+ * Where the drifting glow is at a moment: its rest, plus two slow waves across and one up and
286
+ * down, at periods that do not divide into each other — so the path never visibly repeats, and
287
+ * the light wanders rather than swinging like a pendulum.
288
+ */
289
+ const drifted = (now) => {
290
+ const turn = (now / 1000 / tuning.driftPeriod) * Math.PI * 2;
291
+ const across = Math.sin(turn) * 0.7 + Math.sin(turn * 0.59 + 1.3) * 0.3;
292
+ const rise = Math.sin(turn * 0.77 + 0.4);
293
+ const at = rest();
294
+ return { x: at.x + across * width * tuning.drift, y: at.y + rise * height * tuning.driftRise };
295
+ };
296
+ // When the last frame was drawn. A frame where only the drift has moved draws at half the
297
+ // display's rate: ample for light moving this slowly, and half of what an idle band costs.
298
+ let drawn = 0;
299
+ const IDLE_FRAME = 33;
300
+ /** A dot this small is drawn as a square: below a pixel and a half nobody can tell, and it is cheap. */
301
+ const SQUARE_BELOW = 0.75;
302
+ function draw() {
303
+ if (context === null || width === 0 || height === 0)
304
+ return;
305
+ context.clearRect(0, 0, width, height);
306
+ const reachX = Math.max(1, width * tuning.reachX * scale);
307
+ const reachY = Math.max(1, height * tuning.reachY * scale);
308
+ const widest = tuning.dot / 2;
309
+ if (widest < SMALLEST)
310
+ return;
311
+ const falloff = Math.max(0.0001, tuning.falloff);
312
+ // The furthest a dot can be from the glow, in reach units squared, and still be drawn: beyond
313
+ // it the light makes the dot smaller than SMALLEST whatever else is true. Tested before the
314
+ // exponential, which is the one costly operation per dot. Where the size floor keeps every dot
315
+ // above SMALLEST there is no such distance, and every dot is drawn.
316
+ const sizeFloor = Math.max(0, Math.min(1, tuning.sizeFloor));
317
+ const farthest = widest * sizeFloor >= SMALLEST ? Infinity : Math.log(widest / SMALLEST) / falloff;
318
+ const paths = Array.from({ length: STEPS }, () => new Path2D());
319
+ const used = new Uint8Array(STEPS);
320
+ // The pattern's upper edge: nothing above `top`, full from `top + topFade` down, and a smooth
321
+ // step between so the edge is a fade rather than a line across the band.
322
+ const edge = height * tuning.top;
323
+ const fade = Math.max(1, height * tuning.topFade);
324
+ // The first row below the edge, found by arithmetic rather than by testing every row above it.
325
+ const firstRow = Math.max(0, Math.floor((edge - startY) / pitch));
326
+ for (let row = firstRow; row < rows; row += 1) {
327
+ const homeRowY = startY + row * pitch;
328
+ const below = (homeRowY - edge) / fade;
329
+ if (below <= 0)
330
+ continue;
331
+ // A straight fade, as the rule grid's and the rings' is, so the four patterns thin out
332
+ // towards the top at the same rate. Like theirs it fades the ink and leaves the mark alone:
333
+ // the dots dim towards the top and keep their size. Shrinking them as well thinned the
334
+ // upper half twice over, and it read as empty beside a grid fading at the same rate.
335
+ const mask = below >= 1 ? 1 : below;
336
+ // Lit by where a dot belongs rather than where it has been knocked to, so a shoved dot keeps
337
+ // its brightness and the glow does not flicker as the dots move through it.
338
+ const dy = (homeRowY - glowY) / reachY;
339
+ const dy2 = dy * dy;
340
+ // A whole row too far from the glow to show is skipped in one test.
341
+ if (dy2 > farthest)
342
+ continue;
343
+ const rowAt = row * columns;
344
+ for (let column = 0; column < columns; column += 1) {
345
+ const dx = (startX + column * pitch - glowX) / reachX;
346
+ const d2 = dx * dx + dy2;
347
+ if (d2 > farthest)
348
+ continue;
349
+ const light = Math.exp(-d2 * falloff);
350
+ const radius = widest * Math.max(light, sizeFloor);
351
+ if (radius < SMALLEST)
352
+ continue;
353
+ // The floor keeps dots far from the glow visible inside the pattern; the mask then takes
354
+ // everything — floor included — down to nothing towards the pattern's edge.
355
+ const brightness = mask * (tuning.floor + (1 - tuning.floor) * light);
356
+ const at = rowAt + column;
357
+ const x = homeX[at] + offX[at];
358
+ const y = homeY[at] + offY[at];
359
+ const step = Math.min(STEPS - 1, (brightness * STEPS) | 0);
360
+ const path = paths[step];
361
+ if (radius < SQUARE_BELOW) {
362
+ path.rect(x - radius, y - radius, radius * 2, radius * 2);
363
+ }
364
+ else {
365
+ path.moveTo(x + radius, y);
366
+ path.arc(x, y, radius, 0, Math.PI * 2);
367
+ }
368
+ used[step] = 1;
369
+ }
370
+ }
371
+ for (let step = 0; step < STEPS; step += 1) {
372
+ if (used[step] === 0)
373
+ continue;
374
+ context.fillStyle = fills[step];
375
+ context.fill(paths[step]);
376
+ }
377
+ }
378
+ /**
379
+ * How far the glow closes on its target in one frame.
380
+ *
381
+ * A chase rather than a glide. Restarting a timed glide on every pointer move kept the glow a
382
+ * whole glide behind a moving pointer, which read as lag. Closing a share of the remaining
383
+ * distance each frame keeps it just behind and always moving smoothly, and the share comes from
384
+ * the time the frame took, so a slow frame does not make the glow slower. The time constant is a
385
+ * third of `follow`: by then two thirds of the gap is closed, and by `follow` all but all of it.
386
+ */
387
+ function chase(elapsed) {
388
+ if (target === null)
389
+ return false;
390
+ const share = tuning.follow <= 0 ? 1 : 1 - Math.exp(-elapsed / (tuning.follow / 3));
391
+ glowX += (target.x - glowX) * share;
392
+ glowY += (target.y - glowY) * share;
393
+ return Math.abs(target.x - glowX) > 0.3 || Math.abs(target.y - glowY) > 0.3;
394
+ }
395
+ /**
396
+ * One step of every knocked dot springing home. A damped spring whose settling time is
397
+ * `returnDuration`; a dot within a twentieth of a pixel of home and all but still is put back
398
+ * exactly and leaves the list, and when the list is empty the loop can stop.
399
+ */
400
+ function spring(elapsed) {
401
+ if (moving.length === 0)
402
+ return false;
403
+ const seconds = Math.min(elapsed, 32) / 1000;
404
+ const omega = 4 / (DAMPING * Math.max(0.05, tuning.returnDuration));
405
+ const stiffness = omega * omega;
406
+ const damping = 2 * DAMPING * omega;
407
+ let kept = 0;
408
+ for (let i = 0; i < moving.length; i += 1) {
409
+ const at = moving[i];
410
+ let vx = velX[at];
411
+ let vy = velY[at];
412
+ const x = offX[at];
413
+ const y = offY[at];
414
+ vx += (-stiffness * x - damping * vx) * seconds;
415
+ vy += (-stiffness * y - damping * vy) * seconds;
416
+ const nx = x + vx * seconds;
417
+ const ny = y + vy * seconds;
418
+ if (Math.abs(nx) < 0.05 && Math.abs(ny) < 0.05 && Math.abs(vx) < 1 && Math.abs(vy) < 1) {
419
+ offX[at] = 0;
420
+ offY[at] = 0;
421
+ velX[at] = 0;
422
+ velY[at] = 0;
423
+ knocked[at] = 0;
424
+ continue;
425
+ }
426
+ offX[at] = nx;
427
+ offY[at] = ny;
428
+ velX[at] = vx;
429
+ velY[at] = vy;
430
+ moving[kept] = at;
431
+ kept += 1;
432
+ }
433
+ moving.length = kept;
434
+ return kept > 0;
435
+ }
436
+ /** Ease the glow's size towards what it should be now: smaller under a pointer, full at rest. */
437
+ function grow(elapsed) {
438
+ const wanted = resting ? 1 : tuning.hoverScale;
439
+ const share = tuning.follow <= 0 ? 1 : 1 - Math.exp(-elapsed / (tuning.follow / 3));
440
+ scale += (wanted - scale) * share;
441
+ if (Math.abs(wanted - scale) < 0.002) {
442
+ scale = wanted;
443
+ return false;
444
+ }
445
+ return true;
446
+ }
447
+ /**
448
+ * Knock every dot within `radius` of a point, by a velocity each dot's nearness decides. Visits
449
+ * only the grid cells the radius can reach — a dot is never further than its own reach from home
450
+ * for long, so a margin of one cell is enough — rather than every dot on the band.
451
+ */
452
+ function knock(x, y, radius, kick) {
453
+ if (radius <= 0 || columns === 0)
454
+ return;
455
+ const squared = radius * radius;
456
+ const fromColumn = Math.max(0, Math.floor((x - radius - startX) / pitch) - 1);
457
+ const toColumn = Math.min(columns - 1, Math.ceil((x + radius - startX) / pitch) + 1);
458
+ const fromRow = Math.max(0, Math.floor((y - radius - startY) / pitch) - 1);
459
+ const toRow = Math.min(rows - 1, Math.ceil((y + radius - startY) / pitch) + 1);
460
+ for (let row = fromRow; row <= toRow; row += 1) {
461
+ for (let column = fromColumn; column <= toColumn; column += 1) {
462
+ const at = row * columns + column;
463
+ const dx = homeX[at] + offX[at] - x;
464
+ const dy = homeY[at] + offY[at] - y;
465
+ const distance = dx * dx + dy * dy;
466
+ if (distance > squared)
467
+ continue;
468
+ const near = 1 - Math.sqrt(distance) / radius;
469
+ const [kx, ky] = kick(dx, dy, near);
470
+ velX[at] = velX[at] + kx;
471
+ velY[at] = velY[at] + ky;
472
+ if (knocked[at] === 0) {
473
+ knocked[at] = 1;
474
+ moving.push(at);
475
+ }
476
+ }
477
+ }
478
+ }
479
+ // The pointer, as the last event left it. Events only record it; the frame reads the band's
480
+ // position once and acts on it — so a fast mouse sending a hundred events a second costs one
481
+ // layout read a frame rather than one an event.
482
+ let pointer = null;
483
+ // Where the pointer was when the last frame acted on it, to know how fast it crossed the dots.
484
+ let previous = null;
485
+ // A press waiting for the next frame to send its ripple.
486
+ let pressed = null;
487
+ function takePointer() {
488
+ if (pointer === null && pressed === null)
489
+ return;
490
+ const box = root.getBoundingClientRect();
491
+ if (pointer !== null) {
492
+ const x = pointer.clientX - box.left;
493
+ const y = pointer.clientY - box.top;
494
+ // The glow follows a pointer with hover — a mouse or a pen. A touch has no hover to follow,
495
+ // and a glow jumping to wherever a finger lands to scroll is motion nobody asked for.
496
+ if (pointer.hovers) {
497
+ resting = false;
498
+ glide = null;
499
+ target = { x, y: Math.max(y, ceiling()) };
500
+ }
501
+ // The shove, from how fast the pointer crossed the dots near it: a pointer drifting slowly
502
+ // parts them a little, a flick scatters them. Divided by resistance so one setting tunes it.
503
+ if (previous !== null && tuning.proximity > 0) {
504
+ const seconds = Math.max(0.001, (pointer.time - previous.time) / 1000);
505
+ const speedX = (x - previous.x) / seconds;
506
+ const speedY = (y - previous.y) / seconds;
507
+ const strength = (seconds * 2000) / Math.max(1, tuning.resistance);
508
+ knock(x, y, tuning.proximity, (_dx, _dy, near) => [speedX * near * strength, speedY * near * strength]);
509
+ }
510
+ previous = { x, y, time: pointer.time };
511
+ pointer = null;
512
+ }
513
+ if (pressed !== null) {
514
+ // A press sends a ripple out from where it landed: every dot it reaches is pushed straight
515
+ // away from that point, harder the nearer it was.
516
+ const push = tuning.shockStrength * 60;
517
+ knock(pressed.clientX - box.left, pressed.clientY - box.top, tuning.shockRadius, (dx, dy, near) => {
518
+ const distance = Math.hypot(dx, dy) || 1;
519
+ return [(dx / distance) * push * near, (dy / distance) * push * near];
520
+ });
521
+ pressed = null;
522
+ }
523
+ }
524
+ function tick(now) {
525
+ frame = 0;
526
+ const elapsed = last === 0 ? 16 : Math.min(now - last, 64);
527
+ takePointer();
528
+ // At rest and drifting, the glow chases its point on the drift path the way it chases a
529
+ // pointer — so leaving the band eases it back onto the path rather than jumping to it.
530
+ const idle = resting && drifts();
531
+ if (idle) {
532
+ glide = null;
533
+ target = drifted(now);
534
+ }
535
+ const chasing = chase(elapsed);
536
+ const growing = grow(elapsed);
537
+ const springing = spring(elapsed);
538
+ if (glide !== null) {
539
+ const progress = tuning.follow <= 0 ? 1 : Math.min(1, (now - glide.start) / tuning.follow);
540
+ const along = ease(progress);
541
+ glowX = glide.fromX + (glide.toX - glide.fromX) * along;
542
+ glowY = glide.fromY + (glide.toY - glide.fromY) * along;
543
+ if (progress >= 1)
544
+ glide = null;
545
+ }
546
+ const settling = glide !== null || springing || growing || (chasing && !idle);
547
+ // Nothing but the drift is moving: draw at half rate. Anything the reader is doing — a pointer
548
+ // over the band, dots springing back — draws every frame.
549
+ const onlyDrifting = idle && !settling;
550
+ if (!onlyDrifting || now - drawn >= IDLE_FRAME) {
551
+ draw();
552
+ drawn = now;
553
+ }
554
+ const more = settling || idle || chasing;
555
+ last = more ? now : 0;
556
+ if (more && visible)
557
+ frame = requestAnimationFrame(tick);
558
+ }
559
+ function request() {
560
+ if (frame === 0 && visible)
561
+ frame = requestAnimationFrame(tick);
562
+ }
563
+ /** Back to rest along the eased curve: the pointer has gone and there is no drift to chase. */
564
+ function glideTo(to) {
565
+ target = null;
566
+ glide = { fromX: glowX, fromY: glowY, toX: to.x, toY: to.y, start: performance.now() };
567
+ request();
568
+ }
569
+ /**
570
+ * The highest the glow may rise, in CSS pixels from the top. Above it the glow's brightest dots
571
+ * would sit where the fade has thinned them, so the glow tracks the pointer across and stays
572
+ * below this going up. Its own setting rather than the fade's edge, now the fade spans the band.
573
+ */
574
+ const ceiling = () => height * tuning.ceiling;
575
+ const resize = new ResizeObserver(() => {
576
+ const box = root.getBoundingClientRect();
577
+ width = box.width;
578
+ height = box.height;
579
+ const density = Math.min(window.devicePixelRatio || 1, DENSITY_CAP);
580
+ canvas.width = Math.round(width * density);
581
+ canvas.height = Math.round(height * density);
582
+ context.setTransform(density, 0, 0, density, 0, 0);
583
+ layout();
584
+ // A resize moves the rest point with the band; a glow at rest goes with it at once rather than
585
+ // gliding, which would be motion nobody asked for.
586
+ if (resting) {
587
+ const at = idleAt(performance.now());
588
+ glowX = at.x;
589
+ glowY = at.y;
590
+ }
591
+ request();
592
+ });
593
+ /** Where a resting glow belongs now: on its drift path if it drifts, at rest if it does not. */
594
+ const idleAt = (now) => (drifts() ? drifted(now) : rest());
595
+ const sight = new IntersectionObserver((seen) => {
596
+ visible = seen.some((each) => each.isIntersecting);
597
+ if (visible)
598
+ request();
599
+ else if (frame !== 0) {
600
+ cancelAnimationFrame(frame);
601
+ frame = 0;
602
+ last = 0;
603
+ }
604
+ });
605
+ const move = (event) => {
606
+ if (!allowed())
607
+ return;
608
+ pointer = {
609
+ clientX: event.clientX,
610
+ clientY: event.clientY,
611
+ time: event.timeStamp,
612
+ hovers: event.pointerType !== 'touch',
613
+ };
614
+ request();
615
+ };
616
+ const leave = (event) => {
617
+ pointer = null;
618
+ previous = null;
619
+ if (!allowed() || event.pointerType === 'touch')
620
+ return;
621
+ resting = true;
622
+ // A drifting glow is picked up by the next frame and eased back onto its path.
623
+ if (drifts())
624
+ request();
625
+ else
626
+ glideTo(rest());
627
+ };
628
+ const press = (event) => {
629
+ if (!allowed())
630
+ return;
631
+ pressed = { clientX: event.clientX, clientY: event.clientY };
632
+ request();
633
+ };
634
+ // A reader turning reduced motion on or off while the band is on screen: the drift stops or
635
+ // starts without waiting for the pointer.
636
+ const motionChanged = () => {
637
+ if (!allowed()) {
638
+ resting = true;
639
+ target = null;
640
+ glide = null;
641
+ pointer = null;
642
+ const at = rest();
643
+ glowX = at.x;
644
+ glowY = at.y;
645
+ }
646
+ request();
647
+ };
648
+ reduced?.addEventListener?.('change', motionChanged);
649
+ const refresh = () => {
650
+ const pitchChanged = Math.max(4, tuning.pitch) !== Math.max(4, { ...fromTokens, ...SETTINGS, ...overrides }.pitch);
651
+ settle();
652
+ if (pitchChanged)
653
+ layout();
654
+ if (resting) {
655
+ const at = idleAt(performance.now());
656
+ glowX = at.x;
657
+ glowY = at.y;
658
+ }
659
+ request();
660
+ };
661
+ settle();
662
+ resize.observe(root);
663
+ sight.observe(root);
664
+ const host = options.host ?? root;
665
+ host.addEventListener('pointermove', move, { passive: true });
666
+ host.addEventListener('pointerleave', leave, { passive: true });
667
+ host.addEventListener('pointerdown', press, { passive: true });
668
+ live.add(refresh);
669
+ return {
670
+ destroy: () => {
671
+ live.delete(refresh);
672
+ resize.disconnect();
673
+ sight.disconnect();
674
+ reduced?.removeEventListener?.('change', motionChanged);
675
+ host.removeEventListener('pointermove', move);
676
+ host.removeEventListener('pointerleave', leave);
677
+ host.removeEventListener('pointerdown', press);
678
+ if (frame !== 0)
679
+ cancelAnimationFrame(frame);
680
+ },
681
+ };
682
+ }