@apliteni/apliteni-ui 0.32.0 → 0.33.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/README.md CHANGED
@@ -28,11 +28,13 @@ They use the vanilla kit's `.ui-*` classes and tokens.
28
28
  **Which one:** does the surface hold meaningful client state? No → the HTML-string
29
29
  factories below. Yes → the [React components](#react-components-stateful-surfaces).
30
30
 
31
- Either layer follows the same UI rules — which component to reach for, the states it
32
- owes, how colour and wording work. They live in the **Guidelines** section of Storybook,
33
- which opens on
34
- [an overview of the five pages](https://ui.apli.tech/storybook/?path=/story/guidelines-overview--overview)
35
- and what the kit does and does not yet meet. Worth reading before you design a screen.
31
+ Either layer follows the same UI rules — what one page may hold, which component to reach
32
+ for, the states it owes, how colour and wording work. They live in the **Guidelines** section
33
+ of Storybook, which opens on
34
+ [an overview of every page](https://ui.apli.tech/storybook/?path=/story/guidelines-overview--overview)
35
+ and what the kit does and does not yet meet.
36
+ [The page](https://ui.apli.tech/storybook/?path=/story/guidelines-the-page--the-page) is the one
37
+ to read before you design a screen: the limits one page keeps, whatever it is about.
36
38
 
37
39
  ## Install
38
40
 
@@ -78,7 +80,7 @@ The whole `/account` layout (topbar + sticky sidebar + page body) ships as one
78
80
  factory, so every product renders the same account shell instead of re-building it:
79
81
 
80
82
  ```js
81
- import { accountShell, card, switchToggle, wireTopbar } from '@apliteni/apliteni-ui';
83
+ import { accountShell, card, switchToggle, wireTopbar, wireShell } from '@apliteni/apliteni-ui';
82
84
 
83
85
  el.innerHTML = accountShell({
84
86
  word: 'Strategy', // the product word in the topbar
@@ -89,8 +91,10 @@ el.innerHTML = accountShell({
89
91
  body: card({ title: 'Appearance', body: switchToggle({ label: 'Reduce motion' }) }),
90
92
  });
91
93
  wireTopbar(el); // menus, theme toggle, segmented controls
94
+ wireShell(el); // the toggle that folds the rail, the reader's menu, the nav's groups
92
95
 
93
96
  // Custom sidebar nav? pass `nav: [['prefs','gear','Preferences'], ['billing','wallet','Billing']]`
97
+ // A page that will never call wireShell()? pass `collapsible: false` and no toggle is drawn
94
98
  ```
95
99
 
96
100
  Server-rendered apps that inline CSS (like the strategy portal) import the stylesheet
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apliteni/apliteni-ui",
3
- "version": "0.32.0",
3
+ "version": "0.33.1",
4
4
  "workspaces": [
5
5
  "react"
6
6
  ],
@@ -44,7 +44,8 @@
44
44
  .rx-modal {
45
45
  width: min(480px, 92vw);
46
46
  background: var(--bg-elevated);
47
- border: 1px solid var(--border);
47
+ border: 1px solid var(--border-strong);
48
+ box-shadow: inset 0 0 0 1px var(--elev-edge, var(--border)), var(--elev-drop);
48
49
  border-radius: var(--radius-xl);
49
50
  overflow: hidden;
50
51
  transform: translateY(8px);
@@ -222,7 +222,27 @@ export function dropdown({
222
222
  // Per-instance trigger + keyboard handlers are attached once (guarded by a flag
223
223
  // on the element). Document-level click-outside + Esc are attached once per
224
224
  // document. Safe to call repeatedly (e.g. Storybook re-renders).
225
- let _ddGlobalWired = false;
225
+
226
+ // Every wired container, so the close handlers can reach a dropdown wherever it
227
+ // was drawn: `document.querySelectorAll` enters no shadow root and sees no other
228
+ // document. A Set and not a WeakSet, because this has to be walked.
229
+ // why: docs/specification.md#the-dropdown-panel
230
+ const _ddAll = new Set();
231
+ // One pair of close handlers per document that holds a dropdown, the same way
232
+ // wireShell() listens once per document it is handed.
233
+ const _ddWiredDocs = new WeakSet();
234
+
235
+ /** Where a portalled panel goes: the top of the tree its trigger lives in. A
236
+ * shadow root is its own top — moving the panel out to the page's <body> would
237
+ * leave every style scoped to that root behind. */
238
+ const ddHostOf = (node) => {
239
+ const root = node.getRootNode ? node.getRootNode() : node.ownerDocument;
240
+ return root.nodeType === 9 ? root.body : root;
241
+ };
242
+
243
+ /** The window a box is measured in. A panel in a frame is laid out against the
244
+ * frame's viewport, not the top page's. */
245
+ const ddViewOf = (node) => node.ownerDocument?.defaultView || window;
226
246
 
227
247
  // The trigger-to-panel offset is --ui-dropdown-gap in src/styles/dropdown.css.
228
248
  // This is the fallback for a document that has not loaded the sheet;
@@ -234,7 +254,7 @@ const DD_GAP = 9;
234
254
  const ddPanelOf = (dd) => dd.__ddPanel || dd.querySelector('[data-dropdown-panel]');
235
255
 
236
256
  function ddGap(panel) {
237
- const declared = parseFloat(getComputedStyle(panel).getPropertyValue('--ui-dropdown-gap'));
257
+ const declared = parseFloat(ddViewOf(panel).getComputedStyle(panel).getPropertyValue('--ui-dropdown-gap'));
238
258
  return Number.isFinite(declared) ? declared : DD_GAP;
239
259
  }
240
260
 
@@ -305,7 +325,7 @@ function ddResolveDirection(dd, panel) {
305
325
  const trigger = dd.querySelector('[data-dropdown-trigger]');
306
326
  if (!trigger || typeof trigger.getBoundingClientRect !== 'function') return;
307
327
  const t = trigger.getBoundingClientRect();
308
- const below = window.innerHeight - t.bottom;
328
+ const below = ddViewOf(panel).innerHeight - t.bottom;
309
329
  panel.classList.toggle('is-up', below < panel.offsetHeight + ddGap(panel) && t.top > below);
310
330
  }
311
331
 
@@ -314,29 +334,31 @@ function ddResolveDirection(dd, panel) {
314
334
  function positionPortalPanel(dd, panel) {
315
335
  const trigger = dd.querySelector('[data-dropdown-trigger]');
316
336
  if (!trigger || typeof trigger.getBoundingClientRect !== 'function') return;
337
+ const view = ddViewOf(panel);
317
338
  const t = trigger.getBoundingClientRect();
318
339
  const gap = ddGap(panel);
319
340
  const s = panel.style;
320
341
  if (panel.classList.contains('is-up')) {
321
342
  s.top = 'auto';
322
- s.bottom = `${window.innerHeight - t.top + gap}px`;
343
+ s.bottom = `${view.innerHeight - t.top + gap}px`;
323
344
  } else {
324
345
  s.bottom = 'auto';
325
346
  s.top = `${t.bottom + gap}px`;
326
347
  }
327
348
  if (panel.classList.contains('is-end')) {
328
349
  s.left = 'auto';
329
- s.right = `${window.innerWidth - t.right}px`;
350
+ s.right = `${view.innerWidth - t.right}px`;
330
351
  } else {
331
352
  s.right = 'auto';
332
353
  s.left = `${t.left}px`;
333
354
  }
334
355
  }
335
356
 
336
- // A panel left on <body> outlives the container that owned it — a re-render
337
- // replaces the container and the old panel has nothing pointing at it.
338
- function sweepOrphanPanels() {
339
- document.querySelectorAll('body > [data-dropdown-panel][data-dropdown-portal]')
357
+ // A portalled panel outlives the container that owned it — a re-render replaces
358
+ // the container and the old panel has nothing pointing at it. Swept in the host
359
+ // it was put in, which is the tree the new container is in too.
360
+ function sweepOrphanPanels(host) {
361
+ host.querySelectorAll(':scope > [data-dropdown-panel][data-dropdown-portal]')
340
362
  .forEach((p) => { if (!p.__ddOwner || !p.__ddOwner.isConnected) p.remove(); });
341
363
  }
342
364
 
@@ -347,8 +369,19 @@ function closeDropdown(dd) {
347
369
  dd.querySelector('[data-dropdown-trigger]')?.setAttribute('aria-expanded', 'false');
348
370
  }
349
371
 
372
+ /** Every wired dropdown still in a tree, the ones in frames and shadow roots
373
+ * included. Disconnected containers are dropped as they are passed. */
374
+ function ddLive() {
375
+ const out = [];
376
+ for (const dd of _ddAll) {
377
+ if (dd.isConnected) out.push(dd);
378
+ else _ddAll.delete(dd);
379
+ }
380
+ return out;
381
+ }
382
+
350
383
  function closeAllDropdowns(except) {
351
- document.querySelectorAll('[data-dropdown].open').forEach((dd) => { if (dd !== except) closeDropdown(dd); });
384
+ for (const dd of ddLive()) if (dd !== except && dd.classList.contains('open')) closeDropdown(dd);
352
385
  }
353
386
 
354
387
  function openDropdown(dd, focusIdx) {
@@ -390,11 +423,39 @@ function selectOption(dd, item) {
390
423
  if (valueEl && label) valueEl.textContent = label.textContent;
391
424
  }
392
425
 
426
+ // Click-outside, Escape and the repositioning sweep, registered once per document
427
+ // that holds a dropdown — the same shape wireShell()'s listen() has, and for the
428
+ // same reason: a frame is its own document and a listener on the page's never
429
+ // fires there. why: docs/specification.md#the-dropdown-panel
430
+ function ddListen(doc) {
431
+ if (!doc || _ddWiredDocs.has(doc)) return;
432
+ _ddWiredDocs.add(doc);
433
+ doc.addEventListener('click', () => closeAllDropdowns());
434
+ doc.addEventListener('keydown', (e) => {
435
+ if (e.key !== 'Escape' || ddComposing(e)) return;
436
+ const open = ddLive().find((dd) => dd.classList.contains('open') && dd.ownerDocument === doc);
437
+ if (open) { closeDropdown(open); open.querySelector('[data-dropdown-trigger]')?.focus(); }
438
+ });
439
+ // Viewport coordinates go stale the moment anything scrolls. Capture, so a
440
+ // scroll inside the rail the panel was lifted out of counts too.
441
+ const reposition = () => {
442
+ for (const dd of ddLive()) {
443
+ if (dd.classList.contains('open') && dd.__ddPanel) positionPortalPanel(dd, dd.__ddPanel);
444
+ }
445
+ };
446
+ const view = doc.defaultView;
447
+ if (!view) return;
448
+ view.addEventListener('scroll', reposition, true);
449
+ view.addEventListener('resize', reposition);
450
+ }
451
+
393
452
  export function wireDropdown(root = document) {
394
453
  const scope = root === document ? document : root;
395
454
  scope.querySelectorAll('[data-dropdown]').forEach((dd) => {
396
455
  if (dd.__ddWired) return;
397
456
  dd.__ddWired = true;
457
+ _ddAll.add(dd);
458
+ ddListen(dd.ownerDocument);
398
459
  const trigger = dd.querySelector('[data-dropdown-trigger]');
399
460
  const panel = dd.querySelector('[data-dropdown-panel]');
400
461
  if (!trigger) return;
@@ -404,11 +465,15 @@ export function wireDropdown(root = document) {
404
465
  // a stacking context whatever z-index the panel carries — the app rail is
405
466
  // both at once. why: docs/specification.md#the-dropdown-panel
406
467
  if (panel && dd.hasAttribute('data-dropdown-portal')) {
407
- sweepOrphanPanels();
468
+ // The tree the trigger is in, not the page's: a panel lifted out of a
469
+ // frame or a shadow root into the top document leaves its stylesheet and
470
+ // its close handler behind. why: docs/specification.md#the-dropdown-panel
471
+ const host = ddHostOf(dd);
472
+ sweepOrphanPanels(host);
408
473
  panel.setAttribute('data-dropdown-portal', '');
409
474
  panel.__ddOwner = dd;
410
475
  dd.__ddPanel = panel;
411
- document.body.appendChild(panel);
476
+ host.appendChild(panel);
412
477
  if (dd.classList.contains('open')) {
413
478
  ddResolveDirection(dd, panel);
414
479
  positionPortalPanel(dd, panel);
@@ -508,22 +573,4 @@ export function wireDropdown(root = document) {
508
573
  }
509
574
  });
510
575
 
511
- if (!_ddGlobalWired) {
512
- _ddGlobalWired = true;
513
- document.addEventListener('click', () => closeAllDropdowns());
514
- document.addEventListener('keydown', (e) => {
515
- if (e.key !== 'Escape' || ddComposing(e)) return;
516
- const open = document.querySelector('[data-dropdown].open');
517
- if (open) { closeDropdown(open); open.querySelector('[data-dropdown-trigger]')?.focus(); }
518
- });
519
- // Viewport coordinates go stale the moment anything scrolls. Capture, so a
520
- // scroll inside the rail the panel was lifted out of counts too.
521
- const reposition = () => {
522
- document.querySelectorAll('[data-dropdown].open').forEach((dd) => {
523
- if (dd.__ddPanel) positionPortalPanel(dd, dd.__ddPanel);
524
- });
525
- };
526
- window.addEventListener('scroll', reposition, true);
527
- window.addEventListener('resize', reposition);
528
- }
529
576
  }
@@ -32,9 +32,14 @@ function linkList(links = []) {
32
32
  `<li><a href="${href}"${target ? ` target="${target}" rel="noreferrer"` : ''}>${esc(label)}</a></li>`).join('')}</ul>`;
33
33
  }
34
34
 
35
+ // The column title is an h2: it names a top-level section of the page's end
36
+ // matter, and h2 is the one rank that cannot skip whatever heading came before
37
+ // it. It was an h4 — the only h4 the kit drew — which read h2 → h4 on the
38
+ // landing page and left a rank a reader hears missing. The look is the class's,
39
+ // not the tag's. why: docs/specification.md#the-page
35
40
  function column({ title, links = [] } = {}) {
36
41
  return `<div class="ui-footer__col">` +
37
- (title ? `<h4 class="ui-footer__col-title">${esc(title)}</h4>` : '') +
42
+ (title ? `<h2 class="ui-footer__col-title">${esc(title)}</h2>` : '') +
38
43
  linkList(links) + `</div>`;
39
44
  }
40
45
 
@@ -68,12 +68,13 @@ function sideLeaf(it, active, { collapsed, sub, current = 'page' } = {}) {
68
68
  }
69
69
 
70
70
  // A collapsible group: a toggle button (aria-expanded/-controls) over a nested
71
- // list. In collapsed (icon-only) mode groups don't expand, so we render the
72
- // group head as a plain, non-collapsing icon row.
71
+ // list. It opens and closes the same way on the folded rail: forcing it shut
72
+ // there hid the current page's own row and left the toggle announcing a list
73
+ // nobody could reach.
73
74
  function sideGroup(it, active, { collapsed, current } = {}) {
74
75
  const listId = nextId('nav-grp');
75
76
  const childActive = (it.items || []).some((c) => c.id != null && c.id === active);
76
- const open = collapsed ? false : (it.open != null ? !!it.open : childActive);
77
+ const open = it.open != null ? !!it.open : childActive;
77
78
  const lead = it.icon ? `<span class="ui-nav__ic">${icon(it.icon)}</span>` : '';
78
79
  const label = it.label || '';
79
80
  const text = `<span class="ui-nav__label">${esc(label)}</span>`;
@@ -1,12 +1,11 @@
1
- // The kit's one page shell. `appShell()` is a full-height rail — brand, the
2
- // kit's own sidebarNav(), the signed-in reader — beside one <main> that opens
3
- // with a breadcrumb trail the caller owns. `accountShell()` is a thin preset
4
- // over it that keeps the topbar, so the published /account API still works.
5
- // Call wireTopbar() once after mounting to wire the account menu + theme toggle.
1
+ // The kit's one page shell: a full-height rail beside one <main>. accountShell()
2
+ // is a thin preset over it that keeps the topbar. wireShell() once after mounting
3
+ // wires the fold, the nav's groups and the reader's menu.
6
4
  // why: docs/specification.md#the-page-shell
7
5
  import { topbar as productTopbar } from './topbar.js';
8
6
  import { esc, icon } from './index.js';
9
- import { sidebarNav, breadcrumbs } from './nav.js';
7
+ import { sidebarNav, breadcrumbs, wireNav } from './nav.js';
8
+ import { dropdown, wireDropdown } from './dropdown.js';
10
9
  import { backLink } from './back.js';
11
10
  import { prism } from '../assets/brand.js';
12
11
  import { ACCOUNT_NAV, toMenuTuple, initials } from './account-nav.js';
@@ -91,10 +90,58 @@ const mainMax = (v) => {
91
90
  return s === 'none' || LENGTH.test(s) ? s : '';
92
91
  };
93
92
 
93
+ // ---- the fold, and where the reader's choice is kept ---------------------
94
+ //
95
+ // A cookie rather than localStorage, because a server can read one and paint the
96
+ // rail at the width the reader left it. why: docs/specification.md#the-page-shell
97
+ export const RAIL_COOKIE = 'apliteni-ui-rail';
98
+ const RAIL_MAX_AGE = 60 * 60 * 24 * 365;
99
+ const RAIL_VALUE = new RegExp(`(?:^|;\\s*)${RAIL_COOKIE}=(collapsed|expanded)(?:;|$)`);
100
+
101
+ // A sandboxed frame throws on document.cookie; it has no stored choice.
102
+ const cookieOf = (doc) => {
103
+ try { return doc ? doc.cookie : ''; } catch (e) { return ''; }
104
+ };
105
+
106
+ /** The reader's stored choice — true, false, or null when there is none. With no
107
+ * argument it reads `document.cookie`; handed a Cookie header, only that. */
108
+ export function railCollapsed(cookies) {
109
+ const src = arguments.length ? cookies : cookieOf(typeof document === 'undefined' ? null : document);
110
+ const m = RAIL_VALUE.exec(String(src ?? ''));
111
+ return m ? m[1] === 'collapsed' : null;
112
+ }
113
+
114
+ // The name says what the press will do, and aria-expanded says what the rail is.
115
+ const railName = (collapsed) => (collapsed ? 'Expand sidebar' : 'Collapse sidebar');
116
+
117
+ // A frame that holds still and a seam that crosses it. Only the two nodes are
118
+ // written here — a seam that travels has to be a child a stylesheet can reach —
119
+ // and they are spliced into icon()'s own wrapper rather than a copy of it.
120
+ // why: docs/specification.md#the-page-shell
121
+ const MARK = '<rect x="3" y="3" width="18" height="18" rx="2"/>'
122
+ + '<path class="ui-app__fold-seam" d="M9 3v18"/>';
123
+ const railMark = () => icon('').replace('></svg>', `>${MARK}</svg>`);
124
+
125
+ // The rail's own skin, outside the <nav>: folding the rail is not a place to go.
126
+ // It stands at the far end of the head band's brand row, and its name is written
127
+ // out rather than put in a tooltip, because the name IS the chip layout.css lands
128
+ // beside the glyph — at both widths. why: docs/specification.md#the-page-shell
129
+ const railToggle = (collapsed) =>
130
+ `<div class="ui-app__fold-row">`
131
+ + `<button type="button" class="ui-nav__item ui-app__fold" data-rail-toggle`
132
+ + ` aria-expanded="${collapsed ? 'false' : 'true'}" aria-label="${railName(collapsed)}">`
133
+ + `<span class="ui-nav__ic">${railMark()}</span>`
134
+ + `<span class="ui-nav__label">${railName(collapsed)}</span></button></div>`;
135
+
94
136
  // The one pass. Each key names the function that settles it; nothing else in
95
137
  // this file re-checks a value that has been through here.
96
138
  const SHAPES = {
97
139
  nav: toItems, crumbs: toCrumbs, back: toBack, account: toReader, maxWidth: mainMax, topbar: toTopbar,
140
+ // Drawn by default; `collapsible: false` is the way out, for a page that will
141
+ // never call wireShell(). why: docs/specification.md#the-page-shell
142
+ collapsible: (v) => v !== false,
143
+ // A boolean is the caller's answer. Anything else leaves it to the reader.
144
+ collapsed: (v) => (typeof v === 'boolean' ? v : null),
98
145
  };
99
146
 
100
147
  // The text options settle by the same argument. `body: null` from a record with no
@@ -109,28 +156,46 @@ function settle(options) {
109
156
  return out;
110
157
  }
111
158
 
112
- // Signing out is a navigation action, so it belongs in the rail nav's footer slot.
113
- // Opt-in: rendering it unasked puts a dead link on a page with no session behind it.
114
- const signOut = (href) =>
115
- `<a class="ui-nav__item is-danger" href="${esc(href)}" aria-label="Sign out">` +
116
- `<span class="ui-nav__ic">${icon('logout')}</span>` +
117
- `<span class="ui-nav__label">Sign out</span></a>`;
118
-
119
- // Who is signed in. A sibling of the <nav>, not its footer: a name and address are not
120
- // navigation, and inside the landmark a screen reader announces the address as an entry.
121
- // Empty when nobody is. The initials carry the name and the spelled-out half is
122
- // aria-hidden, because the narrow rail folds `.ui-app__who` out of view and a name that
123
- // lived only there left nothing in the accessibility tree.
124
- function railUser({ name, email }) {
159
+ // The face of the reader block: the initials, and the two lines beside them that
160
+ // the fold takes away. `named` is the accessible name when nothing else carries
161
+ // one; under the menu trigger it is null, because the button is named by the words
162
+ // inside it. why: docs/specification.md#the-page-shell
163
+ const readerFace = (name, email, named) =>
164
+ `<span class="ui-app__av"${named ? ` role="img" aria-label="Signed in as ${esc(named)}"` : ' aria-hidden="true"'}>`
165
+ + `${esc(initials(name, email))}</span>`
166
+ + `<span class="ui-app__who"${named ? ' aria-hidden="true"' : ''}>`
167
+ + (name ? `<b>${esc(name)}</b>` : '')
168
+ + (email ? `<span>${esc(email)}</span>` : '')
169
+ + `</span>`;
170
+
171
+ // Who is signed in, and the one action on the session. A sibling of the <nav>, not
172
+ // its footer: a name and address are not navigation. Given a sign-out href the block
173
+ // is the trigger of the kit's own dropdown(), with Sign out inside it; without one
174
+ // there is no menu, and with nobody signed in there is no block.
175
+ // `portal: true` and `direction: 'up'` are what the rail asks of a panel at its foot.
176
+ // why: docs/specification.md#the-page-shell
177
+ function railUser({ name, email }, signOutHref) {
125
178
  if (!name && !email) return '';
126
179
  const who = [name, email].filter(Boolean).join(', ');
127
- return `<div class="ui-app__user">` +
128
- `<span class="ui-app__av" role="img" aria-label="Signed in as ${esc(who)}">` +
129
- `${esc(initials(name, email))}</span>` +
130
- `<span class="ui-app__who" aria-hidden="true">` +
131
- (name ? `<b>${esc(name)}</b>` : '') +
132
- (email ? `<span>${esc(email)}</span>` : '') +
133
- `</span></div>`;
180
+ if (!signOutHref) {
181
+ return `<div class="ui-app__user">${readerFace(name, email, who)}</div>`;
182
+ }
183
+ // The head says who the menu belongs to — and on a folded rail it is the only place
184
+ // a sighted reader can read the address. why: docs/specification.md#the-page-shell
185
+ const head = `<div class="ui-dropdown__head">`
186
+ + (name ? `<b>${esc(name)}</b>` : '')
187
+ + (email ? `<span>${esc(email)}</span>` : '')
188
+ + `</div>`;
189
+ return `<div class="ui-app__user">${dropdown({
190
+ variant: 'menu',
191
+ portal: true,
192
+ direction: 'up',
193
+ triggerClass: 'ui-app__user-trigger',
194
+ triggerContent: readerFace(name, email, null),
195
+ panelClass: 'ui-app__user-panel',
196
+ header: head,
197
+ items: [{ label: 'Sign out', icon: 'logout', href: signOutHref, danger: true }],
198
+ })}</div>`;
134
199
  }
135
200
 
136
201
  // Unique-per-render suffix for the brand mark's clip id — the same reason
@@ -155,14 +220,17 @@ export function appShell(options = {}) {
155
220
  signOutHref = '',
156
221
  topbar,
157
222
  maxWidth,
223
+ collapsible,
224
+ collapsed,
158
225
  } = settle(options);
159
226
  const up = back ? backLink(back) : '';
227
+ // No footer slot: the nav is places to go, and the one thing that was in it —
228
+ // sign out — is in the reader's menu at the rail's foot.
160
229
  const rail = sidebarNav({
161
230
  sections: [{ label: navLabel, items: nav }],
162
231
  active,
163
232
  activeIs: up ? 'section' : 'page',
164
233
  ariaLabel: navLabel,
165
- footer: signOutHref ? signOut(signOutHref) : '',
166
234
  });
167
235
  // The topbar already says the product word, so the rail head steps aside when there is
168
236
  // one. The word is the link's only text and the narrow rail folds it out of view, so
@@ -172,11 +240,22 @@ export function appShell(options = {}) {
172
240
  // A <div>, not an <aside>: <aside> is the `complementary` landmark, and this holds the
173
241
  // page's primary navigation and the signed-in reader. The <nav> inside it is already
174
242
  // the landmark that names the menu.
175
- const grid = `<div class="ui-app">
243
+ // A fold needs the toggle that undoes it. `data-rail="auto"` marks a shell whose
244
+ // caller left the choice to the reader; wireShell() applies the stored one there.
245
+ const folded = collapsible && collapsed === true;
246
+ const auto = collapsible && collapsed === null ? ' data-rail="auto"' : '';
247
+ // The head band: the product's mark, and the rail's own control at the far end of
248
+ // the same line, under one rule. Either may be absent — a shell with a topbar says
249
+ // the word up there, and `collapsible: false` draws no toggle — so the band itself
250
+ // goes when both are.
251
+ const head = brand || collapsible
252
+ ? `<div class="ui-app__head">${brand}${collapsible ? railToggle(folded) : ''}</div>`
253
+ : '';
254
+ const grid = `<div class="ui-app${folded ? ' is-collapsed' : ''}"${auto}>
176
255
  <div class="ui-app__rail">
177
- ${brand}
256
+ ${head}
178
257
  ${rail}
179
- ${railUser(account)}
258
+ ${railUser(account, signOutHref)}
180
259
  </div>
181
260
  <main class="ui-app__main"${maxWidth ? ` style="--ui-app-main: ${maxWidth}"` : ''}>
182
261
  ${up || (crumbs.length ? breadcrumbs({ items: crumbs }) : '')}
@@ -188,6 +267,90 @@ export function appShell(options = {}) {
188
267
  return topbar ? `<div class="ui-app-page">${productTopbar(topbar)}${grid}</div>` : grid;
189
268
  }
190
269
 
270
+ // ---- Behaviour -----------------------------------------------------------
271
+ // One click listener per document, so a shell rendered after wiring folds too
272
+ // and a frame is wired in its own document. wireShell(root, { persist: false })
273
+ // keeps every shell under root out of the cookie, shells drawn there later
274
+ // included; only `persist: true` on that root turns it back on.
275
+ const _wiredDocs = new WeakSet();
276
+ const _unpersisted = new WeakSet();
277
+
278
+ // The toggle, addressed from the rail that owns it: the head band of a shell's own
279
+ // rail and nowhere else, so a stray [data-rail-toggle] in the page body folds nothing.
280
+ // The path is written once — the listener, the reflector and wireShell() all have to
281
+ // mean the same control, and the head band put one more step between them.
282
+ // why: docs/specification.md#the-page-shell
283
+ const FOLD_PATH = '.ui-app__head > .ui-app__fold-row > [data-rail-toggle]';
284
+ const RAIL_FOLD = `.ui-app__rail > ${FOLD_PATH}`;
285
+
286
+ // Under an opted-out root? Steps out of a shadow root through its host.
287
+ const optedOut = (node) => {
288
+ for (let n = node; n; n = n.parentNode || n.host) if (_unpersisted.has(n)) return true;
289
+ return false;
290
+ };
291
+
292
+ function setRail(app, collapsed) {
293
+ app.classList.toggle('is-collapsed', collapsed);
294
+ const rail = app.querySelector(':scope > .ui-app__rail');
295
+ if (!rail) return;
296
+ for (const btn of rail.querySelectorAll(`:scope > ${FOLD_PATH}`)) {
297
+ btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
298
+ btn.setAttribute('aria-label', railName(collapsed));
299
+ const label = btn.querySelector('.ui-nav__label');
300
+ if (label) label.textContent = railName(collapsed);
301
+ }
302
+ }
303
+
304
+ function listen(doc) {
305
+ if (_wiredDocs.has(doc)) return;
306
+ _wiredDocs.add(doc);
307
+ doc.addEventListener('click', (e) => {
308
+ // A shell's own toggle only, in the head of its own rail: a stray
309
+ // [data-rail-toggle] in the page body folds nothing. composedPath() rather
310
+ // than target, so a shell inside an open shadow root is found. closest()
311
+ // rather than a chain of parents, which the head band made one link longer
312
+ // and which said nothing about what it was walking through.
313
+ const btn = e.composedPath().find((n) => n.nodeType === 1 && n.matches(RAIL_FOLD));
314
+ const app = btn && btn.closest('.ui-app');
315
+ if (!app) return;
316
+ const next = !app.classList.contains('is-collapsed');
317
+ setRail(app, next);
318
+ if (!optedOut(app)) {
319
+ try {
320
+ doc.cookie = `${RAIL_COOKIE}=${next ? 'collapsed' : 'expanded'}; path=/; `
321
+ + `max-age=${RAIL_MAX_AGE}; SameSite=Lax`;
322
+ } catch (err) { /* a sandboxed frame: the fold works, nothing is kept */ }
323
+ }
324
+ // A document with no window (DOMParser, createHTMLDocument) has no CustomEvent to send.
325
+ const view = doc.defaultView;
326
+ if (view) {
327
+ app.dispatchEvent(new view.CustomEvent('ui-rail', { bubbles: true, composed: true, detail: { collapsed: next } }));
328
+ }
329
+ });
330
+ }
331
+
332
+ export function wireShell(root = document, { persist } = {}) {
333
+ wireNav(root);
334
+ // The reader's menu is a dropdown() like any other, so it is wired like any
335
+ // other. Idempotent, and a shell with no account draws none to find.
336
+ wireDropdown(root);
337
+ const doc = root.nodeType === 9 ? root : root.ownerDocument;
338
+ listen(doc);
339
+ if (persist === false) _unpersisted.add(root);
340
+ else if (persist === true) _unpersisted.delete(root);
341
+ const saved = railCollapsed(cookieOf(doc));
342
+ const apps = [...root.querySelectorAll('.ui-app')];
343
+ if (root.matches && root.matches('.ui-app')) apps.push(root);
344
+ for (const app of apps) {
345
+ if (!app.querySelector(`:scope > ${RAIL_FOLD}`)) continue;
346
+ // The stored choice goes to a shell whose caller left it to the reader — a
347
+ // paint late, where a server drew it without reading the cookie. Any other
348
+ // shell keeps what it was drawn with, and gets its rows' titles if folded.
349
+ const auto = saved != null && !optedOut(app) && app.getAttribute('data-rail') === 'auto';
350
+ setRail(app, auto ? saved : app.classList.contains('is-collapsed'));
351
+ }
352
+ }
353
+
191
354
  // The /account preset: appShell() with the topbar switched on, and the old
192
355
  // `cap` + `crumb` strings folded into the trail the caller now owns.
193
356
  export function accountShell({
@@ -203,6 +366,8 @@ export function accountShell({
203
366
  sub = '',
204
367
  body = '',
205
368
  signOutHref = '#logout',
369
+ collapsible = true,
370
+ collapsed,
206
371
  } = {}) {
207
372
  // The same normaliser appShell() runs, called once here so the rail and the
208
373
  // topbar menu are handed one list rather than two readings of `nav`.
@@ -222,6 +387,8 @@ export function accountShell({
222
387
  body,
223
388
  account,
224
389
  signOutHref,
390
+ collapsible,
391
+ collapsed,
225
392
  topbar: {
226
393
  word,
227
394
  view: 'text',
@@ -64,6 +64,7 @@ function countdownEl({ seconds = 5, label = 'Redirecting' } = {}) {
64
64
  // changing one never moves the other.
65
65
  export function success({
66
66
  layout = 'hero', // 'hero' | 'split' | 'compact'
67
+ level, // heading level of the title; see the note below
67
68
  backdrop = 'aurora', // 'aurora' | 'glow' | 'flat'
68
69
  eyebrow = '',
69
70
  title = 'All done',
@@ -76,6 +77,16 @@ export function success({
76
77
  const cls = ['ui-sx', `ui-sx--${layout}`, `ui-sx--bd-${backdrop}`, confetti && 'ui-sx--confetti', className]
77
78
  .filter(Boolean).join(' ');
78
79
 
80
+ // The title's rank follows the layout, because the layout is the question
81
+ // "how much of the screen does this own": hero and split ARE the page a flow
82
+ // lands on, so their title is its h1; compact sits beside other content and
83
+ // takes h2. It was an h3 either way, which left a page whose whole content is
84
+ // a success() with no h1 at all — the fault Guidelines / The page names.
85
+ // A caller who knows better passes `level`. The look is the class's.
86
+ // why: docs/specification.md#the-page
87
+ const rank = [1, 2, 3, 4, 5, 6].includes(Number(level)) ? Number(level) : (layout === 'compact' ? 2 : 1);
88
+ const h = `h${rank}`;
89
+
79
90
  const bd = backdrop === 'aurora'
80
91
  ? `<div class="ui-sx__aurora" aria-hidden="true">
81
92
  <span class="ui-sx__glow ui-sx__glow--a"></span>
@@ -99,7 +110,7 @@ export function success({
99
110
  <div class="ui-sx__visual">${successCheck()}</div>
100
111
  <div class="ui-sx__content">
101
112
  ${eyebrowEl}
102
- <h3 class="ui-sx__title">${esc(title)}</h3>
113
+ <${h} class="ui-sx__title">${esc(title)}</${h}>
103
114
  ${bodyEl}
104
115
  ${actionsEl}
105
116
  ${countEl}
@@ -44,6 +44,10 @@
44
44
  width: 100%;
45
45
  max-width: var(--panel-md);
46
46
  overflow: hidden; /* clips the timer bar + left marker to the rounded corners */
47
+ /* A toast floats. It carries a status rather than the neutral hairline, so each
48
+ style below re-points --elev-edge at its own tint and leaves the outer line
49
+ where it already drew it. why: docs/specification.md#elevation */
50
+ box-shadow: inset 0 0 0 1px var(--elev-edge, var(--border)), var(--elev-drop);
47
51
  animation: ui-toast-in var(--dur-med) var(--ease-out) both;
48
52
  }
49
53
 
@@ -115,6 +119,7 @@
115
119
  .ui-toast--soft {
116
120
  background: var(--toast-glow);
117
121
  border: 1px solid color-mix(in srgb, var(--toast-accent) 22%, transparent);
122
+ --elev-edge: color-mix(in srgb, var(--toast-accent) 11%, transparent);
118
123
  }
119
124
 
120
125
  /* style: solid — the status at fill strength, inked with the pole opposite it.
@@ -128,6 +133,8 @@
128
133
  --toast-ink: var(--signal-solid-ink);
129
134
  background: var(--toast-solid);
130
135
  color: var(--toast-ink);
136
+ /* No inner line: a solid toast has no outer one either. It keeps the drop. */
137
+ --elev-edge: transparent;
131
138
  }
132
139
  .ui-toast--solid .ui-toast__title { color: var(--toast-ink); }
133
140
  /* The body copy is the quieter of the two, but it stays quiet by dilution the
@@ -147,7 +154,7 @@
147
154
  .ui-toast--solid .ui-toast__timer { background: color-mix(in srgb, var(--toast-ink) 55%, transparent); }
148
155
 
149
156
  /* style: outline — elevated card with a status-tinted border */
150
- .ui-toast--outline { background: var(--bg-elevated); border: 1px solid color-mix(in srgb, var(--toast-accent) 40%, transparent); }
157
+ .ui-toast--outline { background: var(--bg-elevated); border: 1px solid color-mix(in srgb, var(--toast-accent) 40%, transparent); --elev-edge: color-mix(in srgb, var(--toast-accent) 20%, transparent); }
151
158
 
152
159
  /* compact — single line, no body text */
153
160
  .ui-toast--compact { align-items: center; padding-top: 10px; padding-bottom: 10px; }