@dloizides/marketing-astro-kit 1.2.0 → 1.3.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.
package/CHANGELOG.md CHANGED
@@ -1,51 +1,90 @@
1
- # Changelog
2
-
3
- ## 1.2.0
4
-
5
- - New `DemoFrame.astro` — a themeable, **client-side "try it" demo** shell for
6
- static marketing sites (no backend): an eyebrow/title/intro, a row of clickable
7
- example chips, a per-example result panel (bespoke product body injected via a
8
- named slot `result-<example.id>`), a clearly-visible "sample data" disclaimer,
9
- and a primary CTA (reuses `PrimaryCta`). Tiny inline vanilla JS toggles which
10
- result shows on chip click (+ arrow-key roving tablist); the first example is
11
- visible without JS (progressive enhancement). New `DemoExample` / `DemoFrameData`
12
- types in `schema.ts`.
13
- - New `GuideCard.astro` — a themeable card for a **guides / SEO content-hub**
14
- listing, rendering one guide's tags, title, description and date. New
15
- `GuideCardData` type.
16
- - New `guideFrontmatterSchema` (Zod, built on `astro/zod`) + `GuideFrontmatter`
17
- type in `schema.ts` plug straight into an Astro content collection
18
- (`defineCollection({ loader: glob(...), schema: guideFrontmatterSchema })`) so
19
- every product's guides validate the same shape (title, description, slug?,
20
- publishDate, updatedDate?, tags, ogImage?, canonical?, draft?). The article
21
- page + routes + JSON-LD stay product-owned.
22
- - `schema.ts` now imports `z` from `astro/zod` (Astro is already a peer dep) —
23
- the ONE runtime import; every other export stays plain types.
24
-
25
- ## 1.1.0
26
-
27
- - New `PrimaryCta.astro` a themeable primary call-to-action (link OR spam-safe
28
- contact) with an optional secondary CTA and a `tone: 'light' | 'dark'` for
29
- dark bands. Renders a `MarketingCta` so link-vs-lead-capture + analytics wiring
30
- is no longer hand-coded per page.
31
- - New `buildPrimaryCta(opts)` helper + `PrimaryCtaOptions` in `schema.ts`: drive
32
- a primary CTA from ONE `SIGNUP_URL` constant a real signup link when set,
33
- spam-safe lead-capture when `null` (flip at GA with no template change).
34
- - `ctaAttrs()` promoted from `PricingComparison.astro` into `schema.ts` and now
35
- shared by every CTA-rendering component (no duplication).
36
- - `TrustSection` gains three optional, guarded fields (existing sites
37
- unaffected): `foundingPartner` ("first N free during beta" panel),
38
- `founder` (honest one-line credibility) and `sampleDeliverable` (a "here's the
39
- actual report you get" showcase image, or a `slot="sample-deliverable"` for
40
- bespoke mock markup while the section chrome stays shared).
41
-
42
- ## 1.0.0
43
-
44
- - Initial release. `PricingComparison.astro` (Free-vs-Pro comparison table with
45
- tier columns, grouped feature rows, optional footnote + one-time callout card)
46
- and `TrustSection.astro` (security cards with inline icons, customer-logo row
47
- with placeholders, testimonial placeholders, discreet built-by line).
48
- - Framework-agnostic data schema (`schema.ts`) + inline icon set (`icons.ts`).
49
- - Themed via host CSS custom properties with fallbacks; no image assets.
50
- - Extracted from `kefi-marketing` as the 2nd genuine use of the pricing/trust
51
- sections across the Astro marketing sites.
1
+ # Changelog
2
+
3
+ ## 1.3.0
4
+
5
+ ### Added
6
+
7
+ - `<HeroBackdrop />` plus the named hero-backdrop preset catalog (`./heroBackdrop`).
8
+ Nine animated hero treatments -- three canvas, one SVG-filter, five pure-CSS --
9
+ that until now existed ONLY in a disposable per-session scratchpad and had been
10
+ rebuilt from scratch more than once. `HERO_BACKDROP_PRESETS` is the permanent
11
+ record: preset name -> `{ variant, engine, palette, css, ember }`, using the
12
+ design studio's own parameter names verbatim so a studio export pastes in with
13
+ no translation step.
14
+ - `csdf-ember-sparse` -- CSDF's shipped configuration. 40 particles, an eighth of
15
+ the studio default, which is what makes a canvas backdrop affordable against a
16
+ hard Lighthouse >= 80 budget.
17
+ - Only the ember canvas engine ships as a RENDERER. The CSS/SVG entries are
18
+ recorded PARAMETER SETS painted by the host stylesheet; the component renders
19
+ nothing for those, leaving the host's own backdrop untouched.
20
+
21
+ ### Notes
22
+
23
+ - The component's script is `is:inline` deliberately -- a bundled Astro script
24
+ emits a root-absolute `/_astro/*.js` reference, which 404s under kefi-landings'
25
+ host-based per-tenant nginx roots.
26
+ - Respects `prefers-reduced-motion` (one still frame, no loop) and stops the rAF
27
+ loop on `visibilitychange` and when the canvas leaves the viewport.
28
+
29
+ ## 1.2.1
30
+
31
+ - **a11y fix (serious): the pricing comparison was SILENT to screen readers.**
32
+ `PricingComparison.astro` marked each cell with `aria-label` on a bare
33
+ `<span>`. `aria-label` is *prohibited* on a roleless generic element, so
34
+ assistive tech ignored it and the inner `<svg>` is `aria-hidden`. Net
35
+ effect: a screen-reader user reached every included/not-included cell and
36
+ heard **nothing**, so they could not tell which plan carried which feature,
37
+ on a public pricing page. Fixed by giving the span `role="img"` alongside
38
+ the existing label. Found by the `a11y-public` E2E axe scan against the live
39
+ kefi landing: `aria-prohibited-attr`, 27 nodes. Swept the rest of the kit —
40
+ no other instances of this shape.
41
+
42
+ ## 1.2.0
43
+
44
+ - New `DemoFrame.astro` — a themeable, **client-side "try it" demo** shell for
45
+ static marketing sites (no backend): an eyebrow/title/intro, a row of clickable
46
+ example chips, a per-example result panel (bespoke product body injected via a
47
+ named slot `result-<example.id>`), a clearly-visible "sample data" disclaimer,
48
+ and a primary CTA (reuses `PrimaryCta`). Tiny inline vanilla JS toggles which
49
+ result shows on chip click (+ arrow-key roving tablist); the first example is
50
+ visible without JS (progressive enhancement). New `DemoExample` / `DemoFrameData`
51
+ types in `schema.ts`.
52
+ - New `GuideCard.astro` — a themeable card for a **guides / SEO content-hub**
53
+ listing, rendering one guide's tags, title, description and date. New
54
+ `GuideCardData` type.
55
+ - New `guideFrontmatterSchema` (Zod, built on `astro/zod`) + `GuideFrontmatter`
56
+ type in `schema.ts` — plug straight into an Astro content collection
57
+ (`defineCollection({ loader: glob(...), schema: guideFrontmatterSchema })`) so
58
+ every product's guides validate the same shape (title, description, slug?,
59
+ publishDate, updatedDate?, tags, ogImage?, canonical?, draft?). The article
60
+ page + routes + JSON-LD stay product-owned.
61
+ - `schema.ts` now imports `z` from `astro/zod` (Astro is already a peer dep) —
62
+ the ONE runtime import; every other export stays plain types.
63
+
64
+ ## 1.1.0
65
+
66
+ - New `PrimaryCta.astro` — a themeable primary call-to-action (link OR spam-safe
67
+ contact) with an optional secondary CTA and a `tone: 'light' | 'dark'` for
68
+ dark bands. Renders a `MarketingCta` so link-vs-lead-capture + analytics wiring
69
+ is no longer hand-coded per page.
70
+ - New `buildPrimaryCta(opts)` helper + `PrimaryCtaOptions` in `schema.ts`: drive
71
+ a primary CTA from ONE `SIGNUP_URL` constant — a real signup link when set,
72
+ spam-safe lead-capture when `null` (flip at GA with no template change).
73
+ - `ctaAttrs()` promoted from `PricingComparison.astro` into `schema.ts` and now
74
+ shared by every CTA-rendering component (no duplication).
75
+ - `TrustSection` gains three optional, guarded fields (existing sites
76
+ unaffected): `foundingPartner` ("first N free during beta" panel),
77
+ `founder` (honest one-line credibility) and `sampleDeliverable` (a "here's the
78
+ actual report you get" showcase — image, or a `slot="sample-deliverable"` for
79
+ bespoke mock markup while the section chrome stays shared).
80
+
81
+ ## 1.0.0
82
+
83
+ - Initial release. `PricingComparison.astro` (Free-vs-Pro comparison table with
84
+ tier columns, grouped feature rows, optional footnote + one-time callout card)
85
+ and `TrustSection.astro` (security cards with inline icons, customer-logo row
86
+ with placeholders, testimonial placeholders, discreet built-by line).
87
+ - Framework-agnostic data schema (`schema.ts`) + inline icon set (`icons.ts`).
88
+ - Themed via host CSS custom properties with fallbacks; no image assets.
89
+ - Extracted from `kefi-marketing` as the 2nd genuine use of the pricing/trust
90
+ sections across the Astro marketing sites.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dloizides/marketing-astro-kit",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "Reusable, themeable Astro marketing sections for product sites — a Free-vs-Pro pricing comparison table, a trust / credibility section (security cards, customer-logo + testimonial placeholders, discreet built-by), a client-side \"try it\" demo shell, and an SEO guides/content-hub card + frontmatter schema. Data-driven via a shared TypeScript schema; themed entirely through the host site's CSS custom properties.",
5
5
  "keywords": [
6
6
  "astro",
@@ -13,7 +13,11 @@
13
13
  "content-hub",
14
14
  "seo",
15
15
  "landing-page",
16
- "saas"
16
+ "saas",
17
+ "hero",
18
+ "background",
19
+ "canvas",
20
+ "animation"
17
21
  ],
18
22
  "author": "dloizides",
19
23
  "license": "MIT",
@@ -34,7 +38,10 @@
34
38
  "./DemoFrame.astro": "./src/DemoFrame.astro",
35
39
  "./GuideCard.astro": "./src/GuideCard.astro",
36
40
  "./schema": "./src/schema.ts",
37
- "./icons": "./src/icons.ts"
41
+ "./icons": "./src/icons.ts",
42
+ "./HeroBackdrop.astro": "./src/HeroBackdrop.astro",
43
+ "./heroBackdrop": "./src/heroBackdrop/presets.ts",
44
+ "./heroBackdrop/types": "./src/heroBackdrop/types.ts"
38
45
  },
39
46
  "peerDependencies": {
40
47
  "astro": ">=4"
@@ -0,0 +1,339 @@
1
+ ---
2
+ // ============================================================================
3
+ // <HeroBackdrop /> — an animated hero backdrop, pulled by name from the catalog.
4
+ // ----------------------------------------------------------------------------
5
+ // 🔴 READ BEFORE PUTTING THIS ON A NEW TENANT.
6
+ //
7
+ // Every other kefi hero backdrop is pure CSS/SVG with ZERO main-thread
8
+ // JavaScript, and that is a deliberate design goal, not an accident — an
9
+ // earlier rAF loop writing SVG filter attributes ~30x/sec cost roughly half the
10
+ // frame budget of the page. This component is the ONE exception: a <canvas>
11
+ // driven by requestAnimationFrame. It must therefore be OPT-IN PER TENANT and
12
+ // must never become a site default. Seven kefi tenants share one deployment, so
13
+ // a default here regresses all seven at once.
14
+ //
15
+ // Three things keep it affordable. All are load-bearing:
16
+ // 1. `prefers-reduced-motion: reduce` paints ONE settled still frame and
17
+ // never starts the loop.
18
+ // 2. The loop stops when the tab is hidden (`visibilitychange`) and when the
19
+ // canvas scrolls out of view (IntersectionObserver). A hero is off-screen
20
+ // for most of a session; without this the cost is paid for the whole visit.
21
+ // 3. Particle COUNT dominates per-frame cost. `csdf-ember-sparse` runs 40 —
22
+ // an eighth of the studio default — precisely so this can sit on a site
23
+ // with a hard Lighthouse >= 80 budget.
24
+ //
25
+ // The script is `is:inline` ON PURPOSE, not by oversight. kefi-landings serves
26
+ // each tenant from an nginx root pointed at that tenant's own subdirectory, and
27
+ // a bundled Astro <script> emits a ROOT-ABSOLUTE `/_astro/*.js` reference,
28
+ // which 404s under host-based serving (this is the same reason the site sets
29
+ // `inlineStylesheets: 'always'`). Inline keeps the page self-contained. Do not
30
+ // "clean this up" into a module import without changing how tenants are served.
31
+ // ============================================================================
32
+ import type { HeroBackdropPreset } from './heroBackdrop/types';
33
+
34
+ interface Props {
35
+ /** A catalog entry — see `@dloizides/marketing-astro-kit/heroBackdrop`. */
36
+ preset: HeroBackdropPreset;
37
+ /** Extra class on the canvas, for host-side positioning / z-index. */
38
+ class?: string;
39
+ /**
40
+ * Optional selector for the element the canvas should be moved INTO, as its
41
+ * first child, before it is sized.
42
+ *
43
+ * Astro renders a layout's markup and then the caller's, so a component used
44
+ * alongside a layout cannot place a node inside that layout's hero at build
45
+ * time. The move happens synchronously in the inline script -- the hero has
46
+ * already been parsed by the time this script tag is reached -- so the canvas
47
+ * is in its final position before the first `getBoundingClientRect()`. Sizing
48
+ * it in place and moving it afterwards would measure the wrong box.
49
+ */
50
+ mountSelector?: string;
51
+ }
52
+
53
+ const { preset, class: className = '', mountSelector = '' } = Astro.props;
54
+
55
+ // Only the ember engine is implemented here. Every other catalog entry is a
56
+ // recorded PARAMETER SET, not a shipped renderer — the CSS families are painted
57
+ // by the host site's own stylesheet, which is where they belong (no JS at all).
58
+ // Rendering nothing is the correct degradation: the host's existing backdrop is
59
+ // left exactly as it was.
60
+ const isEmberCanvas = preset.engine === 'canvas' && preset.variant === 'ember';
61
+ const cfg = isEmberCanvas ? { palette: preset.palette, ember: preset.ember } : null;
62
+ ---
63
+
64
+ {
65
+ isEmberCanvas && (
66
+ <canvas
67
+ class:list={['hero-backdrop', className]}
68
+ data-hero-backdrop="ember"
69
+ aria-hidden="true"
70
+ />
71
+ )
72
+ }
73
+
74
+ {
75
+ isEmberCanvas && (
76
+ <script is:inline define:vars={{ heroBackdropCfg: cfg, heroBackdropMount: mountSelector }}>
77
+ (function () {
78
+ var cfg = heroBackdropCfg;
79
+ var cv = document.querySelector('canvas[data-hero-backdrop="ember"]');
80
+ if (!cv || !cv.getContext || !cfg || !cfg.ember) return;
81
+
82
+ if (heroBackdropMount) {
83
+ var host = document.querySelector(heroBackdropMount);
84
+ /* No host is not an error: the backdrop simply does not mount and the
85
+ page keeps whatever backdrop it already had. Never blank the hero. */
86
+ if (!host) {
87
+ if (cv.parentNode) cv.parentNode.removeChild(cv);
88
+ return;
89
+ }
90
+ host.insertBefore(cv, host.firstChild);
91
+ }
92
+
93
+ var EP = cfg.ember;
94
+ var ctx = cv.getContext('2d');
95
+ var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
96
+
97
+ /* ---- palette: six hex stops -> rgb triples + soft radial sprites ---- */
98
+ var STOPS = cfg.palette.map(function (h) {
99
+ var n = parseInt(h.slice(1), 16);
100
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
101
+ });
102
+ function rgba(i, a) {
103
+ var c = STOPS[i];
104
+ return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')';
105
+ }
106
+ var SPRITE_PX = 64;
107
+ var sprites = {};
108
+ function sprite(i) {
109
+ if (sprites[i]) return sprites[i];
110
+ var rgb = STOPS[5 - i];
111
+ var c = document.createElement('canvas');
112
+ c.width = SPRITE_PX;
113
+ c.height = SPRITE_PX;
114
+ var g = c.getContext('2d');
115
+ var half = SPRITE_PX / 2;
116
+ var grad = g.createRadialGradient(half, half, 0, half, half, half);
117
+ var col = 'rgba(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ',';
118
+ grad.addColorStop(0, col + '1)');
119
+ grad.addColorStop(0.28, col + '0.55)');
120
+ grad.addColorStop(0.62, col + '0.16)');
121
+ grad.addColorStop(1, col + '0)');
122
+ g.fillStyle = grad;
123
+ g.fillRect(0, 0, SPRITE_PX, SPRITE_PX);
124
+ sprites[i] = c;
125
+ return c;
126
+ }
127
+
128
+ /* ---- value noise, seeded so the field is identical on every load ---- */
129
+ var PERM = new Uint8Array(512);
130
+ (function seedPerm() {
131
+ var p = new Uint8Array(256);
132
+ var i;
133
+ for (i = 0; i < 256; i++) p[i] = i;
134
+ var seed = 1337;
135
+ function rnd() {
136
+ seed = (seed * 1664525 + 1013904223) >>> 0;
137
+ return seed / 4294967296;
138
+ }
139
+ for (i = 255; i > 0; i--) {
140
+ var j = (rnd() * (i + 1)) | 0;
141
+ var tmp = p[i];
142
+ p[i] = p[j];
143
+ p[j] = tmp;
144
+ }
145
+ for (i = 0; i < 512; i++) PERM[i] = p[i & 255];
146
+ })();
147
+ function hash2(x, y) {
148
+ return PERM[(PERM[x & 255] + (y & 255)) & 255] / 255;
149
+ }
150
+ function smooth(v) {
151
+ return v * v * (3 - 2 * v);
152
+ }
153
+ function valueNoise(x, y) {
154
+ var xi = Math.floor(x);
155
+ var yi = Math.floor(y);
156
+ var u = smooth(x - xi);
157
+ var v = smooth(y - yi);
158
+ var a = hash2(xi, yi);
159
+ var b = hash2(xi + 1, yi);
160
+ var c = hash2(xi, yi + 1);
161
+ var d = hash2(xi + 1, yi + 1);
162
+ var top = a + (b - a) * u;
163
+ return top + (c + (d - c) * u - top) * v;
164
+ }
165
+
166
+ /* ---- particles ---- */
167
+ var parts = [];
168
+ var t = 0;
169
+ function gaussian() {
170
+ var r = (Math.random() + Math.random() + Math.random()) / 3 - 0.5;
171
+ return ((r < 0 ? -1 : 1) * Math.pow(Math.abs(r * 2), EP.focus)) / 2;
172
+ }
173
+ function spawn(p) {
174
+ p.x = 0.5 + gaussian() * EP.width;
175
+ p.y = 1.02 + Math.random() * 0.05;
176
+ p.vx = (Math.random() - 0.5) * 0.02;
177
+ p.vy = -(0.09 + Math.random() * 0.13) * EP.rise;
178
+ p.life = 0;
179
+ p.max = (0.85 + Math.random() * 1.05) * EP.life;
180
+ p.r0 = (0.028 + Math.random() * 0.055) * EP.size;
181
+ p.seed = Math.random() * 100;
182
+ }
183
+ function ensure() {
184
+ var want = EP.count | 0;
185
+ while (parts.length < want) {
186
+ var p = {};
187
+ spawn(p);
188
+ p.life = Math.random() * p.max;
189
+ parts.push(p);
190
+ }
191
+ if (parts.length > want) parts.length = want;
192
+ }
193
+ function step(dt) {
194
+ var s = Math.min(dt, 50) / 1000;
195
+ t += s;
196
+ for (var i = 0; i < parts.length; i++) {
197
+ var p = parts[i];
198
+ p.life += s;
199
+ if (p.life >= p.max || p.y < -0.12) {
200
+ spawn(p);
201
+ continue;
202
+ }
203
+ var n = valueNoise(p.x * 4.2 * EP.swirl + p.seed, p.y * 3.4 * EP.swirl - t * 1.25) - 0.5;
204
+ var n2 = valueNoise(p.x * 9.5 * EP.swirl + 31.7, p.y * 7.1 * EP.swirl - t * 2.1) - 0.5;
205
+ p.vx += (n * 0.3 + n2 * 0.14) * EP.drift * s * 2.4;
206
+ p.vx *= 0.965;
207
+ var age = p.life / p.max;
208
+ p.vy -= 0.055 * s * (1 - age) * EP.rise;
209
+ p.x += p.vx * s;
210
+ p.y += p.vy * s;
211
+ }
212
+ }
213
+ function draw() {
214
+ var w = cv.width;
215
+ var h = cv.height;
216
+ ctx.globalCompositeOperation = 'source-over';
217
+ ctx.globalAlpha = 1;
218
+ ctx.fillStyle = rgba(0, 1);
219
+ ctx.fillRect(0, 0, w, h);
220
+
221
+ /* The bed of heat the column sits in. Without it the sprites read as
222
+ confetti on black rather than as a fire. */
223
+ var bg = ctx.createRadialGradient(w * 0.5, h * 1.02, 0, w * 0.5, h * 1.02, h * EP.bloomH);
224
+ bg.addColorStop(0, rgba(3, 0.42 * EP.bloomA));
225
+ bg.addColorStop(0.35, rgba(2, 0.18 * EP.bloomA));
226
+ bg.addColorStop(0.7, rgba(1, 0.1 * EP.bloomA));
227
+ bg.addColorStop(1, rgba(0, 0));
228
+ ctx.fillStyle = bg;
229
+ ctx.fillRect(0, 0, w, h);
230
+
231
+ ctx.globalCompositeOperation = 'lighter';
232
+ for (var i = 0; i < parts.length; i++) {
233
+ var p = parts[i];
234
+ var age = p.life / p.max;
235
+ if (age < 0 || age > 1) continue;
236
+ /* cool gold -> flame -> ember -> deep, as the particle ages */
237
+ var ci = age < 0.1 ? 0 : age < 0.32 ? 1 : age < 0.58 ? 2 : age < 0.82 ? 3 : 4;
238
+ var rad = p.r0 * (1 + age * 0.55) * (1 - age * 0.42) * h;
239
+ if (rad < 0.5) continue;
240
+ var a = (age < 0.08 ? age / 0.08 : 1 - (age - 0.08) / 0.92) * EP.alpha;
241
+ if (a <= 0) continue;
242
+ ctx.globalAlpha = a;
243
+ ctx.drawImage(sprite(ci), p.x * w - rad, p.y * h - rad, rad * 2, rad * 2);
244
+ }
245
+ ctx.globalAlpha = 1;
246
+ ctx.globalCompositeOperation = 'source-over';
247
+ }
248
+
249
+ /* ---- sizing. DPR is capped: a 3x phone would trebl the fill cost for
250
+ a backdrop nobody inspects at pixel level. ---- */
251
+ var MAX_DPR = 2;
252
+ function resize() {
253
+ var dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
254
+ var r = cv.getBoundingClientRect();
255
+ cv.width = Math.max(1, Math.round((r.width || 1) * dpr));
256
+ cv.height = Math.max(1, Math.round((r.height || 1) * dpr));
257
+ draw();
258
+ }
259
+
260
+ /* ---- the loop, and the two conditions that stop it ---- */
261
+ var rafId = 0;
262
+ var running = false;
263
+ var lastT = 0;
264
+ var visible = !document.hidden;
265
+ var onScreen = true;
266
+ function loop(now) {
267
+ if (!running) {
268
+ rafId = 0;
269
+ return;
270
+ }
271
+ var dt = lastT ? now - lastT : 16;
272
+ lastT = now;
273
+ if (dt > 100) dt = 100;
274
+ ensure();
275
+ step(dt);
276
+ draw();
277
+ rafId = window.requestAnimationFrame(loop);
278
+ }
279
+ function stopLoop() {
280
+ running = false;
281
+ if (rafId) {
282
+ window.cancelAnimationFrame(rafId);
283
+ rafId = 0;
284
+ }
285
+ lastT = 0;
286
+ }
287
+ function startLoop() {
288
+ if (reduced || running || !visible || !onScreen) return;
289
+ running = true;
290
+ lastT = 0;
291
+ rafId = window.requestAnimationFrame(loop);
292
+ }
293
+
294
+ resize();
295
+ ensure();
296
+
297
+ if (reduced) {
298
+ /* One settled still frame: the composition, none of the motion. */
299
+ for (var k = 0; k < 70; k++) step(16);
300
+ draw();
301
+ return;
302
+ }
303
+
304
+ document.addEventListener('visibilitychange', function () {
305
+ visible = !document.hidden;
306
+ if (visible) startLoop();
307
+ else stopLoop();
308
+ });
309
+ if ('IntersectionObserver' in window) {
310
+ new IntersectionObserver(
311
+ function (entries) {
312
+ for (var i = 0; i < entries.length; i++) onScreen = entries[i].isIntersecting;
313
+ if (onScreen) startLoop();
314
+ else stopLoop();
315
+ },
316
+ { threshold: 0.02 }
317
+ ).observe(cv);
318
+ }
319
+ var rz = 0;
320
+ window.addEventListener('resize', function () {
321
+ window.clearTimeout(rz);
322
+ rz = window.setTimeout(resize, 150);
323
+ });
324
+ startLoop();
325
+ })();
326
+ </script>
327
+ )
328
+ }
329
+
330
+ <style>
331
+ .hero-backdrop {
332
+ position: absolute;
333
+ inset: 0;
334
+ width: 100%;
335
+ height: 100%;
336
+ display: block;
337
+ pointer-events: none;
338
+ }
339
+ </style>
@@ -71,11 +71,11 @@ const { tiers, groups } = data;
71
71
  >
72
72
  {typeof cell === 'boolean' ? (
73
73
  cell ? (
74
- <span class="pmk-yes" aria-label="Included">
74
+ <span class="pmk-yes" role="img" aria-label="Included">
75
75
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7" /></svg>
76
76
  </span>
77
77
  ) : (
78
- <span class="pmk-no" aria-label="Not included">
78
+ <span class="pmk-no" role="img" aria-label="Not included">
79
79
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M7 7l10 10M17 7L7 17" /></svg>
80
80
  </span>
81
81
  )
@@ -0,0 +1,193 @@
1
+ // ============================================================================
2
+ // Hero backdrop — the NAMED PRESET CATALOG.
3
+ // ----------------------------------------------------------------------------
4
+ // This file is DATA, deliberately. Every number below was tuned by eye in
5
+ // `background-studio.html` and exported from it; nothing here is derived, and
6
+ // nothing here should be computed at read time. If a value looks arbitrary that
7
+ // is because it is — it is a design decision, not a calculation.
8
+ //
9
+ // WHY THIS FILE EXISTS AT ALL: the nine variants below only ever existed inside
10
+ // a disposable per-session scratchpad. They were rebuilt from nothing more than
11
+ // once because the previous copy had been garbage-collected. A named entry here
12
+ // is the difference between "pull the backdrop by name" and "redesign it".
13
+ //
14
+ // Adding a preset: copy the studio's Export block verbatim. Its key names are
15
+ // these key names on purpose.
16
+ // ============================================================================
17
+
18
+ import type {
19
+ HeroBackdropCssParams,
20
+ HeroBackdropEmberParams,
21
+ HeroBackdropPalette,
22
+ HeroBackdropPreset,
23
+ HeroBackdropPresetName,
24
+ } from './types';
25
+
26
+ /**
27
+ * The studio's default six stops, dark -> bright. This is the CSDF/Kefi ember
28
+ * ramp: near-black ground, oxblood, two flame oranges, gold, and a bone-white
29
+ * top end that reads as the hottest core.
30
+ */
31
+ export const EMBER_PALETTE: HeroBackdropPalette = [
32
+ '#0d0d0d',
33
+ '#591202',
34
+ '#f23005',
35
+ '#f25d07',
36
+ '#f2a007',
37
+ '#ffe9b0',
38
+ ];
39
+
40
+ /** Studio `CSS_PRESET.low|med|high` — the CSS-layer intensity triplet. */
41
+ const CSS_LOW: HeroBackdropCssParams = { amp: 0.72, spd: 1.35, bri: 0.72, blurk: 1.25 };
42
+ const CSS_MED: HeroBackdropCssParams = { amp: 1.0, spd: 1.0, bri: 1.0, blurk: 1.0 };
43
+ const CSS_HIGH: HeroBackdropCssParams = { amp: 1.34, spd: 0.72, bri: 1.3, blurk: 0.82 };
44
+
45
+ /** Studio `EP_DEFAULT` — every ember preset is this, overlaid. */
46
+ const EMBER_BASE: HeroBackdropEmberParams = {
47
+ width: 0.46,
48
+ focus: 1.0,
49
+ count: 320,
50
+ rise: 0.95,
51
+ drift: 1.15,
52
+ swirl: 1.0,
53
+ size: 1.12,
54
+ life: 1.0,
55
+ alpha: 0.62,
56
+ bloomA: 1.0,
57
+ bloomH: 0.85,
58
+ };
59
+
60
+ /**
61
+ * The catalog.
62
+ *
63
+ * `*-med` is the studio's own default for that variant — the honest starting
64
+ * point, not a recommendation. The three `ember-*` entries are the studio's
65
+ * Low/Medium/High intensity triplet.
66
+ */
67
+ export const HERO_BACKDROP_PRESETS: Record<HeroBackdropPresetName, HeroBackdropPreset> = {
68
+ // ── canvas + rAF: real main-thread cost, opt-in per tenant ───────────────
69
+ 'ember-low': {
70
+ variant: 'ember',
71
+ engine: 'canvas',
72
+ label: 'Ember column — low',
73
+ palette: EMBER_PALETTE,
74
+ css: CSS_LOW,
75
+ ember: { ...EMBER_BASE, count: 250, rise: 0.72, drift: 0.85, size: 1.0 },
76
+ },
77
+ 'ember-med': {
78
+ variant: 'ember',
79
+ engine: 'canvas',
80
+ label: 'Ember column — medium',
81
+ palette: EMBER_PALETTE,
82
+ css: CSS_MED,
83
+ ember: { ...EMBER_BASE },
84
+ },
85
+ 'ember-high': {
86
+ variant: 'ember',
87
+ engine: 'canvas',
88
+ label: 'Ember column — high',
89
+ palette: EMBER_PALETTE,
90
+ css: CSS_HIGH,
91
+ ember: { ...EMBER_BASE, count: 400, rise: 1.24, drift: 1.5, size: 1.24 },
92
+ },
93
+ /**
94
+ * CSDF's shipped backdrop, exported from the studio verbatim.
95
+ *
96
+ * Read `count: 40` before judging this against `ember-high`. It is High's
97
+ * CSS intensity and High's `size`/`drift` feel on a deliberately SPARSE
98
+ * field — an eighth of the 320-particle default. Per-frame cost is dominated
99
+ * by the sprite draw loop, so the sparse field is what makes a canvas
100
+ * backdrop defensible on a site with a hard Lighthouse >= 80 budget.
101
+ * A `focus` of 0.85 flattens the column into a wider sheet to compensate,
102
+ * and the longer `life` (1.7) keeps each of the few sprites on screen longer.
103
+ */
104
+ 'csdf-ember-sparse': {
105
+ variant: 'ember',
106
+ engine: 'canvas',
107
+ label: 'CSDF ember — sparse column, high intensity',
108
+ palette: EMBER_PALETTE,
109
+ css: { amp: 1.34, spd: 0.72, bri: 1.3, blurk: 0.82 },
110
+ ember: {
111
+ width: 1.2,
112
+ focus: 0.85,
113
+ count: 40,
114
+ rise: 2.6,
115
+ drift: 1.15,
116
+ swirl: 2.7,
117
+ size: 1.24,
118
+ life: 1.7,
119
+ alpha: 0.62,
120
+ bloomA: 1,
121
+ bloomH: 0.85,
122
+ },
123
+ },
124
+ 'doom-med': {
125
+ variant: 'doom',
126
+ engine: 'canvas',
127
+ label: 'Doom fire — 160x110 palette-index buffer, upscaled',
128
+ palette: EMBER_PALETTE,
129
+ css: CSS_MED,
130
+ },
131
+ 'noise-med': {
132
+ variant: 'noise',
133
+ engine: 'canvas',
134
+ label: 'Noise fire — fBm scrolling upward',
135
+ palette: EMBER_PALETTE,
136
+ css: CSS_MED,
137
+ },
138
+
139
+ // ── SVG filter: no per-frame JS, but painted on the main thread ──────────
140
+ 'haze-med': {
141
+ variant: 'haze',
142
+ engine: 'svg',
143
+ label: 'Turbulent haze — feTurbulence + feDisplacementMap',
144
+ palette: EMBER_PALETTE,
145
+ css: CSS_MED,
146
+ },
147
+
148
+ // ── pure CSS: zero main-thread JavaScript. The safe default family ───────
149
+ 'tongues-med': {
150
+ variant: 'tongues',
151
+ engine: 'css',
152
+ label: 'Tongues — stacked blurred gradient blades',
153
+ palette: EMBER_PALETTE,
154
+ css: CSS_MED,
155
+ },
156
+ 'cssnoise-med': {
157
+ variant: 'cssnoise',
158
+ engine: 'css',
159
+ label: 'Masked noise — static noise tile under an animated mask',
160
+ palette: EMBER_PALETTE,
161
+ css: CSS_MED,
162
+ },
163
+ 'silhouette-med': {
164
+ variant: 'silhouette',
165
+ engine: 'css',
166
+ label: 'Silhouettes — dark shapes over a glow layer',
167
+ palette: EMBER_PALETTE,
168
+ css: CSS_MED,
169
+ },
170
+ 'wash-med': {
171
+ variant: 'wash',
172
+ engine: 'css',
173
+ label: 'Heat wash — two counter-drifting radial glows (the kefi default)',
174
+ palette: EMBER_PALETTE,
175
+ css: CSS_MED,
176
+ },
177
+ 'stack-med': {
178
+ variant: 'stack',
179
+ engine: 'css',
180
+ label: 'Full stack — tongues + noise + silhouette + wash combined',
181
+ palette: EMBER_PALETTE,
182
+ css: CSS_MED,
183
+ },
184
+ };
185
+
186
+ /** Narrowing lookup. Returns `undefined` for an unknown name rather than throwing:
187
+ * a bad tenant config must degrade to the site's default backdrop, never blank
188
+ * the hero. */
189
+ export function getHeroBackdropPreset(name: string): HeroBackdropPreset | undefined {
190
+ return Object.prototype.hasOwnProperty.call(HERO_BACKDROP_PRESETS, name)
191
+ ? HERO_BACKDROP_PRESETS[name as HeroBackdropPresetName]
192
+ : undefined;
193
+ }
@@ -0,0 +1,119 @@
1
+ // ============================================================================
2
+ // Hero backdrop — the shared type contract.
3
+ // ----------------------------------------------------------------------------
4
+ // Every animated hero treatment we have ever built for a Kefi tenant lived only
5
+ // in a throwaway per-session scratchpad, so the same nine variants were rebuilt
6
+ // from scratch more than once. This module is their permanent home: the
7
+ // parameter NAMES here are the ones the design studio
8
+ // (`BaseClient/docs/design/background-lab/background-studio.html`) exports
9
+ // verbatim, so a config pasted out of the studio is a valid `HeroBackdropPreset`
10
+ // with no translation step. Do not rename a key to something tidier — the
11
+ // studio is the source of truth and a rename silently breaks the paste path.
12
+ //
13
+ // Unions rather than `const enum` on purpose: this package is consumed by Astro
14
+ // under `isolatedModules`, where a `const enum` cannot be inlined across the
15
+ // module boundary, and the sibling contracts in @dloizides/site-template-kit
16
+ // (`'light' | 'dark'`, `'primary' | 'ghost'`) already use unions.
17
+ // ============================================================================
18
+
19
+ /**
20
+ * Which engine paints the backdrop.
21
+ *
22
+ * - `doom`, `noise`, `ember` — <canvas> + requestAnimationFrame. Real
23
+ * main-thread cost, opt-in per tenant only.
24
+ * - `haze` — SVG feTurbulence/feDisplacementMap. No per-frame JS, but the
25
+ * filter is painted on the main thread and cannot be composited.
26
+ * - `tongues`, `cssnoise`, `silhouette`, `wash`, `stack` — pure CSS keyframes
27
+ * on transform/opacity. Zero main-thread JavaScript; the compositor runs
28
+ * them off-thread. This is the family every existing kefi tenant uses.
29
+ */
30
+ export type HeroBackdropVariant =
31
+ | 'doom'
32
+ | 'noise'
33
+ | 'ember'
34
+ | 'haze'
35
+ | 'tongues'
36
+ | 'cssnoise'
37
+ | 'silhouette'
38
+ | 'wash'
39
+ | 'stack';
40
+
41
+ /** True when the variant needs a <canvas> and a rAF loop. */
42
+ export type HeroBackdropEngine = 'canvas' | 'svg' | 'css';
43
+
44
+ /**
45
+ * Six palette stops, dark -> bright, as `#rrggbb`.
46
+ * Canvas variants ramp all six into a 37-step LUT; the CSS variants read
47
+ * stops 1..4 as `--c1rgb` .. `--c4rgb`.
48
+ */
49
+ export type HeroBackdropPalette = readonly [string, string, string, string, string, string];
50
+
51
+ /** Knobs the CSS layers read as custom properties. Studio key names. */
52
+ export interface HeroBackdropCssParams {
53
+ /** Movement amplitude multiplier. */
54
+ amp: number;
55
+ /** Speed multiplier — HIGHER IS SLOWER (it scales the keyframe duration). */
56
+ spd: number;
57
+ /** Brightness multiplier. */
58
+ bri: number;
59
+ /** Blur multiplier applied to every blurred layer. */
60
+ blurk: number;
61
+ }
62
+
63
+ /** Knobs the ember-column particle simulation reads. Studio key names. */
64
+ export interface HeroBackdropEmberParams {
65
+ /** Half-width of the spawn base, as a fraction of the hero width. */
66
+ width: number;
67
+ /** Spawn concentration. >1 tightens the core, <1 flattens it to a sheet. */
68
+ focus: number;
69
+ /** Live particle count. The single biggest lever on main-thread cost. */
70
+ count: number;
71
+ /** Buoyancy multiplier. */
72
+ rise: number;
73
+ /** Horizontal turbulence gain. */
74
+ drift: number;
75
+ /** Noise frequency — high is fine and busy. */
76
+ swirl: number;
77
+ /** Sprite radius multiplier. */
78
+ size: number;
79
+ /** Lifetime multiplier. */
80
+ life: number;
81
+ /** Per-sprite peak alpha. */
82
+ alpha: number;
83
+ /** Strength of the base bloom the column sits in. */
84
+ bloomA: number;
85
+ /** Height of that bloom, as a fraction of the hero height. */
86
+ bloomH: number;
87
+ }
88
+
89
+ /**
90
+ * One named, complete backdrop configuration.
91
+ *
92
+ * `ember` is present only for `variant: 'ember'`; the other variants ignore it.
93
+ * `css` applies to every variant, canvas included, because the CSS scrim and
94
+ * glow layers sit above the canvas in all of them.
95
+ */
96
+ export interface HeroBackdropPreset {
97
+ variant: HeroBackdropVariant;
98
+ engine: HeroBackdropEngine;
99
+ /** Human-readable note for the catalog page / code review. */
100
+ label: string;
101
+ palette: HeroBackdropPalette;
102
+ css: HeroBackdropCssParams;
103
+ ember?: HeroBackdropEmberParams;
104
+ }
105
+
106
+ /** Keys of the shipped catalog — see `presets.ts`. */
107
+ export type HeroBackdropPresetName =
108
+ | 'ember-low'
109
+ | 'ember-med'
110
+ | 'ember-high'
111
+ | 'csdf-ember-sparse'
112
+ | 'doom-med'
113
+ | 'noise-med'
114
+ | 'haze-med'
115
+ | 'tongues-med'
116
+ | 'cssnoise-med'
117
+ | 'silhouette-med'
118
+ | 'wash-med'
119
+ | 'stack-med';