@apliteni/apliteni-ui 0.72.0 → 0.74.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.
@@ -6,13 +6,11 @@
6
6
 
7
7
  **Rule:** Give every pointer target at least 24x24 CSS px.
8
8
 
9
- **Why:** Larger targets are easier to activate accurately.
9
+ **Do:** The dashed square is the hit area the box declares, not a drawing of one.
10
10
 
11
- **Do:** Use a centred 24x24 `::before` hit area for a 19x19 checkbox.
11
+ **Don't:** The target stops where the 19x19 box stops.
12
12
 
13
- **Don't:** Leave a 19x19 target unchanged.
14
-
15
- **Except:** WCAG 2.5.8 permits spacing, equivalent controls, inline text, user-agent sizes and essential presentation. Overlays must not reach neighbours.
13
+ **Except:** WCAG 2.5.8 permits spacing, equivalent controls, inline text, user-agent sizes and essential presentation. An overlay must not reach its neighbours.
16
14
 
17
15
  ## Tap target on a phone
18
16
 
@@ -48,27 +46,23 @@
48
46
 
49
47
  **Rule:** Keep a focus indicator at least 3:1 against every ground it reaches.
50
48
 
51
- **Why:** A solid band remains visible where glow alone may disappear.
52
-
53
- **Do:** Use G2’s solid band with a surface-coloured gap around accent-filled controls.
49
+ **Do:** The solid band, with a surface-coloured gap around an accent-filled control.
54
50
 
55
- **Don't:** Use the halo alone as the focus indicator.
51
+ **Don't:** The halo on its own, with no solid band inside it to measure.
56
52
 
57
- **Except:** The band must reach 4.22:1 on flat ground and 3:1 against actual gap and halo pixels.
53
+ **Except:** The band must clear the bar against the gap and halo pixels too, not only against flat ground.
58
54
 
59
55
  ## Disabled control legibility
60
56
 
61
57
  <!-- rule: disabled-legibility -->
62
58
 
63
- **Rule:** Paint disabled controls with a different ink/surface pair from enabled controls.
59
+ **Rule:** Give a disabled control its own ink and surface pair, and take the accent off it.
64
60
 
65
- **Why:** Opaque colours keep disabled text and surfaces readable.
61
+ **Do:** Unavailable reads as unavailable with the pointer nowhere near it.
66
62
 
67
- **Do:** Use opaque `--disabled-ink`, `--disabled-surface` and `--disabled-border`.
63
+ **Don't:** Unavailable keeps the live paint, so only the cursor reports the state.
68
64
 
69
- **Don't:** Fade the label and box together.
70
-
71
- **Except:** Labels must reach 3:1. Boxless ghosts use `--disabled-ink-bare`, floored at 4.89:1. Disabled controls must look weaker by changing the pair and removing accent. Only the label-free switch track may fade.
65
+ **Except:** A disabled label must stay legible, so the pair changes rather than fades. Only the label-free switch track may fade.
72
66
 
73
67
  ## Touch field text
74
68
 
@@ -76,58 +70,60 @@
76
70
 
77
71
  **Rule:** Set coarse-pointer field text to 16px without scaling it down.
78
72
 
79
- **Why:** iOS Safari zooms fields below 16px and does not zoom out.
73
+ **Why:** iOS Safari zooms into a field under 16px and does not zoom out. There is no specimen pair because the size answers to the pointer rather than the width, so both halves would draw the desktop size.
80
74
 
81
- **Do:** Apply real 16px text to `input`, `select` and `textarea`, including host fields.
75
+ **Do:** Real 16px text in the field itself.
82
76
 
83
- **Don't:** Scale 16px down with transforms or smaller computed text.
77
+ **Don't:** 16px scaled down, or a smaller computed size.
84
78
 
85
- **Except:** Preserve host fields above 16px with `!important` on a more specific selector. Checkbox, radio, range, colour, file and button types do not zoom. Do not remove pinch-zoom with `user-scalable=no` or `maximum-scale=1`. The host owns the viewport tag.
79
+ **Except:** A host field above 16px keeps its size. Checkbox, radio, range, colour, file and button types do not zoom. Never take pinch-zoom away; the host owns the viewport tag.
86
80
 
87
81
  ## Body contrast
88
82
 
89
83
  <!-- rule: body-contrast -->
90
84
 
91
- **Rule:** Aim for body text contrast close to 7:1, not merely 4.5:1.
85
+ **Rule:** Keep body text close to 7:1 at every size, not merely at 4.5:1.
92
86
 
93
- **Why:** Stronger contrast supports comfortable reading at every size.
87
+ **Why:** Stronger contrast supports comfortable reading at every size. There is no specimen pair because a pale example would break this rule in front of the reader.
94
88
 
95
- **Do:** Use body ink at every size, then vary size, weight or spacing for hierarchy.
89
+ **Do:** Body ink at every size, with size, weight and spacing carrying the hierarchy.
96
90
 
97
- **Don't:** Use pale body ink and rely on larger text for hierarchy.
91
+ **Don't:** Pale ink propped up by larger text.
98
92
 
99
93
  ## Status labels
100
94
 
101
95
  <!-- rule: status-label -->
102
96
 
103
- **Rule:** Show every status with both a mark and a word.
97
+ **Rule:** Name every status in words, not by its colour.
104
98
 
105
99
  **Why:** Colour alone is not reliably distinguishable.
106
100
 
107
- **Do:** Pair a status icon with text such as “Paused.”
101
+ **Do:** The word carries the status; the tone fill agrees with it.
102
+
103
+ **Don't:** A tone fill with the word taken out of it.
108
104
 
109
- **Don't:** Show success using green colour alone.
105
+ **Except:** A status badge needs no glyph or dot. A callout or toast keeps its glyph, because its words sit in a sentence rather than in a fill.
110
106
 
111
107
  ## Measurable pairs
112
108
 
113
109
  <!-- rule: measurable-pair -->
114
110
 
115
- **Rule:** Prefer opaque grounds behind text so contrast can be measured.
111
+ **Rule:** Keep text on opaque grounds so its contrast can be measured.
116
112
 
117
- **Why:** Gradients, filters and translucent layers make contrast uncertain.
113
+ **Why:** A gradient, filter or translucent layer puts the ink on more than one ground, so no single pair can be read off it.
118
114
 
119
- **Do:** Place text on an opaque surface with a measurable ink pair.
115
+ **Do:** One opaque ground under the whole sentence.
120
116
 
121
- **Don't:** Place text over a gradient, filter or translucent layer.
117
+ **Don't:** The same sentence crossing a gradient, where each word sits on a different pair.
122
118
 
123
119
  ## Keyboard first
124
120
 
125
121
  <!-- rule: keyboard-first -->
126
122
 
127
- **Rule:** Implement keyboard behaviour before pointer behaviour and styling.
123
+ **Rule:** Build keyboard behaviour before pointer behaviour and styling.
128
124
 
129
- **Why:** Keyboard access defines the interaction before visual polish.
125
+ **Why:** Keyboard access defines the interaction before visual polish. There is no specimen pair because this is an order of work: both halves look the same once they are finished.
130
126
 
131
- **Do:** Make focus, order and keyboard actions work before styling controls.
127
+ **Do:** Focus, order and keyboard actions working before anything is styled.
132
128
 
133
- **Don't:** Style pointer interactions before implementing keyboard behaviour.
129
+ **Don't:** Pointer styling first and keyboard behaviour afterwards.
@@ -6,6 +6,8 @@
6
6
 
7
7
  **Rule:** Labels, buttons, badges and status text name the field, action or state in a few words.
8
8
 
9
+ **Why:** A control is read in the moment before acting, not studied.
10
+
9
11
  **Do:** “Save invoice.”
10
12
 
11
13
  **Don't:** “Click here to save your invoice.”
@@ -16,6 +18,8 @@
16
18
 
17
19
  **Rule:** Use one sentence for needed units, dates or limits.
18
20
 
21
+ **Why:** The figure above the caption cannot say its unit, its period or what it leaves out.
22
+
19
23
  **Do:** “March revenue in EUR, excluding refunds.”
20
24
 
21
25
  **Don't:** “An overview of your revenue.”
@@ -26,6 +30,8 @@
26
30
 
27
31
  **Rule:** Add a short introduction only for a purpose or step the title and controls leave unclear.
28
32
 
33
+ **Why:** An introduction that repeats the title costs the reader their place on the page.
34
+
29
35
  **Do:** “Upload a CSV with invoice numbers, dates and amounts.”
30
36
 
31
37
  **Don't:** “Manage your invoices here.”
@@ -36,6 +42,8 @@
36
42
 
37
43
  **Rule:** Explain a consequence or next step in one or two sentences.
38
44
 
45
+ **Why:** A callout earns its interruption by naming the consequence and what to do about it.
46
+
39
47
  **Do:** “Deleting this workspace deletes its reports. Export them first.”
40
48
 
41
49
  **Don't:** “Think carefully before proceeding.”
@@ -48,6 +56,8 @@
48
56
 
49
57
  **Rule:** Cut text readers can decide and finish without.
50
58
 
59
+ **Why:** Every spare word is read before it can be discarded.
60
+
51
61
  **Do:** “Exports include only filtered rows.”
52
62
 
53
63
  **Don't:** “Export quickly and easily.”
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apliteni/apliteni-ui",
3
- "version": "0.72.0",
3
+ "version": "0.74.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
 
@@ -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.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;
@@ -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); }