@apliteni/apliteni-ui 0.71.0 → 0.73.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.
@@ -14,6 +14,34 @@
14
14
 
15
15
  **Except:** WCAG 2.5.8 permits spacing, equivalent controls, inline text, user-agent sizes and essential presentation. Overlays must not reach neighbours.
16
16
 
17
+ ## Tap target on a phone
18
+
19
+ <!-- rule: tap-zone -->
20
+
21
+ **Rule:** Below the phone step, reach 44x44 with a transparent layer outside the control, never by growing the control.
22
+
23
+ **Why:** A finger covers more than a cursor, and a control that grows on a phone moves the page a reader already knows.
24
+
25
+ **Do:** Centre a transparent layer on the control and let it stop at half the gap to the nearest neighbour.
26
+
27
+ **Don't:** Grow the drawn control to 44 and push out every row that holds one.
28
+
29
+ **Except:** Only a coarse pointer gets the layer, or a cursor lights a control it is not over. Rows that share an edge — a menu, a dense table, a settings list — have nothing to grow into and stay as they are drawn. Fields generate no layer, so a field reaches the floor by its own height or not at all.
30
+
31
+ ## Space between small targets
32
+
33
+ <!-- rule: tap-spacing -->
34
+
35
+ **Rule:** Below the phone step, open a row of small controls to 44 minus their drawn size, so both tap zones fit between the drawn edges.
36
+
37
+ **Why:** A zone reaches half the gap before it crosses its neighbour's edge, so the gap is what decides whether either control reaches the floor.
38
+
39
+ **Do:** Open a row of 24px marks to 20px on a phone, where both zones fit and neither mark changes size.
40
+
41
+ **Don't:** Keep the desktop 8px and let the control written later in the markup win the overlap.
42
+
43
+ **Except:** A row that is already full opens downward only — widening it across makes the controls narrower, which is the one thing the floor must not do. Rows that share an edge — a menu, a dense table, a settings list — have nothing to open and keep their drawn targets. A row that opens nothing grows no zone at all.
44
+
17
45
  ## Focus ring contrast
18
46
 
19
47
  <!-- rule: ring-contrast -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apliteni/apliteni-ui",
3
- "version": "0.71.0",
3
+ "version": "0.73.0",
4
4
  "workspaces": [
5
5
  "react"
6
6
  ],
package/react/README.md CHANGED
@@ -43,7 +43,7 @@ Both forward a span ref and native span attributes, including `className`.
43
43
  <span><StatusDot live /> API online</span>
44
44
  ```
45
45
 
46
- `Snippet` accepts `label`, `code`, `reveal`, and `copyLabel` props. It treats `code`
46
+ `Snippet` accepts `label`, `code`, `reveal`, `copy`, `copyLabel`, and `children` props. It treats `code`
47
47
  as plain text and copies it exactly as provided. After a successful clipboard write,
48
48
  it shows “Copied” for 1.4 seconds. If copying fails, it shows “Copy failed” so readers
49
49
  can try again or select the text manually.
@@ -52,6 +52,32 @@ can try again or select the text manually.
52
52
  <Snippet label="Terminal" code="npm install @apliteni/apliteni-ui" />
53
53
  ```
54
54
 
55
+ Set `copy={false}` to omit the copy button. Supply React children for highlighted
56
+ text using the existing `.k` (command), `.f` (flag), `.s` (string), `.u` (URL), and
57
+ `.c` (comment) classes. Keep `code` as the original source: copying always uses it,
58
+ regardless of the displayed children. Neither strings nor children are parsed as HTML.
59
+
60
+ Build the children from `code` with `codeTokens`, the same tokenizer the vanilla
61
+ `hlShell` and `hlCode` use, so the text on screen cannot drift from the text the
62
+ button copies. Writing the spans out by hand means keeping two copies of the string
63
+ in step. The second argument is the language — `shell` (the default), `json` or
64
+ `ts`; `codeLanguages` lists them.
65
+
66
+ ```tsx
67
+ import { codeTokens } from '@apliteni/apliteni-ui';
68
+
69
+ const code = '{ "url": "https://example.com/mcp" }';
70
+
71
+ <Snippet label="mcp.json" code={code} copyLabel="Copy configuration">
72
+ {codeTokens(code, 'json').map(({ cls, text }, at) =>
73
+ cls ? <span key={at} className={cls}>{text}</span> : text)}
74
+ </Snippet>
75
+ ```
76
+
77
+ The copy button is icon-only: `copyLabel` is its accessible name and its tooltip
78
+ rather than visible text, so name what is being copied — “Copy command”, “Copy
79
+ configuration”. It still shows words while it confirms.
80
+
55
81
  Before a reveal snippet, explain that the secret is stored hashed and will not be
56
82
  shown again, and ask the reader to copy it now. The page decides when to remove it.
57
83
 
@@ -26,6 +26,89 @@
26
26
  }
27
27
  }
28
28
 
29
+ /* ../src/styles/tap-zone.css */
30
+ :root {
31
+ --tap-min: 44px;
32
+ --tap-aa: 24px;
33
+ --tap-gap: var(--space-5, 20px);
34
+ --tap-clear-x: 0px;
35
+ --tap-clear-y: 0px;
36
+ }
37
+ @media (max-width: 560px) and (pointer: coarse) {
38
+ :where(.ui-seg button, .ui-tabs__tab, .ui-dropdown__trigger, .ui-toast__action, .ui-drawer__close, .ui-snippet__copy, .ui-footer__social) {
39
+ position: relative;
40
+ }
41
+ :where(.ui-btn, .ui-seg button, .ui-tabs__tab, .ui-dropdown__trigger, .ui-toast__action, .ui-drawer__close, .ui-snippet__copy, .ui-footer__social, .ui-switch)::after {
42
+ content: "";
43
+ position: absolute;
44
+ left: 50%;
45
+ top: 50%;
46
+ transform: translate(-50%, -50%);
47
+ width: min(max(100%, var(--tap-min)), calc(100% + var(--tap-clear-x)));
48
+ height: min(max(100%, var(--tap-min)), calc(100% + var(--tap-clear-y)));
49
+ }
50
+ .ui-check input::before,
51
+ .ui-toast__close::before {
52
+ width: max(var(--tap-aa), min(max(100%, var(--tap-min)), calc(100% + var(--tap-clear-x))));
53
+ height: max(var(--tap-aa), min(max(100%, var(--tap-min)), calc(100% + var(--tap-clear-y))));
54
+ }
55
+ .ui-seg {
56
+ row-gap: var(--tap-gap) !important;
57
+ --tap-clear-x: var(--space-1, 4px);
58
+ --tap-clear-y: var(--tap-gap);
59
+ }
60
+ .ui-tabs__list {
61
+ --tap-clear-x: 2px;
62
+ --tap-clear-y: var(--tap-gap);
63
+ }
64
+ .ui-pager__steps {
65
+ row-gap: var(--tap-gap) !important;
66
+ --tap-clear-x: var(--space-1, 4px);
67
+ --tap-clear-y: var(--tap-gap);
68
+ }
69
+ .ui-pager {
70
+ row-gap: var(--tap-gap) !important;
71
+ --tap-clear-y: var(--tap-gap);
72
+ }
73
+ .ui-filter-bar {
74
+ gap: var(--tap-gap) !important;
75
+ --tap-clear-x: var(--tap-gap);
76
+ --tap-clear-y: var(--tap-gap);
77
+ }
78
+ .ui-filter-bar__chip {
79
+ --tap-clear-x: 0px;
80
+ --tap-clear-y: 0px;
81
+ }
82
+ .ui-table__act .ui-btn + .ui-btn {
83
+ margin-inline-start: var(--tap-gap) !important;
84
+ }
85
+ .ui-table__act {
86
+ --tap-clear-x: var(--tap-gap);
87
+ --tap-clear-y: 0px;
88
+ }
89
+ .ui-toast {
90
+ row-gap: var(--tap-gap) !important;
91
+ --tap-clear-y: var(--tap-gap);
92
+ }
93
+ .ui-confirm__acts,
94
+ .ui-toolbar,
95
+ .ui-empty__actions,
96
+ .ui-sx__actions,
97
+ .ui-fbc__foot,
98
+ .ui-drawer__footer,
99
+ .ui-snippet__bar,
100
+ .ui-app__main .ui-card__row {
101
+ row-gap: var(--tap-gap) !important;
102
+ --tap-clear-y: var(--tap-gap);
103
+ }
104
+ .ui-footer__socials,
105
+ .ui-drawer__header {
106
+ gap: var(--tap-gap) !important;
107
+ --tap-clear-x: var(--tap-gap);
108
+ --tap-clear-y: var(--tap-gap);
109
+ }
110
+ }
111
+
29
112
  /* ../src/styles/input.css */
30
113
  .ui-field {
31
114
  display: block;
@@ -519,12 +519,16 @@ declare function DrawerSection({ title, headingLevel, className, children, ...re
519
519
 
520
520
  type SnippetProps = {
521
521
  label?: string;
522
- /** Plain text, displayed and copied without HTML parsing. */
522
+ /** Original text to copy; displayed when children are absent. */
523
523
  code?: string;
524
+ /** Token markup for display only. Strings are never parsed as HTML. */
525
+ children?: ReactNode;
526
+ copy?: boolean;
524
527
  reveal?: boolean;
528
+ /** Accessible name and tooltip for the icon-only copy button; name what it copies. */
525
529
  copyLabel?: string;
526
530
  };
527
- declare function Snippet({ label, code, reveal, copyLabel }: SnippetProps): react.JSX.Element;
531
+ declare function Snippet({ label, code, children, copy, reveal, copyLabel }: SnippetProps): react.JSX.Element;
528
532
 
529
533
  type TabItem = {
530
534
  value: string;
@@ -2048,8 +2048,8 @@ function DrawerSection({ title, headingLevel = 3, className, children, ...rest }
2048
2048
 
2049
2049
  // src/Snippet.tsx
2050
2050
  import { useEffect as useEffect7, useRef as useRef12, useState as useState8 } from "react";
2051
- import { jsx as jsx25, jsxs as jsxs20 } from "react/jsx-runtime";
2052
- function Snippet({ label = "shell", code = "", reveal = false, copyLabel = "Copy" }) {
2051
+ import { Fragment as Fragment6, jsx as jsx25, jsxs as jsxs20 } from "react/jsx-runtime";
2052
+ function Snippet({ label = "shell", code = "", children, copy = true, reveal = false, copyLabel = "Copy code" }) {
2053
2053
  const [status, setStatus] = useState8("idle");
2054
2054
  const request = useRef12(0);
2055
2055
  const timer = useRef12(void 0);
@@ -2059,8 +2059,8 @@ function Snippet({ label = "shell", code = "", reveal = false, copyLabel = "Copy
2059
2059
  request.current++;
2060
2060
  clearTimeout(timer.current);
2061
2061
  };
2062
- }, [code]);
2063
- async function copy() {
2062
+ }, [code, copy]);
2063
+ async function handleCopy() {
2064
2064
  const current = ++request.current;
2065
2065
  clearTimeout(timer.current);
2066
2066
  setStatus("idle");
@@ -2076,19 +2076,29 @@ function Snippet({ label = "shell", code = "", reveal = false, copyLabel = "Copy
2076
2076
  return /* @__PURE__ */ jsxs20("div", { className: reveal ? "ui-snippet ui-snippet--reveal" : "ui-snippet", children: [
2077
2077
  /* @__PURE__ */ jsxs20("div", { className: "ui-snippet__bar", children: [
2078
2078
  /* @__PURE__ */ jsx25("span", { children: label }),
2079
- /* @__PURE__ */ jsxs20("button", { type: "button", className: "ui-snippet__copy", "aria-live": "polite", "aria-atomic": "true", onClick: copy, children: [
2080
- /* @__PURE__ */ jsx25(Icon, { name: status === "copied" ? "check" : "copy" }),
2081
- status === "copied" ? "Copied" : status === "failed" ? "Copy failed" : copyLabel
2079
+ copy && /* @__PURE__ */ jsxs20(Fragment6, { children: [
2080
+ /* @__PURE__ */ jsx25(
2081
+ "button",
2082
+ {
2083
+ type: "button",
2084
+ className: "ui-snippet__copy",
2085
+ "aria-label": copyLabel,
2086
+ title: copyLabel,
2087
+ onClick: handleCopy,
2088
+ children: /* @__PURE__ */ jsx25(Icon, { name: status === "idle" ? "copy" : "check" })
2089
+ }
2090
+ ),
2091
+ /* @__PURE__ */ jsx25("span", { className: "ui-sr ui-snippet__status", role: "status", "aria-live": "polite", children: status === "copied" ? "Copied" : status === "failed" ? "Copy failed" : "" })
2082
2092
  ] })
2083
2093
  ] }),
2084
- /* @__PURE__ */ jsx25("pre", { children: code })
2094
+ /* @__PURE__ */ jsx25("pre", { children: children ?? code })
2085
2095
  ] });
2086
2096
  }
2087
2097
 
2088
2098
  // src/Tabs.tsx
2089
2099
  import { useEffect as useEffect8, useId as useId9, useRef as useRef13 } from "react";
2090
2100
  import { playEntrance } from "@apliteni/apliteni-ui/motion";
2091
- import { Fragment as Fragment6, jsx as jsx26, jsxs as jsxs21 } from "react/jsx-runtime";
2101
+ import { Fragment as Fragment7, jsx as jsx26, jsxs as jsxs21 } from "react/jsx-runtime";
2092
2102
  function Tabs({ items, value, onChange, label }) {
2093
2103
  const id = useId9();
2094
2104
  const previous = useRef13(value);
@@ -2137,7 +2147,7 @@ function Tabs({ items, value, onChange, label }) {
2137
2147
  },
2138
2148
  children: [
2139
2149
  item.label,
2140
- item.count != null && /* @__PURE__ */ jsxs21(Fragment6, { children: [
2150
+ item.count != null && /* @__PURE__ */ jsxs21(Fragment7, { children: [
2141
2151
  " ",
2142
2152
  item.count
2143
2153
  ] })
@@ -2524,7 +2534,7 @@ import { applyTheme, paletteHotkey, prism, railCollapsed, themeIcon, themeName }
2524
2534
  // src/SidebarNav.tsx
2525
2535
  import { forwardRef as forwardRef8, useEffect as useEffect12, useId as useId11, useState as useState11 } from "react";
2526
2536
  import { ENTRANCE_FALLBACK_MS } from "@apliteni/apliteni-ui/motion";
2527
- import { Fragment as Fragment7, jsx as jsx31, jsxs as jsxs26 } from "react/jsx-runtime";
2537
+ import { Fragment as Fragment8, jsx as jsx31, jsxs as jsxs26 } from "react/jsx-runtime";
2528
2538
  var badgeText = (badge) => typeof badge === "object" ? badge.text : badge;
2529
2539
  function Row({ item, active, activeIs, collapsed, renderLink, sub }) {
2530
2540
  const uid = useId11();
@@ -2550,7 +2560,7 @@ function Row({ item, active, activeIs, collapsed, renderLink, sub }) {
2550
2560
  item.disabled && "is-disabled",
2551
2561
  item.danger && "is-danger"
2552
2562
  ].filter(Boolean).join(" ");
2553
- const children = /* @__PURE__ */ jsxs26(Fragment7, { children: [
2563
+ const children = /* @__PURE__ */ jsxs26(Fragment8, { children: [
2554
2564
  (item.leading ?? item.icon) && /* @__PURE__ */ jsx31("span", { className: "ui-nav__ic", children: item.leading ?? /* @__PURE__ */ jsx31(Icon, { name: item.icon }) }),
2555
2565
  /* @__PURE__ */ jsx31("span", { className: "ui-nav__label", children: item.label }),
2556
2566
  count != null && count !== "" && /* @__PURE__ */ jsx31("span", { className: `ui-nav__badge is-${typeof item.badge === "object" ? item.badge.tone ?? "neutral" : "neutral"}`, children: count }),
@@ -2619,7 +2629,7 @@ var SidebarNav = forwardRef8(function SidebarNav2({
2619
2629
  });
2620
2630
 
2621
2631
  // src/AppShell.tsx
2622
- import { Fragment as Fragment8, jsx as jsx32, jsxs as jsxs27 } from "react/jsx-runtime";
2632
+ import { Fragment as Fragment9, jsx as jsx32, jsxs as jsxs27 } from "react/jsx-runtime";
2623
2633
  var path = (value) => value.split(/[?#]/)[0].replace(/\/+$/, "") || "/";
2624
2634
  function AppShell({
2625
2635
  sections,
@@ -2692,7 +2702,7 @@ function AppShell({
2692
2702
  onClick: (event) => {
2693
2703
  if (!event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) setMore(false);
2694
2704
  },
2695
- children: /* @__PURE__ */ jsxs27(Fragment8, { children: [
2705
+ children: /* @__PURE__ */ jsxs27(Fragment9, { children: [
2696
2706
  /* @__PURE__ */ jsx32("span", { className: "ui-nav__ic", children: /* @__PURE__ */ jsx32(Icon, { name: section.icon }) }),
2697
2707
  /* @__PURE__ */ jsx32("span", { className: "ui-nav__label", children: section.label }),
2698
2708
  section.count != null && /* @__PURE__ */ jsx32("span", { className: "ui-nav__badge is-neutral", children: section.count })
@@ -289,30 +289,103 @@ export function emptyState({ art, icon: ic, title, sub, actions } = {}) {
289
289
  }
290
290
 
291
291
  // ---- Snippet -------------------------------------------------------------
292
- // `copyLabel` is both the visible text and the accessible name of the copy
293
- // button. wireTopbar() swaps that text to "✓ Copied" and back, so the button is
294
- // never left nameless — the glyph beside it is decorative (aria-hidden).
292
+ // The copy button is icon-only: `copy` is on the closed list in
293
+ // src/assets/icons.js, the bar is narrow, and the word said nothing the glyph
294
+ // does not. `copyLabel` is therefore the accessible name and the tooltip rather
295
+ // than visible text, and it should name what is being copied — "Copy command",
296
+ // "Copy configuration" — the way every other icon-only control in the kit is
297
+ // named (aria-label plus title, as button() and the theme toggle write it).
298
+ // The confirmation is a glyph swap, not a word: the button holds its 24px box
299
+ // through it, and the word goes to the live region beside it. A permanent
300
+ // aria-label outranks an element's contents, so swapping text into the button
301
+ // would have changed the pixels and left the announced name frozen — the region
302
+ // is what carries "Copied" to a reader who cannot see the check. It ships empty
303
+ // and is filled later, because a role="status" inserted together with its text is
304
+ // silent on several screen readers. The button's own name stays the action, which
305
+ // is still available; wireTopbar() fills the region. `data-orig` records the
306
+ // resting label for a caller that wants it.
295
307
  // `type="button"`: without it a snippet dropped inside a <form> submits the form.
296
- export function snippet({ label = 'shell', code = '', reveal = false, copy = true, copyLabel = 'Copy' } = {}) {
308
+ export function snippet({ label = 'shell', code = '', reveal = false, copy = true, copyLabel = 'Copy code' } = {}) {
297
309
  const copyBtn = copy
298
- ? `<button type="button" class="ui-snippet__copy" data-orig="${esc(copyLabel)}">${icon('copy')}${esc(copyLabel)}</button>`
310
+ ? `<button type="button" class="ui-snippet__copy" aria-label="${esc(copyLabel)}" title="${esc(copyLabel)}" data-orig="${esc(copyLabel)}">${icon('copy')}</button>`
311
+ + '<span class="ui-sr ui-snippet__status" role="status" aria-live="polite"></span>'
299
312
  : '';
300
313
  return `<div class="${cx('ui-snippet', reveal && 'ui-snippet--reveal')}"><div class="ui-snippet__bar"><span>${esc(label)}</span>${copyBtn}</div><pre>${code}</pre></div>`;
301
314
  }
302
315
 
303
- // Tiny shell highlighter: escapes first, then wraps comments/strings/URLs/flags/command.
304
- // Matches the class names in styles/code.css (.k .f .s .u .c). Ported from viz/account.mjs.
305
- export const hlShell = (raw) =>
306
- esc(raw).replace(
307
- /(#[^\n]*)|(&quot;(?:[^&]|&(?!quot;))*&quot;|'[^']*')|(https?:\/\/[^\s"'&]+)|(\B--?[A-Za-z][\w-]*)|(^[a-z][\w.-]*)/gm,
308
- (m, c, s, u, f, cmd) =>
309
- c ? `<span class="c">${c}</span>`
310
- : s ? `<span class="s">${s}</span>`
311
- : u ? `<span class="u">${u}</span>`
312
- : f ? `<span class="f">${f}</span>`
313
- : cmd ? `<span class="k">${cmd}</span>`
314
- : m,
315
- );
316
+ // Tiny highlighters. Each escapes first, then wraps the tokens its language
317
+ // shows, using the class names in styles/code.css (.k .f .s .u .c). Shell is
318
+ // ported from viz/account.mjs; JSON and TypeScript were added for the snippets
319
+ // the kit's own docs show.
320
+ //
321
+ // Every pattern runs over ESCAPED text, so a quoted string ends at &quot; and a
322
+ // shell URL stops at any entity. The group order is the class order beside it.
323
+ //
324
+ // They are small on purpose — they colour short documentation snippets, not
325
+ // arbitrary programs. Known limits: no nested template expressions, no regex
326
+ // literals, and in TypeScript a `//` inside a string reads as a comment unless a
327
+ // `:` precedes it, which is what keeps a URL in a string whole.
328
+ const LANGUAGES = {
329
+ shell: {
330
+ pattern: /(#[^\n]*)|(&quot;(?:[^&]|&(?!quot;))*&quot;|'[^']*')|(https?:\/\/[^\s"'&]+)|(\B--?[A-Za-z][\w-]*)|(^[a-z][\w.-]*)/gm,
331
+ classes: ['c', 's', 'u', 'f', 'k'],
332
+ },
333
+ // A key is a string followed by a colon, matched by lookahead so the colon
334
+ // stays unpainted. Keys, string values and scalars take three different
335
+ // classes, because a number that looks exactly like a key is not highlighting.
336
+ json: {
337
+ pattern: /(&quot;(?:[^&]|&(?!quot;))*&quot;)(?=\s*:)|(&quot;(?:[^&]|&(?!quot;))*&quot;)|(\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
338
+ classes: ['k', 's', 'f'],
339
+ },
340
+ ts: {
341
+ pattern: /(\/\*[\s\S]*?\*\/|(?<![:/])\/\/[^\n]*)|(&quot;(?:[^&]|&(?!quot;))*&quot;|'[^']*'|`[^`]*`)|(\b(?:import|export|from|const|let|var|function|return|async|await|class|extends|implements|interface|type|enum|new|if|else|for|while|of|in|as|default|readonly|void)\b)|(\b(?:true|false|null|undefined)\b|\b\d+(?:\.\d+)?\b)/g,
342
+ classes: ['c', 's', 'k', 'f'],
343
+ },
344
+ };
345
+
346
+ /** The languages codeTokens and hlCode understand, so a gate can discover them. */
347
+ export const codeLanguages = Object.keys(LANGUAGES);
348
+
349
+ const ENTITY = { amp: '&', lt: '<', gt: '>', quot: '"' };
350
+ // The exact inverse of esc: every & in escaped text starts one of these four.
351
+ const unesc = (text) => text.replace(/&(amp|lt|gt|quot);/g, (_, name) => ENTITY[name]);
352
+
353
+ /**
354
+ * A language's tokens, as `[{ cls, text }]` with `cls` null between tokens and
355
+ * `text` the original unescaped source. Concatenating `text` returns `raw`, so a
356
+ * React caller renders the spans from the same string it copies instead of
357
+ * keeping a second hand-written copy in step. hlCode joins the same tokens into
358
+ * HTML; neither can drift from the other. An unrecognised `lang` is read as
359
+ * shell, the way the kit reads every other unknown option name.
360
+ *
361
+ * No pattern ever matches inside an entity — `#`, `h`, `-`, `/`, `*`, a digit and
362
+ * `^[a-z]` do not occur in `&amp;`, `&lt;`, `&gt;` or `&quot;`, no keyword is one
363
+ * of `amp`, `lt`, `gt` or `quot`, and every alternative either stops before `&` or
364
+ * consumes a whole `&quot;` — so each slice is entity-complete and
365
+ * `esc(unesc(slice))` returns the slice unchanged. hlCode's output is therefore
366
+ * byte-identical to wrapping the matches in place, which
367
+ * src/components/snippet-tokens.test.js holds for every language, against a copy
368
+ * of the one-pass implementation hlShell had before #474.
369
+ */
370
+ export function codeTokens(raw, lang = 'shell') {
371
+ const { pattern, classes } = LANGUAGES[lang] || LANGUAGES.shell;
372
+ const escaped = esc(raw);
373
+ const out = [];
374
+ let at = 0;
375
+ pattern.lastIndex = 0;
376
+ for (const match of escaped.matchAll(pattern)) {
377
+ if (match.index > at) out.push({ cls: null, text: unesc(escaped.slice(at, match.index)) });
378
+ out.push({ cls: classes[match.slice(1).findIndex(Boolean)], text: unesc(match[0]) });
379
+ at = match.index + match[0].length;
380
+ }
381
+ if (at < escaped.length) out.push({ cls: null, text: unesc(escaped.slice(at)) });
382
+ return out;
383
+ }
384
+
385
+ export const hlCode = (raw, lang = 'shell') =>
386
+ codeTokens(raw, lang).map(({ cls, text }) => (cls ? `<span class="${cls}">${esc(text)}</span>` : esc(text))).join('');
387
+
388
+ export const hlShell = (raw) => hlCode(raw, 'shell');
316
389
 
317
390
  export { icon };
318
391
  export { illo, illoNames } from '../assets/illustrations.js';
@@ -242,11 +242,26 @@ export function wireTopbar(root = document) {
242
242
  });
243
243
  });
244
244
  });
245
- // Copy buttons
245
+ // Copy buttons. The confirmation is a glyph swap plus a word in the live region
246
+ // beside the button, so the control holds its 24px box and a reader who cannot
247
+ // see the check still hears it. Writing the word into the button would do
248
+ // neither: the box jumps, and the permanent aria-label outranks the contents, so
249
+ // the announced name would never move. The restore reads the markup the button
250
+ // started with — putting `data-orig` back wrote the label as words and dropped
251
+ // the glyph, which only looked harmless while a word sat beside it. #474
246
252
  root.querySelectorAll('.ui-snippet__copy').forEach((btn) => {
253
+ const resting = btn.innerHTML;
254
+ const status = btn.parentElement?.querySelector('.ui-snippet__status');
247
255
  btn.addEventListener('click', () => {
248
256
  const pre = btn.closest('.ui-snippet')?.querySelector('pre');
249
- if (pre) { navigator.clipboard?.writeText(pre.innerText); btn.innerHTML = '✓ Copied'; setTimeout(() => { btn.innerHTML = btn.dataset.orig || 'Copy'; }, 1400); }
257
+ if (!pre) return;
258
+ navigator.clipboard?.writeText(pre.innerText);
259
+ btn.innerHTML = icon('check');
260
+ if (status) status.textContent = 'Copied';
261
+ setTimeout(() => {
262
+ btn.innerHTML = resting;
263
+ if (status) status.textContent = '';
264
+ }, 1400);
250
265
  });
251
266
  });
252
267
  }
package/src/index.css CHANGED
@@ -44,3 +44,6 @@
44
44
  @import "./styles/feedback.css";
45
45
  @import "./styles/success.css";
46
46
  @import "./styles/loading.css";
47
+ /* Last, and that is the point: the tap zone opens a gap a component sheet set,
48
+ so it has to be read after every one of them. why: specification, the tap floor */
49
+ @import "./styles/tap-zone.css";
package/src/index.d.ts CHANGED
@@ -83,6 +83,12 @@ export declare function topbar(opts?: Record<string, unknown>): string;
83
83
  export declare function commandPaletteList(groups?: readonly Record<string, unknown>[], opts?: { uid?: string; from?: number }): string;
84
84
  export declare function deckTextSwitch(active?: string): string;
85
85
  export declare function hlShell(raw: string): string;
86
+ /** Token class from styles/code.css, or null for the text between tokens. */
87
+ export type CodeTokenClass = 'c' | 'f' | 'k' | 's' | 'u';
88
+ export type CodeLanguage = 'shell' | 'json' | 'ts';
89
+ export declare const codeLanguages: readonly CodeLanguage[];
90
+ export declare function codeTokens(raw: string, lang?: CodeLanguage): { cls: CodeTokenClass | null; text: string }[];
91
+ export declare function hlCode(raw: string, lang?: CodeLanguage): string;
86
92
  export declare function pill(label: string, variant?: string): string;
87
93
  export declare function statusDot(live?: boolean): string;
88
94
  export declare function successCheck(variant?: 'line' | 'circled'): string;
package/src/inline.js CHANGED
@@ -30,6 +30,7 @@ export const styles = {
30
30
  base: baseCss,
31
31
  reducedMotion: read('styles/reduced-motion.css'),
32
32
  fieldZoom: read('styles/field-zoom.css'),
33
+ tapZone: read('styles/tap-zone.css'),
33
34
  motion: read('styles/motion.css'),
34
35
  button: read('styles/button.css'),
35
36
  card: read('styles/card.css'),
@@ -92,4 +93,7 @@ export const cssText = [
92
93
  styles.feedback,
93
94
  styles.success,
94
95
  styles.loading,
96
+ // Last in the list for the reason src/index.css imports it last: it opens a
97
+ // gap a component sheet set, so it is read after every one of them.
98
+ styles.tapZone,
95
99
  ].join('\n');
@@ -138,6 +138,7 @@ svg:where(:not([width]):not([height])) {
138
138
 
139
139
  /* Focus-visible ring, shared by all interactive controls */
140
140
  .ui-focusable:focus-visible,
141
+ .ui-snippet :focus-visible,
141
142
  .ui-btn:focus-visible,
142
143
  .ui-input:focus-visible,
143
144
  .ui-seg button:focus-visible,
@@ -30,6 +30,27 @@
30
30
  white-space: pre;
31
31
  font-family: var(--font-mono);
32
32
  }
33
+ /* Scrollable code is a keyboard target, but a <pre> flush with the card and
34
+ without its radius draws a square ring that cuts across the card and squares
35
+ its corners. The card carries the ring instead, so it follows --radius-md and
36
+ survives `overflow: hidden`, which clips descendants and not the card's own
37
+ shadow. --ring-gap comes from the page here, matching the container rule in
38
+ tokens.css: a ring outside its own surface cannot use that surface as its gap.
39
+ The card also keeps a transparent outline, which forced colors repaints as the
40
+ focus signal once box-shadow is gone; the block below is why only one box has
41
+ one there. See stories/snippet-focus.test.js. */
42
+ .ui-snippet pre:focus-visible { box-shadow: none; }
43
+ .ui-snippet:has(pre:focus-visible) { outline: 2px solid transparent; --ring-gap: inherit; box-shadow: var(--ring); }
44
+
45
+ /* Forced colors drops box-shadow, so both boxes fall back to their outlines and
46
+ the system repaints the pre's transparent one as the square this rule set out
47
+ to remove — with the card's rounded one 2px further out, two indicators for one
48
+ focus. The card's outline is the one that survives, so the pre keeps none.
49
+ Emulated in stories/snippet-focus.test.js and captured in Chromium. #474 */
50
+ @media (forced-colors: active) {
51
+ .ui-snippet pre:focus-visible { outline: none; }
52
+ }
53
+
33
54
  .ui-snippet__copy {
34
55
  background: none;
35
56
  border: 0;
@@ -39,14 +60,16 @@
39
60
  font-size: calc(var(--text-xs) + 1px);
40
61
  display: inline-flex;
41
62
  align-items: center;
42
- gap: 6px;
43
63
  padding: 2px 4px;
44
64
  /* WCAG 2.5.8's 24 CSS px target floor, taken as a real box rather than as an
45
- overlay: 2 + a 12px label at line-height 1.62 + 2 came to 23.44, so the
46
- drawn button grows by 0.56px and the bar it sits in grows with it. Nothing
47
- smaller than a rounding error moves, which is why this one is not a hit
48
- area. Measured in stories/guidelines/accessibility-floor.test.js. #219 */
65
+ overlay. The height came first (#219): 2 + a 12px label at line-height 1.62
66
+ + 2 came to 23.44, so the drawn button grew by 0.56px and the bar with it.
67
+ The width is the same floor on the other axis, and it is load-bearing now
68
+ that the label is gone: 4 + a 13px glyph + 4 is 21px. Both are measured in
69
+ stories/guidelines/accessibility-floor.test.js. #474 */
49
70
  min-height: 24px;
71
+ min-width: 24px;
72
+ justify-content: center;
50
73
  transition: color var(--dur-fast) var(--ease);
51
74
  }
52
75
  .ui-snippet__copy:hover { color: var(--accent); }
@@ -0,0 +1,167 @@
1
+ /* apliteni-ui — the tap zone. Below the phone step, to a coarse pointer, a
2
+ * control that draws smaller than --tap-min carries a transparent layer that
3
+ * reaches the floor, and no control is drawn any bigger. Zones alone do not
4
+ * finish it: a layer takes HALF the clear space its container declares, so
5
+ * where the kit packs small controls closer than that, the GAP opens to
6
+ * --tap-gap. The controls keep their size; the space between them grows.
7
+ *
8
+ * Read LAST, after every component sheet — see src/index.css — because each
9
+ * opening overrides a gap one of them already set.
10
+ *
11
+ * why: docs/specification.md#a-tap-reaches-the-floor-below-the-phone-step
12
+ * Held by stories/tap-zone.test.js, measured in a browser at 390.
13
+ */
14
+
15
+ :root {
16
+ /* WCAG 2.5.5, and where iOS and Android land. Not on the spacing scale,
17
+ which runs 40 then 48, because a fingertip is not spacing. Declared here
18
+ rather than in tokens.css for the reason field-zoom.css carries its own 16:
19
+ react/src/index.ts imports this sheet without the token file, and a var()
20
+ resolving to nothing there would size the layer to `auto` in silence. */
21
+ --tap-min: 44px;
22
+
23
+ /* WCAG 2.5.8 (AA), the target the kit holds at every width: the floor this
24
+ sheet raises FROM and may never drop below. */
25
+ --tap-aa: 24px;
26
+
27
+ /* 44 − 24: two zones fit between two of the smallest marks the kit draws. */
28
+ --tap-gap: var(--space-5, 20px);
29
+
30
+ /* Clear space to the nearest neighbour, declared by the container that knows
31
+ its own gap; a zone takes half on each side.
32
+
33
+ ZERO by default, so a zone grows nowhere until a container has opened the
34
+ room — assuming --tap-gap everywhere was measured handing `Keep the
35
+ workspace` a pixel of its own box that answered for `Delete it
36
+ permanently`. */
37
+ --tap-clear-x: 0px;
38
+ --tap-clear-y: 0px;
39
+ }
40
+
41
+ @media (max-width: 560px) and (pointer: coarse) {
42
+ /* The containing block for every layer below. `.ui-btn` already has it; the
43
+ rest take it only inside this query, because `position: relative` lifts a
44
+ control over the in-flow one beside it and that changes who wins a shared
45
+ pixel. */
46
+ :where(
47
+ .ui-seg button,
48
+ .ui-tabs__tab,
49
+ .ui-dropdown__trigger,
50
+ .ui-toast__action,
51
+ .ui-drawer__close,
52
+ .ui-snippet__copy,
53
+ .ui-footer__social
54
+ ) { position: relative; }
55
+
56
+ /* The layer: centred, transparent, sized to the floor, clamped to half the
57
+ clearance. */
58
+ :where(
59
+ .ui-btn,
60
+ .ui-seg button,
61
+ .ui-tabs__tab,
62
+ .ui-dropdown__trigger,
63
+ .ui-toast__action,
64
+ .ui-drawer__close,
65
+ .ui-snippet__copy,
66
+ .ui-footer__social,
67
+ .ui-switch
68
+ )::after {
69
+ content: "";
70
+ position: absolute;
71
+ left: 50%;
72
+ top: 50%;
73
+ transform: translate(-50%, -50%);
74
+ width: min(max(100%, var(--tap-min)), calc(100% + var(--tap-clear-x)));
75
+ height: min(max(100%, var(--tap-min)), calc(100% + var(--tap-clear-y)));
76
+ }
77
+
78
+ /* ::after on these is spoken for — the tick and the dot, the toast close's
79
+ glyph plate — so the existing ::before grows, floored at --tap-aa. The
80
+ floor is load-bearing: `100%` is the PADDING box, 16 on a 19px checkbox
81
+ with a 1.5px border, so without it the clamp takes this layer DOWN to 22
82
+ and under the kit's own minimum. */
83
+ .ui-check input::before,
84
+ .ui-toast__close::before {
85
+ width: max(var(--tap-aa), min(max(100%, var(--tap-min)), calc(100% + var(--tap-clear-x))));
86
+ height: max(var(--tap-aa), min(max(100%, var(--tap-min)), calc(100% + var(--tap-clear-y))));
87
+ }
88
+
89
+ /* -- Where the kit opens a gap, and where it cannot --------------------
90
+ * `!important` for the reason field-zoom.css takes it: each opening
91
+ * overrides a `gap` shorthand a component sheet set, and the React bundle
92
+ * ships this sheet without those sheets, where order is the consumer's. */
93
+
94
+ /* A strip keeps its pills packed across — that tight track IS the control —
95
+ and opens down, where the room is free until it wraps. */
96
+ .ui-seg {
97
+ row-gap: var(--tap-gap) !important;
98
+ --tap-clear-x: var(--space-1, 4px);
99
+ --tap-clear-y: var(--tap-gap);
100
+ }
101
+
102
+ /* Tabs are wide enough across; down, the panel under them has the room. */
103
+ .ui-tabs__list { --tap-clear-x: 2px; --tap-clear-y: var(--tap-gap); }
104
+
105
+ /* Equal cells read as one strip, so they stay packed across and open down,
106
+ which is where this strip wraps at this width. */
107
+ .ui-pager__steps {
108
+ row-gap: var(--tap-gap) !important;
109
+ --tap-clear-x: var(--space-1, 4px);
110
+ --tap-clear-y: var(--tap-gap);
111
+ }
112
+ .ui-pager { row-gap: var(--tap-gap) !important; --tap-clear-y: var(--tap-gap); }
113
+
114
+ /* Chips open both ways: the row wraps, and the button at the end of it is a
115
+ target like the chips are. */
116
+ .ui-filter-bar { gap: var(--tap-gap) !important; --tap-clear-x: var(--tap-gap); --tap-clear-y: var(--tap-gap); }
117
+
118
+ /* Inside a chip there is nothing to open: parting the value from its remove
119
+ mark would draw two controls where the reader sees one. Named on the
120
+ Accessibility minimums page. */
121
+ .ui-filter-bar__chip { --tap-clear-x: 0px; --tap-clear-y: 0px; }
122
+
123
+ /* Row actions open across — the pair #488 names — and cannot open down:
124
+ a dense table's rows share an edge, and parting them is the row height the
125
+ guideline protects. Down, they get the row's own padding, twice. */
126
+ /* Row actions — the pair #488 names — part ACROSS with a margin: the cell is
127
+ a `table-cell`, where `gap` does nothing, and declaring it anyway left the
128
+ mark's zone 9px inside `Revoke`. Nothing down, because a dense table's
129
+ rows share an edge and its padding is not clearance to spend. */
130
+ .ui-table__act .ui-btn + .ui-btn { margin-inline-start: var(--tap-gap) !important; }
131
+ .ui-table__act { --tap-clear-x: var(--tap-gap); --tap-clear-y: 0px; }
132
+
133
+ /* Down only, like the nine rows below: a toast is a full row at this width,
134
+ and opening it across took 24px out of the message for no reach at all —
135
+ its action already clears the floor on the vertical half. The box does not
136
+ open either: a toast clips to its own rounded corners for the timer bar,
137
+ and widening its padding grows the surface rather than the space between
138
+ controls, measured at four marks for 8px on every toast. */
139
+ .ui-toast { row-gap: var(--tap-gap) !important; --tap-clear-y: var(--tap-gap); }
140
+
141
+ /* The kit's remaining control rows open DOWN only. A row of labelled buttons
142
+ at 390 is often already full, so widening its column gap does not move the
143
+ buttons apart — it makes them narrower, and that is the half #488 rules
144
+ out. Measured on a toolbar of three buttons and a dropdown. These rows
145
+ wrap, which is also where the review measured them colliding. */
146
+ .ui-confirm__acts,
147
+ .ui-toolbar,
148
+ .ui-empty__actions,
149
+ .ui-sx__actions,
150
+ .ui-fbc__foot,
151
+ .ui-drawer__footer,
152
+ .ui-snippet__bar,
153
+ .ui-app__main .ui-card__row {
154
+ row-gap: var(--tap-gap) !important;
155
+ --tap-clear-y: var(--tap-gap);
156
+ }
157
+
158
+ /* Both ways: fixed squares cannot overflow the way labels can, and a drawer
159
+ header spends its column gap on the title. Not the accent picker — its
160
+ swatches are on no carrier list, so opening it bought no reach. */
161
+ .ui-footer__socials,
162
+ .ui-drawer__header {
163
+ gap: var(--tap-gap) !important;
164
+ --tap-clear-x: var(--tap-gap);
165
+ --tap-clear-y: var(--tap-gap);
166
+ }
167
+ }