@apliteni/apliteni-ui 0.33.1 → 0.34.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.
@@ -1,6 +1,6 @@
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.
1
+ // The kit's one page shell: a full-height rail beside one <main>, in two layouts.
2
+ // accountShell() is a thin preset over it that keeps the topbar. wireShell() once
3
+ // after mounting wires the fold, the nav's groups and the reader's menu.
4
4
  // why: docs/specification.md#the-page-shell
5
5
  import { topbar as productTopbar } from './topbar.js';
6
6
  import { esc, icon } from './index.js';
@@ -9,6 +9,7 @@ import { dropdown, wireDropdown } from './dropdown.js';
9
9
  import { backLink } from './back.js';
10
10
  import { prism } from '../assets/brand.js';
11
11
  import { ACCOUNT_NAV, toMenuTuple, initials } from './account-nav.js';
12
+ import { paletteHotkey } from './command-palette.js';
12
13
 
13
14
  // The one account navigation definition lives in account-nav.js because topbar.js needs
14
15
  // it too; re-exported here so the name docs/library.md publishes keeps working.
@@ -78,6 +79,26 @@ const toTopbar = (t) => {
78
79
  return out;
79
80
  };
80
81
 
82
+ // ---- the two layouts, and the two widths ---------------------------------
83
+ //
84
+ // Each is a name the kit knows or the default — read strictly, and not through
85
+ // String(), which turned `['topbar']` into a layout and drew half a page.
86
+ // why: docs/specification.md#the-page-shell
87
+ const toLayout = (v) => (v === 'topbar' ? 'topbar' : 'rail');
88
+
89
+ // why: docs/specification.md#widths
90
+ const toWidth = (v) => (v === 'wide' ? 'wide' : 'centered');
91
+
92
+ // `search: 'palette-id'`, or the same id in `{ palette }` with a placeholder beside
93
+ // it. No palette to open, no field — the argument `signOutHref` takes.
94
+ // why: docs/specification.md#the-page-shell
95
+ const toSearch = (v) => {
96
+ const given = typeof v === 'string' ? { palette: v } : (isRecord(v) && !Array.isArray(v) ? v : null);
97
+ const palette = given && typeof given.palette === 'string' ? given.palette.trim() : '';
98
+ if (!palette) return null;
99
+ return { palette, placeholder: str(given.placeholder) || 'Search or run a command…' };
100
+ };
101
+
81
102
  // `maxWidth` lands inside a style attribute, so a length is all this accepts — a number
82
103
  // and a unit, or `none`. Anything else yields '' and the caller writes no style
83
104
  // attribute, falling through to --measure. It must REMOVE the property rather than pass
@@ -137,6 +158,7 @@ const railToggle = (collapsed) =>
137
158
  // this file re-checks a value that has been through here.
138
159
  const SHAPES = {
139
160
  nav: toItems, crumbs: toCrumbs, back: toBack, account: toReader, maxWidth: mainMax, topbar: toTopbar,
161
+ layout: toLayout, width: toWidth, search: toSearch,
140
162
  // Drawn by default; `collapsible: false` is the way out, for a page that will
141
163
  // never call wireShell(). why: docs/specification.md#the-page-shell
142
164
  collapsible: (v) => v !== false,
@@ -160,44 +182,71 @@ function settle(options) {
160
182
  // the fold takes away. `named` is the accessible name when nothing else carries
161
183
  // one; under the menu trigger it is null, because the button is named by the words
162
184
  // inside it. why: docs/specification.md#the-page-shell
163
- const readerFace = (name, email, named) =>
185
+ const readerFace = (name, email, named, markOnly = false) =>
164
186
  `<span class="ui-app__av"${named ? ` role="img" aria-label="Signed in as ${esc(named)}"` : ' aria-hidden="true"'}>`
165
187
  + `${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>`;
188
+ + (markOnly ? '' : `<span class="ui-app__who"${named ? ' aria-hidden="true"' : ''}>`
189
+ + (name ? `<b>${esc(name)}</b>` : '')
190
+ + (email ? `<span>${esc(email)}</span>` : '')
191
+ + `</span>`);
170
192
 
171
193
  // Who is signed in, and the one action on the session. A sibling of the <nav>, not
172
194
  // its footer: a name and address are not navigation. Given a sign-out href the block
173
195
  // is the trigger of the kit's own dropdown(), with Sign out inside it; without one
174
196
  // 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) {
197
+ //
198
+ // One block, two places: only the direction it opens is the layout's answer.
199
+ // `portal: true` either way — the rail and the band are both sticky.
200
+ function readerBlock({ name, email }, signOutHref, { band = false } = {}) {
178
201
  if (!name && !email) return '';
202
+ const cls = `ui-app__user${band ? ' ui-app__user--bar' : ''}`;
179
203
  const who = [name, email].filter(Boolean).join(', ');
204
+ // The name is said once, by whichever part is on screen: the two lines in the rail,
205
+ // an aria-label on the band where the mark is the whole trigger.
206
+ const face = band ? readerFace(name, email, null, true) : readerFace(name, email, null);
180
207
  if (!signOutHref) {
181
- return `<div class="ui-app__user">${readerFace(name, email, who)}</div>`;
208
+ return `<div class="${cls}">${readerFace(name, email, who, band)}</div>`;
182
209
  }
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
210
+ // The head says who the menu belongs to — and on a folded rail, or on a band, it is
211
+ // the only place a sighted reader can read the address.
185
212
  const head = `<div class="ui-dropdown__head">`
186
213
  + (name ? `<b>${esc(name)}</b>` : '')
187
214
  + (email ? `<span>${esc(email)}</span>` : '')
188
215
  + `</div>`;
189
- return `<div class="ui-app__user">${dropdown({
216
+ return `<div class="${cls}">${dropdown({
190
217
  variant: 'menu',
191
218
  portal: true,
192
- direction: 'up',
219
+ direction: band ? 'down' : 'up',
220
+ align: band ? 'end' : 'start',
221
+ chevron: !band,
222
+ ariaLabel: band ? `Signed in as ${who}` : undefined,
193
223
  triggerClass: 'ui-app__user-trigger',
194
- triggerContent: readerFace(name, email, null),
224
+ triggerContent: face,
195
225
  panelClass: 'ui-app__user-panel',
196
226
  header: head,
197
227
  items: [{ label: 'Sign out', icon: 'logout', href: signOutHref, danger: true }],
198
228
  })}</div>`;
199
229
  }
200
230
 
231
+ // A field to look at, a button to press. `data-cmdk-open` and the cap's class are the
232
+ // palette's own, and the cap is NOT aria-hidden: it is half the button's name.
233
+ // why: docs/specification.md#the-page-shell
234
+ const searchField = ({ palette, placeholder }) =>
235
+ `<button type="button" class="ui-app__search" data-cmdk-open="${esc(palette)}" aria-haspopup="dialog">`
236
+ + `<span class="ui-app__search-ic" aria-hidden="true">${icon('search')}</span>`
237
+ + `<span class="ui-app__search-txt">${esc(placeholder)}</span>`
238
+ + `<kbd class="ui-cmdk__key" data-palette-hotkey>${esc(paletteHotkey())}</kbd>`
239
+ + `</button>`;
240
+
241
+ // The band: search at its start, the reader at its end, beside the rail rather than
242
+ // across the top of both. why: docs/specification.md#the-page-shell
243
+ const shellBar = (search, account, signOutHref) =>
244
+ `<header class="ui-app__bar">`
245
+ + (search ? searchField(search) : '')
246
+ + `<span class="ui-app__bar-gap"></span>`
247
+ + readerBlock(account, signOutHref, { band: true })
248
+ + `</header>`;
249
+
201
250
  // Unique-per-render suffix for the brand mark's clip id — the same reason
202
251
  // nav.js keeps a module counter. Two shells on one page must not collide.
203
252
  let _shellUid = 0;
@@ -222,7 +271,12 @@ export function appShell(options = {}) {
222
271
  maxWidth,
223
272
  collapsible,
224
273
  collapsed,
274
+ layout,
275
+ width,
276
+ search,
225
277
  } = settle(options);
278
+ // The layout's one branch, read once. Everything below asks this and not the string.
279
+ const banded = layout === 'topbar';
226
280
  const up = back ? backLink(back) : '';
227
281
  // No footer slot: the nav is places to go, and the one thing that was in it —
228
282
  // sign out — is in the reader's menu at the rail's foot.
@@ -232,10 +286,15 @@ export function appShell(options = {}) {
232
286
  activeIs: up ? 'section' : 'page',
233
287
  ariaLabel: navLabel,
234
288
  });
289
+ // One band over a page, never two: the banded layout draws its own, so the
290
+ // compatibility bag is not drawn beside it. why: docs/specification.md#the-page-shell
291
+ const compat = banded ? null : topbar;
235
292
  // The topbar already says the product word, so the rail head steps aside when there is
236
293
  // one. The word is the link's only text and the narrow rail folds it out of view, so
237
- // the name is written out — the mark itself is aria-hidden.
238
- const brand = topbar ? '' : `<a class="ui-app__brand" href="${esc(brandHref)}" aria-label="${esc(word)}">`
294
+ // the name is written out — the mark itself is aria-hidden. The banded layout's own
295
+ // bar says no word, so there the rail keeps the lockup: that layout's head band IS
296
+ // the product's mark. why: docs/specification.md#the-page-shell
297
+ const brand = compat ? '' : `<a class="ui-app__brand" href="${esc(brandHref)}" aria-label="${esc(word)}">`
239
298
  + `${prism(`appb-${++_shellUid}`, 24)}<span>${esc(word)}</span></a>`;
240
299
  // A <div>, not an <aside>: <aside> is the `complementary` landmark, and this holds the
241
300
  // page's primary navigation and the signed-in reader. The <nav> inside it is already
@@ -244,27 +303,40 @@ export function appShell(options = {}) {
244
303
  // caller left the choice to the reader; wireShell() applies the stored one there.
245
304
  const folded = collapsible && collapsed === true;
246
305
  const auto = collapsible && collapsed === null ? ' data-rail="auto"' : '';
306
+ // Where this stands is the one thing the two layouts disagree about on the rail.
307
+ const fold = collapsible ? railToggle(folded) : '';
247
308
  // The head band: the product's mark, and the rail's own control at the far end of
248
309
  // the same line, under one rule. Either may be absent — a shell with a topbar says
249
310
  // the word up there, and `collapsible: false` draws no toggle — so the band itself
250
311
  // goes when both are.
251
- const head = brand || collapsible
252
- ? `<div class="ui-app__head">${brand}${collapsible ? railToggle(folded) : ''}</div>`
312
+ const inHead = banded ? '' : fold;
313
+ const head = brand || inHead
314
+ ? `<div class="ui-app__head">${brand}${inHead}</div>`
253
315
  : '';
254
- const grid = `<div class="ui-app${folded ? ' is-collapsed' : ''}"${auto}>
255
- <div class="ui-app__rail">
256
- ${head}
257
- ${rail}
258
- ${railUser(account, signOutHref)}
259
- </div>
260
- <main class="ui-app__main"${maxWidth ? ` style="--ui-app-main: ${maxWidth}"` : ''}>
316
+ // The rail's foot: the reader, or the control that took their place. One block
317
+ // either way. why: docs/specification.md#the-page-shell
318
+ const foot = banded
319
+ ? (fold ? `<div class="ui-app__foot">${fold}</div>` : '')
320
+ : readerBlock(account, signOutHref);
321
+ const main = `<main class="ui-app__main${width === 'wide' ? ' ui-app__main--wide' : ''}"${maxWidth ? ` style="--ui-app-main: ${maxWidth}"` : ''}>
261
322
  ${up || (crumbs.length ? breadcrumbs({ items: crumbs }) : '')}
262
323
  ${title ? `<h1>${title}</h1>` : ''}
263
324
  ${sub ? `<p class="ui-app__sub">${sub}</p>` : ''}
264
325
  <div class="ui-app__body">${body}</div>
265
- </main>
326
+ </main>`;
327
+ // The second column is a box of its own: the band and the page are two rows of it.
328
+ const well = banded
329
+ ? `<div class="ui-app__well">${shellBar(search, account, signOutHref)}${main}</div>`
330
+ : main;
331
+ const grid = `<div class="ui-app${banded ? ' ui-app--topbar' : ''}${folded ? ' is-collapsed' : ''}"${auto}>
332
+ <div class="ui-app__rail">
333
+ ${head}
334
+ ${rail}
335
+ ${foot}
336
+ </div>
337
+ ${well}
266
338
  </div>`;
267
- return topbar ? `<div class="ui-app-page">${productTopbar(topbar)}${grid}</div>` : grid;
339
+ return compat ? `<div class="ui-app-page">${productTopbar(compat)}${grid}</div>` : grid;
268
340
  }
269
341
 
270
342
  // ---- Behaviour -----------------------------------------------------------
@@ -275,13 +347,14 @@ export function appShell(options = {}) {
275
347
  const _wiredDocs = new WeakSet();
276
348
  const _unpersisted = new WeakSet();
277
349
 
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.
350
+ // The toggle, addressed from the rail that owns it: a fold row of a shell's own rail
351
+ // and nowhere else, so a stray [data-rail-toggle] in the page body folds nothing. The
352
+ // path is written once — the listener, the reflector and wireShell() all have to mean
353
+ // the same control. A descendant and not a child, because the two layouts stand the
354
+ // row in two places: the head band, or the rail's foot.
282
355
  // 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}`;
356
+ const FOLD_PATH = '.ui-app__fold-row > [data-rail-toggle]';
357
+ const RAIL_FOLD = `.ui-app__rail ${FOLD_PATH}`;
285
358
 
286
359
  // Under an opted-out root? Steps out of a shadow root through its host.
287
360
  const optedOut = (node) => {
@@ -293,7 +366,7 @@ function setRail(app, collapsed) {
293
366
  app.classList.toggle('is-collapsed', collapsed);
294
367
  const rail = app.querySelector(':scope > .ui-app__rail');
295
368
  if (!rail) return;
296
- for (const btn of rail.querySelectorAll(`:scope > ${FOLD_PATH}`)) {
369
+ for (const btn of rail.querySelectorAll(`:scope ${FOLD_PATH}`)) {
297
370
  btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
298
371
  btn.setAttribute('aria-label', railName(collapsed));
299
372
  const label = btn.querySelector('.ui-nav__label');
@@ -334,6 +407,12 @@ export function wireShell(root = document, { persist } = {}) {
334
407
  // The reader's menu is a dropdown() like any other, so it is wired like any
335
408
  // other. Idempotent, and a shell with no account draws none to find.
336
409
  wireDropdown(root);
410
+ // A server cannot know which key the reader holds, so the browser corrects it here —
411
+ // off the ROOT's window, not the global one. why: docs/specification.md#the-page-shell
412
+ const here = root.nodeType === 9 ? root : root.ownerDocument;
413
+ const view = here && here.defaultView;
414
+ const key = paletteHotkey(view && view.navigator ? view.navigator.platform : '');
415
+ for (const kbd of root.querySelectorAll('[data-palette-hotkey]')) kbd.textContent = key;
337
416
  const doc = root.nodeType === 9 ? root : root.ownerDocument;
338
417
  listen(doc);
339
418
  if (persist === false) _unpersisted.add(root);
@@ -368,6 +447,9 @@ export function accountShell({
368
447
  signOutHref = '#logout',
369
448
  collapsible = true,
370
449
  collapsed,
450
+ layout,
451
+ width,
452
+ search,
371
453
  } = {}) {
372
454
  // The same normaliser appShell() runs, called once here so the rail and the
373
455
  // topbar menu are handed one list rather than two readings of `nav`.
@@ -389,6 +471,11 @@ export function accountShell({
389
471
  signOutHref,
390
472
  collapsible,
391
473
  collapsed,
474
+ // Settled nowhere but appShell(). Under `layout: 'topbar'` the bag below is not
475
+ // drawn, which is what that layout costs the preset.
476
+ layout,
477
+ width,
478
+ search,
392
479
  topbar: {
393
480
  word,
394
481
  view: 'text',
@@ -14,6 +14,9 @@
14
14
  import { esc } from './index.js';
15
15
 
16
16
  const cx = (...a) => a.filter(Boolean).join(' ');
17
+ /* classes: ui-tip__label ui-tip__value ui-tip__detail — built from `k` below, so
18
+ a sweep reading class names off the source cannot see them.
19
+ why: CONTRIBUTING.md#a-spelling-the-sweep-cannot-see-costs-coverage-in-silence */
17
20
  const PARTS = ['label', 'value', 'detail'];
18
21
  const px = (n) => (typeof n === 'number' && Number.isFinite(n) ? `${n}px` : null);
19
22
  let seq = 0;
@@ -7,10 +7,12 @@
7
7
  * ========================================================================== */
8
8
 
9
9
  /* Its own line, and only as wide as its words, so the hover ground does not run
10
- the width of the column. */
10
+ the width of the column — and never wider than it, which `fit-content` alone
11
+ does not give once the label below is `nowrap`. why: #303 */
11
12
  .ui-back {
12
13
  display: flex;
13
14
  width: fit-content;
15
+ max-width: 100%;
14
16
  align-items: center;
15
17
  gap: var(--space-1);
16
18
  /* WCAG 2.5.8's 24px: the line of text alone is 17px tall. */
@@ -40,3 +42,12 @@
40
42
  /* In the page shell it takes the trail's place, and the trail's distance from
41
43
  the title with it. */
42
44
  .ui-app__main > .ui-back { margin-bottom: var(--space-5); }
45
+
46
+ /* The one part that can run long. It clips rather than wrapping, as .ui-nav__label
47
+ does. why: docs/specification.md#the-back-link */
48
+ .ui-back__label {
49
+ min-width: 0;
50
+ overflow: hidden;
51
+ text-overflow: ellipsis;
52
+ white-space: nowrap;
53
+ }
@@ -28,16 +28,23 @@
28
28
  .ui-dropdown__pre { color: var(--muted); }
29
29
  .ui-dropdown__value { color: var(--strong); font-weight: 600; letter-spacing: 0.01em; }
30
30
 
31
- /* Chevron — border caret that flips when the container is .open */
31
+ /* Chevron — border caret that flips when the container is .open. Its ink is an L
32
+ on a rotated square, so centring the BOX leaves the mark off centre; --caret-off
33
+ is that gap, derived rather than typed.
34
+ why: docs/specification.md#the-dropdown-panel */
32
35
  .ui-dropdown__chevron {
33
- width: 8px; height: 8px;
34
- border-right: 1.6px solid var(--muted);
35
- border-bottom: 1.6px solid var(--muted);
36
- transform: rotate(45deg) translateY(-1px);
36
+ --caret: 8px;
37
+ --caret-ink: 1.6px;
38
+ --caret-off: calc((var(--caret) - var(--caret-ink)) / 2.8284); /* 2√2 — the 45° turn's projection */
39
+ width: var(--caret); height: var(--caret);
40
+ border-right: var(--caret-ink) solid var(--muted);
41
+ border-bottom: var(--caret-ink) solid var(--muted);
42
+ /* Shift BEFORE rotate, so it lands in page space rather than on the turned axis. */
43
+ transform: translateY(calc(var(--caret-off) * -1)) rotate(45deg);
37
44
  transition: transform var(--dur-fast) var(--ease);
38
45
  flex: none;
39
46
  }
40
- .ui-dropdown.open .ui-dropdown__chevron { transform: rotate(-135deg) translateY(2px); }
47
+ .ui-dropdown.open .ui-dropdown__chevron { transform: translateY(var(--caret-off)) rotate(-135deg); }
41
48
 
42
49
  /* Panel — the popover surface */
43
50
  .ui-dropdown__panel {
@@ -45,6 +52,10 @@
45
52
  the portal's JS, so an upward panel cannot drift from a downward one.
46
53
  why: docs/specification.md#the-dropdown-panel */
47
54
  --ui-dropdown-gap: 9px;
55
+ /* Named for the same reason the offset is: a block pinned to an edge has to
56
+ bleed back through it, and #306 is a page copying the number by hand.
57
+ why: docs/specification.md#the-dropdown-panel */
58
+ --ui-dropdown-pad: 6px;
48
59
  /* The panel states its own role rather than inheriting one, because `portal:
49
60
  true` moves it onto <body> and an inherited face is then decided by where
50
61
  the panel was MOUNTED rather than by what it is. Measured: the same dropdown
@@ -65,7 +76,7 @@
65
76
  border: 1px solid var(--border-strong);
66
77
  box-shadow: inset 0 0 0 1px var(--elev-edge, var(--border)), var(--elev-drop);
67
78
  border-radius: var(--radius-lg);
68
- padding: 6px;
79
+ padding: var(--ui-dropdown-pad);
69
80
  opacity: 0;
70
81
  visibility: hidden;
71
82
  /* Drawn for the whole of the fade out, so the rows do not vanish mid-fade — and a
@@ -203,13 +214,23 @@
203
214
  /* Separator */
204
215
  .ui-dropdown__sep { height: 1px; background: var(--border); margin: 5px 4px; }
205
216
 
206
- /* Optional pinned header/footer blocks */
207
- .ui-dropdown__panel > .ui-dropdown__head {
217
+ /* Optional pinned head/foot blocks — symmetrical: one inner padding, and each
218
+ bleeding to the edge it sits on, lining the edge it faces.
219
+ why: docs/specification.md#the-dropdown-panel */
220
+ .ui-dropdown__panel > .ui-dropdown__head,
221
+ .ui-dropdown__panel > .ui-dropdown__foot {
208
222
  padding: 11px 13px;
209
- margin: -6px -6px 5px;
223
+ }
224
+ .ui-dropdown__panel > .ui-dropdown__head {
225
+ margin: calc(var(--ui-dropdown-pad) * -1) calc(var(--ui-dropdown-pad) * -1) 5px;
210
226
  border-bottom: 1px solid var(--border);
211
227
  border-radius: var(--radius-lg) var(--radius-lg) 0 0;
212
228
  }
229
+ .ui-dropdown__panel > .ui-dropdown__foot {
230
+ margin: 5px calc(var(--ui-dropdown-pad) * -1) calc(var(--ui-dropdown-pad) * -1);
231
+ border-top: 1px solid var(--border);
232
+ border-radius: 0 0 var(--radius-lg) var(--radius-lg);
233
+ }
213
234
 
214
235
  /* Search — `search: true` pins a field above the rows and filters them as the
215
236
  reader types. The rows move into a list of their own, so the field stays put
@@ -246,6 +267,12 @@
246
267
  min-height: 38px;
247
268
  transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease);
248
269
  }
270
+ /* Light sinks the field one rung less far: its panel is the only pure white the
271
+ kit paints, so --surface-2 falls two rungs under it rather than one. The
272
+ numbers are in the spec and the gate is stories/dropdown-field-ground.test.js.
273
+ why: docs/specification.md#elevation */
274
+ :root[data-theme="light"] .ui-dropdown__search-input { background: var(--bg); }
275
+
249
276
  .ui-dropdown__search-input::placeholder { color: var(--muted); }
250
277
  .ui-dropdown__search-input:hover { border-color: var(--border-strong); }
251
278
  .ui-dropdown__search-input:focus-visible { outline: none; border-color: var(--accent); box-shadow: var(--ring); }
@@ -52,9 +52,13 @@
52
52
  transition: width var(--dur-med) var(--ease);
53
53
  }
54
54
  /* Each block of the rail keeps the open column while the box closes over it. The
55
- nav declares its own; these are the two that are not the nav. */
56
- .ui-app__head,
57
- .ui-app__user { width: var(--ui-nav-col); flex: none; }
55
+ nav declares its own; these are the three that are not the nav. Scoped to the rail,
56
+ because the topbar layout draws the reader's block in its band instead and a column
57
+ the rail's width is no measurement for a box on a 52px line.
58
+ why: docs/specification.md#the-page-shell */
59
+ .ui-app__rail > .ui-app__head,
60
+ .ui-app__rail > .ui-app__user,
61
+ .ui-app__rail > .ui-app__foot { width: var(--ui-nav-col); flex: none; }
58
62
  /* The rail's hover WAS --surface-2, so moving the rail onto it would have made
59
63
  hovering a row do nothing. The danger row and the current row are left out by
60
64
  name, because this rule outranks the states nav.css writes for them.
@@ -105,7 +109,7 @@
105
109
  smallest of three weak signals doing the most work, so it grows with the row. */
106
110
  /* Re-tuned at #295, which took the light rail down a step with the page. Below 720px
107
111
  the glyph is the whole row, so it is held at text grade rather than at 1.4.11's bar —
108
- stories/apps/shell-states.test.js:1340 `a resting glyph is dimmer but still legible`. */
112
+ stories/apps/shell-states.test.js:1400 `a resting glyph is dimmer but still legible`. */
109
113
  .ui-app__rail .ui-nav__ic svg { opacity: 0.66; }
110
114
  .ui-app__rail .ui-nav__item.is-active .ui-nav__ic svg,
111
115
  .ui-app__rail .ui-nav__item.is-current .ui-nav__ic svg { opacity: 1; stroke: currentColor; } /* motion: still — the glyph brightens with its row, whose own highlight already transitions */
@@ -128,6 +132,18 @@
128
132
  padding-top: var(--space-5);
129
133
  border-top: 1px solid var(--border);
130
134
  }
135
+ /* The other thing that can close a rail: where the reader's block is in the band,
136
+ the fold's control takes its place under the same rule.
137
+ why: docs/specification.md#the-page-shell */
138
+ .ui-app__foot {
139
+ display: flex;
140
+ align-items: center;
141
+ padding-top: var(--space-5);
142
+ border-top: 1px solid var(--border);
143
+ }
144
+ /* On the glyph column from the first frame, so the fold moves it nowhere — the ride
145
+ along the closing edge belongs to the head band, where the mark holds that column. */
146
+ .ui-app__foot > .ui-app__fold-row { margin-inline-start: 0; }
131
147
  /* The avatar is the reader block's own glyph, so half the difference between the
132
148
  glyph column and itself is the inset that centres it on the line every glyph above
133
149
  it stands on. The arithmetic and not the 5.5 it comes to: both numbers are declared
@@ -231,8 +247,14 @@
231
247
  where the control arrives: below 720px the toggle is not drawn, so the 720px block
232
248
  gives the lockup back rather than leaving an empty band with a hairline under it.
233
249
  why: docs/specification.md#the-page-shell */
234
- :where(.ui-app.is-collapsed) .ui-app__fold-row { inset-inline-start: calc(var(--ui-nav-strip) - var(--ui-nav-col)); }
235
- :where(.ui-app.is-collapsed) .ui-app__brand { opacity: 0; visibility: hidden; pointer-events: none; }
250
+ :where(.ui-app.is-collapsed) .ui-app__head > .ui-app__fold-row { inset-inline-start: calc(var(--ui-nav-strip) - var(--ui-nav-col)); }
251
+ /* …and only in the layout where something arrives on the mark's column. In the
252
+ topbar layout the toggle is at the rail's foot, nothing lands where the mark
253
+ stands, and a lockup faded out there leaves a band with nothing in it and its
254
+ hairline still under it — the defect the 720px block below exists to avoid. So
255
+ that layout keeps the mark and lets the shared rule above fold away its words.
256
+ why: docs/specification.md#the-page-shell */
257
+ :where(.ui-app:not(.ui-app--topbar).is-collapsed) .ui-app__brand { opacity: 0; visibility: hidden; pointer-events: none; }
236
258
  :where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__sub { margin-inline-start: 0; padding-inline-start: 0; border-inline-start-width: 0; }
237
259
  :where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__item:not(:has(.ui-nav__ic))::after { opacity: 0.62; }
238
260
  /* The label IS the chip. `fixed` is the fallback placement, out of the rail's own
@@ -277,6 +299,97 @@
277
299
  margin-inline: auto;
278
300
  padding: 40px clamp(24px, 4vw, 56px) 72px;
279
301
  }
302
+ /* The whole of the difference: `centered` is the rule above and `wide` takes its cap
303
+ off. `maxWidth` is the number under either name. why: docs/specification.md#widths */
304
+ .ui-app__main--wide { max-width: var(--ui-app-main, none); }
305
+
306
+ /* -- The topbar layout ----------------------------------------------------- */
307
+ /* The band stands BESIDE the rail, not across the top of both, which is the
308
+ arrangement where the rail's head band and the band close at the same height — what
309
+ the three heights below are for. Level, not one continuous stroke: the rail insets
310
+ its own rule, and in light that half is 1.009:1 on the rail's ground.
311
+ why: docs/specification.md#the-second-layout */
312
+ .ui-app { --ui-app-band: 52px; }
313
+ .ui-app__well { display: flex; flex-direction: column; min-width: 0; }
314
+ .ui-app__bar {
315
+ display: flex;
316
+ align-items: center;
317
+ gap: var(--space-3);
318
+ height: var(--ui-app-band);
319
+ flex: none;
320
+ padding-inline: clamp(24px, 4vw, 56px);
321
+ background: var(--bg);
322
+ border-bottom: 1px solid var(--border);
323
+ /* Sticky for the reason the rail beside it is: chrome that scrolls away is chrome
324
+ you scroll back up for. */
325
+ position: sticky;
326
+ top: 0;
327
+ z-index: var(--z-sticky);
328
+ }
329
+ .ui-app__bar-gap { flex: 1 1 auto; }
330
+ /* The rail's two bands take the band's height, so the rule under the head lands on
331
+ the line the band's own rule lands on. The rail gives up its top inset to buy it. */
332
+ .ui-app--topbar > .ui-app__rail { padding-top: 0; }
333
+ .ui-app--topbar > .ui-app__rail > .ui-app__head { height: var(--ui-app-band); padding-bottom: 0; }
334
+ .ui-app--topbar > .ui-app__rail > .ui-app__foot { height: var(--ui-app-band); padding-top: 0; }
335
+
336
+ /* Drawn as a field, behaving as a button. The hairline and the radius are the
337
+ field's — src/styles/input.css:20 `.ui-input,` sets both — because what it looks
338
+ like is the point. The STEP is not, and that is the one thing this control does
339
+ differently from every other field in the kit: the reason is on the declaration
340
+ below. It gives way before anything else on the band.
341
+ why: docs/specification.md#the-page-shell */
342
+ .ui-app__search {
343
+ display: flex;
344
+ align-items: center;
345
+ gap: var(--space-2);
346
+ flex: 0 1 var(--panel-sm);
347
+ min-width: 0;
348
+ padding: 7px 10px;
349
+ /* A family alone leaves the browser's leading behind: `font: inherit` first, then
350
+ the one step this row takes. why: CONTRIBUTING.md#button-chrome-ledgers */
351
+ font: inherit;
352
+ font-size: var(--text-sm);
353
+ text-align: start;
354
+ color: var(--muted);
355
+ /* --surface, not the sunken --surface-2 every other field in the kit takes:
356
+ those sit on a card and this one sits on the band, and the band IS --bg, the
357
+ bottom of the ladder — so a sunken fill has nowhere to go but down into its
358
+ own ground. Measured in light, the field was 1.10:1 BELOW the band and its
359
+ key cap 1.04:1 against the field it sits in; they are 1.08 above and 1.14 now.
360
+ Dark trades the other way and is stated rather than hidden: the cap goes
361
+ 1.29 -> 1.16, because --surface-3 and --surface are adjacent rungs there.
362
+ Artur chose this over three alternatives on #318; the survey of what seven
363
+ other systems do is docs/reviews/318-search-field.md.
364
+ why: docs/specification.md#elevation */
365
+ background: var(--surface);
366
+ border: 1px solid var(--border);
367
+ border-radius: var(--radius-md);
368
+ cursor: pointer;
369
+ transition: border-color var(--dur-med) var(--ease), color var(--dur-med) var(--ease);
370
+ }
371
+ .ui-app__search:hover { border-color: var(--border-strong); color: var(--strong); }
372
+ /* Every other control on the band takes the kit's ring. This one is a <button>
373
+ without `.ui-btn`, so base.css's shared rule never reached it and focus fell
374
+ through to the browser's own square outline, drawn around a 12px radius.
375
+ why: docs/specification.md#the-focus-ring */
376
+ .ui-app__search:focus-visible { outline: none; box-shadow: var(--ring); border-color: var(--accent); }
377
+ /* The same glyph the palette draws in its own search box, at the same box and the
378
+ same stroke: the trigger and the thing it opens are not two search marks.
379
+ src/styles/command-palette.css:107 `.ui-cmdk__search-ic svg` */
380
+ .ui-app__search-ic { display: flex; flex: none; }
381
+ .ui-app__search-ic svg { width: 17px; height: 17px; stroke: currentColor; fill: none; stroke-width: 2.2; }
382
+ /* Part of the button's name, so it is squeezed rather than dropped: an ellipsis
383
+ still leaves the whole string in the tree. */
384
+ .ui-app__search-txt { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
385
+ /* The other half of the name, so it never gives way. */
386
+ .ui-app__search kbd { flex: none; }
387
+
388
+ /* The mark alone — no room for two lines on a 52px row. No rule over it, since the
389
+ band's hairline is under the whole of it, and no column, since this is not the rail.
390
+ why: docs/specification.md#the-page-shell */
391
+ .ui-app__user--bar { flex: none; padding-top: 0; border-top: 0; }
392
+ .ui-app__user--bar .ui-app__av { margin-inline-start: 0; }
280
393
  /* Crumb, then the title block. At --space-2 the crumb sat 8px off the h1 and
281
394
  the h1 8px off its subtitle, so all three ranks read as one. */
282
395
  .ui-app__main .ui-nav--crumbs { margin-bottom: var(--space-5); }
@@ -321,6 +434,11 @@
321
434
  .ui-app { --ui-rail-w: calc(var(--ui-nav-strip) + 2 * var(--space-4) + var(--ui-rail-rule)); }
322
435
  /* The strip is the only layout this width has, so there is nothing to fold. */
323
436
  .ui-app__rail .ui-app__fold-row { display: none; }
437
+ /* …and the foot goes with it, for the reason the head band does below: in the
438
+ topbar layout that box holds the toggle and nothing else, so at this width it is
439
+ padding and a hairline drawn over nothing. Scoped to the shell's own rail, like
440
+ every other rule in this block. why: docs/specification.md#the-page-shell */
441
+ .ui-app__rail .ui-app__foot { display: none; }
324
442
  /* …and with it the band, when the band has nothing else in it: a head with no
325
443
  wordmark is the toggle alone, so at this width it is padding and a hairline
326
444
  drawn over nothing. The reader's fold cannot take this rule — there the toggle
@@ -180,7 +180,7 @@
180
180
  transition: background var(--dur-fast) var(--ease);
181
181
  }
182
182
  /* The menu is raised, so a hovered row takes the step ABOVE it — see
183
- src/styles/dropdown.css:144 `.ui-dropdown__item:hover`.
183
+ src/styles/dropdown.css:155 `.ui-dropdown__item:hover`.
184
184
  why: docs/specification.md#elevation */
185
185
  .vopt:hover { background: var(--surface-3); }
186
186
  .vopt .vname { font-family: var(--font-sans); font-size: 12.5px; color: var(--strong); font-weight: 600; letter-spacing: 0.01em; }