@apliteni/apliteni-ui 0.9.1 → 0.11.4

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.
@@ -0,0 +1,231 @@
1
+ // Overlay primitives — the page state the kit's modal surfaces share.
2
+ //
3
+ // Drawer and Confirm are the same problem twice: content over a scrim that owns
4
+ // the keyboard until it is answered. Three of the questions they raise are
5
+ // properties of the *page*, not of either component — what is inert right now,
6
+ // which overlay Escape talks to, and where Tab may go — and an overlay that
7
+ // answers them from its own storage gets them wrong as soon as a second one is
8
+ // open. So they are answered here, once, from one stack per document.
9
+ //
10
+ // Internal — not re-exported from src/index.js.
11
+
12
+ // What each overlay paints on, as its stylesheet resolves it today: a drawer at
13
+ // `--z-overlay` (src/styles/drawer.css) and a confirm one above it
14
+ // (src/styles/confirm.css). Absolute values, not ranks, so a sheet that moves and
15
+ // a table that did not is a failed test rather than a keyboard on the wrong layer
16
+ // — stories/overlay-css.test.js holds these two numbers to those two rules.
17
+ export const OVERLAY_LAYER = { drawer: 100, confirm: 101 };
18
+
19
+ const FOCUSABLE = [
20
+ 'a[href]', 'button:not([disabled])', 'input:not([disabled])',
21
+ 'select:not([disabled])', 'textarea:not([disabled])',
22
+ '[tabindex]:not([tabindex="-1"])',
23
+ ].join(',');
24
+
25
+ // Per document, not per module: JSDOM tests and Storybook iframes each get their
26
+ // own page, and one global stack would let one page's overlays decide another's.
27
+ // stack open overlays, bottom → top: { root, panel, dismiss, layer }
28
+ // marked what the current top hid, with the state to give back
29
+ const pages = new WeakMap();
30
+ function pageOf(doc) {
31
+ let page = pages.get(doc);
32
+ if (!page) { page = { stack: [], marked: [], keys: false }; pages.set(doc, page); }
33
+ return page;
34
+ }
35
+
36
+ // Focusable to the *browser*, not merely matching the selector. A control in a
37
+ // closed overlay, an inert subtree or a hidden ancestor is skipped by the real
38
+ // tab order, so a trap that counts it wraps at an element focus never reaches
39
+ // and Tab walks straight out of the modal.
40
+ function reachable(el) {
41
+ for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
42
+ if (n.inert || n.hasAttribute('inert') || n.hasAttribute('hidden')) return false;
43
+ const overlayRoot = n.hasAttribute('data-drawer') || n.hasAttribute('data-confirm');
44
+ if (overlayRoot && !n.classList.contains('is-open')) return false;
45
+ }
46
+ // Browsers can answer the rest properly; JSDOM has no layout and no such method.
47
+ return typeof el.checkVisibility !== 'function'
48
+ || el.checkVisibility({ visibilityProperty: true, contentVisibilityAuto: true });
49
+ }
50
+
51
+ // Focusable controls inside the panel, in DOM order.
52
+ export function focusablesIn(panel) {
53
+ return Array.from(panel.querySelectorAll(FOCUSABLE)).filter(reachable);
54
+ }
55
+
56
+ // Hide everything *outside* `root` from AT + the tab order: walk root→body and
57
+ // mark each ancestor's other children. The scrim and panel live inside the root
58
+ // so they stay interactive; an overlay one layer down is outside it, so it does
59
+ // not — a drawer under an aria-modal alertdialog must not be tabbable.
60
+ function mark(page, doc, root) {
61
+ let node = root;
62
+ while (node && node.parentElement && node !== doc.body) {
63
+ for (const sib of node.parentElement.children) {
64
+ if (sib === node) continue;
65
+ page.marked.push([sib, sib.getAttribute('aria-hidden'), sib.hasAttribute('inert')]);
66
+ sib.setAttribute('aria-hidden', 'true');
67
+ sib.setAttribute('inert', '');
68
+ sib.inert = true;
69
+ }
70
+ node = node.parentElement;
71
+ }
72
+ }
73
+
74
+ function unmark(page) {
75
+ for (let i = page.marked.length - 1; i >= 0; i--) {
76
+ const [el, ariaHidden, hadInert] = page.marked[i];
77
+ if (ariaHidden == null) el.removeAttribute('aria-hidden');
78
+ else el.setAttribute('aria-hidden', ariaHidden);
79
+ if (!hadInert) { el.removeAttribute('inert'); el.inert = false; }
80
+ }
81
+ page.marked = [];
82
+ }
83
+
84
+ // Recompute the page from the stack — never replay a snapshot taken when an
85
+ // overlay opened, because by the time it closes that snapshot describes a page
86
+ // that has moved on. Roots torn down while open drop out here, so the record of
87
+ // what to give back outlives the node that hid it.
88
+ function sync(doc) {
89
+ const page = pageOf(doc);
90
+ for (let i = page.stack.length - 1; i >= 0; i--) {
91
+ if (!page.stack[i].root.isConnected) page.stack.splice(i, 1);
92
+ }
93
+ unmark(page);
94
+ const top = page.stack[page.stack.length - 1];
95
+ if (top) mark(page, doc, top.root);
96
+ }
97
+
98
+ // One keydown owner per document. Escape closes the single topmost overlay and
99
+ // Tab is trapped in that one's panel, so the answer never turns on which
100
+ // component registered a listener first or on where focus happens to be.
101
+ function ownKeys(page, doc) {
102
+ if (page.keys) return;
103
+ page.keys = true;
104
+ doc.addEventListener('keydown', (e) => {
105
+ const top = page.stack[page.stack.length - 1];
106
+ if (!top) return;
107
+ if (e.key === 'Escape') {
108
+ if (top.dismiss) { e.preventDefault(); top.dismiss(); }
109
+ } else if (e.key === 'Tab') {
110
+ trapTab(top.panel, e);
111
+ }
112
+ });
113
+ }
114
+
115
+ // Node.DOCUMENT_POSITION_PRECEDING, without reaching for a global Node that a
116
+ // document-scoped module has no business assuming is there.
117
+ const PRECEDING = 2;
118
+
119
+ // One way onto the stack. `where` picks the slot; everything after it — the
120
+ // duplicate guard, the key owner, the recompute — is the same either way. Every
121
+ // entry carries its layer, so the comparisons in adoptOverlay never meet undefined.
122
+ function place(root, panel, dismiss, layer, where) {
123
+ const doc = root.ownerDocument;
124
+ const page = pageOf(doc);
125
+ if (page.stack.some((e) => e.root === root)) return;
126
+ page.stack.splice(where(page), 0, { root, panel, dismiss, layer });
127
+ ownKeys(page, doc);
128
+ sync(doc);
129
+ }
130
+
131
+ // The layer this root actually paints on. In a browser the live z-index is the
132
+ // truth, so a consumer who moves either overlay in their own stylesheet gets the
133
+ // keyboard on the layer they can see. JSDOM has no cascade to resolve: it hands
134
+ // back the declared text — `calc(var(--z-overlay) + 1)` with the kit's sheets,
135
+ // `auto` with none — and neither is a number, so under test the passed constant
136
+ // stands. `auto` must not read as 0; anything unparseable falls through.
137
+ function paintedLayer(root, layer) {
138
+ const painted = root.ownerDocument.defaultView?.getComputedStyle(root)?.zIndex;
139
+ const live = painted ? Number(painted) : NaN;
140
+ return Number.isFinite(live) ? live : layer;
141
+ }
142
+
143
+ /**
144
+ * Put an overlay on top of the page. `dismiss` is what Escape calls — pass null
145
+ * for one that refuses to be dismissed, and Escape then does nothing rather than
146
+ * falling through to the overlay underneath. This one goes on top whatever layer
147
+ * it paints on, because opening is history the stack can order by: the thing just
148
+ * opened is the thing the reader is looking at.
149
+ */
150
+ export function pushOverlay(root, panel, dismiss, layer) {
151
+ place(root, panel, dismiss, layer, (page) => page.stack.length);
152
+ }
153
+
154
+ /**
155
+ * Take on a root that arrived already open — markup rendered with `open: true`,
156
+ * which nobody called open…() for. Without this its aria-modal is a claim the
157
+ * page contradicts: nothing is inert, Tab walks out, Escape has no owner.
158
+ *
159
+ * It goes in by paint order, because wiring has no history to order by and the
160
+ * overlay the reader is looking at is the one drawn over the rest: a confirm sits
161
+ * a layer above a drawer whichever component's wiring ran first, and whichever
162
+ * root the markup happens to put first. Document position only separates two
163
+ * overlays on the same layer, and there it is the right answer rather than a
164
+ * fallback — at equal stack levels the later root paints on top (CSS 2.2 Appendix
165
+ * E, steps 8 and 9). That comparison earns its keep whenever adoption order and
166
+ * document order disagree: a root inserted above one already on the stack and
167
+ * wired after it, and — in a browser only — a consumer stylesheet that lifts the
168
+ * drawer onto the confirm's layer, where wiring runs drawer-first however the
169
+ * markup is ordered. A browser refines the layer from the live z-index; JSDOM has
170
+ * none to give, so the tests hold OVERLAY_LAYER to the sheets instead. A root that
171
+ * renders closed is left alone: wiring is not an opening, and it waits for the one
172
+ * that is. A specimen never reaches here at all — it carries no hook for the
173
+ * wiring to find.
174
+ */
175
+ export function adoptOverlay(root, panel, dismiss, layer) {
176
+ if (!root.classList.contains('is-open')) return;
177
+ const level = paintedLayer(root, layer);
178
+ place(root, panel, dismiss, level, (page) => {
179
+ const at = page.stack.findIndex((e) => e.layer > level
180
+ || (e.layer === level && (e.root.compareDocumentPosition(root) & PRECEDING)));
181
+ return at === -1 ? page.stack.length : at;
182
+ });
183
+ }
184
+
185
+ /** Take an overlay off the page, wherever in the stack it sits. */
186
+ export function popOverlay(root) {
187
+ const doc = root.ownerDocument;
188
+ const page = pageOf(doc);
189
+ const at = page.stack.findIndex((e) => e.root === root);
190
+ if (at !== -1) page.stack.splice(at, 1);
191
+ sync(doc);
192
+ }
193
+
194
+ /**
195
+ * Recompute from what is still on the page. The wiring calls this, and the
196
+ * wiring runs on every re-render, so an overlay destroyed while open cannot
197
+ * leave the page inert with no node left to hand it back.
198
+ */
199
+ export function syncOverlays(doc = document) {
200
+ sync(doc);
201
+ }
202
+
203
+ /**
204
+ * Hand focus back to `el`. The overlay was open while the page carried on, so
205
+ * the element it came from may be detached by now — and focus() on a detached
206
+ * node is a silent no-op that leaves the reader with no place on the page. Look
207
+ * for whatever inherited its identity in the re-render, then give up to the page.
208
+ */
209
+ export function returnFocus(el, doc) {
210
+ const live = el && !el.isConnected && el.id ? doc?.getElementById(el.id) : el;
211
+ if (live && live.isConnected && typeof live.focus === 'function') { live.focus(); return; }
212
+ doc?.body?.focus?.();
213
+ }
214
+
215
+ // Trap Tab within the panel while open; wrap at both ends, and pull focus back
216
+ // in when it has fallen outside the panel altogether.
217
+ export function trapTab(panel, e) {
218
+ if (e.key !== 'Tab' || !panel) return;
219
+ const items = focusablesIn(panel);
220
+ if (!items.length) { e.preventDefault(); panel.focus(); return; }
221
+ const first = items[0];
222
+ const last = items[items.length - 1];
223
+ const active = (panel.ownerDocument || document).activeElement;
224
+ if (!panel.contains(active)) {
225
+ e.preventDefault(); (e.shiftKey ? last : first).focus();
226
+ } else if (e.shiftKey && (active === first || active === panel)) {
227
+ e.preventDefault(); last.focus();
228
+ } else if (!e.shiftKey && active === last) {
229
+ e.preventDefault(); first.focus();
230
+ }
231
+ }
@@ -1,24 +1,211 @@
1
- // Account page shell topbar + sticky sidebar + page body. Drop it into any
2
- // product to reuse the exact /account layout: pass the product `word`, the
3
- // signed-in `account`, the current nav `active` id, and the page `title`/`body`.
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.
4
5
  // Call wireTopbar() once after mounting to wire the account menu + theme toggle.
5
- import { topbar } from './topbar.js';
6
- import { icon } from './index.js';
7
-
8
- // Default account navigation: [id, icon, label, href?, target?]. Override with
9
- // the `nav` option. href defaults to `#{id}` pass your real route (and an
10
- // optional target) to make each item a working link.
11
- export const ACCOUNT_NAV = [
12
- ['prefs', 'gear', 'Preferences'],
13
- ['access', 'key', 'Access &amp; agents'],
14
- ];
15
-
16
- const sidebar = (nav, active, cap = 'Account') =>
17
- `<nav class="ui-side"><div class="cap">${cap}</div>` +
18
- nav.map(([id, ic, label, href, target]) =>
19
- `<a href="${href || '#' + id}"${target ? ` target="${target}"` : ''}${id === active ? ' class="on"' : ''}>${icon(ic)}${label}</a>`).join('') +
20
- `<div class="ssep"></div><a class="out" href="#logout">${icon('logout')}Sign out</a></nav>`;
6
+ // why: docs/adr/0007-one-page-shell-built-from-the-kits-own-nav.md
7
+ import { topbar as productTopbar } from './topbar.js';
8
+ import { esc, icon } from './index.js';
9
+ import { sidebarNav, breadcrumbs } from './nav.js';
10
+ import { prism } from '../assets/brand.js';
11
+ import { ACCOUNT_NAV, toMenuTuple, initials } from './account-nav.js';
21
12
 
13
+ // The one account navigation definition lives in account-nav.js, because
14
+ // topbar.js needs it too and this file already imports topbar.js. Re-exported
15
+ // here so the published name docs/library.md documents keeps working.
16
+ export { ACCOUNT_NAV };
17
+
18
+ const str = (v) => (v == null ? '' : String(v));
19
+ const isRecord = (v) => typeof v === 'object' && v !== null;
20
+
21
+ // ---- the options bag, settled once --------------------------------------
22
+ //
23
+ // Every option below carries a shape, and a default parameter covers
24
+ // `undefined` and nothing else — so `nav: null` from an /auth/me, a `maxWidth`
25
+ // out of tenant config, a `crumbs` string written by somebody reading the
26
+ // migration note all arrived as they were. A shell that throws mid-render takes
27
+ // the page with it, so each is settled here, before the first sink sees it, and
28
+ // a parameter is protected by being declared rather than by what it crashes
29
+ // into. Adding one to appShell() means adding it to SHAPES or deciding in the
30
+ // open that it needs nothing.
31
+
32
+ // accountShell() has always taken its nav as [id, icon, label, href?, target?].
33
+ // Accept that shape and nav.js's object shape side by side, so a consumer's
34
+ // existing tuples and the exported ACCOUNT_NAV both work. A nav that is not a
35
+ // list at all falls back to the default; an entry inside one that is neither
36
+ // shape is dropped, because sideItem() reads `.items` off whatever it is given.
37
+ // An empty list is an answer, not a mistake — it stays empty.
38
+ const toItems = (nav) => (Array.isArray(nav) ? nav : ACCOUNT_NAV)
39
+ .filter(isRecord)
40
+ .map((n) => (Array.isArray(n)
41
+ ? { id: n[0], icon: n[1], label: n[2], href: n[3], target: n[4] }
42
+ : n));
43
+
44
+ // The trail is the caller's, and `crumbs` is the one option whose shape changed
45
+ // in this release: the old API was `crumb: 'Payouts'`, a string, and the new one
46
+ // is `crumbs: [{ label }]`. One letter apart. A value that is not a list is not
47
+ // read as a one-crumb trail — that would draw a plausible page and hide the
48
+ // migration mistake — so it is no trail at all, which is the visible answer. A
49
+ // crumb with no label would draw an empty cell, so it goes too.
50
+ const toCrumbs = (crumbs) => (Array.isArray(crumbs) ? crumbs : [])
51
+ .filter((c) => isRecord(c) && !Array.isArray(c) && str(c.label) !== '');
52
+
53
+ // The reader, as two strings. railUser() and initials() both read them, and an
54
+ // /auth/me answering `account: null` or a numeric display name reached both.
55
+ const toReader = (a) => (isRecord(a) ? { name: str(a.name), email: str(a.email) } : { name: '', email: '' });
56
+
57
+ // ---- the topbar, which interpolates where the rail escapes ----------------
58
+ //
59
+ // brand() writes `word` straight into its markup and accountMenu() does the
60
+ // same with the reader's name, address and menu entries. So the escaping is
61
+ // here, on the one path into productTopbar(), and not in each caller:
62
+ // accountShell() escaped for itself and the `topbar` option underneath it
63
+ // escaped for nobody. The caller passes text either way.
64
+
65
+ // The reader the menu draws. Both fields are always written, empty when the
66
+ // caller gave none — accountMenu()'s own defaults are a demo identity, and a
67
+ // key dropped here is a key its default fills in, which is how a consumer's
68
+ // page came to name its own reader in the rail and the kit's fixture beside it.
69
+ //
70
+ // `initials` travels with them because it is derived, and a derived value comes
71
+ // from what the caller passed, not from the entities made of it: `<Ada>` and
72
+ // `&lt;Ada&gt;` do not start with the same character. The entries land in the
73
+ // same sink and toMenuTuple() is what escapes them; it reads item objects, so
74
+ // tuples go through toItems() first. A nav nobody passed stays unpassed, and
75
+ // accountMenu() falls back to its own derived default rather than to nothing.
76
+ const toMenuReader = (a) => {
77
+ const { name, email } = toReader(a);
78
+ const rest = isRecord(a) && !Array.isArray(a) ? a : {};
79
+ const out = { ...rest, name: esc(name), email: esc(email), initials: esc(initials(name, email)) };
80
+ if (out.nav != null) out.nav = toItems(out.nav).map(toMenuTuple);
81
+ return out;
82
+ };
83
+
84
+ const toTopbar = (t) => {
85
+ if (!isRecord(t) || Array.isArray(t)) return null;
86
+ const out = { ...t };
87
+ if (out.word != null) out.word = esc(str(out.word));
88
+ if (out.account != null) out.account = toMenuReader(out.account);
89
+ return out;
90
+ };
91
+
92
+ // `maxWidth` lands inside a style attribute, which is a declaration list: esc()
93
+ // stops a quote closing the attribute, and `;` is the character that matters
94
+ // there. So a length is all this accepts — a number and a unit the reading
95
+ // column can use, or `none`. Anything else falls back to the default rather
96
+ // than throwing, because a shell that throws mid-render takes the page with it.
97
+ const MAIN_MAX = '860px';
98
+ const LENGTH = /^(?:\d+|\d*\.\d+)(?:px|rem|em|ch|%|vw)$/;
99
+ const mainMax = (v) => {
100
+ const s = str(v).trim();
101
+ return s === 'none' || LENGTH.test(s) ? s : MAIN_MAX;
102
+ };
103
+
104
+ // The one pass. Each key names the function that settles it; nothing else in
105
+ // this file re-checks a value that has been through here.
106
+ const SHAPES = {
107
+ nav: toItems, crumbs: toCrumbs, account: toReader, maxWidth: mainMax, topbar: toTopbar,
108
+ };
109
+
110
+ // The text options settle too, by the same argument the rest of them do. A
111
+ // default parameter covers `undefined` and not `null`, so `body: null` from a
112
+ // record with no description drew the word "null" on the page, and `word: null`
113
+ // left the brand link with no accessible name at all. Dropping the key is what
114
+ // lets the declared default apply.
115
+ const TEXT = ['word', 'brandHref', 'navLabel', 'title', 'sub', 'body', 'signOutHref', 'active'];
116
+
117
+ function settle(options) {
118
+ const out = { ...options };
119
+ for (const key of Object.keys(SHAPES)) out[key] = SHAPES[key](out[key]);
120
+ for (const key of TEXT) if (out[key] == null) delete out[key];
121
+ return out;
122
+ }
123
+
124
+ // Signing out is a navigation action, so it belongs in the rail nav's footer
125
+ // slot. Opt-in: a shell that renders it unasked puts a dead link on a page with
126
+ // no session behind it.
127
+ const signOut = (href) =>
128
+ `<a class="ui-nav__item is-danger" href="${esc(href)}" aria-label="Sign out">` +
129
+ `<span class="ui-nav__ic">${icon('logout')}</span>` +
130
+ `<span class="ui-nav__label">Sign out</span></a>`;
131
+
132
+ // Who is signed in. A sibling of the <nav>, not its footer: a reader's name and
133
+ // address are not navigation, and inside the landmark a screen reader announces
134
+ // the address as an entry. Empty when nobody is — a shell must not invent an
135
+ // identity for a reader it does not know.
136
+ //
137
+ // The initials carry the name, and the spelled-out half is aria-hidden. The
138
+ // narrow rail folds `.ui-app__who` out of view, so a name that lived only there
139
+ // left the initials on screen with nothing at all in the accessibility tree.
140
+ // Naming the mark instead makes the two agree at every width, and says it once.
141
+ function railUser({ name, email }) {
142
+ if (!name && !email) return '';
143
+ const who = [name, email].filter(Boolean).join(', ');
144
+ return `<div class="ui-app__user">` +
145
+ `<span class="ui-app__av" role="img" aria-label="Signed in as ${esc(who)}">` +
146
+ `${esc(initials(name, email))}</span>` +
147
+ `<span class="ui-app__who" aria-hidden="true">` +
148
+ (name ? `<b>${esc(name)}</b>` : '') +
149
+ (email ? `<span>${esc(email)}</span>` : '') +
150
+ `</span></div>`;
151
+ }
152
+
153
+ // Unique-per-render suffix for the brand mark's clip id — the same reason
154
+ // nav.js keeps a module counter. Two shells on one page must not collide.
155
+ let _shellUid = 0;
156
+
157
+ export function appShell(options = {}) {
158
+ // Everything in SHAPES arrives settled; the rest is text, and a text default
159
+ // is what a default parameter is for.
160
+ const {
161
+ word = 'apliteni-ui',
162
+ brandHref = '#',
163
+ nav,
164
+ active,
165
+ navLabel = 'Account',
166
+ crumbs,
167
+ title = '',
168
+ sub = '',
169
+ body = '',
170
+ account,
171
+ signOutHref = '',
172
+ topbar,
173
+ maxWidth,
174
+ } = settle(options);
175
+ const rail = sidebarNav({
176
+ sections: [{ label: navLabel, items: nav }],
177
+ active,
178
+ ariaLabel: navLabel,
179
+ footer: signOutHref ? signOut(signOutHref) : '',
180
+ });
181
+ // The topbar already says the product word. Two lockups on one screen is one
182
+ // product word too many, so the rail head steps aside when there is a topbar.
183
+ // The word is the link's only text and the narrow rail folds it out of view,
184
+ // so the name is written out — the mark itself is aria-hidden.
185
+ const brand = topbar ? '' : `<a class="ui-app__brand" href="${esc(brandHref)}" aria-label="${esc(word)}">`
186
+ + `${prism(`appb-${++_shellUid}`, 24)}<span>${esc(word)}</span></a>`;
187
+ // A <div>, not an <aside>: <aside> is the `complementary` landmark — content
188
+ // related to the page but separable from it — and this holds the page's
189
+ // primary navigation and the reader who is signed in. The <nav> inside it is
190
+ // already the landmark that names the menu.
191
+ const grid = `<div class="ui-app">
192
+ <div class="ui-app__rail">
193
+ ${brand}
194
+ ${rail}
195
+ ${railUser(account)}
196
+ </div>
197
+ <main class="ui-app__main" style="--ui-app-main: ${maxWidth}">
198
+ ${crumbs.length ? breadcrumbs({ items: crumbs }) : ''}
199
+ ${title ? `<h1>${title}</h1>` : ''}
200
+ ${sub ? `<p class="ui-app__sub">${sub}</p>` : ''}
201
+ <div class="ui-app__body">${body}</div>
202
+ </main>
203
+ </div>`;
204
+ return topbar ? `<div class="ui-app-page">${productTopbar(topbar)}${grid}</div>` : grid;
205
+ }
206
+
207
+ // The /account preset: appShell() with the topbar switched on, and the old
208
+ // `cap` + `crumb` strings folded into the trail the caller now owns.
22
209
  export function accountShell({
23
210
  word = 'Account',
24
211
  versions,
@@ -31,18 +218,34 @@ export function accountShell({
31
218
  title = '',
32
219
  sub = '',
33
220
  body = '',
221
+ signOutHref = '#logout',
34
222
  } = {}) {
35
- return `<div style="position:relative;overflow:hidden;min-height:100vh">
36
- <span class="ui-glow ui-glow--purple" style="top:-120px;right:6%;opacity:.35"></span>
37
- ${topbar({ word, view: 'text', showSwitch, versions, account: { ...account, active, nav } })}
38
- <div class="ui-shell">
39
- ${sidebar(nav, active, cap)}
40
- <div class="ui-shell__page">
41
- <div class="ui-shell__crumbs">${cap} / <b>${crumb || title}</b></div>
42
- <h1>${title}</h1>
43
- <div class="sub">${sub}</div>
44
- <div class="ui-card-stack">${body}</div>
45
- </div>
46
- </div>
47
- </div>`;
223
+ // The same normaliser appShell() runs, called once here so the rail and the
224
+ // topbar menu are handed one list rather than two readings of `nav`.
225
+ const items = toItems(nav);
226
+ const trail = [{ label: cap }, { label: crumb || title }];
227
+ // The preset hands the topbar the caller's text, exactly as it hands the rail
228
+ // the caller's text. toTopbar() is what escapes for the menu's raw sinks, and
229
+ // it runs once inside appShell() — escaping here as well would reach the menu
230
+ // as entities, and escaping nowhere is what the public `topbar` option used
231
+ // to do.
232
+ return appShell({
233
+ word,
234
+ nav: items,
235
+ active,
236
+ navLabel: cap,
237
+ crumbs: trail,
238
+ title,
239
+ sub,
240
+ body,
241
+ account,
242
+ signOutHref,
243
+ topbar: {
244
+ word,
245
+ view: 'text',
246
+ showSwitch,
247
+ versions,
248
+ account: { ...(isRecord(account) ? account : {}), active, nav: items },
249
+ },
250
+ });
48
251
  }
@@ -4,6 +4,7 @@ import { brand } from '../assets/brand.js';
4
4
  import { icon, sun, moon } from '../assets/icons.js';
5
5
  import { esc } from './index.js';
6
6
  import { wireDropdown } from './dropdown.js';
7
+ import { accountMenuNav, initials, toMenuTuple } from './account-nav.js';
7
8
 
8
9
  const THEME_KEY = 'apliteni-strategy-theme';
9
10
 
@@ -52,10 +53,35 @@ export function versionSwitcher(versions = [], activeIdx = 0) {
52
53
  }
53
54
 
54
55
  // `nav` ([id, icon, label, href?, target?][]) mirrors the account sidebar so the
55
- // dropdown and the sidebar stay in sync; falls back to the default two items.
56
- export function accountMenu({ name = 'Ada Lovelace', email = 'ada@apliteni.com', active = 'prefs', nav } = {}) {
57
- const ini = (email.split('@')[0].split(/[._-]+/).filter(Boolean).map((w) => w[0]).slice(0, 2).join('') || '?').toUpperCase();
58
- const items = nav && nav.length ? nav : [['prefs', 'gear', 'Preferences'], ['access', 'key', 'Access &amp; agents']];
56
+ // dropdown and the sidebar stay in sync. The fallback is derived from the one
57
+ // ACCOUNT_NAV definition rather than restated here: a second literal agreed
58
+ // with it by hand about the icon and disagreed about the encoding, which is the
59
+ // drift #127 was filed about. Every field below is interpolated raw, so what
60
+ // arrives has to arrive escaped — accountMenuNav() is what does that.
61
+ //
62
+ // `initials` is the avatar, for a caller that escapes on the way in. A mark is
63
+ // derived from the reader's name, and a derived value has to be derived before
64
+ // the escaping: `<Ada>` and `&lt;Ada&gt;` do not begin with the same character,
65
+ // so shell.js — which escapes both fields for this sink — computes the mark
66
+ // from the caller's own strings and passes it down beside them. Left out, it is
67
+ // computed here from `name` and `email`, exactly where it always came from.
68
+ export function accountMenu({
69
+ name = 'Ada Lovelace', email = 'ada@apliteni.com', active = 'prefs', nav, initials: mark,
70
+ } = {}) {
71
+ // initials() is shared with the rail's avatar — the two are the same reader
72
+ // on the /account preset, and they used to disagree about who that was.
73
+ const ini = mark == null ? initials(name, email) : mark;
74
+ // ACCOUNT_NAV is published, and it is a list of item objects — so the shape a
75
+ // consumer most naturally hands this option is the one that used to throw here.
76
+ // Read either; toMenuTuple() escapes an object on the way, which a tuple that
77
+ // arrives already escaped does not need.
78
+ //
79
+ // A list that is empty is an answer and stays empty, which is what the rail
80
+ // does with the same value. Falling back on `.length` meant a caller who asked
81
+ // for no entries got none in the rail and the kit's two in the menu — one nav,
82
+ // two answers, which is the drift #127 exists to close.
83
+ const items = (Array.isArray(nav) ? nav : accountMenuNav())
84
+ .map((n) => (Array.isArray(n) ? n : toMenuTuple(n)));
59
85
  const it = ([id, ic, label, href, target]) =>
60
86
  `<a href="${href || '#' + id}"${target ? ` target="${target}"` : ''} data-dd-item tabindex="-1"${active === id ? ' class="cur"' : ''} role="menuitem">${icon(ic)}${label}</a>`;
61
87
  // `on` so the menu is visible in Storybook / standalone use (no /auth/me gate).
package/src/index.css CHANGED
@@ -17,6 +17,7 @@
17
17
  @import "./styles/dropdown.css";
18
18
  @import "./styles/nav.css";
19
19
  @import "./styles/drawer.css";
20
+ @import "./styles/confirm.css";
20
21
  @import "./styles/table.css";
21
22
  @import "./styles/empty.css";
22
23
  @import "./styles/callout.css";
package/src/index.js CHANGED
@@ -5,6 +5,7 @@ export * from './components/dropdown.js';
5
5
  export * from './components/tabs.js';
6
6
  export * from './components/nav.js';
7
7
  export * from './components/drawer.js';
8
+ export * from './components/confirm.js';
8
9
  export * from './components/topbar.js';
9
10
  export * from './components/shell.js';
10
11
  export * from './components/footer.js';
package/src/inline.js CHANGED
@@ -38,6 +38,7 @@ export const styles = {
38
38
  dropdown: read('styles/dropdown.css'),
39
39
  nav: read('styles/nav.css'),
40
40
  drawer: read('styles/drawer.css'),
41
+ confirm: read('styles/confirm.css'),
41
42
  table: read('styles/table.css'),
42
43
  empty: read('styles/empty.css'),
43
44
  callout: read('styles/callout.css'),
@@ -63,6 +64,7 @@ export const cssText = [
63
64
  styles.dropdown,
64
65
  styles.nav,
65
66
  styles.drawer,
67
+ styles.confirm,
66
68
  styles.table,
67
69
  styles.empty,
68
70
  styles.callout,