@getvitops/astro 1.0.0 → 2.0.0
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 +476 -0
- package/README.md +145 -3
- package/dist/index.d.mts +342 -5
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +463 -15
- package/dist/index.mjs.map +1 -1
- package/package.json +12 -5
- package/src/analytics.ts +325 -0
- package/src/components/Analytics.astro +59 -0
- package/src/components/CookieConsent.astro +106 -0
- package/src/components/Details.astro +10 -4
- package/src/components/Drawer.astro +9 -3
- package/src/components/Head.astro +46 -1
- package/src/components/Icon.astro +94 -0
- package/src/components/Popover.astro +9 -3
- package/src/seo.ts +33 -3
- package/virtual.d.ts +49 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,481 @@
|
|
|
1
1
|
# @getvitops/astro
|
|
2
2
|
|
|
3
|
+
## 2.0.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 4756788: Add `<Analytics />` and a general-purpose consent gate.
|
|
8
|
+
|
|
9
|
+
`getvitops({ analytics })` configures Google Analytics 4, Microsoft Clarity, Matomo and Plausible;
|
|
10
|
+
`<Analytics />` emits their tags. Nothing touches the critical path — `strategy` defaults to `'idle'`,
|
|
11
|
+
which loads every tag after `load` on an idle callback (`'async'` and `'interaction'` are the other
|
|
12
|
+
options), and no `preconnect` is emitted, since warming a third-party connection during parse is the
|
|
13
|
+
cost `idle` exists to avoid.
|
|
14
|
+
|
|
15
|
+
`getvitops({ consent })` adds the gate: `@getvitops/core/consent`, a 2.3 KB gzipped Lit-free bundle,
|
|
16
|
+
plus `<CookieConsent />`.
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
vitops({
|
|
20
|
+
analytics: { googleAnalytics: 'G-XXXXXXXXXX', plausible: 'acme.com' },
|
|
21
|
+
consent: { policyUrl: '/legal/cookies' },
|
|
22
|
+
});
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Consent is not an analytics feature.** The gate is general — mark anything
|
|
26
|
+
`data-consent="<category>"` and it waits on the same choice, so A/B assignment, personalisation and
|
|
27
|
+
third-party embeds use it too, and a site can enable the banner with no analytics at all. Categories
|
|
28
|
+
are `necessary` / `analytics` / `marketing` / `preferences`, and `window.vitopsConsent` plus a
|
|
29
|
+
`vitops:consent` event on `document` are how your own code reads the answer.
|
|
30
|
+
|
|
31
|
+
**Which category a provider needs is derived, not declared.** It follows from whether that provider
|
|
32
|
+
sets cookies, which follows from its own config: Matomo runs cookieless by default (`disableCookies`)
|
|
33
|
+
and so needs no banner; `cookies: true` opts in and moves it behind `analytics`. You can't mark
|
|
34
|
+
Google Analytics `necessary` to skip the banner — but you can pick a genuinely cookieless provider.
|
|
35
|
+
|
|
36
|
+
Gated tags render as `<script type="text/plain">` with the URL on `data-src`, so an undecided or
|
|
37
|
+
declining visitor's page issues **no third-party request at all**. For Google Analytics that is basic
|
|
38
|
+
consent mode rather than Consent Mode v2 advanced: nothing reaches Google until the visitor accepts.
|
|
39
|
+
Clarity additionally receives `clarity('consentv2', …)`, because Microsoft enforces the signal
|
|
40
|
+
separately for EEA/UK/CH traffic. Nothing is stored until a choice is made — the banner can't be the
|
|
41
|
+
thing that needs consent — and revoking clears the provider's cookies and reloads, because an
|
|
42
|
+
already-executing tracker can't be unloaded any other way.
|
|
43
|
+
|
|
44
|
+
The banner is shown in the top layer via `popover="manual"`, like `.tooltip`: a plain fixed banner
|
|
45
|
+
resolves against the nearest containing block, and `body { container-type: inline-size }` — ordinary
|
|
46
|
+
in a framework whose breakpoints are container queries — would otherwise trap it mid-page.
|
|
47
|
+
|
|
48
|
+
**The site config gained `analytics.clarityId` and `analytics.matomo`**, so `vitops legal` discloses
|
|
49
|
+
them. Clarity and Matomo join the processor table with their real cookie names, cookieless Matomo
|
|
50
|
+
asserts positively that it sets none, and a Clarity site's privacy policy now describes session
|
|
51
|
+
replay rather than filing it under page-view analytics. Configure `legal` alongside `analytics` and
|
|
52
|
+
the Astro integration cross-checks the two, naming any provider you'd otherwise run without
|
|
53
|
+
disclosing; it also warns when a cookie-setting provider has no `consent` gate.
|
|
54
|
+
|
|
55
|
+
Existing configs are unaffected — both options are off unless provided. One behaviour change if you
|
|
56
|
+
use the ad-conversion tracking script: `packages/astro/src/scripts/tracking.ts` now waits for
|
|
57
|
+
`marketing` consent before writing its 90-day `_ac` click-ID cookie, when the gate is present. With
|
|
58
|
+
no gate on the page it behaves exactly as before.
|
|
59
|
+
|
|
60
|
+
- bf453b0: Animation families that never actually animated, and a driver that fired before you could see it.
|
|
61
|
+
|
|
62
|
+
Each of these looked correct in source and failed somewhere else — in the bundler, in hit-testing,
|
|
63
|
+
or in the difference between a time-based and a progress-based timeline. Nothing here needs a
|
|
64
|
+
markup change.
|
|
65
|
+
|
|
66
|
+
**Changed: entrances are now timed off the element's midpoint.** Every driver used to key off a
|
|
67
|
+
fraction of the element's _own height_, which meant the same class behaved differently on a 4rem
|
|
68
|
+
card and a full-bleed section — and on small elements it was over before they appeared
|
|
69
|
+
(`entry 20%` is about 17px of scroll for a 5.5rem tile). Motion now starts once the element's
|
|
70
|
+
**midpoint is 10% of the viewport in**, and a one-shot entrance completes at **25%**. This applies
|
|
71
|
+
to `animate-view` and to the `.is-active` observer that drives `animate-trigger` and the
|
|
72
|
+
`active-<fx>` transition variants, so everything on a page starts at the same moment on screen.
|
|
73
|
+
|
|
74
|
+
The pivot is `entry 50%` — the one point that means "midpoint on the viewport edge" for an element
|
|
75
|
+
of any height — plus a viewport length. Shift the window with `--anim-start` / `--anim-end`, or
|
|
76
|
+
replace it with `--anim-range`. `--stagger-range-step` is now a viewport length (`5vh`) to match.
|
|
77
|
+
|
|
78
|
+
**Fixed: every journey ran on a truncated range.** The generator emitted `animation-range: entry
|
|
79
|
+
exit`; lightningcss parsed the end of that shorthand as `exit 0%` rather than the spec's `exit
|
|
80
|
+
100%`, so the bundle shipped `entry exit 0%`. Journeys reach their `100%` keyframe — the hidden
|
|
81
|
+
`from` state — as the element hits the top of the viewport, which is to say they faded out while
|
|
82
|
+
still fully on screen. Journeys now run `entry calc(50% + 10vh) → exit 100%`: they start on the same
|
|
83
|
+
midpoint pivot as everything else and keep the full entry → hold → exit arc, so the hold sits in the
|
|
84
|
+
middle of the crossing. `animation-effects.test.ts` asserts on the **bundled** css, because the
|
|
85
|
+
emitter was right the whole time.
|
|
86
|
+
|
|
87
|
+
**Fixed: `slide-journey` had no distance to travel.** `animations.journeys.base.slide` was `{}` in
|
|
88
|
+
both `defaultConfig()` and the shipped example, so the keyframe animated `translate: 0 → 0`. It now
|
|
89
|
+
declares `translate-y-from: var(--slide-distance, 2rem)` — the same var the `slide-up` effect uses,
|
|
90
|
+
so one knob tunes both. If your own config has an empty `slide` base, add the same line.
|
|
91
|
+
|
|
92
|
+
**Fixed: `reveal-*` on hover was unreachable.** A `hover-reveal-left` element rests at `clip-path:
|
|
93
|
+
inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting — so it had zero
|
|
94
|
+
hittable area and could never receive the hover that would reveal it. The state variants now match
|
|
95
|
+
the element **or its direct parent**, mirroring what `animation.css` already did for the trigger
|
|
96
|
+
driver (`:is(.is-active, [data-active]) > .animate-trigger`):
|
|
97
|
+
|
|
98
|
+
```css
|
|
99
|
+
.hover-<fx>:hover, :hover > .hover-<fx> { … }
|
|
100
|
+
.focus-<fx>:focus-visible, :focus-within > .focus-<fx> { … }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Specificity is unchanged (0-2-0), so nothing reorders. **Behaviour change:** a `hover-<fx>` element
|
|
104
|
+
that is a direct child of a hovered element now flips with its parent. Wrap it in an intermediate
|
|
105
|
+
element if you need the old element-only behaviour.
|
|
106
|
+
|
|
107
|
+
**Fixed: `.stagger` did nothing on a scroll-driven timeline.** It offsets children with
|
|
108
|
+
`animation-delay`, which is time-based and is ignored outright on a `view()` / `scroll()` timeline,
|
|
109
|
+
so `.stagger > .animate-view` arrived all at once. It now also offsets each child's view
|
|
110
|
+
`animation-range` by the same index, so one class works under both driver families — tune the
|
|
111
|
+
scroll-driven step with `--stagger-range-step` (default `5vh`). Journeys declare their own range and
|
|
112
|
+
opt out by construction.
|
|
113
|
+
|
|
114
|
+
Two smaller faults found alongside it: `@supports (--x: sibling-index())` is **always true** — any
|
|
115
|
+
token stream is a valid custom-property value — so the guard around `sibling-index()` guarded
|
|
116
|
+
nothing in both `.stagger` and `.subgrid`'s row index, and on an engine without support the
|
|
117
|
+
declaration went invalid rather than being skipped. Both now test a real property. And the CSS path
|
|
118
|
+
was 1-based while the JS fallback wrote 0-based, so the two disagreed by one step wherever both ran.
|
|
119
|
+
|
|
120
|
+
**Fixed: the pre-paint `<html>` class script was missing entirely.** `<Head />` now emits it again,
|
|
121
|
+
outside the `webComponents` block since it gates stylesheet behaviour rather than the element
|
|
122
|
+
runtime. It does two things `animation.css` depends on and that three other files documented as
|
|
123
|
+
already shipping:
|
|
124
|
+
- `no-js` → `js`, so `.animate-trigger` is paused only where JS can un-pause it;
|
|
125
|
+
- `.no-scroll-timeline` when `animation-timeline: view()` is unsupported, which is what cancels
|
|
126
|
+
`.animate-view` / `.animate-scroll`. Scroll-driven animations are deliberately **not** polyfilled,
|
|
127
|
+
so without this flag those elements sit at their `from` keyframe — `opacity: 0`, i.e. invisible
|
|
128
|
+
content — on any engine without support. It had been dropped in the move to publishable packages,
|
|
129
|
+
leaving the cancel rule as dead code.
|
|
130
|
+
|
|
131
|
+
**New: `hover-size-grow` and friends exist.** The layout effects (`size-grow`, `size-shrink`) were
|
|
132
|
+
the one family with no state variants, on the grounds that `.transition` doesn't cover `height`.
|
|
133
|
+
That wasn't a platform limit — `height: 0 → auto` just needs `interpolate-size: allow-keywords`,
|
|
134
|
+
which the framework already sets on `:root`, and which the `layout` **keyframe** depended on
|
|
135
|
+
equally. So the exclusion bought no portability; it only made `hover-size-grow` a class the docs
|
|
136
|
+
advertised and the stylesheet didn't define. `.transition` now declares `height` inside an
|
|
137
|
+
`@supports (interpolate-size: allow-keywords)` block, and the generator emits the full
|
|
138
|
+
`hover-`/`focus-`/`active-` set for layout effects, carrying each effect's own `overflow: clip` so a
|
|
139
|
+
collapsed box hides its content instead of spilling it. Where `interpolate-size` is missing, the
|
|
140
|
+
height simply doesn't transition — the same degradation the keyframe path has. (For the record:
|
|
141
|
+
`transition-behavior: allow-discrete` is _not_ the tool here — it only lets genuinely discrete
|
|
142
|
+
properties transition, and would snap at the midpoint.)
|
|
143
|
+
|
|
144
|
+
`.transition` switched from the `transition` shorthand to `transition-property` +
|
|
145
|
+
`transition-duration` + `transition-timing-function`, so the gated block can append `height` without
|
|
146
|
+
restating the list. If you override `.transition`'s timing, override the longhands.
|
|
147
|
+
|
|
148
|
+
The generated class reference (`vitops docs classes`) was the upstream source of that mismatch — it
|
|
149
|
+
listed every effect under "with the state prefixes above", including the layout ones. It now states
|
|
150
|
+
which stage needs the feature gate, when each driver plays, and how `stagger` composes with both.
|
|
151
|
+
|
|
152
|
+
- bf453b0: Anywhere the toolchain takes a config, that file may now be a `design-system.json` **or** the larger
|
|
153
|
+
site config that embeds one.
|
|
154
|
+
|
|
155
|
+
If you keep your tokens inside a `company.json` / `site.json`, you had to maintain a second file for
|
|
156
|
+
the tooling's sake — or duplicate your whole token set, because a site config must carry a complete
|
|
157
|
+
`designSystem`. Now you point the same option at the file you already have:
|
|
158
|
+
|
|
159
|
+
```js
|
|
160
|
+
// astro.config.mjs
|
|
161
|
+
getvitops({ css: { input: 'company.json', format: 'css' } });
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
vitops generate --input company.json --format css --out dist
|
|
166
|
+
vitops lint --input company.json --src src
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The design system is taken from `designSystem.themes[theme]` with its `extends` chain resolved —
|
|
170
|
+
`defaultTheme`, else `default`, else whatever `--theme` / the `theme` option names. Nothing changes
|
|
171
|
+
for a plain `design-system.json`; the two are told apart by shape, not by filename, so you can call
|
|
172
|
+
the file anything.
|
|
173
|
+
|
|
174
|
+
**A site config also supplies the site-level facts generation reads**, so the path is declared once
|
|
175
|
+
rather than per option:
|
|
176
|
+
- `designSystem.defaultColorScheme: "system"` — emits the `prefers-color-scheme` block, which is
|
|
177
|
+
what makes `<color-scheme-toggle>`'s System position do anything.
|
|
178
|
+
- `legal.*.enabled` — renders the documents. `legal: {}` is now the whole declaration; `legal.input`
|
|
179
|
+
is optional.
|
|
180
|
+
- `icons.sprite` — builds `icons.svg`.
|
|
181
|
+
- `fonts` — `fonts: true` reads the families from it.
|
|
182
|
+
|
|
183
|
+
Each of those is already gated on a field in that config, so nothing new appears unless the config
|
|
184
|
+
asks for it. An explicit `site` option still wins when the two are genuinely different files.
|
|
185
|
+
|
|
186
|
+
Also:
|
|
187
|
+
- **`vitops validate` routes on the file's shape.** Pointed at a site config it used to report a
|
|
188
|
+
single `unrecognized_keys` for `designSystem` and nothing about the file's actual contents — a
|
|
189
|
+
wrong answer that reads like a right one. It now validates a site config as one, including the
|
|
190
|
+
cross-field integrity JSON Schema can't express, and checks every theme resolves to a complete
|
|
191
|
+
design system (`validateSite` only checked that the `extends` chain resolved, not what it resolved
|
|
192
|
+
to).
|
|
193
|
+
- **`generate()` gained `theme` and `siteEnv`.** The A/B variant for `siteEnv` is applied before the
|
|
194
|
+
theme is selected, so a variant can override tokens.
|
|
195
|
+
- **The theme editor's Save to source follows the design system into a site config.** Its patch is
|
|
196
|
+
design-system-relative, so the dev server merges into that subtree and writes only the surrounding
|
|
197
|
+
file whole. It locates the subtree in the raw on-disk object rather than the normalised one, so an
|
|
198
|
+
author who wrote either `designSystem` shorthand still gets their own keys edited.
|
|
199
|
+
|
|
200
|
+
- bf453b0: **Breaking (site config):** `designSystem` is now an object, and the toggle's "System" position works.
|
|
201
|
+
|
|
202
|
+
`<color-scheme-toggle>` has always shipped three segments, and "System" did nothing. It removes the
|
|
203
|
+
theme attribute, and with no `prefers-color-scheme` block in the generated CSS the page fell straight
|
|
204
|
+
through to light on every machine — so a third of the control was inert, and a no-JS page could never
|
|
205
|
+
follow the OS at all.
|
|
206
|
+
|
|
207
|
+
The fix is a second, opt-in copy of the dark delta under
|
|
208
|
+
`@media (prefers-color-scheme: dark)`, scoped to "no explicit choice has been made". An explicit
|
|
209
|
+
light choice still wins over a dark OS. It costs **+303 B gzipped** on the colour layer — the block
|
|
210
|
+
repeats only the tokens whose dark value differs, and two identical runs of text compress to almost
|
|
211
|
+
nothing. It is opt-in because switching it on visibly flips an existing site dark for dark-OS
|
|
212
|
+
visitors.
|
|
213
|
+
|
|
214
|
+
Turning it on is a site-level fact, which needed somewhere to live:
|
|
215
|
+
|
|
216
|
+
```jsonc
|
|
217
|
+
"designSystem": {
|
|
218
|
+
"themes": { "default": { … }, "elegant": { "extends": "default", … } },
|
|
219
|
+
"defaultTheme": "default",
|
|
220
|
+
"defaultColorScheme": "system" // 'light' | 'dark' | 'system'
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**What changed.** `designSystem` was a bare map of themeName → design system, so _every_ key was a
|
|
225
|
+
theme name and there was nowhere to put a system-wide field without it colliding with a theme of that
|
|
226
|
+
name. The map moves under `themes`, and `defaultTheme` + `defaultColorScheme` move inside the block.
|
|
227
|
+
`respectSystemPreference` is gone — `defaultColorScheme: "system"` says the same thing, and the two
|
|
228
|
+
were always read together, so the incoherent combination is no longer expressible.
|
|
229
|
+
|
|
230
|
+
**Migration is automatic at runtime.** `resolveSiteConfig` accepts all three spellings — the
|
|
231
|
+
canonical shape, a bare theme map (the old schema), and a bare design system written inline. But
|
|
232
|
+
`site.schema.json` is published to a stable URL, so an editor pinned to `$schema` will flag the old
|
|
233
|
+
shape; update it when convenient.
|
|
234
|
+
|
|
235
|
+
One behavioural change worth knowing: shorthand normalisation now runs **before** the A/B variant
|
|
236
|
+
merge, not after. Previously an `abTesting` override's key path depended on which shorthand the base
|
|
237
|
+
config happened to use, so the same patch landed in different places in two otherwise-equivalent
|
|
238
|
+
configs. Overrides now always address `designSystem.themes.<name>`.
|
|
239
|
+
|
|
240
|
+
Also: `getvitops({ site: { input } })` gives the Astro integration one place to name the site config
|
|
241
|
+
— `legal`, `fonts` and the colour scheme all read from it — and `css.systemColorScheme` sets the
|
|
242
|
+
appearance directly for consumers who have no site config.
|
|
243
|
+
|
|
244
|
+
Light/dark remains **derived**, not a theme: `functionalRole()` builds both appearances from one ramp,
|
|
245
|
+
which is what lets the contrast contract check both at build time and what gives every consumer a
|
|
246
|
+
working dark mode without authoring one. `themes` is for authored variants.
|
|
247
|
+
|
|
248
|
+
- bf453b0: `getvitops({ fonts })` — load webfonts through Astro's Fonts API instead of hand-rolling it.
|
|
249
|
+
|
|
250
|
+
The design system names fonts and loads none of them, and there was no seam for the loading half —
|
|
251
|
+
so the only available path was installing a `@fontsource*` package and importing its CSS, which
|
|
252
|
+
renders correctly while silently giving up subsetting, preload, and the `size-adjust` /
|
|
253
|
+
`ascent-override` fallback metrics. That is a CLS regression that looks like a working setup. This
|
|
254
|
+
is the counterpart to `vitopsHosting()`: declare the family, let the integration wire it.
|
|
255
|
+
|
|
256
|
+
```js
|
|
257
|
+
getvitops({
|
|
258
|
+
fonts: [
|
|
259
|
+
{
|
|
260
|
+
name: 'League Spartan',
|
|
261
|
+
provider: 'fontsource',
|
|
262
|
+
cssVariable: '--font-league-spartan',
|
|
263
|
+
weights: ['100 900'],
|
|
264
|
+
subsets: ['latin'],
|
|
265
|
+
preload: true,
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
});
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
```jsonc
|
|
272
|
+
// design-system.json — the token points at the variable, not at a literal stack
|
|
273
|
+
"fonts": { "display": "var(--font-league-spartan), sans-serif" }
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
`fonts` also takes a **string**: the path to a site config, whose `fonts` array has carried exactly
|
|
277
|
+
these declarations (provider / weights / subsets / preload) since it was written — nothing had ever
|
|
278
|
+
read it. Or `{ input, families, siteEnv }` for both.
|
|
279
|
+
|
|
280
|
+
`<Head />` now emits `<Font />` for each declared family, with `preload` driven by the declaration.
|
|
281
|
+
That half is not optional: Astro's `fonts:` config resolves the files, but `<Font />` is what puts
|
|
282
|
+
the `@font-face` on the page, so a declaration without `<Head />` in your layout loads nothing.
|
|
283
|
+
|
|
284
|
+
Notes:
|
|
285
|
+
- Independent of `css` — the wiring runs in `astro:config:setup`, not the Vite plugin.
|
|
286
|
+
- Only families declared here get a `<Font />`. One from your own `astro:config` `fonts:` array or
|
|
287
|
+
from another integration is left alone, because `<Font />` throws on a `cssVariable` Astro cannot
|
|
288
|
+
resolve. Astro concatenates the arrays, so the three coexist; two entries claiming one variable
|
|
289
|
+
is the collision that matters, and it now throws (within our set) or warns (against yours)
|
|
290
|
+
instead of silently dropping a family.
|
|
291
|
+
- `provider: 'adobe'` throws with instructions: `fontProviders.adobe({ id })` needs a key from the
|
|
292
|
+
environment, and a JSON declaration has nowhere to hold one.
|
|
293
|
+
- New exports from `@getvitops/generator`: `SiteFontSchema` and the `SiteFont` type.
|
|
294
|
+
|
|
295
|
+
- 04a51d8: Icons: one semantic vocabulary across icon sets, with the bundle derived from your source.
|
|
296
|
+
|
|
297
|
+
Name icons by meaning — `<Icon name="menu" />` resolves to `fa7-solid:bars`, `ph:list` or
|
|
298
|
+
`lucide:menu` depending on the set you configure, so swapping sets is a config edit rather than a
|
|
299
|
+
find-and-replace. A name containing `:` still passes through untouched, which is the escape hatch
|
|
300
|
+
for a set-specific glyph.
|
|
301
|
+
|
|
302
|
+
**New `icons` option on `getvitops()`.** Configures the sets once per site and, under
|
|
303
|
+
`output: 'server'`, derives astro-icon's `include` map by scanning your source — the list most
|
|
304
|
+
projects end up maintaining by hand. On a static build no `include` is passed at all, because
|
|
305
|
+
astro-icon is already zero-config there and a list could only drop a glyph the scan couldn't see.
|
|
306
|
+
Names you declare that don't resolve fail the build; names only the scan found just warn; names
|
|
307
|
+
computed at runtime are reported with file and line so you can declare them.
|
|
308
|
+
|
|
309
|
+
**New `<Icon />` component**, and a real fix with it: `Popover`, `Details` and `Drawer` imported
|
|
310
|
+
`astro-icon/components` at module scope, so the _optional_ peer was resolved whether or not an icon
|
|
311
|
+
ever rendered — hard-failing anyone who hadn't installed it. Engines now load dynamically. The
|
|
312
|
+
per-component `iconResolver` prop still works but is deprecated in favour of the integration option.
|
|
313
|
+
|
|
314
|
+
**New SVG sprite** (`icons.sprite: true` in your site config) for consumers that can't run an icon
|
|
315
|
+
integration — Bricks/WordPress, EmDash renderers, plain HTML. `<use href="…/icons.svg#ph--list">`,
|
|
316
|
+
no JavaScript and no icon-API request. Every semantic name also gets a set-independent `icon-<name>`
|
|
317
|
+
alias, so sprite markup survives an icon-set change. WordPress gets `vitops_icon()`, a
|
|
318
|
+
`[vitops_icon]` shortcode and a **Vitops → Icon** Bricks element.
|
|
319
|
+
|
|
320
|
+
**New `vitops icons`** command: reports which icons your source uses, which names couldn't be
|
|
321
|
+
resolved, and which are computed at runtime; `--sprite` builds the sprite, `--json` for CI.
|
|
322
|
+
|
|
323
|
+
**Renamed, and completed to all four directions.** Chevrons and arrows are the
|
|
324
|
+
one family whose _meaning is_ its direction, and that direction is physical, not
|
|
325
|
+
logical — a chevron in a details marker points down in every writing mode. So they
|
|
326
|
+
are named for where they point: `expand-more`/`expand-less` →
|
|
327
|
+
`chevron-down`/`chevron-up`, and `arrow-forward`/`arrow-back` →
|
|
328
|
+
`arrow-right`/`arrow-left`. `chevron-left`/`chevron-right` keep their names.
|
|
329
|
+
`arrow-up`/`arrow-down` are new, so both families now cover all four directions.
|
|
330
|
+
`lightning` is new.
|
|
331
|
+
|
|
332
|
+
If you passed any of the old names to `<Icon />`, `resolveIcon` or an `icons`
|
|
333
|
+
config, update them. They fail loudly rather than silently: an unresolvable
|
|
334
|
+
declared name throws, and `vitops icons` reports scanned ones.
|
|
335
|
+
|
|
336
|
+
**Fixed three Font Awesome mappings that named no real glyph** and so rendered an
|
|
337
|
+
empty box: `login`/`logout` were mapped to `login`/`logout` (Font Awesome calls
|
|
338
|
+
them `right-to-bracket`/`right-from-bracket`) and `backup` to `backup`
|
|
339
|
+
(`cloud-arrow-up`). Every value in all four UI sets is now checked against the
|
|
340
|
+
installed collection.
|
|
341
|
+
|
|
342
|
+
**Phosphor (`ph`) joins the semantic map**, with all 83 names verified against the real icon set.
|
|
343
|
+
Phosphor keeps every weight in one collection and varies the name (`list`, `list-bold`), unlike Font
|
|
344
|
+
Awesome's per-weight collections, so `resolveIcon` and `generateIconInclude` gained a `weight`
|
|
345
|
+
option for sets shaped that way.
|
|
346
|
+
|
|
347
|
+
Fixes: `site.icons` was a closed object, so any icon collection not in its hand-written key list was
|
|
348
|
+
silently dropped during validation — your config passed and the icons never bundled. It accepts any
|
|
349
|
+
collection name now.
|
|
350
|
+
|
|
351
|
+
- bf453b0: Detect JavaScript and scroll-driven timelines in CSS, so no-JS visitors stop getting invisible
|
|
352
|
+
content.
|
|
353
|
+
|
|
354
|
+
`animation.css` gated two rules on classes that had to be put on `<html>`: `no-js`, which the
|
|
355
|
+
framework expected an author to write into their own markup, and `.no-scroll-timeline`, which an
|
|
356
|
+
inline `<head>` snippet was supposed to set. **That snippet never shipped.** Three source comments
|
|
357
|
+
described it and nothing implemented it, so on every Astro and Bricks site the `:root:not(.no-js)`
|
|
358
|
+
gate matched unconditionally and `.animate-trigger` stayed `animation-play-state: paused` with no
|
|
359
|
+
`IntersectionObserver` coming to release it. An entrance animation that never runs is `opacity: 0`.
|
|
360
|
+
|
|
361
|
+
Both gates are now platform queries:
|
|
362
|
+
|
|
363
|
+
```css
|
|
364
|
+
@media (scripting: enabled) {
|
|
365
|
+
.animate-trigger {
|
|
366
|
+
animation-play-state: paused;
|
|
367
|
+
}
|
|
368
|
+
/* …released by .is-active */
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
@supports not (animation-timeline: view()) {
|
|
372
|
+
:where(.animate-view, .animate-scroll) {
|
|
373
|
+
animation: none;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Nothing to install, nothing to remember, and correct for consumers the old approach could not reach —
|
|
379
|
+
a Bricks site, or anyone rendering their own `<head>`. It also fails the right way round: an engine
|
|
380
|
+
that doesn't know either feature drops the block and the animation simply runs, costing an
|
|
381
|
+
enhancement rather than hiding the page.
|
|
382
|
+
|
|
383
|
+
**Nothing to do to adopt this.** Drop `class="no-js"` from your `<html>` and any script that removes
|
|
384
|
+
it if you like — both are inert now, not harmful. `<Head />` no longer emits a class-flipping script.
|
|
385
|
+
|
|
386
|
+
Support is baseline: `scripting` shipped in Firefox 113, Safari 17 and Chrome 120, all in 2023.
|
|
387
|
+
|
|
388
|
+
- bf453b0: Maskable favicons are now opaque, so they stop rendering as a logo in a black box.
|
|
389
|
+
|
|
390
|
+
`icon-mask.png` is declared `purpose: "maskable"` and `apple-touch-icon.png` is linked on every
|
|
391
|
+
page. Both sit a deliberately-inset logo on a larger canvas — that inset **is** the maskable safe
|
|
392
|
+
zone and was always correct — but the canvas was filled with `alpha: 0`. From any transparent
|
|
393
|
+
source that left 36% of `icon-mask.png` and 40% of `apple-touch-icon.png` transparent, on two files
|
|
394
|
+
whose entire contract is full bleed: the OS crops them to its own shape and composites the rest onto
|
|
395
|
+
whatever it likes, usually black. iOS discards alpha on the apple-touch-icon outright.
|
|
396
|
+
|
|
397
|
+
`backgroundColor` was already the obvious input for this and already plumbed — as far as the web
|
|
398
|
+
manifest, and no further. So the raster and the manifest disagreed about the very colour meant to
|
|
399
|
+
sit behind the icon.
|
|
400
|
+
|
|
401
|
+
Now:
|
|
402
|
+
- those two outputs are composited onto `backgroundColor`, defaulting to `#ffffff` — the same
|
|
403
|
+
default the manifest's `background_color` already used;
|
|
404
|
+
- `favicon.svg`, `icon-{16,32,192,512}.png` and `favicon.ico` keep the source's transparency, since
|
|
405
|
+
none of them is maskable;
|
|
406
|
+
- a transparent source with no `backgroundColor` set now warns, rather than silently inheriting
|
|
407
|
+
white — a dark logo would lose against it;
|
|
408
|
+
- `icon-192`/`icon-512` declare `purpose: "any"` explicitly. Omitting it was legal (unset means
|
|
409
|
+
"any"), but with a maskable present and nothing claiming "any", some launchers picked the maskable
|
|
410
|
+
— the one with the safe-zone inset — for slots that wanted a plain icon.
|
|
411
|
+
|
|
412
|
+
`vitops favicon` gained `--background <hex>`; it previously had no way to set this at all.
|
|
413
|
+
`getvitops({ favicon })` and the Vite plugin now forward the option they were already accepting.
|
|
414
|
+
|
|
415
|
+
- feae1a3: `<Seo />`: `titleTemplate` now applies to `<title>` only — `og:title` and `twitter:title` get the
|
|
416
|
+
untemplated page title.
|
|
417
|
+
|
|
418
|
+
A social card is self-contained and already shows `og:site_name` and the domain, so the templated
|
|
419
|
+
value put the brand on screen twice: `og:title="Installation · Acme"` sitting directly above
|
|
420
|
+
`og:site_name="Acme"`. `<title>` keeps the suffix, because a browser tab or a search result has
|
|
421
|
+
nothing else to disambiguate it.
|
|
422
|
+
|
|
423
|
+
```html
|
|
424
|
+
<!-- before -->
|
|
425
|
+
<title>Installation · Acme</title>
|
|
426
|
+
<meta property="og:title" content="Installation · Acme" />
|
|
427
|
+
<meta property="og:site_name" content="Acme" />
|
|
428
|
+
|
|
429
|
+
<!-- after -->
|
|
430
|
+
<title>Installation · Acme</title>
|
|
431
|
+
<meta property="og:title" content="Installation" />
|
|
432
|
+
<meta property="og:site_name" content="Acme" />
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
Adds an `ogTitle` prop for pages that want a genuinely different social headline, and exposes the
|
|
436
|
+
resolved value as `socialTitle` on `resolveSeo()`'s return. Nothing changes for sites that don't set
|
|
437
|
+
`titleTemplate` — the two values are identical there.
|
|
438
|
+
|
|
439
|
+
### Patch Changes
|
|
440
|
+
|
|
441
|
+
- bf453b0: Fixed: a site on `css.format: 'css'` failed to build because of Tailwind.
|
|
442
|
+
|
|
443
|
+
`getvitops()` loads `@tailwindcss/vite` lazily, so a project that never sets `css.format:
|
|
444
|
+
'tailwind'` should never touch it. But the specifier was a literal, which makes it statically
|
|
445
|
+
analysable — so the bundler **followed** the import regardless of the branch ever running, down
|
|
446
|
+
through `@tailwindcss/node` to `@tailwindcss/oxide`'s native `.node` binding, which it cannot parse.
|
|
447
|
+
The build died on `[UNLOADABLE_DEPENDENCY] … stream did not contain valid UTF-8`, naming a package
|
|
448
|
+
the project does not use and never asked for.
|
|
449
|
+
|
|
450
|
+
The specifier now goes through a constant with `/* @vite-ignore */`, matching how the other
|
|
451
|
+
optional peers in that file (`astro-icon`, `astro-iconset`) were already loaded. Optional has to
|
|
452
|
+
mean optional to the bundler too.
|
|
453
|
+
|
|
454
|
+
- Updated dependencies [4756788]
|
|
455
|
+
- Updated dependencies [bf453b0]
|
|
456
|
+
- Updated dependencies [bf453b0]
|
|
457
|
+
- Updated dependencies [bf453b0]
|
|
458
|
+
- Updated dependencies [bf453b0]
|
|
459
|
+
- Updated dependencies [bf453b0]
|
|
460
|
+
- Updated dependencies [bf453b0]
|
|
461
|
+
- Updated dependencies [bf453b0]
|
|
462
|
+
- Updated dependencies [bf453b0]
|
|
463
|
+
- Updated dependencies [04a51d8]
|
|
464
|
+
- Updated dependencies [bf453b0]
|
|
465
|
+
- Updated dependencies [bf453b0]
|
|
466
|
+
- Updated dependencies [bf453b0]
|
|
467
|
+
- Updated dependencies [bf453b0]
|
|
468
|
+
- Updated dependencies [bf453b0]
|
|
469
|
+
- Updated dependencies [bf453b0]
|
|
470
|
+
- Updated dependencies [bf453b0]
|
|
471
|
+
- Updated dependencies [bf453b0]
|
|
472
|
+
- Updated dependencies [bf453b0]
|
|
473
|
+
- Updated dependencies [bf453b0]
|
|
474
|
+
- @getvitops/generator@2.0.0
|
|
475
|
+
- @getvitops/core@2.0.0
|
|
476
|
+
- @getvitops/vite@2.0.0
|
|
477
|
+
- @getvitops/utils@2.0.0
|
|
478
|
+
|
|
3
479
|
## 1.0.0
|
|
4
480
|
|
|
5
481
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -50,7 +50,8 @@ Astro's module graph and Astro emits that link itself.
|
|
|
50
50
|
| option | default | does |
|
|
51
51
|
| --------------- | -------------------- | ----------------------------------------------------------------- |
|
|
52
52
|
| `css` | off unless given | generate + auto-inject the design-system CSS |
|
|
53
|
-
| `css.input` | `design-system.json` | source config
|
|
53
|
+
| `css.input` | `design-system.json` | source config — a design-system.json, or a site config |
|
|
54
|
+
| `css.theme` | the config's default | `designSystem.themes` entry to build, for a site-config `input` |
|
|
54
55
|
| `css.format` | `tailwind` | `tailwind` \| `css` \| `bricks` |
|
|
55
56
|
| `css.out` | `src/styles` | directory the generated CSS is written to |
|
|
56
57
|
| `css.inject` | `true` | inject the stylesheet into every SSR page |
|
|
@@ -58,6 +59,13 @@ Astro's module graph and Astro emits that link itself.
|
|
|
58
59
|
| `favicon` | off unless given | `source`, `lowResSource`, `name`, `themeColor`, `backgroundColor` |
|
|
59
60
|
| `sitemap` | off unless given | generate `sitemap-index.xml` via `@astrojs/sitemap` |
|
|
60
61
|
| `seo` | off unless given | site-level defaults for `<Seo />` |
|
|
62
|
+
| `analytics` | off unless given | providers for `<Analytics />` (GA4, Clarity, Matomo, Plausible) |
|
|
63
|
+
| `consent` | off unless given | the consent gate + `<CookieConsent />` |
|
|
64
|
+
|
|
65
|
+
**`css.input` takes either config kind.** Point it at a `design-system.json`, or at the larger site
|
|
66
|
+
config that embeds one (`company.json` / `site.json`) — they're told apart by shape, so the file can
|
|
67
|
+
be called anything. A site config also supplies what `site`, `legal` and `fonts` would otherwise need
|
|
68
|
+
their own path for, so `legal: {}` and `fonts: true` become the whole declaration.
|
|
61
69
|
|
|
62
70
|
Set `css.inject: false` when another integration adds routes that must not inherit the design system
|
|
63
71
|
(e.g. EmDash's `/_emdash/admin`) — then import the generated file (`<out>/tailwind.css` or
|
|
@@ -130,8 +138,11 @@ web-component runtime); use both.
|
|
|
130
138
|
|
|
131
139
|
Behaviour worth knowing:
|
|
132
140
|
|
|
133
|
-
- **`titleTemplate`
|
|
134
|
-
|
|
141
|
+
- **`titleTemplate` applies to `<title>` only.** `og:title`/`twitter:title` get the _untemplated_
|
|
142
|
+
title, because a social card already shows `og:site_name` and the domain — "Pricing · Acme" would
|
|
143
|
+
sit directly above "Acme". Pass `ogTitle` for a different social headline. The template is also
|
|
144
|
+
skipped when a page's title already equals `siteName`, so a homepage titled "Acme" emits `Acme`,
|
|
145
|
+
not `Acme · Acme`, and it never applies to `defaultTitle`.
|
|
135
146
|
- **Canonical needs the `site` astro.config option.** Without it, canonical, `og:url` and any
|
|
136
147
|
_relative_ `og:image` are omitted rather than derived from the request — a canonical built from a
|
|
137
148
|
dev or preview origin can de-index you. The integration warns at build time. Absolute image URLs
|
|
@@ -155,6 +166,137 @@ both duplicates every one of them; the integration warns if you configure `seo`
|
|
|
155
166
|
The merge logic is a pure function, exported as `resolveSeo(defaults, props, ctx)` if you need to
|
|
156
167
|
drive it yourself.
|
|
157
168
|
|
|
169
|
+
## Analytics
|
|
170
|
+
|
|
171
|
+
`<Analytics />` emits the tags for the providers you configure. Nothing touches the critical path,
|
|
172
|
+
and anything that sets cookies waits for consent.
|
|
173
|
+
|
|
174
|
+
```js
|
|
175
|
+
// astro.config.mjs
|
|
176
|
+
vitops({
|
|
177
|
+
analytics: {
|
|
178
|
+
googleAnalytics: 'G-XXXXXXXXXX',
|
|
179
|
+
clarity: 'abcd1234',
|
|
180
|
+
matomo: { url: 'https://stats.acme.com', siteId: '1' },
|
|
181
|
+
plausible: 'acme.com',
|
|
182
|
+
strategy: 'idle',
|
|
183
|
+
},
|
|
184
|
+
consent: { policyUrl: '/legal/cookies' },
|
|
185
|
+
});
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
```astro
|
|
189
|
+
---
|
|
190
|
+
import Analytics from '@getvitops/astro/Analytics.astro';
|
|
191
|
+
import CookieConsent from '@getvitops/astro/CookieConsent.astro';
|
|
192
|
+
---
|
|
193
|
+
<head>
|
|
194
|
+
<Head />
|
|
195
|
+
<Analytics />
|
|
196
|
+
</head>
|
|
197
|
+
<body>
|
|
198
|
+
<slot />
|
|
199
|
+
<CookieConsent />
|
|
200
|
+
</body>
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
| provider | sets cookies | consent category | notes |
|
|
204
|
+
| ---------------------- | ------------ | ---------------- | -------------------------------------------------------------------------------- |
|
|
205
|
+
| **Google Analytics** 4 | yes | `analytics` | `_ga`, `_ga_*`, `_gid`. `category: 'marketing'` if the property feeds Ads |
|
|
206
|
+
| **Microsoft Clarity** | yes | `analytics` | session replay + heatmaps; `_clck`, `_clsk`, `MUID` |
|
|
207
|
+
| **Matomo** | **no** | `necessary` | `disableCookies` by default; `cookies: true` opts in and moves it to `analytics` |
|
|
208
|
+
| **Plausible** | no | `necessary` | cookieless, ~1 KB |
|
|
209
|
+
|
|
210
|
+
**The category is derived, not declared.** It follows from whether the provider sets cookies, which
|
|
211
|
+
follows from that provider's own configuration. You can't mark Google Analytics `necessary` to skip
|
|
212
|
+
the banner — but you _can_ pick a genuinely cookieless provider and be done with it, which is the
|
|
213
|
+
choice the table is trying to make legible.
|
|
214
|
+
|
|
215
|
+
### Loading
|
|
216
|
+
|
|
217
|
+
`strategy` decides when a tag runs. The default keeps analytics off the critical path entirely:
|
|
218
|
+
|
|
219
|
+
| `strategy` | loads |
|
|
220
|
+
| ---------------- | ---------------------------------------------------------------- |
|
|
221
|
+
| `idle` (default) | after `load`, on an idle callback (3s timeout) |
|
|
222
|
+
| `async` | immediately, with the vendor's own `async` semantics |
|
|
223
|
+
| `interaction` | on first pointer/key/scroll, or after 8s — whichever comes first |
|
|
224
|
+
|
|
225
|
+
`interaction` is the cheapest and the least accurate: a visitor who reads and leaves is counted only
|
|
226
|
+
by the 8s fallback. No `preconnect` is emitted for any of them — warming a third-party connection
|
|
227
|
+
during parse is exactly the cost `idle` exists to avoid.
|
|
228
|
+
|
|
229
|
+
### How consent actually blocks a tag
|
|
230
|
+
|
|
231
|
+
Gated tags render as `<script type="text/plain">` with the URL on `data-src`. The browser never parses
|
|
232
|
+
the body and never fetches the library, so an undecided or declining visitor's page issues **no
|
|
233
|
+
third-party request at all**. Consent implemented by asking a tracker not to track is a promise; this
|
|
234
|
+
is a fact about the document.
|
|
235
|
+
|
|
236
|
+
For Google Analytics that means **basic consent mode, not advanced**: nothing reaches Google until the
|
|
237
|
+
visitor accepts, rather than loading immediately with signals denied to send cookieless pings. Fewer
|
|
238
|
+
modelled conversions, nothing to defend. Clarity is gated the same way and additionally receives
|
|
239
|
+
`clarity('consentv2', …)`, because Microsoft enforces the signal separately for EEA/UK/CH traffic.
|
|
240
|
+
|
|
241
|
+
## Cookie consent
|
|
242
|
+
|
|
243
|
+
`consent: true` ships `@getvitops/core/consent` — a 2.3 KB gzipped, Lit-free bundle — and enables
|
|
244
|
+
`<CookieConsent />`.
|
|
245
|
+
|
|
246
|
+
**It is not an analytics feature.** The gate is general: mark anything `data-consent="<category>"`
|
|
247
|
+
and it waits on the same choice.
|
|
248
|
+
|
|
249
|
+
```html
|
|
250
|
+
<script type="text/plain" data-vitops-tag data-consent="marketing" data-src="https://…"></script>
|
|
251
|
+
<iframe data-consent="marketing" data-consent-src="https://www.youtube.com/embed/…"></iframe>
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Categories are `necessary` (always granted), `analytics`, `marketing`, `preferences`. The banner
|
|
255
|
+
offers only the ones something is actually waiting on.
|
|
256
|
+
|
|
257
|
+
Anything else — A/B assignment, account personalisation, your own scripts — uses `window.vitopsConsent`:
|
|
258
|
+
|
|
259
|
+
```js
|
|
260
|
+
window.vitopsConsent.subscribe((state) => {
|
|
261
|
+
if (window.vitopsConsent.granted('preferences')) restoreSavedLayout();
|
|
262
|
+
});
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
`get()` · `granted(category)` · `needed()` · `set({ analytics: true })` · `acceptAll()` ·
|
|
266
|
+
`rejectAll()` · `reset()` · `open()` · `subscribe(fn)`. A `vitops:consent` event fires on `document`
|
|
267
|
+
at startup and on every change. Anything with `[data-consent-open]` reopens the banner, so a footer
|
|
268
|
+
"Cookie settings" link needs no JS of its own.
|
|
269
|
+
|
|
270
|
+
Behaviour worth knowing:
|
|
271
|
+
|
|
272
|
+
- **Nothing is stored until the visitor chooses.** No cookie, no localStorage. Showing the banner
|
|
273
|
+
can't be the thing that needs consent. An unreadable or wrong-version cookie re-prompts rather than
|
|
274
|
+
being read permissively.
|
|
275
|
+
- **Revoking clears cookies and reloads.** An already-executing tracker can't be unloaded any other
|
|
276
|
+
way. `<CookieConsent noReloadOnRevoke />` turns the reload off, at the cost of that tracker running
|
|
277
|
+
until the next navigation.
|
|
278
|
+
- **Rejecting is a decision** — the banner stays gone, it doesn't keep asking until told yes.
|
|
279
|
+
- **No geo detection.** The banner shows for everyone once enabled. Suppressing it from a timezone or
|
|
280
|
+
an IP guess fails toward _not asking_, which is the expensive direction to be wrong in.
|
|
281
|
+
- **With no JS, nothing happens and that's correct** — the gate never runs, so no gated tag loads and
|
|
282
|
+
no non-essential cookie is set. The banner stays hidden because there is nothing to consent to.
|
|
283
|
+
- **`<Analytics />` alone still uses the runtime** for scheduling, unless every provider is cookieless
|
|
284
|
+
_and_ `strategy: 'async'` — the one configuration that ships no consent JavaScript at all.
|
|
285
|
+
|
|
286
|
+
### Keep it in step with your cookie notice
|
|
287
|
+
|
|
288
|
+
`vitops legal` derives the privacy policy and cookie notice from your **site config**, and
|
|
289
|
+
`getvitops({ analytics })` is a separate surface. Declare each provider in both:
|
|
290
|
+
|
|
291
|
+
```jsonc
|
|
292
|
+
// site config — what the documents disclose
|
|
293
|
+
{ "analytics": { "googleAnalyticsId": "G-XXXXXXXXXX", "clarityId": "abcd1234" } }
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Configure `legal` alongside `analytics` and the integration checks this for you, naming any provider
|
|
297
|
+
you'd otherwise be running without disclosing. It also warns when a cookie-setting provider is
|
|
298
|
+
configured with no `consent` gate.
|
|
299
|
+
|
|
158
300
|
## Components
|
|
159
301
|
|
|
160
302
|
Thin authoring helpers that emit the framework's HTML/CSS patterns — **none require runtime JS**:
|