@getvitops/generator 3.0.0 → 4.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 CHANGED
@@ -1,5 +1,242 @@
1
1
  # @getvitops/generator
2
2
 
3
+ ## 4.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - f7bc0a0: Add `vitops search setup` — onboard domains into Google Search Console as domain properties.
8
+
9
+ **Breaking:** the `vitops indexing` command has been renamed to **`vitops search notify`**. There is
10
+ no alias — update any scripts or CI that call `vitops indexing` to `vitops search notify` (same flags,
11
+ same behaviour).
12
+
13
+ **New: `vitops search setup`.** For each domain in a site config's new `site.searchConsole` block
14
+ (a record keyed by bare hostname), it:
15
+ - ensures the apex verification TXT record in Cloudflare,
16
+ - verifies ownership via the Site Verification API (DNS_TXT), retried with exponential backoff while
17
+ DNS propagates — a still-unverified domain is reported **PENDING**, not failed,
18
+ - adds the `sc-domain:` property via the Search Console API,
19
+ - adds any `delegatedOwners` to the verified Site Verification web resource.
20
+
21
+ It is idempotent (a re-run of an onboarded domain is a no-op), supports `--check` (report drift, exit
22
+ non-zero, mutate nothing) and `--dry` (print the plan, change nothing), and `--domain <name>` to
23
+ scope to one entry. DNS records are only ever created, never edited or deleted.
24
+
25
+ Credentials come from the environment, never the config: `CLOUDFLARE_API_TOKEN` (a `Zone:DNS:Edit`
26
+ token) and a Google **user OAuth refresh token** as `VITOPS_GOOGLE_CLIENT_ID` /
27
+ `VITOPS_GOOGLE_CLIENT_SECRET` / `VITOPS_GOOGLE_REFRESH_TOKEN` (scoped to `siteverification` +
28
+ `webmasters`). Granting a Google Group **Full-User** access has no Search Console API and is surfaced
29
+ as a reminder in the summary rather than automated.
30
+
31
+ ### Minor Changes
32
+
33
+ - 14813fa: Document which tier provides each pattern — `vitops docs components`.
34
+
35
+ The toolkit ships four tiers that compose: CSS framework classes, `<wc-*>` web components, Astro
36
+ components, Bricks elements. Nothing said so. The class reference listed pattern names, the
37
+ elements reference listed Bricks controls, and the fact that `tree` is _also_ a web component
38
+ _and_ an Astro component existed only in the source layout. The cost is silent: you hand-write
39
+ the markup a component already emits, or you wrap a component that already emits its own tag.
40
+
41
+ `TIERS` in `@getvitops/generator` is the new manifest — per pattern, its CSS partial and
42
+ representative classes, its `wc-*` tag and which bundle that ships in, its Astro component(s),
43
+ its Bricks element, and one line saying what to actually write. `tierPatterns(tier)` projects one
44
+ tier. `vitops docs components` renders all four in one document, and the reference sections it
45
+ feeds render the same data per tier.
46
+
47
+ It is **authored rather than derived, and drift-guarded** instead. Naming convention carries most
48
+ of it and breaks exactly where it matters — `splitter.css` hosts two components, `tag.css` serves
49
+ three config patterns, `anchor-link.css` provides `.link`, `layout.css` provides `.split` and
50
+ `.centered` — so a derivation would need an exceptions table longer than the rule, and a rename
51
+ would silently drop a link. Instead, tests fail the build when a hand-written CSS partial, a
52
+ `customElements.define`, an exported Astro component or a Bricks element isn't accounted for.
53
+
54
+ Four things the manifest records that cannot be inferred, each a real failure mode:
55
+ - **Whether an Astro component wraps a web component.** `Tree` and `CookieConsent` emit the
56
+ `<wc-*>` tag with the fallback inside; `Details`, `Drawer`, `Popover`, `NavShell` and `Subgrid`
57
+ emit tier-1 markup with no web component at all. Reading the first group as the second gives
58
+ `<wc-tree><Tree /></wc-tree>` — two elements on one tree, which renders and misbehaves.
59
+ - **Where each element ships.** `elements.js` for the registered set, its own bundle for
60
+ `<wc-consent>` and `<wc-theme-editor>`, and **no bundle at all** for the four editor-v2 tags —
61
+ which are registered, importable-looking and inert in a consumer project. The guard checks this
62
+ against what `js/elements.ts` imports, so it can't claim an element ships when it doesn't.
63
+ - **Whether a pattern is config-authored.** `patterns.items` patterns get the token cascade,
64
+ states, role variants and override hooks; a structural partial gets none of them. The docs also
65
+ now say when a config-authored pattern is **absent from your config**, because naming its
66
+ classes while your build emits none of them is worse than omitting it.
67
+ - **The import specifier, verbatim.** These are published as the line to copy, so they are checked
68
+ against `@getvitops/astro`'s `exports`. That guard immediately caught
69
+ `@getvitops/astro/components/../CookieConsent.astro` — plausible-looking, right basename, not an
70
+ importable path (`CookieConsent.astro` ships from the package root).
71
+
72
+ - c6b99e7: Ad-click attribution and conversion notifications ship as part of the toolchain.
73
+
74
+ A visitor arriving on an ad carries a click ID in the URL; `<Tracking />` captures it into a first-party `_ac` cookie, and when that visitor later submits a form or taps a `tel:` link, the conversion handler reads the cookie back and notifies whoever the config says. Previously this was per-site code.
75
+
76
+ **Added — `@getvitops/utils/tracking`.** The attribution vocabulary and cookie, as pure functions over a cookie _string_ so the browser capture and the server-side handler share one implementation: `parseTrackingCookie`, `serializeTrackingCookie`, `mergeTracking`, `identifyPlatform`, `getPrimaryClickId`, plus the click-ID/UTM tables. No DOM, no network, no clock — every function that needs "now" takes it as an argument, so it runs unchanged in a Worker.
77
+
78
+ **Added — `@getvitops/utils/notify`.** Conversion notifications, split the way `indexing/` is: `plan.ts` decides everything purely (which channels fire, who they reach, and **why anything is skipped**), `render.ts` turns the event into prose, `email.ts` executes and decides nothing. A misconfigured site can therefore be told exactly why no notification will arrive — a silently unsent one is indistinguishable from no conversion.
79
+
80
+ The `ConversionEvent` is the abstraction: the event is the _fact_, and how it reads is the channel's business.
81
+
82
+ **Added — the `email` channel, via Cloudflare Email Sending.** Uses the current structured binding (`env.EMAIL.send({ to, from, subject, html, text })`), not the legacy `EmailMessage` + hand-built MIME path. The binding is **passed in, never imported**, so `@getvitops/utils` takes no Cloudflare dependency and the sender is testable with a stub. Retries only the transient error codes; a configuration fault like `E_SENDER_NOT_VERIFIED` is surfaced verbatim rather than collapsed into "send failed". With no binding it prints the notification — the dev path.
83
+
84
+ Remember to onboard the sending domain (`wrangler email sending enable <domain>`) and add `"send_email": [{ "name": "EMAIL" }]` to `wrangler.jsonc`, or every send fails.
85
+
86
+ **Added — `<Tracking />` and `createConversionRoute()`** in `@getvitops/astro` (`@getvitops/astro/Tracking.astro`, `@getvitops/astro/routes`). The route factory handles both the `tel:` beacon and a form POST; validation and business rules stay the consumer's, because every site's form differs. A failed notification never fails the request — the visitor has already submitted, and refusing them because our mail didn't go out turns a lost notification into a lost conversion.
87
+
88
+ **Added — `site.tracking` and a widened `site.notifications`.** `notifications.email` now accepts a full channel object as well as a bare address (which still means what it did). `tracking` gains `category`.
89
+
90
+ **Fixed — the capture script now asks for consent instead of only reading it.** It previously checked `granted('marketing')` passively, which under the demand-driven banner is a permanent no-op: nothing else on a page demands `marketing`, so the banner never offered it, so it was never granted, so `_ac` was never written — silently, on every site with the gate enabled. It now calls `require()`, which is what raises the banner, and only when the URL actually carried a click ID or UTM. A visitor arriving organically has nothing to attribute and is never asked.
91
+
92
+ The integration adds `marketing` to the offered categories when tracking is on, so the banner has a row for the category the script will demand, and warns when tracking is enabled with `consent` off.
93
+
94
+ **Fixed — revoking consent now clears `_ac`.** The marker `<Tracking />` emits carries `data-consent-cookies`, which the consent runtime's cleanup reads. There was no cleanup path for this cookie before.
95
+
96
+ **Fixed — the cookie notice discloses `_ac`.** It is first-party, so no provider table would ever name it, and a site running attribution alongside a cookieless analytics provider was previously described as setting no cookies at all.
97
+
98
+ - c6b99e7: Consent is now demand-driven: the banner appears when something actually needs permission, not on every first visit.
99
+
100
+ Previously, enabling the gate showed a banner to every new visitor regardless of what the site did — including sites whose only analytics provider was cookieless, where there was nothing to consent to. The build decided whether the machinery shipped and the runtime showed the banner because no cookie existed yet.
101
+
102
+ Now those are two separate facts. The build decides what the banner _can_ ask (rows in the markup); the runtime decides what it _does_ ask. A gated tag registers its demand when it reaches its loading strategy, and `<color-scheme-toggle>` registers `preferences` when a visitor picks a scheme. A site that gates nothing never interrupts anyone.
103
+
104
+ **Breaking — consent cookie schema (v1 → v2).** Consent is now recorded per category as granted / declined / **not yet asked**. The third state is what lets a later demand ask about a category an earlier prompt didn't cover: accepting an analytics banner no longer silently declines preferences. Every stored v1 choice is invalid and re-prompts once — a v1 cookie asserted a definite answer for categories the visitor was never shown.
105
+
106
+ **Breaking — `ConsentApi`.**
107
+ - `needed()` now means "something demanded a category the visitor hasn't answered", not "no cookie yet". A custom banner calling it will now stay hidden until something asks.
108
+ - `ConsentState.decided` (a single boolean) is gone. Use `decidedFor(state, category)`, or `undecidedCategories(state)`.
109
+ - New: `require(category)` registers a demand and reports whether it is granted; `request(category)` resolves once the visitor answers it; `demanded()` lists what has asked so far.
110
+ - `acceptAll()` / `rejectAll()` still mean every optional category. The banner no longer routes its buttons through them — "Accept" applies to the categories on screen.
111
+
112
+ **Breaking — `<color-scheme-toggle>` persistence.** The chosen scheme still applies immediately, but writing it to `localStorage` now waits on the `preferences` category **when the site has a consent gate**. Sites without `consent` enabled are unaffected and keep storing as before. If your site enables consent and offers a theme toggle, make sure `preferences` is among the offered categories (it is by default).
113
+
114
+ **Changed — offered categories.** `consent.categories` defaults to `['analytics', 'preferences']` (plus `marketing` when a configured tag needs it) rather than only what analytics detection could see. An unused row is hidden markup, so offering broadly costs nothing and covers `data-consent` markup no build-time scan can find. Pass `categories` explicitly to narrow it.
115
+
116
+ **Added — `<Head />` emits a small inline consent stub** when the gate is on. `consent.js` and `elements.js` are both deferred with no ordering between them, so without it a theme toggle could be clicked before the gate existed, read "no gate", and store. The stub answers `false` and queues, and the runtime replays the queue on load.
117
+
118
+ **Fixed — the generated cookie notice** now discloses the stored display preference when `preferences` is offered, and no longer promises a banner "shown when you first visit", which is no longer how the banner works.
119
+
120
+ - 14813fa: Document the consent, conversion-tracking, search and legal subsystems for agents.
121
+
122
+ `vitops docs` gains four topics — `consent`, `tracking`, `search`, `legal` — and the docs
123
+ bundle gains the matching concept docs under `concepts/`. Until now these subsystems were
124
+ described only by their generated config-schema field descriptions, so an agent working in a
125
+ consumer project had no account of the rules that make them work, and every one of those rules
126
+ fails **silently** when broken:
127
+ - a gated tag given a live `src` instead of `type="text/plain"` fetches the third party anyway,
128
+ so the gate becomes decorative;
129
+ - a caller using `granted()` instead of `require()` never raises the banner, so on a site where
130
+ nothing else demands that category the permission is never offered, never granted, and the
131
+ write never happens;
132
+ - an absent `window.vitopsConsent` read as "denied" rather than "this site has no gate";
133
+ - a consent patch widened to every category records consent nobody gave;
134
+ - a sitemap with no `<lastmod>` makes edited pages undetectable, so `search notify` looks
135
+ healthy while never resubmitting anything;
136
+ - a stale IndexNow key file returns `202` and is then discarded;
137
+ - Google's Indexing API accepts ordinary URLs, discards them, and violates its own terms.
138
+
139
+ The packaged agent skill now covers consent and conversion tracking, and its description names
140
+ these subsystems so it is actually surfaced when they are the task.
141
+
142
+ Also fixed: the icons concept doc still called the Astro integration `getvitops()`, renamed to
143
+ `vitops()` in 3.0.
144
+
145
+ - 14813fa: Add `<wc-tree>`, and a schema walker that feeds it.
146
+
147
+ `<wc-tree>` is progressive enhancement for the existing `.tree` pattern: given a nested
148
+ `<details>` disclosure tree it adds a filter, expand-all / collapse-all, and hash deep-linking.
149
+ It ships in `elements.js`.
150
+
151
+ `@getvitops/astro` gains `./components/Tree.astro` and `./components/tree.ts` (its `TreeItem`
152
+ type) to build that markup from data. As with every wrapper over a web component, `<Tree />`
153
+ emits the `<wc-tree>` tag itself with the accessible fallback inside — so `<Tree items={…} />`
154
+ is the whole call, and wrapping it in your own `<wc-tree>` nests two elements on one tree.
155
+
156
+ The slotted markup is the whole content and works without it — every node readable, expandable
157
+ and linkable — so the element only adds what CSS cannot do. Four things worth knowing:
158
+ - **The toolbar is generated, never authored.** A search field that does nothing is worse than
159
+ no search field, so the controls exist only once the element upgrades.
160
+ - **Deep-linking is the non-obvious one.** A node inside a closed `<details>` has no layout box,
161
+ so the browser's own fragment navigation finds nothing and silently stays at the top of the
162
+ page. `<wc-tree>` opens the target's ancestors — and the target's own disclosure — then scrolls.
163
+ - **Filtering matches a node's own label and description, never its subtree's text.** The obvious
164
+ `textContent` implementation makes every ancestor of a hit match, so the root matches any query
165
+ and the filter narrows nothing. That decision lives in a pure, tested module (`matchTree`)
166
+ rather than in the DOM wiring.
167
+ - **It initialises even when upgraded mid-insertion.** An element connected during an
168
+ `innerHTML` write — a view-transition swap, a client-side navigation — has no children yet
169
+ (measured: zero). Setup retries once the insertion completes, because the failure mode was
170
+ silent: no toolbar, no filter, no deep link, no error.
171
+
172
+ `patterns/tree.css` now supports two markup shapes: the item may be the `<details>` (the
173
+ pattern's original contract) or an `<li class="tree__item">` wrapping one, which is what gives
174
+ assistive tech list position and depth.
175
+
176
+ **`.tree` indent is fixed and now fluid**, which matters for any deep tree. Three separate leaks
177
+ compounded, each invisible in the CSS and each charging roughly one extra indent per level:
178
+ - the rule set both `margin-inline-start` and `padding-inline-start` to `--tree-indent`;
179
+ - `.tree { padding: var(--_p) }` applied to every nested `.tree`, not just the outermost — on all
180
+ four sides, so it narrowed from the end as well;
181
+ - `patterns/details.css` gives every non-summary child of a `<details>`
182
+ `margin-inline: var(--_content-margin)`, and its `details, .details` selector takes `:is()`
183
+ specificity under CSS nesting (**0-1-1**), so `.tree { margin: 0 }` at 0-1-0 _loses_ to it. A
184
+ nested tree is exactly such a child.
185
+
186
+ Measured in a browser: **41px per level against a 24px design**, so a 9-deep tree spent 382px on
187
+ indent and left its deepest label 162px wide. Now 24px per level, 198px total, and 653px of label.
188
+ The nested rule resets both box axes before applying the one indent it owes, and the default is
189
+ `clamp(0.75rem, 2.5vw, 1.5rem)` — 12px on a phone, 24px on a desktop — because a tree's depth is a
190
+ property of the data and cannot be known when the pattern is written. `--tree-indent` still
191
+ overrides it, and the `--lines` elbow derives from the same resolved value so the two cannot
192
+ disagree.
193
+
194
+ **Leaf rows now align with their branch siblings.** A branch spends a toggle column on its chevron
195
+ before its label; a leaf has none, so every leaf label sat one full column (24px, measured) to the
196
+ inline-start of its siblings and nothing at a given depth lined up. `.tree` exposes
197
+ `--_node-indent` (the toggle column) and `--_leaf-indent` (what a row without a toggle owes to
198
+ reach the same label column); `.tree__content` and `.tree__desc` both read the latter rather than
199
+ re-deriving the sum.
200
+
201
+ `<wc-tree>` also wraps its generated filter in `.input-group`, because `forms.css` styles text
202
+ controls as `.form-group > input` / `.input-group > input` — a bare `<input>` rendered with the
203
+ browser's 2px inset border and square corners next to the framework's own rounded controls.
204
+
205
+ `schemaTreeNodes(schema, { idPrefix, maxDepth, prune })` is new in `@getvitops/generator`: it walks
206
+ a JSON Schema — the same walk behind the `authoring.md` / `config.md` references — and returns tree
207
+ **data**, not markup, so each medium renders it (markdown for agents, an accessible `<details>`
208
+ tree for a site). Field ids are dotted config paths (`site.analytics.clarityId`), so any field can
209
+ be linked to directly.
210
+
211
+ `prune` exists for a specific hazard. The project config declares `designSystem.themes` as a
212
+ looser shape than `DesignSystemSchema`, because the full one is applied separately by
213
+ `resolveTheme` — so its embedded copy is an **approximation**, and measurably missing the
214
+ descriptions for `colors`, `colors.palette`, `colors.roles` and `colors.utilities`. Anything
215
+ rendering that copy as the token reference would silently document the colour system less well
216
+ than the toolchain already does; `prune` lets a caller stop at the wrapper and render `jsonSchema`
217
+ alongside instead.
218
+
219
+ `renderInlineMarkdown()` is exported for schema description text. It **lifts code spans out before
220
+ applying emphasis**, and that ordering is load-bearing rather than tidy: `colors.utilities`
221
+ describes its families as `` `bg-*` ``, `` `text-*` ``, `` `border-*` `` — literal asterisk
222
+ _wildcards_. Run emphasis over the raw string and the `*` closing `bg-*` pairs with the one
223
+ closing `text-*`, italicising the text between two unrelated utilities and eating both asterisks,
224
+ leaving prose that names families which don't exist. It also now renders single-asterisk emphasis,
225
+ which the schema does use (`*presentation*`, `*domain properties*`) and which previously printed
226
+ the asterisks literally.
227
+
228
+ Also fixed: `config.md` claimed "Only the wrapper is listed here" under `designSystem` and then
229
+ emitted the entire token schema anyway — `themes.<name>` _is_ a design system, so the walk
230
+ descended into it. It now stops at the wrapper and delegates to `authoring.md` as it always said
231
+ it did.
232
+
233
+ ### Patch Changes
234
+
235
+ - Updated dependencies [c6b99e7]
236
+ - Updated dependencies [f7bc0a0]
237
+ - Updated dependencies [ceed51f]
238
+ - @getvitops/utils@4.0.0
239
+
3
240
  ## 3.0.0
4
241
 
5
242
  ### Major Changes
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * Vitops — Color Scheme Toggle element (Bricks Builder).
4
4
  *
5
- * Renders the <color-scheme-toggle> Lit component (src/web-components/WCColorSchemeToggle.ts):
5
+ * Renders the <wc-color-scheme-toggle> Lit component (src/web-components/WCColorSchemeToggle.ts):
6
6
  * a segmented System / Light / Dark theme toggle, hidden until JS loads. Optionally seed
7
7
  * the initial `scheme`.
8
8
  *
@@ -21,7 +21,7 @@ class Vitops_Element_Color_Scheme_Toggle extends \Bricks\Element {
21
21
  public $category = 'vitops';
22
22
  public $name = 'vitops-color-scheme-toggle';
23
23
  public $icon = 'ti-shine';
24
- public $css_selector = 'color-scheme-toggle';
24
+ public $css_selector = 'wc-color-scheme-toggle';
25
25
 
26
26
  public function get_label() {
27
27
  return esc_html__( 'Color Scheme Toggle', 'bricks' );
@@ -56,6 +56,6 @@ class Vitops_Element_Color_Scheme_Toggle extends \Bricks\Element {
56
56
 
57
57
  $attr_str = implode( ' ', $attrs );
58
58
 
59
- echo "<color-scheme-toggle {$attr_str} {$this->render_attributes( '_root' )}></color-scheme-toggle>";
59
+ echo "<wc-color-scheme-toggle {$attr_str} {$this->render_attributes( '_root' )}></wc-color-scheme-toggle>";
60
60
  }
61
61
  }
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * Vitops — Multi Field element (Bricks Builder).
4
4
  *
5
- * Renders the <wc-multifield> Lit component (src/web-components/WCMultiField.ts): a
5
+ * Renders the <wc-multi-field> Lit component (src/web-components/WCMultiField.ts): a
6
6
  * form-associated repeatable input group (add / remove entries, min / max). Default
7
7
  * entries are supplied as slotted <input value="…"> children, which the component reads
8
8
  * on connect.
@@ -22,7 +22,7 @@ class Vitops_Element_Multi_Field extends \Bricks\Element {
22
22
  public $category = 'vitops';
23
23
  public $name = 'vitops-multi-field';
24
24
  public $icon = 'ti-list';
25
- public $css_selector = 'wc-multifield';
25
+ public $css_selector = 'wc-multi-field';
26
26
 
27
27
  public function get_label() {
28
28
  return esc_html__( 'Multi Field', 'bricks' );
@@ -143,6 +143,6 @@ class Vitops_Element_Multi_Field extends \Bricks\Element {
143
143
  }
144
144
  }
145
145
 
146
- echo "<wc-multifield {$attr_str} {$this->render_attributes( '_root' )}>{$inputs}</wc-multifield>";
146
+ echo "<wc-multi-field {$attr_str} {$this->render_attributes( '_root' )}>{$inputs}</wc-multi-field>";
147
147
  }
148
148
  }
@@ -75,15 +75,26 @@
75
75
  margin-inline-start: auto;
76
76
  }
77
77
 
78
- /* The per-category checkboxes. Two buttons answer a one-category question, so
79
- `<CookieConsent />` only renders these when there is more than one — a banner
80
- that demands reading is a banner that gets accepted unread. */
78
+ /* The per-category checkboxes. A row is hidden unless this showing is about it,
79
+ so the markup can carry every category the site might ever need while the
80
+ banner still only asks what was actually demanded. */
81
81
  .consent__options {
82
82
  flex-basis: 100%;
83
83
  display: flex;
84
84
  flex-wrap: wrap;
85
85
  gap: 1rem;
86
86
  }
87
+
88
+ /* One category is a yes/no question and the Reject/Accept buttons already ask
89
+ it — a lone checkbox would be a third control for the same decision, and the
90
+ Save button a second way to press Accept. `<wc-consent>` sets this class at
91
+ show time because how many rows are on offer is now a runtime fact. */
92
+ &.consent--single {
93
+ .consent__options,
94
+ [data-consent-save] {
95
+ display: none;
96
+ }
97
+ }
87
98
  }
88
99
 
89
100
  /* Open, in the top layer. */
@@ -17,10 +17,32 @@
17
17
  * OR click (native `[open]` pins it). Top-level submenus drop below their item;
18
18
  * deeper (megamenu) submenus fly out to the inline-end side.
19
19
  *
20
- * `.sitenav__disclosure` is `display: contents` so the `<summary>` (caret) and the
21
- * submenu `<ul>` participate directly in the branch item's grid — that is what lets
22
- * the caret sit inline with the link while the submenu spans full width on its own
23
- * row. Native `[open]` toggling is unaffected by `display: contents`.
20
+ * The `<details>` is grid-placed at column 2 and the submenu `<ul>` is its SIBLING,
21
+ * a real grid child of the branch item — that is what lets the caret sit inline with
22
+ * the link while the submenu spans full width on its own row.
23
+ *
24
+ * ── TWO BRANCHES, NOT BASE-PLUS-OVERRIDE ──
25
+ *
26
+ * `@container` contributes ZERO specificity, so a rule inside the wide block competes
27
+ * at its bare selector weight. A bare `.sitenav--drawer-end .sitenav__panel` (0,2,0)
28
+ * therefore beat the wide block's `.sitenav__panel` (0,1,0) reset *in the wide state*:
29
+ * the promoted navbar shipped translated a full panel-width off-screen, and the
30
+ * accordion's `overflow: hidden` clipped away every third-level megamenu. Before the
31
+ * four per-breakpoint blocks were folded into one style query, those resets carried a
32
+ * `.sitenav--bp-*` class and won the tie on source order; folding them removed it.
33
+ *
34
+ * So the rule is: **a narrow-state rule that is MORE SPECIFIC than its wide-state
35
+ * counterpart lives inside `@container style(--_sitenav-wide: 0)`.** Rules at (0,1,0)
36
+ * — the panel's box, the lists, the links — stay unscoped, because that same work is
37
+ * what neutralises the UA `[popover]` sheet, which applies in BOTH states; scoping it
38
+ * would mean writing it twice. `src/css/state-branch.test.ts` asserts the invariant.
39
+ *
40
+ * Both branches read an INHERITED flag, so every part must live inside `.sitenav`,
41
+ * which declares it. Note the file mixes two unnamed query kinds that resolve against
42
+ * DIFFERENT containers: `style()` against the nearest ancestor ELEMENT (every element
43
+ * is a style container — no `container-type` needed, and it resolves for a panel
44
+ * promoted to the top layer), the `(min-width:)` queries below against `body`
45
+ * (layout.css). That asymmetry is why the flag has to be a custom property.
24
46
  *
25
47
  * Per-breakpoint depth caps come pre-resolved from PHP as item classes:
26
48
  * `.sitenav__item--desktop-branch` — caret + submenu shown only ≥ breakpoint;
@@ -75,6 +97,19 @@
75
97
  inset-block: 0;
76
98
  inline-size: min(100dvw, var(--sitenav-drawer-size, 20rem));
77
99
  max-inline-size: 100dvw;
100
+
101
+ /* The UA `[popover]` sheet supplies `margin: auto` and `height: fit-content`, and
102
+ neither was being undone — so `inset-block: 0` bought nothing and the "drawer"
103
+ rendered as a content-height card floating in the vertical centre, while at
104
+ width the auto margins absorbed the free space of any stretched `.sitenav`.
105
+ `drawer.css` resets margin for exactly this reason; this panel doesn't compose
106
+ it. Both live in the base block because the UA sheet applies in BOTH states:
107
+ against `position: fixed` + `inset-block: 0` an auto block-size stretches to
108
+ the drawer's full height, and against the wide branch's `position: static` the
109
+ same declaration sizes the navbar to its content. */
110
+ margin: 0;
111
+ block-size: auto;
112
+
78
113
  padding: 1.5rem;
79
114
  padding-block-start: 3.5rem; /* clearance for the close button */
80
115
  background: var(--color-bg-surface);
@@ -106,30 +141,42 @@
106
141
  }
107
142
  }
108
143
 
109
- /* Slide from the chosen edge (default inline-end). */
110
- .sitenav--drawer-end .sitenav__panel {
111
- inset-inline-end: 0;
112
- inset-inline-start: auto;
113
- translate: 100% 0;
114
- }
115
- .sitenav--drawer-start .sitenav__panel {
116
- inset-inline-start: 0;
117
- inset-inline-end: auto;
118
- translate: -100% 0;
119
- }
120
- .sitenav__panel:popover-open {
121
- opacity: 1;
122
- translate: 0 0;
123
- }
124
- @starting-style {
125
- .sitenav--drawer-end .sitenav__panel:popover-open {
126
- opacity: 0;
144
+ /* ── Drawer geometry + transition — NARROW ONLY ─────────────────────────────
145
+ Scoped rather than bare because these are (0,2,0)/(0,3,0) and would outrank the
146
+ wide block's (0,1,0) panel reset. `:popover-open` and the `@starting-style`
147
+ pair are one transition system with the edge modifiers, so they travel with
148
+ them: both are inert at width only by coincidence (the panel is always
149
+ displayed there, and their values happen to match the reset's), which is the
150
+ same shape of accident as the bug itself. ── */
151
+ @container style(--_sitenav-wide: 0) {
152
+ /* Slide from the chosen edge (default inline-end). The insets also undo the UA
153
+ `[popover] { inset: 0 }`; the wide branch does that job with `inset: auto`. */
154
+ .sitenav--drawer-end .sitenav__panel {
155
+ inset-inline-end: 0;
156
+ inset-inline-start: auto;
127
157
  translate: 100% 0;
128
158
  }
129
- .sitenav--drawer-start .sitenav__panel:popover-open {
130
- opacity: 0;
159
+ .sitenav--drawer-start .sitenav__panel {
160
+ inset-inline-start: 0;
161
+ inset-inline-end: auto;
131
162
  translate: -100% 0;
132
163
  }
164
+
165
+ .sitenav__panel:popover-open {
166
+ opacity: 1;
167
+ translate: 0 0;
168
+ }
169
+
170
+ @starting-style {
171
+ .sitenav--drawer-end .sitenav__panel:popover-open {
172
+ opacity: 0;
173
+ translate: 100% 0;
174
+ }
175
+ .sitenav--drawer-start .sitenav__panel:popover-open {
176
+ opacity: 0;
177
+ translate: -100% 0;
178
+ }
179
+ }
133
180
  }
134
181
 
135
182
  /* ── Lists + links ─────────────────────────────────────────────────────────── */
@@ -167,31 +214,7 @@
167
214
  font-style: italic;
168
215
  }
169
216
 
170
- /* ── Branch item — mobile accordion ────────────────────────────────────────
171
- The item is a grid: [link | caret] on row 1, the submenu spanning row 2. The
172
- <details> holds only the caret <summary>; the submenu <ul> is its sibling and a
173
- real grid child of the item. The row-2 track animates 0fr→1fr when the details is
174
- open (the classic no-JS accordion, driven by `:has`), so the submenu pushes the
175
- content below rather than overlaying it. */
176
- .sitenav__item--branch {
177
- display: grid;
178
- grid-template-columns: 1fr auto;
179
- grid-template-rows: auto 0fr;
180
- align-items: center;
181
- column-gap: 0.5rem;
182
- transition: grid-template-rows 0.25s ease;
183
- }
184
-
185
- .sitenav__item--branch > .split-link__link {
186
- grid-column: 1;
187
- grid-row: 1;
188
- }
189
-
190
- /* <details> is only the caret; keep its box out of the flow's height. */
191
- .sitenav__disclosure {
192
- grid-column: 2;
193
- grid-row: 1;
194
- }
217
+ /* ── Branch item — the two rules both presentations share ──────────────────── */
195
218
 
196
219
  /* The <summary>. `.split-link__toggle` supplies the flush look, hover + focus, and
197
220
  the caret transition; only the disclosure-marker reset is added here. */
@@ -204,31 +227,68 @@
204
227
  }
205
228
  }
206
229
 
207
- .sitenav__item--branch > .sitenav__submenu {
208
- grid-column: 1 / -1;
209
- grid-row: 2;
210
- /* Stretch to the row-2 track (not content-sized) so the 0fr collapsed state
211
- actually clips to 0 — the item's own align-self must beat the grid's
212
- `align-items: center`, or overflow:hidden has nothing to clip against. */
213
- align-self: stretch;
214
- min-block-size: 0;
215
- overflow: hidden;
216
- padding-inline-start: 1rem;
217
- }
218
-
219
- .sitenav__item--branch:has(> .sitenav__disclosure[open]) {
220
- grid-template-rows: auto 1fr;
221
- }
222
-
223
- /* Caret flips while the disclosure is open (summary carries no aria-expanded). */
230
+ /* Caret flips while the disclosure is open (summary carries no aria-expanded).
231
+ Unscoped: at width the same native `[open]` pins a dropdown, so the flip is
232
+ wanted in both states. */
224
233
  .sitenav__disclosure[open] > .sitenav__subtoggle .split-link__caret {
225
234
  rotate: 180deg;
226
235
  }
227
236
 
228
- /* Depth cap (mobile side): a desktop-only branch shows just its parent link. */
229
- .sitenav__item--desktop-branch > .sitenav__disclosure,
230
- .sitenav__item--desktop-branch > .sitenav__submenu {
231
- display: none;
237
+ /* ── Mobile accordion — NARROW ONLY ─────────────────────────────────────────
238
+ The item is a grid: [link | caret] on row 1, the submenu spanning row 2. The
239
+ <details> holds only the caret <summary>; the submenu <ul> is its sibling and a
240
+ real grid child of the item. The row-2 track animates 0fr→1fr when the details is
241
+ open (the classic no-JS accordion, driven by `:has`), so the submenu pushes the
242
+ content below rather than overlaying it.
243
+
244
+ Scoped because these selectors are (0,2,0)/(0,3,0). Most of what is in here is
245
+ inert at width anyway — grid placement on a flex item, `align-self` on an
246
+ absolutely positioned box, `min-block-size: 0` where `auto` already computes to
247
+ 0 — but `overflow: hidden` and `padding-inline-start` are NOT, and leaking them
248
+ into the navbar is what clipped the megamenus. ── */
249
+ @container style(--_sitenav-wide: 0) {
250
+ .sitenav__item--branch {
251
+ display: grid;
252
+ grid-template-columns: 1fr auto;
253
+ grid-template-rows: auto 0fr;
254
+ align-items: center;
255
+ column-gap: 0.5rem;
256
+ transition: grid-template-rows 0.25s ease;
257
+ }
258
+
259
+ .sitenav__item--branch > .split-link__link {
260
+ grid-column: 1;
261
+ grid-row: 1;
262
+ }
263
+
264
+ /* <details> is only the caret; keep its box out of the flow's height. */
265
+ .sitenav__disclosure {
266
+ grid-column: 2;
267
+ grid-row: 1;
268
+ }
269
+
270
+ .sitenav__item--branch > .sitenav__submenu {
271
+ grid-column: 1 / -1;
272
+ grid-row: 2;
273
+ /* Stretch to the row-2 track (not content-sized) so the 0fr collapsed state
274
+ actually clips to 0 — the item's own align-self must beat the grid's
275
+ `align-items: center`, or overflow:hidden has nothing to clip against. */
276
+ align-self: stretch;
277
+ min-block-size: 0;
278
+ overflow: hidden;
279
+ padding-inline-start: 1rem;
280
+ }
281
+
282
+ .sitenav__item--branch:has(> .sitenav__disclosure[open]) {
283
+ grid-template-rows: auto 1fr;
284
+ }
285
+
286
+ /* Depth cap (mobile side): a desktop-only branch shows just its parent link.
287
+ Scoped, so the wide branch needs no counter-rule — it simply stops applying. */
288
+ .sitenav__item--desktop-branch > .sitenav__disclosure,
289
+ .sitenav__item--desktop-branch > .sitenav__submenu {
290
+ display: none;
291
+ }
232
292
  }
233
293
  /* ── Desktop promotion.
234
294
  *
@@ -310,8 +370,9 @@
310
370
  position: relative;
311
371
  display: flex;
312
372
  align-items: center;
313
- grid-template-columns: none;
314
- grid-template-rows: none;
373
+ /* Restated, not inherited: this used to leak in from the accordion rule, which
374
+ is now narrow-scoped. Without it the caret hugs the link at width. */
375
+ column-gap: 0.5rem;
315
376
  }
316
377
  .sitenav__submenu {
317
378
  position: absolute;
@@ -319,7 +380,6 @@
319
380
  inset-inline-start: 0;
320
381
  min-inline-size: 13rem;
321
382
  padding: 0.5rem;
322
- overflow: visible;
323
383
  background: var(--color-bg-surface);
324
384
  color: var(--color-text-surface);
325
385
  border: var(--b-panel);
@@ -335,6 +395,12 @@
335
395
  translate 0.15s ease,
336
396
  visibility 0.15s allow-discrete;
337
397
  }
398
+ /* Third level (megamenu) flies out to the inline-end side. Its containing block
399
+ is the inner `.sitenav__item--branch` (position: relative, above), which sits
400
+ INSIDE the parent submenu — so nothing between the two may clip. The
401
+ accordion's `overflow: hidden` used to leak in here at (0,2,0) and beat this
402
+ branch's (0,1,0) reset, and every megamenu was invisible at width. It is now
403
+ scoped to `style(--_sitenav-wide: 0)` and cannot. */
338
404
  .sitenav__submenu .sitenav__item--branch > .sitenav__submenu {
339
405
  inset-block-start: 0;
340
406
  inset-inline-start: 100%;
@@ -348,12 +414,12 @@
348
414
  pointer-events: auto;
349
415
  translate: 0 0;
350
416
  }
351
- /* depth caps flip at the breakpoint: desktop-only branches reveal their caret +
352
- dropdown; mobile-only branches collapse to a plain link. */
353
- .sitenav__item--desktop-branch > .sitenav__disclosure,
354
- .sitenav__item--desktop-branch > .sitenav__submenu {
355
- display: block;
356
- }
417
+ /* Depth cap (desktop side): a mobile-only branch collapses to a plain link. Its
418
+ mirror — hiding a DESKTOP-only branch below the breakpoint — lives in the
419
+ narrow branch, so there is nothing to undo here. A `display: block`
420
+ counter-rule used to sit alongside this one and, at (0,2,0), beat the base
421
+ `.sitenav__submenu`'s `display: flex` (0,1,0) — costing every desktop-only
422
+ dropdown its column direction and gap. */
357
423
  .sitenav__item--mobile-branch > .sitenav__disclosure,
358
424
  .sitenav__item--mobile-branch > .sitenav__submenu {
359
425
  display: none;