@dloizides/marketing-astro-kit 1.2.1 → 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 +90 -64
- package/package.json +10 -3
- package/src/HeroBackdrop.astro +339 -0
- package/src/heroBackdrop/presets.ts +193 -0
- package/src/heroBackdrop/types.ts +119 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,64 +1,90 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## 1.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
- New `
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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.
|
|
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>
|
|
@@ -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';
|