@apliteni/apliteni-ui 0.26.0 → 0.30.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.
Files changed (48) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/react/README.md +111 -2
  4. package/react/dist/index.css +11 -13
  5. package/react/dist/index.d.ts +111 -4
  6. package/react/dist/index.js +811 -167
  7. package/src/components/back.js +57 -0
  8. package/src/components/command-palette.js +597 -0
  9. package/src/components/confirm.js +3 -2
  10. package/src/components/drawer.js +31 -2
  11. package/src/components/dropdown.js +192 -17
  12. package/src/components/feedback.js +2 -2
  13. package/src/components/index.js +5 -2
  14. package/src/components/loading.js +3 -2
  15. package/src/components/nav.js +15 -7
  16. package/src/components/overlay.js +67 -14
  17. package/src/components/pagination.js +344 -0
  18. package/src/components/shell.js +11 -2
  19. package/src/components/tabs.js +7 -1
  20. package/src/components/tooltip.js +237 -0
  21. package/src/components/topbar.js +9 -4
  22. package/src/index.css +4 -0
  23. package/src/index.js +4 -0
  24. package/src/inline.js +8 -0
  25. package/src/motion.js +43 -2
  26. package/src/styles/back.css +42 -0
  27. package/src/styles/badge.css +5 -7
  28. package/src/styles/base.css +8 -7
  29. package/src/styles/button.css +14 -0
  30. package/src/styles/card.css +12 -8
  31. package/src/styles/code.css +3 -4
  32. package/src/styles/command-palette.css +321 -0
  33. package/src/styles/confirm.css +11 -11
  34. package/src/styles/drawer.css +57 -15
  35. package/src/styles/dropdown.css +70 -11
  36. package/src/styles/feedback.css +2 -0
  37. package/src/styles/footer.css +3 -4
  38. package/src/styles/input.css +7 -1
  39. package/src/styles/layout.css +3 -2
  40. package/src/styles/loading.css +5 -0
  41. package/src/styles/nav.css +12 -8
  42. package/src/styles/pagination.css +124 -0
  43. package/src/styles/success.css +3 -2
  44. package/src/styles/table.css +4 -5
  45. package/src/styles/tabs.css +3 -0
  46. package/src/styles/tooltip.css +65 -0
  47. package/src/styles/topbar.css +3 -4
  48. package/src/tokens/tokens.css +14 -8
@@ -0,0 +1,237 @@
1
+ // Tooltip — the readout that shows a value while a pointer rests on the mark
2
+ // holding it. An overlay in every state: one element, rendered once inside its
3
+ // host and absolutely placed there, so showing it moves nothing on the page.
4
+ //
5
+ // <div class="ui-tip-host" data-tip-host>
6
+ // <svg>… <rect data-tip-label="Mar 2026" data-tip-value="€48,210" …/> …</svg>
7
+ // ${tooltip()}
8
+ // </div>
9
+ // wireTooltip(container);
10
+ //
11
+ // A mark carries `data-tip-value`, with `data-tip-label` and `data-tip-detail`;
12
+ // a `[data-tip-anchor]` inside it is where the readout opens.
13
+ // why: docs/specification.md#the-hover-readout
14
+ import { esc } from './index.js';
15
+
16
+ const cx = (...a) => a.filter(Boolean).join(' ');
17
+ const PARTS = ['label', 'value', 'detail'];
18
+ const px = (n) => (typeof n === 'number' && Number.isFinite(n) ? `${n}px` : null);
19
+ let seq = 0;
20
+
21
+ /**
22
+ * The readout. Render it once inside a `[data-tip-host]`; wireTooltip() fills it.
23
+ *
24
+ * @param {object} [o]
25
+ * @param {string} [o.label] which point — a date, a category, a series
26
+ * @param {string} [o.value] the number, already formatted
27
+ * @param {string} [o.detail] at most one comparison, such as "+4.2% on February"
28
+ * @param {string} [o.placement] 'top' (default) | 'bottom' — the side it prefers; the wiring flips it when clipped
29
+ * @param {boolean} [o.open] render it shown, as a picture; its host leaves off `data-tip-host` so no wiring takes it down
30
+ * @param {number} [o.x] with `open`: the mark's centre, px from the host's left
31
+ * @param {number} [o.y] with `open`: the mark's top edge (its bottom for 'bottom'), px from the host's top
32
+ * @param {string} [o.id]
33
+ * @returns {string} html
34
+ */
35
+ export function tooltip({
36
+ label = '', value = '', detail = '', placement = 'top', open = false, x, y, id,
37
+ } = {}) {
38
+ const text = { label, value, detail };
39
+ const named = value != null && String(value) !== '';
40
+ const pos =[['--ui-tip-x', px(x)], ['--ui-tip-y', px(y)]]
41
+ .filter(([, v]) => v).map(([k, v]) => `${k}:${v}`).join(';');
42
+ // A tooltip with nothing in it has no name, so an empty readout — the one a
43
+ // chart renders before any mark is hovered — takes the role with its first value.
44
+ const attrs = [
45
+ `class="${cx('ui-tip', placement === 'bottom' && 'is-below', open && 'is-open')}"`,
46
+ named ? 'role="tooltip"' : '',
47
+ 'data-tip',
48
+ id ? `id="${esc(id)}"` : '',
49
+ pos ? `style="${pos}"` : '',
50
+ ].filter(Boolean).join(' ');
51
+ const spans = PARTS.map((k) => {
52
+ const t = text[k] == null ? '' : String(text[k]);
53
+ return `<span class="ui-tip__${k}"${t ? '' : ' hidden'}>${esc(t)}</span>`;
54
+ }).join('');
55
+ return `<div ${attrs}>${spans}</div>`;
56
+ }
57
+
58
+ // ---- Placement -----------------------------------------------------------
59
+
60
+ // The mark-to-readout distance is --ui-tip-gap in src/styles/tooltip.css. This is
61
+ // the fallback for a document that has not loaded the sheet;
62
+ // src/components/tooltip.test.js pins the two to each other.
63
+ const TIP_GAP = 8;
64
+
65
+ function tipGap(tip) {
66
+ const declared = parseFloat(getComputedStyle(tip).getPropertyValue('--ui-tip-gap'));
67
+ return Number.isFinite(declared) ? declared : TIP_GAP;
68
+ }
69
+
70
+ // Hosts nest. A mark or a readout belongs to the nearest [data-tip-host] above it,
71
+ // so one mark opens one readout, and an outer host is never handed an inner one's.
72
+ function holds(host, el) {
73
+ const nearest = el.parentElement?.closest('[data-tip-host]') ?? null;
74
+ return host.contains(el) && (nearest === host || !host.contains(nearest));
75
+ }
76
+
77
+ const tipOf = (host) => [...host.querySelectorAll('[data-tip]')].find((tip) => holds(host, tip)) ?? null;
78
+ const clips = (cs) => [cs.overflow, cs.overflowX, cs.overflowY].some((v) => v && v !== 'visible');
79
+
80
+ // What the readout has to stay inside: the viewport less its scrollbars, cut
81
+ // down by the host and every ancestor whose overflow clips. <body> is left out —
82
+ // its overflow is the viewport's, and its box can be shorter than the page.
83
+ function clipBox(host) {
84
+ const view = host.ownerDocument.documentElement;
85
+ const box = { top: 0, left: 0, right: view.clientWidth, bottom: view.clientHeight };
86
+ for (let el = host; el && el !== document.body && el !== document.documentElement; el = el.parentElement) {
87
+ if (!clips(getComputedStyle(el))) continue;
88
+ const r = el.getBoundingClientRect();
89
+ box.top = Math.max(box.top, r.top);
90
+ box.left = Math.max(box.left, r.left);
91
+ box.right = Math.min(box.right, r.right);
92
+ box.bottom = Math.min(box.bottom, r.bottom);
93
+ }
94
+ return box;
95
+ }
96
+
97
+ // Flip only when the preferred side is too tight AND the other side is roomier,
98
+ // so a readout that fits on neither side opens on the roomier one. Then slide
99
+ // it along the mark's edge, no further than it takes to stay inside the box.
100
+ function place(host, tip, mark) {
101
+ const m = (mark.querySelector('[data-tip-anchor]') || mark).getBoundingClientRect();
102
+ const h = host.getBoundingClientRect();
103
+ const clip = clipBox(host);
104
+ const need = tip.offsetHeight + tipGap(tip);
105
+ const above = m.top - clip.top;
106
+ const below = clip.bottom - m.bottom;
107
+ const prefersBelow = tip.__tipPrefersBelow;
108
+ const flip = prefersBelow ? below < need && above > below : above < need && below > above;
109
+ const isBelow = prefersBelow !== flip;
110
+ tip.classList.toggle('is-below', isBelow);
111
+
112
+ const centre = m.left + m.width / 2;
113
+ const ideal = centre - tip.offsetWidth / 2;
114
+ const left = Math.max(clip.left, Math.min(ideal, clip.right - tip.offsetWidth));
115
+ // An absolute box is measured from the host's padding edge and rides its scroll.
116
+ const s = tip.style;
117
+ s.setProperty('--ui-tip-x', `${centre - h.left - host.clientLeft + host.scrollLeft}px`);
118
+ s.setProperty('--ui-tip-y', `${(isBelow ? m.bottom : m.top) - h.top - host.clientTop + host.scrollTop}px`);
119
+ s.setProperty('--ui-tip-shift', `${left - ideal}px`);
120
+ }
121
+
122
+ function fill(tip, mark) {
123
+ tip.setAttribute('role', 'tooltip');
124
+ for (const k of PARTS) {
125
+ const el = tip.querySelector(`.ui-tip__${k}`);
126
+ if (!el) continue;
127
+ const t = mark.getAttribute(`data-tip-${k}`) || '';
128
+ el.textContent = t;
129
+ el.hidden = !t;
130
+ }
131
+ }
132
+
133
+ // ---- Behaviour -----------------------------------------------------------
134
+
135
+ /**
136
+ * Show the host's readout for one mark, until hideTooltip() or Escape. For a chart that does its own
137
+ * hit-testing; its host needs `.ui-tip-host`. After Escape it shows nothing for the dismissed mark
138
+ * until another mark shows or hideTooltip() is called, so a chart may call it on every pointer sample.
139
+ */
140
+ export function showTooltip(host, mark) {
141
+ const tip = tipOf(host);
142
+ if (!tip || !mark || mark === host.__tipDismissed) return;
143
+ if (tip.__tipPrefersBelow == null) tip.__tipPrefersBelow = tip.classList.contains('is-below');
144
+ if (!tip.id) tip.id = `ui-tip-${++seq}`;
145
+ releaseMark(host);
146
+ fill(tip, mark);
147
+ place(host, tip, mark);
148
+ // Described only while shown, and only a mark that carried no description of its own.
149
+ if (!mark.hasAttribute('aria-describedby')) {
150
+ mark.setAttribute('aria-describedby', tip.id);
151
+ mark.__tipDescribed = true;
152
+ }
153
+ host.__tipMark = mark;
154
+ host.__tipDismissed = null;
155
+ tip.__tipHost = host;
156
+ tip.classList.add('is-open');
157
+ wireEscape(host.ownerDocument);
158
+ }
159
+
160
+ function releaseMark(host) {
161
+ const mark = host.__tipMark;
162
+ if (mark?.__tipDescribed) { mark.removeAttribute('aria-describedby'); mark.__tipDescribed = false; }
163
+ host.__tipMark = null;
164
+ }
165
+
166
+ function close(host) {
167
+ releaseMark(host);
168
+ tipOf(host)?.classList.remove('is-open');
169
+ }
170
+
171
+ /** Hide the host's readout: the pointer has left, so the mark Escape dismissed may show again. */
172
+ export function hideTooltip(host) {
173
+ close(host);
174
+ host.__tipDismissed = null;
175
+ }
176
+
177
+ // Escape dismisses every readout the kit is showing without the pointer having
178
+ // to move — the reader it covers something for. One rendered open and never
179
+ // shown is a picture, and stays. The dismissed mark is remembered until another
180
+ // mark shows or the pointer leaves, so crossing the gap between marks and coming
181
+ // back does not return the readout to the mark it was dismissed from.
182
+ function wireEscape(doc) {
183
+ if (doc.__tipEscapeWired) return;
184
+ doc.__tipEscapeWired = true;
185
+ doc.addEventListener('keydown', (e) => {
186
+ if (e.key !== 'Escape') return;
187
+ doc.querySelectorAll('[data-tip].is-open').forEach((tip) => {
188
+ const host = tip.__tipHost;
189
+ if (!host) return;
190
+ const mark = host.__tipMark;
191
+ close(host);
192
+ host.__tipDismissed = mark;
193
+ });
194
+ });
195
+ }
196
+
197
+ // The readout is placed in px from its host, so the host has to be the box it is
198
+ // positioned against. .ui-tip-host makes it one; a host without it is given the same.
199
+ // A host outside the document has no computed style to read, so it waits until it is in one.
200
+ function anchorHost(host) {
201
+ if (host.__tipAnchored || !host.isConnected) return;
202
+ host.__tipAnchored = true;
203
+ if (getComputedStyle(host).position === 'static') host.style.position = 'relative';
204
+ }
205
+
206
+ /**
207
+ * Wire every `[data-tip-host]` under root: a pointer resting on a mark, or focus
208
+ * landing on one, shows the readout; leaving the marks hides it. A host with no
209
+ * readout of its own is given one here, once, never on hover. Safe to call again.
210
+ */
211
+ export function wireTooltip(root = document) {
212
+ const hosts = [...root.querySelectorAll('[data-tip-host]')];
213
+ if (root.matches?.('[data-tip-host]')) hosts.unshift(root);
214
+ hosts.forEach((host) => {
215
+ if (host.__tipWired) return;
216
+ host.__tipWired = true;
217
+ anchorHost(host);
218
+ if (!tipOf(host)) host.insertAdjacentHTML('beforeend', tooltip());
219
+ const markOf = (t) => {
220
+ const mark = t?.closest?.('[data-tip-value]');
221
+ return mark && holds(host, mark) ? mark : null;
222
+ };
223
+ host.addEventListener('pointerover', (e) => {
224
+ anchorHost(host);
225
+ const mark = markOf(e.target);
226
+ if (!mark) close(host);
227
+ else if (mark !== host.__tipMark) showTooltip(host, mark);
228
+ });
229
+ host.addEventListener('pointerleave', () => hideTooltip(host));
230
+ host.addEventListener('focusin', (e) => {
231
+ anchorHost(host);
232
+ const mark = markOf(e.target);
233
+ if (mark) showTooltip(host, mark);
234
+ });
235
+ host.addEventListener('focusout', (e) => { if (!markOf(e.relatedTarget)) hideTooltip(host); });
236
+ });
237
+ }
@@ -41,12 +41,17 @@ export function deckTextSwitch(active = 'deck') {
41
41
  // Thin consumer of the shared dropdown wiring (wireDropdown): keeps its own
42
42
  // bespoke .vsw/.vopt classes for a pixel-identical look, but emits the generic
43
43
  // [data-dropdown] hooks so there's ONE open/close/keyboard implementation.
44
+ // `badge` is a tone key and the kit writes the word for it; the stylesheet used
45
+ // to uppercase the key itself. why: docs/specification.md#labels-and-titles
46
+ const VBADGE = { live: 'Live', archive: 'Archive' };
44
47
  export function versionSwitcher(versions = [], activeIdx = 0) {
45
48
  const cur = versions[activeIdx]?.label || '';
46
- const opts = versions.map((v, i) =>
47
- `<div class="vopt" role="option" data-dd-item tabindex="-1" aria-selected="${i === activeIdx}" data-active="${i === activeIdx ? '1' : '0'}">` +
48
- `<span><div class="vname">${v.label}</div><div class="vmeta">${v.meta || ''}</div></span>` +
49
- `<span class="vbadge ${v.badge === 'live' ? 'live' : 'arch'}">${v.badge || 'archive'}</span></div>`).join('');
49
+ const opts = versions.map((v, i) => {
50
+ const key = String(v.badge || 'archive').toLowerCase();
51
+ return `<div class="vopt" role="option" data-dd-item tabindex="-1" aria-selected="${i === activeIdx}" data-active="${i === activeIdx ? '1' : '0'}">` +
52
+ `<span><div class="vname">${v.label}</div><div class="vmeta">${v.meta || ''}</div></span>` +
53
+ `<span class="vbadge ${key === 'live' ? 'live' : 'arch'}">${Object.hasOwn(VBADGE, key) ? VBADGE[key] : v.badge}</span></div>`;
54
+ }).join('');
50
55
  return `<div class="vsw" data-dropdown><button type="button" class="vsw__btn" data-dropdown-trigger aria-haspopup="listbox" aria-expanded="false" aria-label="Version — ${esc(cur)}">` +
51
56
  `<span class="lbl">version:</span><span class="cur">${cur}</span><span class="car"></span></button>` +
52
57
  `<div class="vsw__menu" data-dropdown-panel role="listbox" aria-label="Version">${opts}</div></div>`;
package/src/index.css CHANGED
@@ -24,10 +24,14 @@
24
24
  @import "./styles/tabs.css";
25
25
  @import "./styles/input.css";
26
26
  @import "./styles/dropdown.css";
27
+ @import "./styles/tooltip.css";
27
28
  @import "./styles/nav.css";
29
+ @import "./styles/back.css";
28
30
  @import "./styles/drawer.css";
29
31
  @import "./styles/confirm.css";
32
+ @import "./styles/command-palette.css";
30
33
  @import "./styles/table.css";
34
+ @import "./styles/pagination.css";
31
35
  @import "./styles/empty.css";
32
36
  @import "./styles/callout.css";
33
37
  @import "./styles/code.css";
package/src/index.js CHANGED
@@ -2,10 +2,13 @@
2
2
  // Styles ship separately: `import 'apliteni-ui/css'`.
3
3
  export * from './components/index.js';
4
4
  export * from './components/dropdown.js';
5
+ export * from './components/tooltip.js';
5
6
  export * from './components/tabs.js';
6
7
  export * from './components/nav.js';
8
+ export * from './components/back.js';
7
9
  export * from './components/drawer.js';
8
10
  export * from './components/confirm.js';
11
+ export * from './components/command-palette.js';
9
12
  export * from './components/topbar.js';
10
13
  export * from './components/shell.js';
11
14
  export * from './components/footer.js';
@@ -13,6 +16,7 @@ export * from './components/feedback.js';
13
16
  export * from './components/toasts.js';
14
17
  export * from './components/success.js';
15
18
  export * from './components/loading.js';
19
+ export * from './components/pagination.js';
16
20
  export * from './assets/icons.js';
17
21
  export * from './assets/brand.js';
18
22
  export * from './motion.js';
package/src/inline.js CHANGED
@@ -37,10 +37,14 @@ export const styles = {
37
37
  tabs: read('styles/tabs.css'),
38
38
  input: read('styles/input.css'),
39
39
  dropdown: read('styles/dropdown.css'),
40
+ tooltip: read('styles/tooltip.css'),
40
41
  nav: read('styles/nav.css'),
42
+ back: read('styles/back.css'),
41
43
  drawer: read('styles/drawer.css'),
42
44
  confirm: read('styles/confirm.css'),
45
+ commandPalette: read('styles/command-palette.css'),
43
46
  table: read('styles/table.css'),
47
+ pagination: read('styles/pagination.css'),
44
48
  empty: read('styles/empty.css'),
45
49
  callout: read('styles/callout.css'),
46
50
  code: read('styles/code.css'),
@@ -65,10 +69,14 @@ export const cssText = [
65
69
  styles.tabs,
66
70
  styles.input,
67
71
  styles.dropdown,
72
+ styles.tooltip,
68
73
  styles.nav,
74
+ styles.back,
69
75
  styles.drawer,
70
76
  styles.confirm,
77
+ styles.commandPalette,
71
78
  styles.table,
79
+ styles.pagination,
72
80
  styles.empty,
73
81
  styles.callout,
74
82
  styles.code,
package/src/motion.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // apliteni-ui — motion hook.
2
2
  //
3
- // The one small, optional piece of JS behind the CSS motion library: a
4
- // scroll-reveal activator plus a replay helper. Everything else is pure CSS
3
+ // The one small piece of JS behind the CSS motion library: a scroll-reveal
4
+ // activator, the entrance player the components call when something they show
5
+ // appears (playEntrance), and a replay helper. Everything else is pure CSS
5
6
  // (src/styles/motion.css). Framework-agnostic, no dependencies, guarded so it
6
7
  // no-ops cleanly under `node --test` / SSR (no window, no IntersectionObserver).
7
8
  //
@@ -59,6 +60,46 @@ export function initReveal(root) {
59
60
  return io;
60
61
  }
61
62
 
63
+ /**
64
+ * How long playEntrance() waits for `animationend` before taking the class off
65
+ * itself. Longer than the slowest --dur-* token, so it only ever fires for an
66
+ * animation that never ran; src/motion.test.js holds it above that token.
67
+ */
68
+ export const ENTRANCE_FALLBACK_MS = 1000;
69
+
70
+ const entering = new WeakMap();
71
+
72
+ /**
73
+ * Play the one-shot entrance of something the reader just caused to appear.
74
+ *
75
+ * Adds `className` and takes it off at the element's own `animationend`, or
76
+ * after ENTRANCE_FALLBACK_MS if that never fires (the element was hidden again,
77
+ * or no stylesheet gives the class an animation) — so the class is never left
78
+ * half-applied. The sheet decides what the entrance looks like: the component
79
+ * pairs the class with an animation in its own stylesheet. A component calls
80
+ * this on a change, never at first render, which is how the page loads still.
81
+ * Reduced motion needs no branch here: the net in reduced-motion.css shortens
82
+ * the animation to nothing and `animationend` still fires.
83
+ */
84
+ export function playEntrance(el, className = 'is-entering') {
85
+ if (!el || !el.classList) return;
86
+ entering.get(el)?.();
87
+ el.classList.remove(className);
88
+ void el.offsetWidth; // restart the animation when the class was already on
89
+ el.classList.add(className);
90
+ let timer;
91
+ const done = (e) => {
92
+ if (e && e.target !== el) return; // a descendant's animation bubbling up
93
+ clearTimeout(timer);
94
+ el.removeEventListener('animationend', done);
95
+ el.classList.remove(className);
96
+ entering.delete(el);
97
+ };
98
+ el.addEventListener('animationend', done);
99
+ timer = setTimeout(done, ENTRANCE_FALLBACK_MS);
100
+ entering.set(el, () => done());
101
+ }
102
+
62
103
  /** Restart the CSS animation on an element (for a "replay" control). */
63
104
  export function replay(el) {
64
105
  if (!el || !el.style) return;
@@ -0,0 +1,42 @@
1
+ /* ============================================================================
2
+ * Back link — .ui-back
3
+ * The way up from a page to the page it sits under. It stands above the page
4
+ * title, in the slot a breadcrumb trail would take, and it is quiet at rest: it
5
+ * is on every page below a list, and it is never what the page is for.
6
+ * why: docs/specification.md#the-back-link
7
+ * ========================================================================== */
8
+
9
+ /* Its own line, and only as wide as its words, so the hover ground does not run
10
+ the width of the column. */
11
+ .ui-back {
12
+ display: flex;
13
+ width: fit-content;
14
+ align-items: center;
15
+ gap: var(--space-1);
16
+ /* WCAG 2.5.8's 24px: the line of text alone is 17px tall. */
17
+ min-height: var(--space-6);
18
+ padding: 0 var(--space-2) 0 var(--space-1);
19
+ border-radius: var(--radius-sm);
20
+ font-family: var(--font-sans);
21
+ font-size: var(--text-sm);
22
+ font-weight: var(--weight-medium);
23
+ line-height: 1.3;
24
+ text-decoration: none;
25
+ }
26
+
27
+ /* [href] as well as the class, because a host stylesheet's `a:link` is (0,1,1)
28
+ and outranks a bare class: it would paint this in the host's link colour.
29
+ (0,2,0) wins without !important — the argument `.ui-nav .ui-nav__item` makes
30
+ in nav.css. */
31
+ .ui-back[href] { color: var(--dim); }
32
+ .ui-back[href]:hover { color: var(--strong); background: var(--surface-2); }
33
+ .ui-back:focus-visible { outline: none; box-shadow: var(--ring); }
34
+
35
+ /* 16px, with the stroke raised to match: the glyph set draws at 1.7 on a 24-unit
36
+ box, which is 1.13 CSS px at this size and under the kit's 1.5 for a stroked
37
+ mark. 2.4 paints 1.6. why: docs/specification.md#icons-and-glyphs */
38
+ .ui-back svg { width: 16px; height: 16px; flex: none; stroke-width: 2.4; }
39
+
40
+ /* In the page shell it takes the trail's place, and the trail's distance from
41
+ the title with it. */
42
+ .ui-app__main > .ui-back { margin-bottom: var(--space-5); }
@@ -1,6 +1,6 @@
1
1
  /* ============================================================================
2
2
  * Badge / Pill / Tag
3
- * .ui-badge — uppercase status chip (live / soon / archive / neutral)
3
+ * .ui-badge — status chip (live / soon / archive / neutral)
4
4
  * .ui-tag — soft solid label
5
5
  * .ui-dot — leading status dot (pulses when .is-live)
6
6
  * ========================================================================== */
@@ -9,10 +9,9 @@
9
9
  display: inline-flex;
10
10
  align-items: center;
11
11
  gap: 6px;
12
- font-size: 10px;
13
- letter-spacing: 0.12em;
14
- text-transform: uppercase;
15
- font-weight: var(--weight-bold);
12
+ /* rank: chip */
13
+ font-size: var(--text-xs);
14
+ font-weight: var(--weight-semibold);
16
15
  padding: 3px 9px;
17
16
  border-radius: var(--radius-pill);
18
17
  color: var(--muted);
@@ -45,9 +44,8 @@
45
44
  display: inline-flex;
46
45
  align-items: center;
47
46
  gap: 7px;
47
+ /* rank: chip */
48
48
  font-size: var(--text-xs);
49
- letter-spacing: 0.12em;
50
- text-transform: uppercase;
51
49
  color: var(--muted);
52
50
  background: var(--surface-2);
53
51
  border-radius: var(--radius-pill);
@@ -19,6 +19,7 @@ body {
19
19
  color: var(--text);
20
20
  font-family: var(--font-sans);
21
21
  font-weight: var(--weight-normal);
22
+ /* rank: body */
22
23
  font-size: var(--text-base);
23
24
  line-height: var(--leading-normal);
24
25
  -webkit-font-smoothing: antialiased;
@@ -40,8 +41,8 @@ a {
40
41
  * ELEMENT decides which — never the size. A size threshold would change a
41
42
  * heading's typeface halfway through a resize, which is the one thing a
42
43
  * reader notices. A component that wants a heading tag set in the text face
43
- * says so on its own rule, which outranks this one at (0,1,0); .ui-drawer__title
44
- * and .ui-confirm__title are the two that do.
44
+ * says so on its own rule, which outranks this one at (0,1,0); .ui-card__title,
45
+ * .ui-drawer__title and .ui-confirm__title are the three that do.
45
46
  * why: docs/specification.md#typefaces */
46
47
  h1, h2, h3, h4, h5, h6 {
47
48
  font-family: var(--font-display);
@@ -113,13 +114,13 @@ strong {
113
114
  .ui-container { width: 100%; max-width: var(--container); margin: 0 auto; padding: 0 clamp(14px, 2.4vw, 26px); }
114
115
  .ui-stack > * + * { margin-top: var(--space-4); }
115
116
 
116
- /* Section eyebrow uppercase caption used across cards and headers */
117
+ /* Eyebrowthe label above a title or a figure, in sentence case like every
118
+ label in the kit. why: docs/specification.md#labels-and-titles */
117
119
  .ui-eyebrow {
118
- font-size: var(--text-xs);
119
- letter-spacing: var(--tracking-caps);
120
- text-transform: uppercase;
120
+ /* rank: label */
121
+ font-size: var(--text-sm);
122
+ font-weight: var(--weight-medium);
121
123
  color: var(--muted);
122
- font-weight: var(--weight-semibold);
123
124
  }
124
125
 
125
126
  /* Sensible default size for inline icons that a parent rule doesn't size.
@@ -100,7 +100,21 @@
100
100
  .ui-btn--ghost[aria-disabled="true"] {
101
101
  background: transparent;
102
102
  border-color: transparent;
103
+ color: var(--disabled-ink-bare);
103
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.
110
+
111
+ #273 got here the long way. It first gave the ghost the flat disabled box
112
+ instead, on the claim that nothing would get less readable. That was false:
113
+ --bg and --surface lost up to 0.46. Then the box was rendered in a pager at
114
+ page 1, and the boxed First and Prev read heavier than the boxless Next and
115
+ Last beside them: the controls that were off looked like the live ones. No
116
+ contrast table shows that. The box was reverted and this ink shipped.
117
+ Held by src/styles/button-disabled.test.js. */
104
118
 
105
119
  /* Busy: keep the label, run an indeterminate accent shimmer along the base, and
106
120
  the button is disabled (not clickable). Pass busy + the .ui-btn__bars markup.
@@ -12,11 +12,12 @@
12
12
  }
13
13
 
14
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 — give it a hairline border +
16
- soft shadow to read as a panel. Dark keeps the borderless deck look. */
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. */
17
18
  :root[data-theme="light"] .ui-card {
18
19
  border: 1px solid var(--border);
19
- box-shadow: var(--shadow-card);
20
+ box-shadow: none;
20
21
  }
21
22
 
22
23
  /* A table wider than its card scrolls INSIDE the card instead of bleeding past
@@ -50,17 +51,20 @@
50
51
  border: 0;
51
52
  text-align: left;
52
53
  font: inherit;
53
- transition: transform var(--dur-fast) var(--ease), box-shadow var(--dur-med) var(--ease);
54
- }
55
- .ui-card--interactive:hover {
56
- transform: translateY(-2px);
57
- box-shadow: var(--shadow-md);
54
+ transition: transform var(--dur-fast) var(--ease);
58
55
  }
56
+ /* The lift alone, no shadow: cards are flat (#284), and light never showed one. */
57
+ .ui-card--interactive:hover { transform: translateY(-2px); }
59
58
 
59
+ /* card() emits the title as an h2, so the text face is named here rather than
60
+ left to the element. why: docs/specification.md#labels-and-titles */
60
61
  .ui-card__title {
61
62
  color: var(--strong);
63
+ font-family: var(--font-sans);
64
+ /* rank: card-title */
62
65
  font-size: var(--text-lg);
63
66
  font-weight: var(--weight-semibold);
67
+ line-height: var(--leading-snug);
64
68
  display: flex;
65
69
  align-items: center;
66
70
  gap: 10px;
@@ -15,11 +15,10 @@
15
15
  padding: 10px 15px;
16
16
  }
17
17
  .ui-snippet__bar span {
18
- font-size: 11.5px;
19
- letter-spacing: 0.12em;
20
- text-transform: uppercase;
18
+ /* rank: label */
19
+ font-size: var(--text-sm);
20
+ font-weight: var(--weight-medium);
21
21
  color: var(--muted);
22
- font-weight: var(--weight-semibold);
23
22
  }
24
23
  .ui-snippet pre {
25
24
  margin: 0;