@apliteni/apliteni-ui 0.31.0 → 0.33.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.
@@ -0,0 +1,75 @@
1
+ // Stat band — a row of key figures, as an HTML string.
2
+ // why: docs/specification.md#stat-bands
3
+ //
4
+ // A band is a <dl>: each figure is a <div> holding its label as a <dt> and its
5
+ // value, change and trend as <dd>s, which is the grouping HTML allows inside a
6
+ // description list and the one a screen reader reads as "term, then its values".
7
+ import { esc } from './index.js';
8
+ import { icon } from '../assets/icons.js';
9
+
10
+ export const STAT_VARIANTS = ['band', 'tiles', 'open'];
11
+ export const STAT_TONES = ['good', 'bad', 'neutral'];
12
+
13
+ const cx = (...a) => a.filter(Boolean).join(' ');
14
+ let seq = 0;
15
+
16
+ // The arrow is read off the sign the caller printed, so the figure and the
17
+ // glyph beside it cannot disagree. U+2212, the en dash and an accounting
18
+ // bracket are negatives too: a formatter that typesets its figures uses them.
19
+ const directionOf = (text) => {
20
+ const t = text.trim();
21
+ return /^[-−–(]/.test(t) ? 'down' : /^\+/.test(t) ? 'up' : 'flat';
22
+ };
23
+ const GLYPH = { up: 'arrowUp', down: 'arrowDown', flat: 'minus' };
24
+ const hasChange = (delta) => delta && delta.value != null && delta.value !== '';
25
+
26
+ const deltaHtml = (delta, basisId) => {
27
+ if (!delta) return '';
28
+ if (!hasChange(delta)) {
29
+ return `<dd class="ui-stat__delta ui-stat__delta--none">${esc(delta.none || 'No earlier figure')}</dd>`;
30
+ }
31
+ const text = String(delta.value);
32
+ const dir = GLYPH[delta.direction] ? delta.direction : directionOf(text);
33
+ const own = delta.basis ? ` <span class="ui-stat__basis">${esc(delta.basis)}</span>` : '';
34
+ const describedby = !delta.basis && basisId ? ` aria-describedby="${basisId}"` : '';
35
+ return `<dd class="ui-stat__delta"${describedby}>${icon(GLYPH[dir])}<span class="ui-stat__change">${esc(text)}</span>${own}</dd>`;
36
+ };
37
+
38
+ // `tone` says whether the change is good news, and nothing else. It is never
39
+ // inferred from the direction: costs going up and unclassified rows going down
40
+ // are both real, and a band that paints every rise green is editorialising.
41
+ // A figure with no change has no news to colour.
42
+ const figure = ({ label = '', value = '', delta, trend = '' }, tile, basisId) => {
43
+ const tone = hasChange(delta) && STAT_TONES.includes(delta.tone) && delta.tone !== 'neutral' ? delta.tone : '';
44
+ const cls = cx('ui-stat', tone && `ui-stat--${tone}`, tile && 'ui-card ui-card--pad-sm');
45
+ // `trend` is trusted markup — an <svg> the caller drew. The kit sizes and
46
+ // colours it and draws no chart of its own.
47
+ return `<div class="${cls}">`
48
+ + `<dt class="ui-stat__label">${esc(label)}</dt>`
49
+ + `<dd class="ui-stat__value">${esc(value)}</dd>`
50
+ + deltaHtml(delta, basisId)
51
+ + (trend ? `<dd class="ui-stat__trend">${trend}</dd>` : '')
52
+ + '</div>';
53
+ };
54
+
55
+ // `basis` is the band's caption, said once above the figures: what every change
56
+ // is measured against, or on a band with no changes, what the figures cover. A
57
+ // figure measured against something else carries its own `delta.basis`, printed
58
+ // beside the change.
59
+ //
60
+ // The caption comes before the list, the way a table's <caption> does, in every
61
+ // layout. It is one statement about all the figures, so it is read before them
62
+ // and it sits outside every one of them — under a row of tiles it would read as
63
+ // a note on the last card. why: docs/specification.md#stat-bands
64
+ export function statBand({ stats = [], variant = 'tiles', basis = '', label, id } = {}) {
65
+ const v = STAT_VARIANTS.includes(variant) ? variant : 'tiles';
66
+ const base = id ? esc(id) : `ui-stats-${++seq}`;
67
+ const basisId = basis ? `${base}-basis` : '';
68
+ const cls = cx('ui-stats', `ui-stats--${v}`, v === 'band' && 'ui-card');
69
+ const named = label ? ` role="group" aria-label="${esc(label)}"` : '';
70
+ const items = stats.map((s) => figure(s, v === 'tiles', basisId)).join('');
71
+ return `<div class="${cls}"${named}>`
72
+ + (basis ? `<p class="ui-stats__basis" id="${basisId}">${esc(basis)}</p>` : '')
73
+ + `<dl class="ui-stats__list">${items}</dl>`
74
+ + '</div>';
75
+ }
@@ -64,6 +64,7 @@ function countdownEl({ seconds = 5, label = 'Redirecting' } = {}) {
64
64
  // changing one never moves the other.
65
65
  export function success({
66
66
  layout = 'hero', // 'hero' | 'split' | 'compact'
67
+ level, // heading level of the title; see the note below
67
68
  backdrop = 'aurora', // 'aurora' | 'glow' | 'flat'
68
69
  eyebrow = '',
69
70
  title = 'All done',
@@ -76,6 +77,16 @@ export function success({
76
77
  const cls = ['ui-sx', `ui-sx--${layout}`, `ui-sx--bd-${backdrop}`, confetti && 'ui-sx--confetti', className]
77
78
  .filter(Boolean).join(' ');
78
79
 
80
+ // The title's rank follows the layout, because the layout is the question
81
+ // "how much of the screen does this own": hero and split ARE the page a flow
82
+ // lands on, so their title is its h1; compact sits beside other content and
83
+ // takes h2. It was an h3 either way, which left a page whose whole content is
84
+ // a success() with no h1 at all — the fault Guidelines / The page names.
85
+ // A caller who knows better passes `level`. The look is the class's.
86
+ // why: docs/specification.md#the-page
87
+ const rank = [1, 2, 3, 4, 5, 6].includes(Number(level)) ? Number(level) : (layout === 'compact' ? 2 : 1);
88
+ const h = `h${rank}`;
89
+
79
90
  const bd = backdrop === 'aurora'
80
91
  ? `<div class="ui-sx__aurora" aria-hidden="true">
81
92
  <span class="ui-sx__glow ui-sx__glow--a"></span>
@@ -99,7 +110,7 @@ export function success({
99
110
  <div class="ui-sx__visual">${successCheck()}</div>
100
111
  <div class="ui-sx__content">
101
112
  ${eyebrowEl}
102
- <h3 class="ui-sx__title">${esc(title)}</h3>
113
+ <${h} class="ui-sx__title">${esc(title)}</${h}>
103
114
  ${bodyEl}
104
115
  ${actionsEl}
105
116
  ${countEl}
package/src/index.css CHANGED
@@ -16,6 +16,7 @@
16
16
  @import "./tokens/accents.css";
17
17
  @import "./styles/base.css";
18
18
  @import "./styles/reduced-motion.css";
19
+ @import "./styles/field-zoom.css";
19
20
  @import "./styles/motion.css";
20
21
  @import "./styles/button.css";
21
22
  @import "./styles/card.css";
@@ -32,6 +33,7 @@
32
33
  @import "./styles/command-palette.css";
33
34
  @import "./styles/table.css";
34
35
  @import "./styles/pagination.css";
36
+ @import "./styles/stat.css";
35
37
  @import "./styles/empty.css";
36
38
  @import "./styles/callout.css";
37
39
  @import "./styles/code.css";
package/src/index.js CHANGED
@@ -17,6 +17,7 @@ export * from './components/toasts.js';
17
17
  export * from './components/success.js';
18
18
  export * from './components/loading.js';
19
19
  export * from './components/pagination.js';
20
+ export * from './components/stat.js';
20
21
  export * from './assets/icons.js';
21
22
  export * from './assets/brand.js';
22
23
  export * from './motion.js';
package/src/inline.js CHANGED
@@ -29,6 +29,7 @@ export const topbarCss = read('styles/topbar.css');
29
29
  export const styles = {
30
30
  base: baseCss,
31
31
  reducedMotion: read('styles/reduced-motion.css'),
32
+ fieldZoom: read('styles/field-zoom.css'),
32
33
  motion: read('styles/motion.css'),
33
34
  button: read('styles/button.css'),
34
35
  card: read('styles/card.css'),
@@ -45,6 +46,7 @@ export const styles = {
45
46
  commandPalette: read('styles/command-palette.css'),
46
47
  table: read('styles/table.css'),
47
48
  pagination: read('styles/pagination.css'),
49
+ stat: read('styles/stat.css'),
48
50
  empty: read('styles/empty.css'),
49
51
  callout: read('styles/callout.css'),
50
52
  code: read('styles/code.css'),
@@ -61,6 +63,7 @@ export const cssText = [
61
63
  tokensCss,
62
64
  styles.base,
63
65
  styles.reducedMotion,
66
+ styles.fieldZoom,
64
67
  styles.motion,
65
68
  styles.button,
66
69
  styles.card,
@@ -77,6 +80,7 @@ export const cssText = [
77
80
  styles.commandPalette,
78
81
  styles.table,
79
82
  styles.pagination,
83
+ styles.stat,
80
84
  styles.empty,
81
85
  styles.callout,
82
86
  styles.code,
@@ -102,11 +102,12 @@
102
102
  border-color: transparent;
103
103
  color: var(--disabled-ink-bare);
104
104
  }
105
- /* With no box, the ink is read on whatever is behind it. --disabled-ink read
106
- 5.18:1 on a card and 4.66:1 on --surface-3 in dark, under #220's 5.56, so the
107
- ghost takes an ink set for the dullest ground: in dark 7.00 / 6.24 / 6.69 /
108
- 5.62 on --bg / --surface / --surface-2 / --surface-3, in light 6.50 / 6.50 /
109
- 6.01 / 5.60. The enabled ghost's --dim reads 1.5 times that in dark, 1.6 in light.
105
+ /* With no box, the ink is read on whatever is behind it, so the ghost takes an ink
106
+ set for the dullest ground: in dark 7.49 / 6.31 / 7.00 / 5.44 on --bg / --surface /
107
+ --surface-2 / --surface-3, in light 5.70 / 6.17 / 5.20 / 5.39. The enabled ghost's
108
+ --dim reads about 1.5 times that in both themes. #273 cut this ink to rescue two
109
+ dark pairs from under #220's floor; #295 re-picked --muted against the elevation
110
+ ladder and lifted both clear, so it is the margin now rather than the rescue.
110
111
 
111
112
  #273 got here the long way. It first gave the ghost the flat disabled box
112
113
  instead, on the claim that nothing would get less readable. That was false:
@@ -110,8 +110,12 @@
110
110
  .ui-toast__timer { position: absolute; left: 0; bottom: 0; height: 3px; width: 100%; background: var(--toast-accent); opacity: 0.85; transform-origin: left; }
111
111
  .ui-toast__timer.is-running { animation: ui-toast-timer var(--toast-dur, 5s) linear forwards; /* motion: ambient — the bar spends the toast's dismiss timer, which the caller sets */ }
112
112
 
113
- /* style: soft — tinted surface */
114
- .ui-toast--soft { background: var(--toast-glow); box-shadow: var(--shadow-sm); }
113
+ /* style: soft — tinted surface. The shadow used to be its edge, so it takes the
114
+ status-tinted hairline the outline style carries, at half the weight. #295 */
115
+ .ui-toast--soft {
116
+ background: var(--toast-glow);
117
+ border: 1px solid color-mix(in srgb, var(--toast-accent) 22%, transparent);
118
+ }
115
119
 
116
120
  /* style: solid — the status at fill strength, inked with the pole opposite it.
117
121
  The fill and the ink are one pair, taken together from --signal-solid-*: dark
@@ -124,7 +128,6 @@
124
128
  --toast-ink: var(--signal-solid-ink);
125
129
  background: var(--toast-solid);
126
130
  color: var(--toast-ink);
127
- box-shadow: var(--shadow-lg);
128
131
  }
129
132
  .ui-toast--solid .ui-toast__title { color: var(--toast-ink); }
130
133
  /* The body copy is the quieter of the two, but it stays quiet by dilution the
@@ -144,7 +147,7 @@
144
147
  .ui-toast--solid .ui-toast__timer { background: color-mix(in srgb, var(--toast-ink) 55%, transparent); }
145
148
 
146
149
  /* style: outline — elevated card with a status-tinted border */
147
- .ui-toast--outline { background: var(--bg-elevated); box-shadow: var(--shadow-md); border: 1px solid color-mix(in srgb, var(--toast-accent) 40%, transparent); }
150
+ .ui-toast--outline { background: var(--bg-elevated); border: 1px solid color-mix(in srgb, var(--toast-accent) 40%, transparent); }
148
151
 
149
152
  /* compact — single line, no body text */
150
153
  .ui-toast--compact { align-items: center; padding-top: 10px; padding-bottom: 10px; }
@@ -1,25 +1,18 @@
1
1
  /* ============================================================================
2
- * Card — .ui-card (filled surface, no borders the deck groups with
3
- * surface + radius, never outlines)
2
+ * Card — .ui-card (a filled surface one step off the page, edged by a hairline)
4
3
  * Rows: .ui-card__row / headers: .ui-card__title / .ui-card__sub
5
4
  * ========================================================================== */
6
5
 
6
+ /* Flat since #284 and edged in both themes since #295: the surface is the step, the
7
+ line is what makes it legible. why: docs/specification.md#elevation */
7
8
  .ui-card {
8
9
  background: var(--surface);
10
+ border: 1px solid var(--border);
9
11
  border-radius: var(--radius-xl);
10
12
  padding: var(--space-6) 26px;
11
13
  position: relative;
12
14
  }
13
15
 
14
- /* Light theme is an all-white app, so a filled card can't rely on a darker-
15
- than-page surface the way the dark deck does — a hairline border delineates
16
- it instead. Flat, no drop shadow (#284). `none` rather than no declaration:
17
- at (0,3,0) it outranks .ui-card--accent's inset ring, which light never showed. */
18
- :root[data-theme="light"] .ui-card {
19
- border: 1px solid var(--border);
20
- box-shadow: none;
21
- }
22
-
23
16
  /* A table wider than its card scrolls INSIDE the card instead of bleeding past
24
17
  the rounded corners / clipping its last column. Clipping to the card's
25
18
  border-box also keeps zebra stripes from escaping the radius. */
@@ -32,10 +25,11 @@
32
25
  .ui-card--pad-sm { padding: var(--space-5); }
33
26
  .ui-card--pad-lg { padding: var(--space-8) var(--space-10); }
34
27
 
35
- /* An accent-tinted card, e.g. a highlighted plan / success surface */
28
+ /* An accent-tinted card, e.g. a highlighted plan / success surface. The accent edge is
29
+ the card's own hairline re-coloured: an inset ring over it would draw two. #295 */
36
30
  .ui-card--accent {
37
31
  background: color-mix(in srgb, var(--accent) 9%, var(--surface));
38
- box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent);
32
+ border-color: color-mix(in srgb, var(--accent) 22%, transparent);
39
33
  }
40
34
  .ui-card--live {
41
35
  background: color-mix(in srgb, var(--green) 10%, var(--surface));
@@ -44,16 +38,15 @@
44
38
  /* Interactive card (link / button semantics) */
45
39
  .ui-card--interactive {
46
40
  cursor: pointer;
47
- /* Written as a <button>, the card would take the browser's frame, centred text
48
- and `font: 400 13.3333px Arial` — a shorthand, so `font: inherit` and not the
49
- family longhand. .ui-card already states its own background, and the light
50
- theme's border rule is (0,3,0) so `border: 0` here cannot reach it. #251 */
51
- border: 0;
41
+ /* Written as a <button>, the card would take the browser's frame, centred text and
42
+ `font: 400 13.3333px Arial` — a shorthand, so `font: inherit` and not the family
43
+ longhand. .ui-card states the background and, since #295, the border, so a
44
+ `border: 0` here would only delete the hairline. #251 */
52
45
  text-align: left;
53
46
  font: inherit;
54
47
  transition: transform var(--dur-fast) var(--ease);
55
48
  }
56
- /* The lift alone, no shadow: cards are flat (#284), and light never showed one. */
49
+ /* The lift alone: the kit casts no shadow anywhere (#295), and cards are flat (#284). */
57
50
  .ui-card--interactive:hover { transform: translateY(-2px); }
58
51
 
59
52
  /* card() emits the title as an h2, so the text face is named here rather than
@@ -15,8 +15,8 @@
15
15
  * why: docs/specification.md#the-command-palette */
16
16
 
17
17
  .ui-cmdk {
18
- --cmdk-surface: var(--surface-2);
19
- --cmdk-shadow: var(--shadow-lg);
18
+ /* The floating step; nothing casts. why: docs/specification.md#elevation */
19
+ --cmdk-surface: var(--bg-elevated);
20
20
  --cmdk-scrim: var(--scrim);
21
21
  --cmdk-w: 560px;
22
22
  --cmdk-top: 12vh;
@@ -71,7 +71,6 @@
71
71
  background: var(--cmdk-surface);
72
72
  border: 1px solid var(--border);
73
73
  border-radius: var(--radius-lg);
74
- box-shadow: var(--cmdk-shadow);
75
74
  overflow: hidden;
76
75
  opacity: 0;
77
76
  transform: translate(-50%, -8px);
@@ -80,7 +79,7 @@
80
79
  transform var(--dur-med) var(--ease);
81
80
  }
82
81
  .ui-cmdk.is-open .ui-cmdk__panel { opacity: 1; transform: translate(-50%, 0); }
83
- .ui-cmdk__panel:focus-visible { outline: none; box-shadow: var(--cmdk-shadow), var(--ring); }
82
+ .ui-cmdk__panel:focus-visible { outline: none; box-shadow: var(--ring); }
84
83
 
85
84
  /* -- The text box --------------------------------------------------------- */
86
85
 
@@ -250,7 +249,9 @@
250
249
  padding: 2px 5px;
251
250
  border: 1px solid var(--border);
252
251
  border-radius: var(--radius-xs);
253
- background: var(--surface);
252
+ /* A key cap reads as raised, so it takes the step above the panel rather than
253
+ the card step below it. why: docs/specification.md#elevation */
254
+ background: var(--surface-3);
254
255
  font-family: var(--font-sans);
255
256
  font-size: var(--text-xs);
256
257
  line-height: 1.5;
@@ -1,20 +1,11 @@
1
1
  /* Confirm — the kit's confirmation dialog: a centred panel over a scrim, for the
2
- * question a destructive action has to ask. Fully token-driven, and it shares
3
- * its scrim and focus-trap behaviour with the drawer (see overlay.js).
4
- *
5
- * `visibility` is SWITCHED rather than transitioned: transitioned, it is still
6
- * `hidden` in the frame the class lands, so openConfirm()'s focus() would have
7
- * nothing to focus. The root owns the switch; panel and scrim inherit it.
8
- *
9
- * Local tokens so the panel and scrim re-theme cleanly:
10
- * --confirm-surface panel background --confirm-shadow panel elevation
11
- * --confirm-scrim backdrop colour
12
- *
2
+ * question a destructive action has to ask. Shares its scrim and focus trap with
3
+ * the drawer (see overlay.js). Local tokens: --confirm-surface, --confirm-scrim.
13
4
  * why: docs/specification.md#motion */
14
5
 
15
6
  .ui-confirm {
16
- --confirm-surface: var(--surface-2);
17
- --confirm-shadow: var(--shadow-lg);
7
+ /* The floating step; nothing casts. why: docs/specification.md#elevation */
8
+ --confirm-surface: var(--bg-elevated);
18
9
  --confirm-scrim: var(--scrim);
19
10
  --confirm-w: var(--panel-md);
20
11
 
@@ -26,10 +17,11 @@
26
17
  z-index: calc(var(--z-overlay) + 2);
27
18
  pointer-events: none; /* container never blocks; scrim/panel opt back in */
28
19
 
29
- /* Only the hide is transitioned, so the panel is still on screen while it
30
- fades out; the show is instant, because focus moves in the same frame.
31
- `linear` and not var(--ease): visibility is discrete, easing a step buys
32
- nothing, and a curve that leaves [0, 1] would flip it mid-fade. */
20
+ /* SWITCHED, not transitioned: transitioned, the panel is still `hidden` in the
21
+ frame the class lands and openConfirm()'s focus() has nothing to focus. Only
22
+ the hide is timed, so the panel is on screen while it fades out. `linear` and
23
+ not var(--ease): visibility is discrete, and a curve leaving [0, 1] flips it
24
+ mid-fade. The root owns the switch; panel and scrim inherit it. */
33
25
  visibility: hidden;
34
26
  transition: visibility var(--dur-med) linear;
35
27
  }
@@ -72,7 +64,6 @@
72
64
  background: var(--confirm-surface);
73
65
  border: 1px solid var(--border);
74
66
  border-radius: var(--radius-lg);
75
- box-shadow: var(--confirm-shadow);
76
67
  opacity: 0;
77
68
  transform: translateY(8px);
78
69
  transition:
@@ -80,7 +71,7 @@
80
71
  transform var(--dur-med) var(--ease);
81
72
  }
82
73
  .ui-confirm.is-open .ui-confirm__panel { opacity: 1; transform: none; }
83
- .ui-confirm__panel:focus-visible { outline: none; box-shadow: var(--confirm-shadow), var(--ring); }
74
+ .ui-confirm__panel:focus-visible { outline: none; box-shadow: var(--ring); }
84
75
 
85
76
  /* -- Slots ----------------------------------------------------------------- */
86
77
  /* The question and the answers keep their size; the consequence between them is
@@ -6,13 +6,14 @@
6
6
  * (reduced-motion.css) makes the slide and the fade instant.
7
7
  *
8
8
  * Local tokens (surface/shadow/scrim) so the panel + scrim re-theme cleanly:
9
- * --drawer-surface panel background --drawer-shadow panel elevation
9
+ * --drawer-surface panel background
10
10
  * --drawer-scrim backdrop colour (--scrim already re-themes per theme)
11
11
  * ========================================================================== */
12
12
 
13
13
  .ui-drawer {
14
- --drawer-surface: var(--surface-2);
15
- --drawer-shadow: var(--shadow-lg);
14
+ /* The floating step; the edge is the hairline each --drawer--<edge> rule below draws.
15
+ why: docs/specification.md#elevation */
16
+ --drawer-surface: var(--bg-elevated);
16
17
  --drawer-scrim: var(--scrim);
17
18
  --drawer-w: var(--panel-md); /* left|right panels: width */
18
19
  --drawer-h: 45vh; /* top|bottom panels: height */
@@ -57,10 +58,9 @@
57
58
  display: flex;
58
59
  flex-direction: column;
59
60
  background: var(--drawer-surface);
60
- box-shadow: var(--drawer-shadow);
61
61
  transition: transform var(--dur-med) var(--ease);
62
62
  }
63
- .ui-drawer__panel:focus-visible { outline: none; box-shadow: var(--drawer-shadow), var(--ring); }
63
+ .ui-drawer__panel:focus-visible { outline: none; box-shadow: var(--ring); }
64
64
  .ui-drawer.is-open .ui-drawer__panel { transform: none; }
65
65
 
66
66
  /* -- Edge anchoring + slide-in direction ---------------------------------- */
@@ -149,7 +149,7 @@
149
149
  background var(--dur-fast) var(--ease),
150
150
  border-color var(--dur-fast) var(--ease);
151
151
  }
152
- .ui-drawer__close:hover { color: var(--strong); background: var(--surface); }
152
+ .ui-drawer__close:hover { color: var(--strong); background: var(--surface-3); }
153
153
  .ui-drawer__close:focus-visible { outline: none; box-shadow: var(--ring); border-color: var(--accent); }
154
154
  .ui-drawer__close svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 2.1; }
155
155
 
@@ -57,13 +57,18 @@
57
57
  top: calc(100% + var(--ui-dropdown-gap));
58
58
  left: 0;
59
59
  min-width: 240px;
60
- background: var(--surface-2);
60
+ /* The floating step, not the sunken one — a panel held open over a card cannot be
61
+ painted the colour of an inset. why: docs/specification.md#elevation */
62
+ background: var(--bg-elevated);
61
63
  border: 1px solid var(--border);
62
64
  border-radius: var(--radius-lg);
63
65
  padding: 6px;
64
- box-shadow: var(--shadow-lg);
65
66
  opacity: 0;
66
67
  visibility: hidden;
68
+ /* Drawn for the whole of the fade out, so the rows do not vanish mid-fade — and a
69
+ box that is drawn is a box that is hit. The open rules take this back.
70
+ why: docs/specification.md#the-dropdown-panel */
71
+ pointer-events: none;
67
72
  transform: translateY(-6px);
68
73
  transition:
69
74
  opacity var(--dur-med) var(--ease),
@@ -88,7 +93,13 @@
88
93
  transform: translateY(6px);
89
94
  }
90
95
 
91
- .ui-dropdown.open .ui-dropdown__panel { opacity: 1; visibility: visible; transform: translateY(0); }
96
+ .ui-dropdown.open .ui-dropdown__panel { opacity: 1; visibility: visible; pointer-events: auto; transform: translateY(0); }
97
+ /* Open, the panel is visible AT ONCE: `visibility` is off the list, so it is not
98
+ held at `hidden` for the frame a key opens the panel in — the frame the focus
99
+ call happens in. Closing still fades on every property it always did.
100
+ why: docs/specification.md#the-dropdown-panel */
101
+ .ui-dropdown.open .ui-dropdown__panel,
102
+ .ui-dropdown__panel--portal.is-open { transition-property: opacity, transform; }
92
103
 
93
104
  /* Portalled panel — wireDropdown() moves it onto <body>, clear of every
94
105
  ancestor's overflow and every ancestor's stacking context. The descendant
@@ -100,7 +111,7 @@
100
111
  position: fixed;
101
112
  top: auto; right: auto; bottom: auto; left: auto;
102
113
  }
103
- .ui-dropdown__panel--portal.is-open { opacity: 1; visibility: visible; transform: translateY(0); }
114
+ .ui-dropdown__panel--portal.is-open { opacity: 1; visibility: visible; pointer-events: auto; transform: translateY(0); }
104
115
 
105
116
  /* Item row — [icon] [main: label + desc] [badge] [tick]. A row is a <div>, an
106
117
  <a href> or a <button>: `is-selected` and __tick below mean a row gets
@@ -126,8 +137,9 @@
126
137
  color: var(--text);
127
138
  transition: background var(--dur-fast) var(--ease);
128
139
  }
129
- .ui-dropdown__item:hover { background: var(--surface); }
130
- .ui-dropdown__item:focus-visible { outline: none; background: var(--surface); box-shadow: var(--ring); }
140
+ /* A row in a raised panel lifts: the step above it, never the card step below. why: docs/specification.md#elevation */
141
+ .ui-dropdown__item:hover { background: var(--surface-3); }
142
+ .ui-dropdown__item:focus-visible { outline: none; background: var(--surface-3); box-shadow: var(--ring); }
131
143
  .ui-dropdown__main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
132
144
  .ui-dropdown__label { font-family: var(--font-sans); font-size: 12.5px; color: var(--strong); font-weight: 600; letter-spacing: 0.01em; }
133
145
  .ui-dropdown__desc { font-size: 11px; color: var(--muted); line-height: 1.35; }
@@ -153,17 +165,26 @@
153
165
  margin-left: auto;
154
166
  flex: none;
155
167
  color: var(--muted);
156
- background: var(--surface);
168
+ /* A chip is the top step. why: docs/specification.md#elevation */
169
+ background: var(--surface-3);
157
170
  }
158
171
  .ui-dropdown__badge.is-live { color: var(--chip-success-ink); background: var(--chip-success-fill); }
159
- .ui-dropdown__badge.is-accent { color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); }
172
+ /* The accent counter keeps the badge's flat surface and changes only the ink — the shape
173
+ src/styles/nav.css:163 `.ui-nav__item.is-active .ui-nav__badge.is-accent` already holds.
174
+ The accent wash goes on a base surface, never a raised one, and #295 made this panel a
175
+ raised one. why: docs/specification.md#elevation */
176
+ .ui-dropdown__badge.is-accent { color: var(--accent); background: var(--surface-3); }
160
177
 
161
178
  /* Disabled + danger rows */
162
179
  .ui-dropdown__item.is-disabled { color: var(--disabled-ink); cursor: not-allowed; pointer-events: none; }
163
180
  .ui-dropdown__item.is-disabled .ui-dropdown__label,
164
181
  .ui-dropdown__item.is-disabled .ui-dropdown__desc { color: inherit; }
182
+ /* Quiet at rest, --pink on the way to being pressed. Both states, not hover alone:
183
+ the ring says where the reader is, not that this row ends something.
184
+ why: docs/specification.md#a-dropdown-row-is-a-div-a-link-or-a-button */
165
185
  .ui-dropdown__item.is-danger .ui-dropdown__label { color: var(--muted); }
166
- .ui-dropdown__item.is-danger:hover .ui-dropdown__label { color: var(--pink); }
186
+ .ui-dropdown__item.is-danger:hover .ui-dropdown__label,
187
+ .ui-dropdown__item.is-danger:focus-visible .ui-dropdown__label { color: var(--pink); }
167
188
 
168
189
  /* Grouped sections. `~` and :not([hidden]), because `+` counts a group a search
169
190
  query has hidden and drew the divider above the first group left showing. */
@@ -193,11 +214,6 @@
193
214
  variant emits, so the plain dropdown renders as it did.
194
215
  why: docs/specification.md#a-dropdown-with-a-search-field */
195
216
  .ui-dropdown__panel--search { min-width: 280px; }
196
- /* Open, the panel is visible at once instead of at the first step of the
197
- `visibility` transition, because a browser will not focus a field in a box
198
- that is still `hidden` and opening puts focus in the field. Closing still fades. */
199
- .ui-dropdown.open .ui-dropdown__panel--search,
200
- .ui-dropdown__panel--search.is-open { transition-property: opacity, transform; }
201
217
 
202
218
  .ui-dropdown__search { position: relative; display: flex; align-items: center; margin-bottom: 6px; }
203
219
  .ui-dropdown__search-ic {
@@ -209,12 +225,16 @@
209
225
  }
210
226
  /* 16px at 2.4 paints 1.6px, over the 1.5px floor stories/glyph-stroke.test.js holds. */
211
227
  .ui-dropdown__search-ic svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: 2.4; }
228
+ /* 12.5px with a mouse only. On a touch screen the touch-zoom net in
229
+ src/styles/field-zoom.css takes this field, and every other field the kit
230
+ ships, to 16px. why: docs/specification.md#a-field-is-16px-on-a-touch-screen */
212
231
  .ui-dropdown__search-input {
213
232
  width: 100%;
214
233
  font: inherit;
215
234
  font-size: 12.5px;
216
235
  color: var(--strong);
217
- background: var(--surface);
236
+ /* A field is the sunken step, the same one .ui-input takes. why: docs/specification.md#elevation */
237
+ background: var(--surface-2);
218
238
  border: 1px solid var(--border);
219
239
  border-radius: var(--radius-sm);
220
240
  padding: 8px 12px 8px 34px;
@@ -223,10 +243,6 @@
223
243
  min-height: 38px;
224
244
  transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease);
225
245
  }
226
- /* iOS Safari zooms the page into a focused field under 16px, and opening the
227
- panel focuses this one. A real 16px, not a scaled one: the zoom reads the
228
- computed size, and a transform would shrink the ring and border with it. */
229
- @media (pointer: coarse) { .ui-dropdown__search-input { font-size: 16px; } }
230
246
  .ui-dropdown__search-input::placeholder { color: var(--muted); }
231
247
  .ui-dropdown__search-input:hover { border-color: var(--border-strong); }
232
248
  .ui-dropdown__search-input:focus-visible { outline: none; border-color: var(--accent); box-shadow: var(--ring); }
@@ -238,7 +254,7 @@
238
254
 
239
255
  /* The row Enter would pick. Focus stays in the field, which carries the ring,
240
256
  so this row takes the hover fill and a bar rather than a second ring. */
241
- .ui-dropdown__item.is-active { background: var(--surface); box-shadow: inset 2px 0 0 var(--accent); }
257
+ .ui-dropdown__item.is-active { background: var(--surface-3); box-shadow: inset 2px 0 0 var(--accent); }
242
258
 
243
259
  /* No match — a nudge and no action, per Guidelines / Microcopy. Empty while
244
260
  anything matches, so it takes no room. */
@@ -9,7 +9,6 @@
9
9
 
10
10
  :root {
11
11
  --ui-fb-pill-grad: linear-gradient(135deg, var(--accent), var(--accent-strong));
12
- --ui-fb-pill-glow: color-mix(in srgb, var(--accent) 45%, transparent);
13
12
  --ui-fb-sel: color-mix(in srgb, var(--accent) 22%, transparent);
14
13
  }
15
14
 
@@ -34,23 +33,21 @@
34
33
  border: 0; text-align: center; font: inherit;
35
34
  font-family: var(--font-sans); font-size: 13px; font-weight: var(--weight-semibold); letter-spacing: .01em;
36
35
  padding: 9px 17px 9px 15px; border-radius: 999px; cursor: pointer; white-space: nowrap; overflow: hidden;
37
- box-shadow: 0 10px 26px var(--ui-fb-pill-glow),
38
- 0 2px 6px color-mix(in srgb, var(--shadow-ink) 28%, transparent),
39
- inset 0 1px 0 color-mix(in srgb, var(--sheen) 28%, transparent);
36
+ /* The pill cast two layers of ink and an accent glow until #295, and grew both on
37
+ hover. Nothing casts now, and it takes no hairline either: it is the accent at full
38
+ fill strength on the page, found the way the solid toast is found. What is left is an
39
+ inset sheen — gloss on the surface, not a shadow under it — so the hover is the travel
40
+ alone and the box-shadow it transitioned went with it.
41
+ why: docs/specification.md#elevation */
42
+ box-shadow: inset 0 1px 0 color-mix(in srgb, var(--sheen) 28%, transparent);
40
43
  transition:
41
44
  transform var(--dur-fast) var(--ease-spring),
42
- opacity var(--dur-fast) var(--ease),
43
- box-shadow var(--dur-fast) var(--ease);
45
+ opacity var(--dur-fast) var(--ease);
44
46
  }
45
47
  .ui-fbpill.show { opacity: 1; pointer-events: auto; transform: translate(-50%, -100%) translateY(-9px) scale(1); }
46
- .ui-fbpill:hover {
47
- box-shadow: 0 14px 34px var(--ui-fb-pill-glow),
48
- 0 3px 9px color-mix(in srgb, var(--shadow-ink) 30%, transparent),
49
- inset 0 1px 0 color-mix(in srgb, var(--sheen) 34%, transparent);
50
- }
51
48
  .ui-fbpill.show:hover { transform: translate(-50%, -100%) translateY(-12px) scale(1.04); }
52
49
  .ui-fbpill:active { transform: translate(-50%, -100%) translateY(-8px) scale(.98); }
53
- .ui-fbpill svg { width: 16px; height: 16px; stroke-width: 2.4; color: currentColor; filter: drop-shadow(0 1px 1px color-mix(in srgb, var(--shadow-ink) 25%, transparent)); }
50
+ .ui-fbpill svg { width: 16px; height: 16px; stroke-width: 2.4; color: currentColor; }
54
51
  .ui-fbpill::before {
55
52
  content: ""; position: absolute; top: 0; bottom: 0; left: -60%; width: 45%;
56
53
  background: linear-gradient(100deg, transparent, color-mix(in srgb, var(--sheen) 55%, transparent), transparent); transform: skewX(-18deg);
@@ -70,7 +67,8 @@
70
67
  .ui-fbcomposer {
71
68
  position: fixed; z-index: 71; left: 50%; bottom: 26px; transform: translateX(-50%) translateY(14px);
72
69
  width: min(520px, calc(100vw - 32px)); background: var(--bg-elevated); border-radius: 18px;
73
- box-shadow: var(--shadow-lg); opacity: 0; pointer-events: none;
70
+ /* The shadow was its only edge until #295; now it takes the hairline. */
71
+ border: 1px solid var(--border); opacity: 0; pointer-events: none;
74
72
  transition: opacity var(--dur-med) var(--ease), transform var(--dur-med) var(--ease); overflow: hidden;
75
73
  }
76
74
  .ui-fbcomposer.show { opacity: 1; pointer-events: auto; transform: translateX(-50%) translateY(0); }
@@ -0,0 +1,20 @@
1
+ /* apliteni-ui — touch-zoom net. iOS Safari zooms the page into a focused field
2
+ * whose text is under 16px and does not zoom back out, so under (pointer: coarse)
3
+ * every field the kit ships is 16px. Element selectors rather than kit classes,
4
+ * and !important for the reason reduced-motion.css takes it; left out are the
5
+ * controls with nothing to type into, which never zoom.
6
+ *
7
+ * The two costs, argued in the spec below: a 16px scaled back down with a
8
+ * transform still zooms, and this is a flat size rather than a floor, so a host
9
+ * field designed ABOVE 16px is shrunk here. The override recipe is there too.
10
+ *
11
+ * why: docs/specification.md#a-field-is-16px-on-a-touch-screen */
12
+
13
+ @media (pointer: coarse) {
14
+ input:not(:where(
15
+ [type="button"], [type="checkbox"], [type="color"], [type="file"], [type="hidden"],
16
+ [type="image"], [type="radio"], [type="range"], [type="reset"], [type="submit"]
17
+ )),
18
+ select,
19
+ textarea { font-size: 16px !important; }
20
+ }