@apliteni/apliteni-ui 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -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; }
@@ -69,7 +69,10 @@
69
69
  flex-direction: column;
70
70
  font-family: var(--font-sans);
71
71
  background: var(--cmdk-surface);
72
- border: 1px solid var(--border);
72
+ /* The two-step edge and the drop: --border-strong outside, --border one pixel
73
+ inside it, then the broad faint drop. why: docs/specification.md#elevation */
74
+ border: 1px solid var(--border-strong);
75
+ box-shadow: inset 0 0 0 1px var(--elev-edge, var(--border)), var(--elev-drop);
73
76
  border-radius: var(--radius-lg);
74
77
  overflow: hidden;
75
78
  opacity: 0;
@@ -79,7 +82,11 @@
79
82
  transform var(--dur-med) var(--ease);
80
83
  }
81
84
  .ui-cmdk.is-open .ui-cmdk__panel { opacity: 1; transform: translate(-50%, 0); }
82
- .ui-cmdk__panel:focus-visible { outline: none; box-shadow: var(--ring); }
85
+ /* The ring is COMPOSED with the floating treatment, not written over it: a
86
+ box-shadow list replaces the whole list, so `box-shadow: var(--ring)` alone
87
+ took the panel's edge and its drop off for as long as it held focus.
88
+ why: docs/specification.md#elevation */
89
+ .ui-cmdk__panel:focus-visible { outline: none; box-shadow: var(--ring), inset 0 0 0 1px var(--elev-edge, var(--border)), var(--elev-drop); }
83
90
 
84
91
  /* -- The text box --------------------------------------------------------- */
85
92
 
@@ -62,7 +62,10 @@
62
62
  gap: var(--space-2);
63
63
  padding: var(--space-5);
64
64
  background: var(--confirm-surface);
65
- border: 1px solid var(--border);
65
+ /* The two-step edge and the drop: --border-strong outside, --border one pixel
66
+ inside it, then the broad faint drop. why: docs/specification.md#elevation */
67
+ border: 1px solid var(--border-strong);
68
+ box-shadow: inset 0 0 0 1px var(--elev-edge, var(--border)), var(--elev-drop);
66
69
  border-radius: var(--radius-lg);
67
70
  opacity: 0;
68
71
  transform: translateY(8px);
@@ -71,7 +74,11 @@
71
74
  transform var(--dur-med) var(--ease);
72
75
  }
73
76
  .ui-confirm.is-open .ui-confirm__panel { opacity: 1; transform: none; }
74
- .ui-confirm__panel:focus-visible { outline: none; box-shadow: var(--ring); }
77
+ /* The ring is COMPOSED with the floating treatment, not written over it: a
78
+ box-shadow list replaces the whole list, so `box-shadow: var(--ring)` alone
79
+ took the panel's edge and its drop off for as long as it held focus.
80
+ why: docs/specification.md#elevation */
81
+ .ui-confirm__panel:focus-visible { outline: none; box-shadow: var(--ring), inset 0 0 0 1px var(--elev-edge, var(--border)), var(--elev-drop); }
75
82
 
76
83
  /* -- Slots ----------------------------------------------------------------- */
77
84
  /* The question and the answers keep their size; the consequence between them is
@@ -58,9 +58,19 @@
58
58
  display: flex;
59
59
  flex-direction: column;
60
60
  background: var(--drawer-surface);
61
+ /* A drawer is flush to a screen edge, so it has one edge rather than four and the
62
+ two-step edge runs along that one: --border-strong on the edge rule below, the
63
+ inner line one pixel inside it. This is the one floating surface that writes no
64
+ inset RING — a full ring would draw 1px lines across the top and bottom of a
65
+ full-height panel, where there is no edge — so it composes its own directional
66
+ line with the drops instead. The line is a variable so the focus rule can
67
+ compose all three layers without knowing which edge it is on; its default is a
68
+ no-op layer rather than `none`, which is valid only alone and would invalidate
69
+ the list. why: docs/specification.md#elevation */
70
+ --drawer-line: 0 0 0 0 transparent;
71
+ box-shadow: var(--drawer-line), var(--elev-drop);
61
72
  transition: transform var(--dur-med) var(--ease);
62
73
  }
63
- .ui-drawer__panel:focus-visible { outline: none; box-shadow: var(--ring); }
64
74
  .ui-drawer.is-open .ui-drawer__panel { transform: none; }
65
75
 
66
76
  /* -- Edge anchoring + slide-in direction ---------------------------------- */
@@ -72,12 +82,14 @@
72
82
  }
73
83
  .ui-drawer--right .ui-drawer__panel {
74
84
  right: 0;
75
- border-left: 1px solid var(--border);
85
+ border-left: 1px solid var(--border-strong);
86
+ --drawer-line: inset 1px 0 0 var(--border);
76
87
  transform: translateX(100%);
77
88
  }
78
89
  .ui-drawer--left .ui-drawer__panel {
79
90
  left: 0;
80
- border-right: 1px solid var(--border);
91
+ border-right: 1px solid var(--border-strong);
92
+ --drawer-line: inset -1px 0 0 var(--border);
81
93
  transform: translateX(-100%);
82
94
  }
83
95
  .ui-drawer--top .ui-drawer__panel,
@@ -88,15 +100,23 @@
88
100
  }
89
101
  .ui-drawer--top .ui-drawer__panel {
90
102
  top: 0;
91
- border-bottom: 1px solid var(--border);
103
+ border-bottom: 1px solid var(--border-strong);
104
+ --drawer-line: inset 0 -1px 0 var(--border);
92
105
  transform: translateY(-100%);
93
106
  }
94
107
  .ui-drawer--bottom .ui-drawer__panel {
95
108
  bottom: 0;
96
- border-top: 1px solid var(--border);
109
+ border-top: 1px solid var(--border-strong);
110
+ --drawer-line: inset 0 1px 0 var(--border);
97
111
  transform: translateY(100%);
98
112
  }
99
113
 
114
+ /* The ring is COMPOSED with the floating treatment rather than written over it — a
115
+ box-shadow list replaces the whole list, so a bare `box-shadow: var(--ring)` would
116
+ take the drawer's edge and its drop off for as long as it held focus.
117
+ why: docs/specification.md#elevation */
118
+ .ui-drawer__panel:focus-visible { outline: none; box-shadow: var(--ring), var(--drawer-line), var(--elev-drop); }
119
+
100
120
  /* -- Sizes ----------------------------------------------------------------- */
101
121
  /* left|right → width; top|bottom → height. Same names, axis chosen by side. */
102
122
  .ui-drawer--right.ui-drawer--sm, .ui-drawer--left.ui-drawer--sm { --drawer-w: var(--panel-sm); }
@@ -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
@@ -60,9 +71,12 @@
60
71
  /* The floating step, not the sunken one — a panel held open over a card cannot be
61
72
  painted the colour of an inset. why: docs/specification.md#elevation */
62
73
  background: var(--bg-elevated);
63
- border: 1px solid var(--border);
74
+ /* The two-step edge and the drop: --border-strong outside, --border one pixel
75
+ inside it, then the broad faint drop. why: docs/specification.md#elevation */
76
+ border: 1px solid var(--border-strong);
77
+ box-shadow: inset 0 0 0 1px var(--elev-edge, var(--border)), var(--elev-drop);
64
78
  border-radius: var(--radius-lg);
65
- padding: 6px;
79
+ padding: var(--ui-dropdown-pad);
66
80
  opacity: 0;
67
81
  visibility: hidden;
68
82
  /* Drawn for the whole of the fade out, so the rows do not vanish mid-fade — and a
@@ -200,13 +214,23 @@
200
214
  /* Separator */
201
215
  .ui-dropdown__sep { height: 1px; background: var(--border); margin: 5px 4px; }
202
216
 
203
- /* Optional pinned header/footer blocks */
204
- .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 {
205
222
  padding: 11px 13px;
206
- 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;
207
226
  border-bottom: 1px solid var(--border);
208
227
  border-radius: var(--radius-lg) var(--radius-lg) 0 0;
209
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
+ }
210
234
 
211
235
  /* Search — `search: true` pins a field above the rows and filters them as the
212
236
  reader types. The rows move into a list of their own, so the field stays put
@@ -243,6 +267,12 @@
243
267
  min-height: 38px;
244
268
  transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease);
245
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
+
246
276
  .ui-dropdown__search-input::placeholder { color: var(--muted); }
247
277
  .ui-dropdown__search-input:hover { border-color: var(--border-strong); }
248
278
  .ui-dropdown__search-input:focus-visible { outline: none; border-color: var(--accent); box-shadow: var(--ring); }