@apliteni/apliteni-ui 0.34.1 → 0.34.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apliteni/apliteni-ui",
3
- "version": "0.34.1",
3
+ "version": "0.34.3",
4
4
  "workspaces": [
5
5
  "react"
6
6
  ],
@@ -9,7 +9,8 @@
9
9
  // wireTooltip(container);
10
10
  //
11
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.
12
+ // a `[data-tip-anchor]` inside it is where the readout opens. Under a finger
13
+ // there is no hover, so a tap is the switch: see Pointer kinds below.
13
14
  // why: docs/specification.md#the-hover-readout
14
15
  import { esc } from './index.js';
15
16
 
@@ -157,7 +158,7 @@ export function showTooltip(host, mark) {
157
158
  host.__tipDismissed = null;
158
159
  tip.__tipHost = host;
159
160
  tip.classList.add('is-open');
160
- wireEscape(host.ownerDocument);
161
+ wireDocument(host.ownerDocument);
161
162
  }
162
163
 
163
164
  function releaseMark(host) {
@@ -177,15 +178,60 @@ export function hideTooltip(host) {
177
178
  host.__tipDismissed = null;
178
179
  }
179
180
 
181
+ // A tap that closes a readout dismisses that mark the way Escape does, so a
182
+ // chart sampling its own marks does not bring it straight back. Which is true
183
+ // of the tap that lands on the mark and of the tap that lands on the host's
184
+ // ground beside it: both are the reader putting the readout away.
185
+ function dismiss(host) {
186
+ const mark = host.__tipMark;
187
+ close(host);
188
+ host.__tipDismissed = mark;
189
+ }
190
+
191
+ // ---- Pointer kinds -------------------------------------------------------
192
+
193
+ // A finger has no hover. It arrives already pressing, so the pointerover under
194
+ // a tap is the tap itself: a readout opened on it would open on the way to
195
+ // whatever the mark does when pressed, and go again the moment the finger
196
+ // lifts. Under a coarse pointer the tap is the switch instead — one tap opens
197
+ // the readout, the next closes it, and a tap anywhere else closes it too.
198
+
199
+ /** Whether the device reports no fine pointer at all: a phone, a tablet. */
200
+ function coarse(doc) {
201
+ try {
202
+ return doc.defaultView?.matchMedia?.('(pointer: coarse)')?.matches === true;
203
+ } catch { return false; }
204
+ }
205
+
206
+ // A pointer event says which kind of pointer it is; a click and a focus do not,
207
+ // so the last kind seen is remembered for them. A keystroke clears it, because
208
+ // a keyboard on a tablet is a third way in and focus has to open the readout
209
+ // again after a tap. Before any of that, the media query answers.
210
+ //
211
+ // A pen is on the tap's side of that line with the finger. It presses a screen
212
+ // rather than resting over one, the tablets it comes with report `(pointer:
213
+ // coarse)`, and a hover a pen does offer is a few millimetres of one nothing
214
+ // here is placed against — so a stylus that fell to the hover path would get
215
+ // the flash this component exists to remove. A mouse is the fine pointer.
216
+ function touching(doc, e) {
217
+ if (e?.pointerType) doc.__tipTouch = e.pointerType === 'touch' || e.pointerType === 'pen';
218
+ return doc.__tipTouch ?? coarse(doc);
219
+ }
220
+
180
221
  // Escape dismisses every readout the kit is showing without the pointer having
181
222
  // to move — the reader it covers something for. One rendered open and never
182
223
  // shown is a picture, and stays. The dismissed mark is remembered until another
183
224
  // mark shows or the pointer leaves, so crossing the gap between marks and coming
184
225
  // back does not return the readout to the mark it was dismissed from.
185
- function wireEscape(doc) {
186
- if (doc.__tipEscapeWired) return;
187
- doc.__tipEscapeWired = true;
226
+ //
227
+ // The same document is where the kind of pointer in play is tracked, and where
228
+ // a tap that lands outside a readout's host takes that readout down.
229
+ function wireDocument(doc) {
230
+ if (doc.__tipDocWired) return;
231
+ doc.__tipDocWired = true;
188
232
  doc.addEventListener('keydown', (e) => {
233
+ doc.__tipTouch = false;
234
+ doc.__tipTapping = false;
189
235
  if (e.key !== 'Escape') return;
190
236
  doc.querySelectorAll('[data-tip].is-open').forEach((tip) => {
191
237
  const host = tip.__tipHost;
@@ -195,6 +241,38 @@ function wireEscape(doc) {
195
241
  host.__tipDismissed = mark;
196
242
  });
197
243
  });
244
+ // A tap lands focus on the mark on its way to the click that decides, so the
245
+ // focus between a pointerdown and its click is the tap's own. Focus that no
246
+ // tap brought is not, and opens the readout under a coarse pointer as it
247
+ // always did — a reader swiping through the marks with a screen reader gets
248
+ // the value and the `aria-describedby` that announces it.
249
+ doc.addEventListener('pointerdown', (e) => { touching(doc, e); doc.__tipTapping = true; }, true);
250
+ doc.addEventListener('pointercancel', () => { doc.__tipTapping = false; }, true);
251
+ // Captured, so a host the tap lands in has already lost every other host's
252
+ // readout by the time it opens its own.
253
+ doc.addEventListener('click', (e) => {
254
+ if (e.__tipDismissal) return;
255
+ doc.__tipTapping = false;
256
+ if (!touching(doc)) return;
257
+ doc.querySelectorAll('[data-tip].is-open').forEach((tip) => {
258
+ const host = tip.__tipHost;
259
+ if (host && !host.contains(e.target)) hideTooltip(host);
260
+ });
261
+ }, true);
262
+ }
263
+
264
+ // An opening tap is stopped on its way to the mark, and stopping it takes it
265
+ // away from the document as well — where other overlays keep the click listener
266
+ // that dismisses them, so a dropdown panel would stand open behind the readout
267
+ // the tap just opened. The tap is handed to the document itself instead. Its
268
+ // target is the root element, which every dismissal listener hears and no
269
+ // delegated trigger matches, since no trigger's attribute is on the root.
270
+ function dismissOverlays(doc) {
271
+ const view = doc.defaultView;
272
+ if (!view || !doc.documentElement) return;
273
+ const passed = new view.MouseEvent('click', { bubbles: true, cancelable: true });
274
+ passed.__tipDismissal = true;
275
+ doc.documentElement.dispatchEvent(passed);
198
276
  }
199
277
 
200
278
  // The readout is placed in px from its host, so the host has to be the box it is
@@ -208,8 +286,10 @@ function anchorHost(host) {
208
286
 
209
287
  /**
210
288
  * Wire every `[data-tip-host]` under root: a pointer resting on a mark, or focus
211
- * landing on one, shows the readout; leaving the marks hides it. A host with no
212
- * readout of its own is given one here, once, never on hover. Safe to call again.
289
+ * landing on one, shows the readout; leaving the marks hides it. Under a coarse
290
+ * pointer nothing rests, so a tap on a mark opens the readout and a second tap —
291
+ * or a tap anywhere else — closes it. A host with no readout of its own is given
292
+ * one here, once, never on hover. Safe to call again.
213
293
  */
214
294
  export function wireTooltip(root = document) {
215
295
  const hosts = [...root.querySelectorAll('[data-tip-host]')];
@@ -217,6 +297,8 @@ export function wireTooltip(root = document) {
217
297
  hosts.forEach((host) => {
218
298
  if (host.__tipWired) return;
219
299
  host.__tipWired = true;
300
+ const doc = host.ownerDocument;
301
+ wireDocument(doc);
220
302
  anchorHost(host);
221
303
  if (!tipOf(host)) host.insertAdjacentHTML('beforeend', tooltip());
222
304
  const markOf = (t) => {
@@ -225,16 +307,41 @@ export function wireTooltip(root = document) {
225
307
  };
226
308
  host.addEventListener('pointerover', (e) => {
227
309
  anchorHost(host);
310
+ if (touching(doc, e)) return;
228
311
  const mark = markOf(e.target);
229
312
  if (!mark) close(host);
230
313
  else if (mark !== host.__tipMark) showTooltip(host, mark);
231
314
  });
232
- host.addEventListener('pointerleave', () => hideTooltip(host));
315
+ host.addEventListener('pointerleave', (e) => { if (!touching(doc, e)) hideTooltip(host); });
233
316
  host.addEventListener('focusin', (e) => {
234
317
  anchorHost(host);
318
+ // A tap lands focus on its way to the click that decides, so under a
319
+ // finger the readout waits for that tap rather than opening twice. Focus
320
+ // arriving on its own under the same pointer is a reader reaching the
321
+ // mark another way, and opens it.
322
+ if (touching(doc) && doc.__tipTapping) return;
235
323
  const mark = markOf(e.target);
236
324
  if (mark) showTooltip(host, mark);
237
325
  });
238
326
  host.addEventListener('focusout', (e) => { if (!markOf(e.relatedTarget)) hideTooltip(host); });
327
+ // The tap. Captured, so the tap that opens the readout is spent opening it
328
+ // and never reaches the mark's own click — a chart drilling down on a bar
329
+ // does not drill down on the tap that asked what the bar says. The tap that
330
+ // closes the readout is let through, so the drill-down is one tap further.
331
+ // What the opening tap is stopped from reaching, it is handed to: see
332
+ // dismissOverlays(), so an open overlay closes under it as under any tap.
333
+ host.addEventListener('click', (e) => {
334
+ if (!touching(doc)) return;
335
+ anchorHost(host);
336
+ const mark = markOf(e.target);
337
+ if (!mark || mark === host.__tipMark) { dismiss(host); return; }
338
+ e.preventDefault();
339
+ e.stopPropagation();
340
+ dismissOverlays(doc);
341
+ // A tap is deliberate, so it ends a dismissal rather than being refused by
342
+ // one. What a dismissal stops is the readout coming back on its own.
343
+ host.__tipDismissed = null;
344
+ showTooltip(host, mark);
345
+ }, true);
239
346
  });
240
347
  }
@@ -72,7 +72,7 @@
72
72
  }
73
73
 
74
74
  /* -- Sizes -------------------------------------------------------------- */
75
- .ui-btn--sm { --btn-pad-y: 6px; --btn-pad-x: 12px; --btn-font: 12.5px; }
75
+ .ui-btn--sm { --btn-pad-y: 6px; --btn-pad-x: 12px; --btn-font: calc(var(--text-sm) - 0.5px); }
76
76
  .ui-btn--lg { --btn-pad-y: 13px; --btn-pad-x: 24px; --btn-font: var(--text-md); border-radius: var(--radius-md); }
77
77
 
78
78
  /* Icon-only */
@@ -8,7 +8,7 @@
8
8
  padding: 15px 17px;
9
9
  border-radius: var(--radius-md);
10
10
  background: var(--surface-2);
11
- font-size: 13.5px;
11
+ font-size: calc(var(--text-sm) + 0.5px);
12
12
  line-height: 1.55;
13
13
  color: var(--dim);
14
14
  }
@@ -80,7 +80,7 @@
80
80
  }
81
81
  .ui-card__row + .ui-card__row { border-top: 1px solid var(--border); }
82
82
  .ui-card__row .lab { color: var(--strong); font-size: var(--text-md); font-weight: var(--weight-medium); }
83
- .ui-card__row .hint { color: var(--dim); font-size: 13.5px; margin-top: 3px; }
83
+ .ui-card__row .hint { color: var(--dim); font-size: calc(var(--text-sm) + 0.5px); margin-top: 3px; }
84
84
 
85
85
  /* Icon chip that leads a card header */
86
86
  .ui-card__icon {
@@ -36,7 +36,7 @@
36
36
  color: var(--muted);
37
37
  cursor: pointer;
38
38
  font-family: var(--font-sans);
39
- font-size: 12px;
39
+ font-size: calc(var(--text-xs) + 1px);
40
40
  display: inline-flex;
41
41
  align-items: center;
42
42
  gap: 6px;
@@ -147,7 +147,7 @@
147
147
  .ui-drawer__title {
148
148
  margin: 0;
149
149
  font-family: var(--font-sans);
150
- font-size: 15px;
150
+ font-size: calc(var(--text-base) + 0.5px);
151
151
  font-weight: 600;
152
152
  letter-spacing: 0.01em;
153
153
  color: var(--strong);
@@ -180,7 +180,7 @@
180
180
  padding: 20px;
181
181
  color: var(--text);
182
182
  font-family: var(--font-sans);
183
- font-size: 13px;
183
+ font-size: var(--text-sm);
184
184
  line-height: 1.55;
185
185
  }
186
186
 
@@ -14,7 +14,7 @@
14
14
  gap: 8px;
15
15
  cursor: pointer;
16
16
  font-family: var(--font-sans);
17
- font-size: 12.5px;
17
+ font-size: calc(var(--text-sm) - 0.5px);
18
18
  font-weight: 500;
19
19
  color: var(--text);
20
20
  background: var(--surface);
@@ -155,8 +155,8 @@
155
155
  .ui-dropdown__item:hover { background: var(--surface-3); }
156
156
  .ui-dropdown__item:focus-visible { outline: none; background: var(--surface-3); box-shadow: var(--ring); }
157
157
  .ui-dropdown__main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
158
- .ui-dropdown__label { font-family: var(--font-sans); font-size: 12.5px; color: var(--strong); font-weight: 600; letter-spacing: 0.01em; }
159
- .ui-dropdown__desc { font-size: 11px; color: var(--muted); line-height: 1.35; }
158
+ .ui-dropdown__label { font-family: var(--font-sans); font-size: calc(var(--text-sm) - 0.5px); color: var(--strong); font-weight: 600; letter-spacing: 0.01em; }
159
+ .ui-dropdown__desc { font-size: var(--text-xs); color: var(--muted); line-height: 1.35; }
160
160
  .ui-dropdown__ic { display: inline-flex; flex: none; color: var(--muted); }
161
161
  .ui-dropdown__ic svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 2.1; }
162
162
 
@@ -255,7 +255,7 @@
255
255
  .ui-dropdown__search-input {
256
256
  width: 100%;
257
257
  font: inherit;
258
- font-size: 12.5px;
258
+ font-size: calc(var(--text-sm) - 0.5px);
259
259
  color: var(--strong);
260
260
  /* A field is the sunken step, the same one .ui-input takes. why: docs/specification.md#elevation */
261
261
  background: var(--surface-2);
@@ -290,5 +290,5 @@
290
290
  anything matches, so it takes no room. */
291
291
  .ui-dropdown__none { display: flex; flex-direction: column; gap: 3px; padding: 14px 12px 10px; text-align: center; }
292
292
  .ui-dropdown__none:empty { display: none; }
293
- .ui-dropdown__none-title { font-size: 12.5px; font-weight: 600; color: var(--strong); }
294
- .ui-dropdown__none-hint { font-size: 11px; color: var(--muted); line-height: 1.35; }
293
+ .ui-dropdown__none-title { font-size: calc(var(--text-sm) - 0.5px); font-weight: 600; color: var(--strong); }
294
+ .ui-dropdown__none-hint { font-size: var(--text-xs); color: var(--muted); line-height: 1.35; }
@@ -31,7 +31,7 @@
31
31
  either way and is declared because leaving it to the element is the dependency
32
32
  this refuses. why: #251 */
33
33
  border: 0; text-align: center; font: inherit;
34
- font-family: var(--font-sans); font-size: 13px; font-weight: var(--weight-semibold); letter-spacing: .01em;
34
+ font-family: var(--font-sans); font-size: var(--text-sm); font-weight: var(--weight-semibold); letter-spacing: .01em;
35
35
  padding: 9px 17px 9px 15px; border-radius: 999px; cursor: pointer; white-space: nowrap; overflow: hidden;
36
36
  /* The pill cast two layers of ink and an accent glow until #295, and grew both on
37
37
  hover. Nothing casts now, and it takes no hairline either: it is the accent at full
@@ -74,24 +74,24 @@
74
74
  .ui-fbcomposer.show { opacity: 1; pointer-events: auto; transform: translateX(-50%) translateY(0); }
75
75
 
76
76
  .ui-fbc__head { display: flex; align-items: center; gap: 12px; padding: 17px 20px 0; }
77
- .ui-fbc__chip { display: inline-flex; align-items: center; gap: 8px; color: var(--accent); font-size: 13px; font-weight: var(--weight-semibold); letter-spacing: .01em; white-space: nowrap; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
77
+ .ui-fbc__chip { display: inline-flex; align-items: center; gap: 8px; color: var(--accent); font-size: var(--text-sm); font-weight: var(--weight-semibold); letter-spacing: .01em; white-space: nowrap; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
78
78
  .ui-fbc__chip svg { width: 14px; height: 14px; flex: none; opacity: .85; stroke-width: 2.7; }
79
79
  .ui-fbc__x { margin-left: auto; background: var(--surface-2); color: var(--dim); border: 0; width: 28px; height: 28px; border-radius: 8px; cursor: pointer; display: grid; place-items: center; flex: none; font: inherit; }
80
80
  .ui-fbc__x:hover { color: var(--strong); }
81
81
  .ui-fbc__x svg { width: 14px; height: 14px; stroke-width: 2.7; }
82
82
  .ui-fbc__quote { margin: 14px 20px 0; background: var(--surface-2); border-radius: 12px; padding: 12px 15px; display: flex; gap: 11px; }
83
- .ui-fbc__qm { color: var(--accent); font-size: 22px; font-weight: var(--weight-bold); line-height: .9; flex: none; opacity: .55; }
84
- .ui-fbc__quote q { quotes: none; color: var(--dim); font-style: italic; font-size: 13.5px; line-height: 1.55; }
83
+ .ui-fbc__qm { color: var(--accent); font-size: var(--text-xl); font-weight: var(--weight-bold); line-height: .9; flex: none; opacity: .55; }
84
+ .ui-fbc__quote q { quotes: none; color: var(--dim); font-style: italic; font-size: calc(var(--text-sm) + 0.5px); line-height: 1.55; }
85
85
  .ui-fbc__body { padding: 14px 20px 0; }
86
86
  .ui-fbcomposer textarea {
87
- width: 100%; min-height: 96px; resize: vertical; font-family: var(--font-sans); font-size: 14.5px; line-height: 1.55;
87
+ width: 100%; min-height: 96px; resize: vertical; font-family: var(--font-sans); font-size: var(--text-base); line-height: 1.55;
88
88
  color: var(--strong); background: var(--surface-2); border: 0; border-radius: 12px; padding: 13px 15px; transition: box-shadow var(--dur-fast) var(--ease);
89
89
  }
90
90
  .ui-fbcomposer textarea::placeholder { color: var(--muted); }
91
91
  .ui-fbcomposer textarea:focus { outline: none; box-shadow: 0 0 0 2px var(--accent); }
92
92
  .ui-fbc__foot { display: flex; align-items: center; justify-content: flex-end; gap: 12px; padding: 16px 20px 18px; }
93
93
 
94
- .ui-fbbtn { font-family: var(--font-sans); font-size: 13.5px; font-weight: var(--weight-semibold); border-radius: 10px; padding: 9px 17px; cursor: pointer; border: 0; transition: filter var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease); }
94
+ .ui-fbbtn { font-family: var(--font-sans); font-size: calc(var(--text-sm) + 0.5px); font-weight: var(--weight-semibold); border-radius: 10px; padding: 9px 17px; cursor: pointer; border: 0; transition: filter var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease); }
95
95
  .ui-fbbtn.ghost { background: transparent; color: var(--dim); }
96
96
  .ui-fbbtn.ghost:hover { color: var(--strong); background: var(--surface-2); }
97
97
  .ui-fbbtn.primary { background: var(--accent-strong); color: var(--accent-contrast); display: inline-flex; align-items: center; gap: 9px; }
@@ -114,8 +114,8 @@
114
114
  .ui-fbck-t { fill: none; stroke: var(--surface-2); stroke-width: 9; stroke-linecap: round; stroke-linejoin: round; }
115
115
  .ui-fbck-m { fill: none; stroke: var(--green); stroke-width: 9; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 118; stroke-dashoffset: 118; animation: uiFbCkDraw .6s cubic-bezier(.5,0,.3,1) .1s forwards; /* motion: choreographed — the mark draws while the spring is still settling */ }
116
116
  .ui-fbck-s { fill: none; stroke: var(--sheen); stroke-width: 9; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 15 400; stroke-dashoffset: 133; filter: drop-shadow(0 0 8px var(--green)) drop-shadow(0 0 15px var(--green)); opacity: 0; animation: uiFbCkComet .6s cubic-bezier(.5,0,.3,1) .1s forwards; /* motion: choreographed — the comet rides the mark, so it shares its duration and delay exactly */ }
117
- .ui-fbc__done h4 { color: var(--strong); font-size: 19px; font-weight: var(--weight-semibold); letter-spacing: -.01em; margin-bottom: 7px; }
118
- .ui-fbc__done p { color: var(--dim); font-size: 13.5px; line-height: 1.55; max-width: var(--prose-caption); margin: 0 auto; }
117
+ .ui-fbc__done h4 { color: var(--strong); font-size: calc(var(--text-lg) + 1px); font-weight: var(--weight-semibold); letter-spacing: -.01em; margin-bottom: 7px; }
118
+ .ui-fbc__done p { color: var(--dim); font-size: calc(var(--text-sm) + 0.5px); line-height: 1.55; max-width: var(--prose-caption); margin: 0 auto; }
119
119
  .ui-fbc__done p b { color: var(--text); font-weight: var(--weight-semibold); }
120
120
  .ui-fbc__done .row { margin-top: 18px; display: flex; gap: 10px; justify-content: center; }
121
121
  @keyframes uiFbCkDraw { to { stroke-dashoffset: 0; } }
@@ -123,7 +123,7 @@
123
123
  @keyframes uiFbCkSpring { 0% { transform: scale(1.32); } 62% { transform: scale(.965); } 82% { transform: scale(1.02); } 100% { transform: scale(1); } }
124
124
 
125
125
  /* Error banner */
126
- .ui-fbc__err { margin: 14px 20px 0; background: color-mix(in srgb, var(--pink) 14%, transparent); color: var(--pink); font-size: 13px; padding: 11px 15px; border-radius: 11px; display: none; }
126
+ .ui-fbc__err { margin: 14px 20px 0; background: color-mix(in srgb, var(--pink) 14%, transparent); color: var(--pink); font-size: var(--text-sm); padding: 11px 15px; border-radius: 11px; display: none; }
127
127
  .ui-fbc__err.show { display: block; }
128
128
  /* A failed send's line fades in; wireFeedback() adds the class as it shows it. */
129
129
  .ui-fbc__err.is-entering { animation: m-fade var(--dur-fast) var(--ease-out) both; }
@@ -39,10 +39,10 @@
39
39
  footer breaks into columns, so it answers to the row rather than to a
40
40
  scale. The one width below the floor the measure gate draws. See #208. */
41
41
  .ui-footer__brand { max-width: 300px; }
42
- .ui-footer .ui-footer__brand .brand { font-size: 15px; }
42
+ .ui-footer .ui-footer__brand .brand { font-size: calc(var(--text-base) + 0.5px); }
43
43
  .ui-footer__tagline {
44
44
  margin: 12px 0 0;
45
- font-size: 13px;
45
+ font-size: var(--text-sm);
46
46
  line-height: 1.6;
47
47
  color: var(--footer-text);
48
48
  }
@@ -79,7 +79,7 @@
79
79
  }
80
80
  .ui-footer__links { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 11px; }
81
81
  .ui-footer__links a {
82
- font-size: 13.5px;
82
+ font-size: calc(var(--text-sm) + 0.5px);
83
83
  color: var(--footer-link);
84
84
  text-decoration: none;
85
85
  transition: color var(--dur-fast) var(--ease);
@@ -95,7 +95,7 @@
95
95
  flex-wrap: wrap;
96
96
  padding-top: 22px;
97
97
  border-top: 1px solid var(--footer-border);
98
- font-size: 13px;
98
+ font-size: var(--text-sm);
99
99
  color: var(--footer-link);
100
100
  }
101
101
  .ui-footer__legal { color: var(--footer-link); }
@@ -114,7 +114,7 @@
114
114
  border-top: 1px solid var(--footer-border);
115
115
  }
116
116
  .ui-footer--app .ui-footer__in { max-width: none; padding: 12px clamp(14px, 2.4vw, 22px); }
117
- .ui-footer--app .ui-footer__bar { padding-top: 0; border-top: 0; font-size: 12.5px; }
117
+ .ui-footer--app .ui-footer__bar { padding-top: 0; border-top: 0; font-size: calc(var(--text-sm) - 0.5px); }
118
118
 
119
119
  /* Responsive — columns collapse to a stacked layout on narrow screens. Two of
120
120
  the kit's three steps, and the second one is the phone step rather than a
@@ -156,10 +156,10 @@
156
156
  background: var(--surface-3);
157
157
  color: var(--strong);
158
158
  display: grid; place-items: center;
159
- font-size: 12px;
159
+ font-size: calc(var(--text-xs) + 1px);
160
160
  font-weight: var(--weight-semibold);
161
161
  }
162
- .ui-app__who { font-size: 12.5px; line-height: 1.3; min-width: 0; }
162
+ .ui-app__who { font-size: calc(var(--text-sm) - 0.5px); line-height: 1.3; min-width: 0; }
163
163
  .ui-app__who b { display: block; color: var(--strong); font-weight: var(--weight-semibold); }
164
164
  /* nowrap is what makes the ellipsis fire — without it the address wraps, and
165
165
  the rail column is max-content, so a long one widens the rail instead. */
@@ -193,12 +193,12 @@
193
193
  time it is open it is no longer inside the rail that drew it.
194
194
  why: docs/specification.md#the-dropdown-panel */
195
195
  .ui-app__user-panel .ui-dropdown__head { display: flex; flex-direction: column; gap: 2px; }
196
- .ui-app__user-panel .ui-dropdown__head b { display: block; color: var(--strong); font-size: 12.5px; font-weight: var(--weight-semibold); }
196
+ .ui-app__user-panel .ui-dropdown__head b { display: block; color: var(--strong); font-size: calc(var(--text-sm) - 0.5px); font-weight: var(--weight-semibold); }
197
197
  /* The address wraps rather than truncating: nothing bounds this line, so
198
198
  `text-overflow` would have nothing to fire against — the inert pair
199
199
  .ui-app__who's own comment warns about. `anywhere` keeps an address with no
200
200
  break in it from widening the panel instead. */
201
- .ui-app__user-panel .ui-dropdown__head span { display: block; color: var(--muted); font-size: 11px; overflow-wrap: anywhere; }
201
+ .ui-app__user-panel .ui-dropdown__head span { display: block; color: var(--muted); font-size: var(--text-xs); overflow-wrap: anywhere; }
202
202
 
203
203
  /* The toggle's cell, at the far end of the brand row: the auto margin puts it there
204
204
  with or without a wordmark beside it, and the offset below rides it back onto the
@@ -402,7 +402,7 @@
402
402
  letter-spacing: var(--tracking-tight);
403
403
  margin-bottom: 8px;
404
404
  }
405
- .ui-app__sub { color: var(--dim); font-size: 15px; line-height: 1.6; max-width: var(--prose-body); margin-bottom: 32px; }
405
+ .ui-app__sub { color: var(--dim); font-size: calc(var(--text-base) + 0.5px); line-height: 1.6; max-width: var(--prose-body); margin-bottom: 32px; }
406
406
  .ui-app__body { display: flex; flex-direction: column; gap: var(--space-5); }
407
407
  /* A flex child's min-width is its content by default, so one wide block — a
408
408
  ledger, a code sample — pushes the whole page sideways instead of scrolling
@@ -477,7 +477,7 @@
477
477
  .ui-app__rail .ui-app__user-trigger:is(:hover, :focus-visible) > .ui-app__who { position-anchor: --ui-rail-row; left: calc(anchor(--ui-rail right) + var(--space-2)); top: anchor(top); bottom: anchor(bottom); align-self: center; margin-block: 0; }
478
478
  }
479
479
  .ui-app__main { padding: 28px var(--space-4) 56px; }
480
- .ui-app__main h1 { font-size: 25px; }
480
+ .ui-app__main h1 { font-size: calc(var(--text-xl) + 3px); }
481
481
  /* A settings row is label-beside-control down to the width where the control
482
482
  no longer fits beside anything; below that it stacks. Scoped to the shell
483
483
  rather than written into card.css, because a card row outside a shell is
@@ -507,12 +507,12 @@
507
507
  padding: 38px 36px 32px;
508
508
  }
509
509
  .ui-auth__brand { display: inline-flex; align-items: center; gap: 10px; margin-bottom: 26px; }
510
- .ui-auth__brand span { font-size: 15px; font-weight: 600; color: var(--strong); }
511
- .ui-auth__title { color: var(--strong); font-size: 24px; font-weight: 600; letter-spacing: -0.01em; margin-bottom: 7px; }
512
- .ui-auth__sub { color: var(--dim); font-size: 14.5px; line-height: 1.55; margin-bottom: 26px; }
513
- .ui-auth__foot { margin-top: 24px; text-align: center; font-size: 13px; color: var(--muted); }
510
+ .ui-auth__brand span { font-size: calc(var(--text-base) + 0.5px); font-weight: 600; color: var(--strong); }
511
+ .ui-auth__title { color: var(--strong); font-size: calc(var(--text-xl) + 2px); font-weight: 600; letter-spacing: -0.01em; margin-bottom: 7px; }
512
+ .ui-auth__sub { color: var(--dim); font-size: var(--text-base); line-height: 1.55; margin-bottom: 26px; }
513
+ .ui-auth__foot { margin-top: 24px; text-align: center; font-size: var(--text-sm); color: var(--muted); }
514
514
  .ui-auth__foot a { color: var(--accent); }
515
- .ui-divider { display: flex; align-items: center; gap: 14px; margin: 22px 0; color: var(--muted); font-size: 12px; }
515
+ .ui-divider { display: flex; align-items: center; gap: 14px; margin: 22px 0; color: var(--muted); font-size: calc(var(--text-xs) + 1px); }
516
516
  .ui-divider::before, .ui-divider::after { content: ""; flex: 1; height: 1px; background: var(--border); }
517
517
 
518
518
  /* App scope grant list (OAuth consent) */
@@ -527,8 +527,8 @@
527
527
  color: var(--accent);
528
528
  }
529
529
  .ui-scope__icon svg { width: 21px; height: 21px; stroke: currentColor; fill: none; stroke-width: 1.8; }
530
- .ui-scope__t { color: var(--strong); font-size: 14px; font-weight: 500; }
531
- .ui-scope__d { color: var(--dim); font-size: 13px; margin-top: 2px; line-height: 1.5; }
530
+ .ui-scope__t { color: var(--strong); font-size: calc(var(--text-base) - 0.5px); font-weight: 500; }
531
+ .ui-scope__d { color: var(--dim); font-size: var(--text-sm); margin-top: 2px; line-height: 1.5; }
532
532
 
533
533
  /* -- Landing hero -------------------------------------------------------- */
534
534
  .ui-hero {
@@ -541,7 +541,7 @@
541
541
  display: inline-flex;
542
542
  align-items: center;
543
543
  gap: 8px;
544
- font-size: 12px;
544
+ font-size: calc(var(--text-xs) + 1px);
545
545
  letter-spacing: 0.06em;
546
546
  color: var(--purple-mid);
547
547
  background: var(--glow-purple);
@@ -594,9 +594,9 @@
594
594
  margin-bottom: 16px;
595
595
  }
596
596
  .ui-feature__icon svg { width: 25px; height: 25px; stroke: currentColor; fill: none; stroke-width: 1.8; }
597
- .ui-feature h3 { color: var(--strong); font-size: 17px; font-weight: 600; margin-bottom: 7px; }
598
- .ui-feature p { color: var(--dim); font-size: 14.5px; line-height: 1.6; }
597
+ .ui-feature h3 { color: var(--strong); font-size: calc(var(--text-lg) - 1px); font-weight: 600; margin-bottom: 7px; }
598
+ .ui-feature p { color: var(--dim); font-size: var(--text-base); line-height: 1.6; }
599
599
 
600
600
  .ui-section-head { text-align: center; max-width: var(--panel-lg); margin: 0 auto clamp(40px, 6vh, 64px); }
601
601
  .ui-section-head h2 { color: var(--strong); font-size: clamp(28px, 4vw, 40px); font-weight: 700; letter-spacing: -0.02em; margin-bottom: 14px; }
602
- .ui-section-head p { color: var(--dim); font-size: 17px; line-height: 1.6; }
602
+ .ui-section-head p { color: var(--dim); font-size: calc(var(--text-lg) - 1px); line-height: 1.6; }
@@ -58,7 +58,7 @@
58
58
  padding: 9px 12px;
59
59
  border-radius: 10px;
60
60
  text-decoration: none;
61
- font-size: 14.5px;
61
+ font-size: var(--text-base);
62
62
  font-weight: 400;
63
63
  line-height: 1.2;
64
64
  background: none;
@@ -137,7 +137,7 @@
137
137
  display: inline-flex;
138
138
  align-items: center;
139
139
  justify-content: center;
140
- font-size: 11px;
140
+ font-size: var(--text-xs);
141
141
  font-weight: 600;
142
142
  line-height: 1;
143
143
  background: var(--surface-3);
@@ -188,7 +188,7 @@
188
188
  --m-dist: var(--space-1);
189
189
  animation: m-slide-down var(--dur-fast) var(--ease-out) both;
190
190
  }
191
- .ui-nav__item--sub { font-size: 14px; padding: 7px 12px; }
191
+ .ui-nav__item--sub { font-size: calc(var(--text-base) - 0.5px); padding: 7px 12px; }
192
192
  /* No hairline on a nested current row — but only at rest. box-shadow carries
193
193
  both the hairline and the focus ring, so a rule about the resting look must
194
194
  say so, or it silently cancels the ring (WCAG 2.4.7). */
@@ -269,7 +269,7 @@
269
269
  padding: 11px 14px;
270
270
  color: var(--dim);
271
271
  text-decoration: none;
272
- font-size: 14px;
272
+ font-size: calc(var(--text-base) - 0.5px);
273
273
  font-weight: 500;
274
274
  border-radius: 10px 10px 0 0;
275
275
  transition: color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease);
@@ -322,7 +322,7 @@
322
322
  padding: 3px 6px;
323
323
  border-radius: 7px;
324
324
  text-decoration: none;
325
- font-size: 13px;
325
+ font-size: var(--text-sm);
326
326
  transition: color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease);
327
327
  }
328
328
  /* Carries the block's class as well as its own, for the reason .ui-nav__item
@@ -12,7 +12,7 @@
12
12
  }
13
13
  .ui-seg button {
14
14
  font-family: var(--font-sans);
15
- font-size: 13.5px;
15
+ font-size: calc(var(--text-sm) + 0.5px);
16
16
  font-weight: var(--weight-semibold);
17
17
  letter-spacing: 0.02em;
18
18
  color: var(--dim);
@@ -33,7 +33,7 @@
33
33
  background: var(--seg-active-bg); /* the step is the lift; nothing casts. #295 */
34
34
  }
35
35
 
36
- .ui-seg--sm button { padding: 5px 13px; font-size: 12px; }
36
+ .ui-seg--sm button { padding: 5px 13px; font-size: calc(var(--text-xs) + 1px); }
37
37
  .ui-seg--block { display: flex; }
38
38
  .ui-seg--block button { flex: 1; }
39
39
 
@@ -60,11 +60,11 @@
60
60
  margin-bottom: 10px;
61
61
  }
62
62
  .ui-sx__title {
63
- color: var(--strong); font-size: 22px; font-weight: var(--weight-semibold);
63
+ color: var(--strong); font-size: var(--text-xl); font-weight: var(--weight-semibold);
64
64
  letter-spacing: -.02em; line-height: 1.2; margin: 0;
65
65
  }
66
66
  .ui-sx__body {
67
- color: var(--dim); font-size: 14.5px; line-height: 1.6; margin: 9px 0 0; max-width: var(--prose-caption);
67
+ color: var(--dim); font-size: var(--text-base); line-height: 1.6; margin: 9px 0 0; max-width: var(--prose-caption);
68
68
  }
69
69
  .ui-sx__actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 20px; }
70
70
 
@@ -93,8 +93,8 @@
93
93
  .ui-sx--compact .ui-sx__inner { display: flex; align-items: center; gap: 16px; }
94
94
  .ui-sx--compact .ui-sx__visual { flex: none; }
95
95
  .ui-sx--compact .ui-sx__check { width: 44px; height: 44px; }
96
- .ui-sx--compact .ui-sx__title { font-size: 16px; }
97
- .ui-sx--compact .ui-sx__body { font-size: 13px; margin-top: 3px; }
96
+ .ui-sx--compact .ui-sx__title { font-size: calc(var(--text-md) + 0.5px); }
97
+ .ui-sx--compact .ui-sx__body { font-size: var(--text-sm); margin-top: 3px; }
98
98
  .ui-sx--compact .ui-sx__actions { margin-top: 0; margin-left: auto; flex: none; }
99
99
  .ui-sx--compact .ui-sx__content { flex: 1; display: flex; align-items: center; gap: 16px; }
100
100
  .ui-sx--compact .ui-sx__content > div:first-child,
@@ -166,7 +166,7 @@
166
166
  animation: ui-sx-sweep var(--sx-secs, 5s) linear forwards; /* motion: ambient — the caller sets the countdown; the ring just spends it */
167
167
  }
168
168
  @keyframes ui-sx-sweep { from { --sx-a: 360deg; } to { --sx-a: 0deg; } }
169
- .ui-sx__count-text { color: var(--muted); font-size: 13px; }
169
+ .ui-sx__count-text { color: var(--muted); font-size: var(--text-sm); }
170
170
  .ui-sx__count-text b { color: var(--dim); font-weight: var(--weight-semibold); font-variant-numeric: tabular-nums; }
171
171
 
172
172
  /* ---- Reduced motion — static check, no burst / confetti / sweep ---------- */
@@ -13,7 +13,7 @@
13
13
  background: none;
14
14
  border: 0;
15
15
  cursor: pointer;
16
- font: 600 14px/1 var(--font-sans);
16
+ font: 600 calc(var(--text-base) - 0.5px)/1 var(--font-sans);
17
17
  color: var(--dim);
18
18
  padding: 11px 14px;
19
19
  border-bottom: 2px solid transparent;
@@ -40,7 +40,7 @@
40
40
  }
41
41
  .brand svg { display: block; flex: none; }
42
42
  /* Topbar wordmark runs a touch smaller than the auth-card lockup. */
43
- .topbar .brand { font-size: 13px; }
43
+ .topbar .brand { font-size: var(--text-sm); }
44
44
  .topbar .spacer { flex: 1; }
45
45
 
46
46
  /* Deck / Text (segmented, pill) */
@@ -57,7 +57,7 @@
57
57
  .dtsw a {
58
58
  text-decoration: none;
59
59
  font-family: var(--font-sans);
60
- font-size: 12px;
60
+ font-size: calc(var(--text-xs) + 1px);
61
61
  font-weight: 600;
62
62
  letter-spacing: 0.04em;
63
63
  color: var(--muted);
@@ -97,7 +97,7 @@
97
97
  gap: 8px;
98
98
  cursor: pointer;
99
99
  font-family: var(--font-sans);
100
- font-size: 12.5px;
100
+ font-size: calc(var(--text-sm) - 0.5px);
101
101
  font-weight: 500;
102
102
  color: var(--text);
103
103
  background: var(--surface);
@@ -183,8 +183,8 @@
183
183
  src/styles/dropdown.css:155 `.ui-dropdown__item:hover`.
184
184
  why: docs/specification.md#elevation */
185
185
  .vopt:hover { background: var(--surface-3); }
186
- .vopt .vname { font-family: var(--font-sans); font-size: 12.5px; color: var(--strong); font-weight: 600; letter-spacing: 0.01em; }
187
- .vopt .vmeta { font-size: 11px; color: var(--muted); margin-top: 2px; }
186
+ .vopt .vname { font-family: var(--font-sans); font-size: calc(var(--text-sm) - 0.5px); color: var(--strong); font-weight: 600; letter-spacing: 0.01em; }
187
+ .vopt .vmeta { font-size: var(--text-xs); color: var(--muted); margin-top: 2px; }
188
188
  .vopt .vbadge {
189
189
  /* rank: chip */
190
190
  font-size: var(--text-xs);
@@ -213,7 +213,7 @@
213
213
  color: var(--accent-contrast);
214
214
  font: inherit;
215
215
  font-weight: 600;
216
- font-size: 12.5px;
216
+ font-size: calc(var(--text-sm) - 0.5px);
217
217
  display: grid;
218
218
  place-items: center;
219
219
  cursor: pointer;
@@ -255,9 +255,9 @@
255
255
  .amenu .ahead { padding: 13px 15px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 11px; }
256
256
  .amenu .ahead .avatar { cursor: default; }
257
257
  .amenu .ahead .aw { min-width: 0; display: flex; flex-direction: column; gap: 1px; line-height: 1.35; }
258
- .amenu .ahead .anm { color: var(--strong); font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
259
- .amenu .ahead .aem { color: var(--muted); font-size: 11.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
260
- .amenu a { display: flex; align-items: center; gap: 11px; padding: 10px 15px; color: var(--text); text-decoration: none; font-family: var(--font-sans); font-size: 13px; cursor: pointer; }
258
+ .amenu .ahead .anm { color: var(--strong); font-size: var(--text-sm); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
259
+ .amenu .ahead .aem { color: var(--muted); font-size: calc(var(--text-xs) + 0.5px); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
260
+ .amenu a { display: flex; align-items: center; gap: 11px; padding: 10px 15px; color: var(--text); text-decoration: none; font-family: var(--font-sans); font-size: var(--text-sm); cursor: pointer; }
261
261
  .amenu a:hover { background: var(--surface-3); }
262
262
  .amenu a.cur { color: var(--accent); }
263
263
  .amenu a svg { width: 19px; height: 19px; flex: none; stroke: currentColor; fill: none; stroke-width: 2; opacity: 0.85; }