@cueplusplus/ui 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +1 -1
  3. package/dist/elements/markdown.d.ts +29 -9
  4. package/dist/elements/markdown.js +102 -13
  5. package/dist/forms/_chassis.d.ts +19 -6
  6. package/dist/forms/_chassis.js +19 -6
  7. package/dist/primitives/chip.d.ts +1 -1
  8. package/dist/styles.css +20 -1
  9. package/dist/system/_focus-ring-field.js +14 -2
  10. package/dist/theming/contrast.d.ts +2 -2
  11. package/dist/theming/contrast.js +2 -2
  12. package/dist/theming/create-theme.js +15 -6
  13. package/dist/theming/index.d.ts +2 -2
  14. package/dist/theming/index.js +2 -2
  15. package/dist/theming/serialize.js +2 -1
  16. package/manifest/components/autocomplete.json +1 -0
  17. package/manifest/components/color-field.json +1 -0
  18. package/manifest/components/color-picker.json +1 -0
  19. package/manifest/components/combobox.json +1 -0
  20. package/manifest/components/composer.json +1 -0
  21. package/manifest/components/date-field.json +1 -0
  22. package/manifest/components/date-picker.json +1 -0
  23. package/manifest/components/date-range-picker.json +1 -0
  24. package/manifest/components/env-var-input.json +1 -0
  25. package/manifest/components/input-group.json +1 -0
  26. package/manifest/components/input.json +1 -0
  27. package/manifest/components/markdown-text.json +16 -2
  28. package/manifest/components/multi-select.json +1 -0
  29. package/manifest/components/musical-time-input.json +1 -0
  30. package/manifest/components/number-field.json +1 -0
  31. package/manifest/components/otp-field.json +1 -0
  32. package/manifest/components/password-input.json +1 -0
  33. package/manifest/components/scrub-input.json +1 -0
  34. package/manifest/components/search-input.json +1 -0
  35. package/manifest/components/select.json +1 -0
  36. package/manifest/components/tags-input.json +1 -0
  37. package/manifest/components/textarea.json +1 -0
  38. package/manifest/components/time-field.json +1 -0
  39. package/manifest/manifest.json +25 -25
  40. package/manifest/tokens.json +3 -1
  41. package/package.json +14 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,77 @@
1
1
  # @cueplusplus/ui
2
2
 
3
+ ## 0.24.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 082ef7c: **A focused field's ring is faint now, and you set how faint.** A text field shows its focus ring
8
+ on a mouse click as well as on Tab, so a full-strength `--cue-focus` ring (pure white on the
9
+ default theme) lit up every clicked field like a box. Every recessed field (inputs, textareas,
10
+ selects, comboboxes, the date, colour and number fields, grouped fields) now draws
11
+ `--cue-focus` mixed to **15%**, and its border still lifts to `border-strong` on focus.
12
+
13
+ - **`--cue-style-field-focus-mix`** sets the share, from `0%` (no ring, only the border lift) to
14
+ `100%` (the full ring). Name it in a style set, or on any element:
15
+ `style={{ "--cue-style-field-focus-mix": "40%" }}`.
16
+ - **Inside `data-field-edge="contrast"`** the ring is back at 100%, beside the 3:1 edge.
17
+ - **An invalid field** keeps its danger border, and its ring turns red at the same share.
18
+ - Buttons, tabs, links and every other control keep their full ring, which only shows to a
19
+ keyboard.
20
+
21
+ ### Patch Changes
22
+
23
+ - Updated dependencies [082ef7c]
24
+ - @cueplusplus/tokens@0.24.0
25
+
26
+ ## 0.23.0
27
+
28
+ ### Minor Changes
29
+
30
+ - dae9dec: **A field's edge can reach 3:1: `data-field-edge="contrast"`** (cue-ui-60). Every shipped theme's
31
+ `--cue-border` measured 1.07:1 to 2.02:1 against `surface-1`, under WCAG 1.4.11's 3:1 for the line
32
+ that marks out where to type. The border stays as it is, because it is a box edge and a quiet one on
33
+ purpose, and a field's edge becomes a role of its own.
34
+
35
+ - **`--cue-field-edge`** is each block's border moved toward its ink until it clears 3:1 on its
36
+ `surface-1`, written per block as a literal. Every theme package, `theme-base`'s own palette and
37
+ `createTheme()` write it, and the contrast report holds it to 3:1 as the required pair
38
+ `field-edge/surface-1`.
39
+ - **Opt in with `data-field-edge="contrast"`** on any element, `<html>` included. Every recessed
40
+ control inside then draws its resting edge in `--cue-field-edge`, disabled ones too, and over a
41
+ paint pack's stroke. Hover, focus and invalid edges keep their own colours. Outside the attribute,
42
+ nothing changes.
43
+ - **`edgeToContrast(edge, ground, toward, minimum = 3)`** is the arithmetic, exported from
44
+ `@cueplusplus/theme-base/contrast` and `@cueplusplus/ui/theming`.
45
+ - `@cueplusplus/tokens` publishes `field-edge` as a derived name, with `var(--cue-border)` as its
46
+ template. A stylesheet built before this release falls back to the plain border.
47
+
48
+ **For a theme package of your own:** `theme-tools` now writes one more line per block. Run
49
+ `generate` once after upgrading; until then `check` reports the stylesheet out of date.
50
+
51
+ - 39884b3: **`MarkdownText` renders GitHub tables** (cue-ui-58). An agent's table used to arrive as one
52
+ paragraph of pipes: the parser read CommonMark only, and the sanitizer allowed no table elements.
53
+ Tables are now the one part of GFM the parser reads. Each column is aligned as its delimiter row
54
+ says, header cells sit on `surface-2`, and the table scrolls sideways inside a named,
55
+ keyboard-reachable region rather than widening the turn. Task lists, footnotes and autolink literals
56
+ stay out, and raw HTML is still refused: a cell's markup is text, and alignment is the only style a
57
+ cell carries.
58
+
59
+ **`remarkPlugins`** adds parser plugins after the table syntax. It is a seam for the parser, not
60
+ for the page: whatever a plugin puts in the tree passes the same sanitizer, so it can change what
61
+ the markdown means but cannot widen what renders.
62
+
63
+ ### Migrating
64
+ - **`@cueplusplus/ui/elements/markdown` has two more optional peers**, the table half of GFM:
65
+ `pnpm add micromark-extension-gfm-table mdast-util-gfm-table`. Like `react-markdown` and
66
+ `rehype-sanitize`, they are needed only where this subpath is imported. A build that imports it
67
+ without them fails to resolve the module, and names it.
68
+
69
+ ### Patch Changes
70
+
71
+ - Updated dependencies [dae9dec]
72
+ - @cueplusplus/tokens@0.23.0
73
+ - @cueplusplus/theme-base@1.4.0
74
+
3
75
  ## 0.22.0
4
76
 
5
77
  ### Minor Changes
package/README.md CHANGED
@@ -105,7 +105,7 @@ their own, so importing a button never pulls a charting library into your bundle
105
105
  | `@cueplusplus/ui/chat` | the multi-agent transcript: messages, composer, HITL questions, agent colour |
106
106
  | `@cueplusplus/ui/agent-runtime` | the streaming layer under Chat; needs `@assistant-ui/react` |
107
107
  | `@cueplusplus/ui/elements` | the agent's own surface, vendored from assistant-ui and repainted onto the tokens; needs `lucide-react` and `heat-graph` (+ `./elements.css`) |
108
- | `@cueplusplus/ui/elements/markdown` | `MarkdownText`; needs `react-markdown`, `rehype-sanitize` |
108
+ | `@cueplusplus/ui/elements/markdown` | `MarkdownText`; needs `react-markdown`, `rehype-sanitize`, `micromark-extension-gfm-table`, `mdast-util-gfm-table` |
109
109
  | `@cueplusplus/ui/elements/generative` | the generative-UI renderer and its spec (+ `./elements/generative.css`) |
110
110
  | `@cueplusplus/ui/elements/replay` | the scripted session-replay frame |
111
111
  | `@cueplusplus/ui/dmx` | 512 bytes at wire rate: the bar, the strip, the patch bar, the channel grid |
@@ -1,4 +1,5 @@
1
1
  import * as React from "react";
2
+ import Markdown from "react-markdown";
2
3
  //#region src/elements/markdown.d.ts
3
4
  /** A fenced block, handed to a highlighter that was opted into. */
4
5
  interface MarkdownCodeBlock {
@@ -50,6 +51,16 @@ interface MarkdownTextProps extends Omit<React.ComponentPropsWithoutRef<"div">,
50
51
  * behaviour they inherited.
51
52
  */
52
53
  renderCodeBlock?: (block: MarkdownCodeBlock) => React.ReactNode;
54
+ /**
55
+ * More remark plugins, run after the component's own table syntax.
56
+ *
57
+ * A seam for the parser, not for the page: whatever a plugin adds to the
58
+ * tree still passes the same sanitizer, so a plugin can change what the
59
+ * markdown *means* — a mention syntax, a glossary link — but cannot widen
60
+ * what renders. An element outside the subset is dropped, and an attribute
61
+ * the schema does not name never arrives.
62
+ */
63
+ remarkPlugins?: React.ComponentProps<typeof Markdown>["remarkPlugins"];
53
64
  }
54
65
  /**
55
66
  * Model output, rendered.
@@ -84,9 +95,11 @@ interface MarkdownTextProps extends Omit<React.ComponentPropsWithoutRef<"div">,
84
95
  * - **Attributes are enumerated, not filtered.** An element that is not in the
85
96
  * schema renders nothing; an element that is renders only the attributes
86
97
  * named beside it — `href`/`title` on a link, `src`/`alt`/`title` on an
87
- * image, `language-…` on a `code`. No element in the subset accepts `id`,
98
+ * image, `language-…` on a `code`, and `align` on a table cell, held to
99
+ * `left`, `center` or `right`. No element in the subset accepts `id`,
88
100
  * `style`, `srcset`, `formaction` or any `on*` handler, so none of them has
89
- * an attribute to arrive on.
101
+ * an attribute to arrive on; the one inline style anything carries is a
102
+ * cell's `text-align`, which the renderer writes from that `align`.
90
103
  * - **URLs are allow-listed twice.** `http`, `https` and `mailto` on both
91
104
  * `href` and `src`; a relative URL stays relative; everything else — a
92
105
  * `javascript:` link, a `data:text/html` image, `vbscript:`, `blob:`,
@@ -98,10 +111,14 @@ interface MarkdownTextProps extends Omit<React.ComponentPropsWithoutRef<"div">,
98
111
  *
99
112
  * **The subset.** Paragraphs, ATX and setext headings, bullet and ordered
100
113
  * lists, block quotes, thematic breaks, fenced and indented code, inline code,
101
- * emphasis, strong, strikethrough, links, images, and hard breaks. No GFM
102
- * plugin is enabled, so no tables, task lists, footnotes or autolink literals —
103
- * an explicit `<https://…>` autolink still works. Fenced blocks are plain text
104
- * unless {@link MarkdownTextProps.renderCodeBlock} is given.
114
+ * emphasis, strong, strikethrough, links, images, hard breaks, and GitHub
115
+ * tables. Tables are the one part of GFM the parser reads, each column aligned
116
+ * as its delimiter row says and wrapped in a named region that scrolls
117
+ * sideways; task lists, footnotes and autolink literals stay out — an explicit
118
+ * `<https://…>` autolink still works. Fenced blocks are plain text unless
119
+ * {@link MarkdownTextProps.renderCodeBlock} is given, and
120
+ * {@link MarkdownTextProps.remarkPlugins} adds parser plugins without widening
121
+ * the subset.
105
122
  *
106
123
  * **Streaming.** Pass `streaming` while the turn is arriving and the token the
107
124
  * model is halfway through typing is hidden rather than rendered as
@@ -110,10 +127,11 @@ interface MarkdownTextProps extends Omit<React.ComponentPropsWithoutRef<"div">,
110
127
  * words appear as they arrive; only their formatting waits for the closing
111
128
  * token.
112
129
  *
113
- * `react-markdown` and `rehype-sanitize` are optional peers of this subpath
114
- * alone, which is why this component is not re-exported from
130
+ * `react-markdown`, `rehype-sanitize` and GFM's table pair
131
+ * (`micromark-extension-gfm-table`, `mdast-util-gfm-table`) are optional peers
132
+ * of this subpath alone, which is why this component is not re-exported from
115
133
  * `@cueplusplus/ui/elements`: a console that renders a transcript without
116
- * markdown should install neither.
134
+ * markdown should install none of them.
117
135
  *
118
136
  * @example
119
137
  * <Message agent="researcher">
@@ -125,6 +143,8 @@ interface MarkdownTextProps extends Omit<React.ComponentPropsWithoutRef<"div">,
125
143
  * <MarkdownText renderCodeBlock={({ language, code }) => <Highlight lang={language} code={code} />}>
126
144
  * {answer}
127
145
  * </MarkdownText>
146
+ * @example
147
+ * <MarkdownText remarkPlugins={[remarkMentions]}>{turn.text}</MarkdownText>
128
148
  */
129
149
  declare const MarkdownText: React.ForwardRefExoticComponent<MarkdownTextProps & React.RefAttributes<HTMLDivElement>>;
130
150
  //#endregion
@@ -4,6 +4,8 @@ import { FOCUS_RING } from "../system/_focus-ring.js";
4
4
  import { codeScroll, codeSurface, field, mono } from "./surfaces.js";
5
5
  import * as React from "react";
6
6
  import { jsx } from "react/jsx-runtime";
7
+ import { gfmTableFromMarkdown } from "mdast-util-gfm-table";
8
+ import { gfmTable } from "micromark-extension-gfm-table";
7
9
  import Markdown from "react-markdown";
8
10
  import rehypeSanitize from "rehype-sanitize";
9
11
  //#region src/elements/markdown.tsx
@@ -19,9 +21,13 @@ import rehypeSanitize from "rehype-sanitize";
19
21
  * `MarkdownText`'s JSDoc quotes it, and a test asserts nothing outside it
20
22
  * survives.
21
23
  *
24
+ * The six table elements are here because GFM's table syntax is the one part
25
+ * of GFM the parser reads (see {@link remarkTables}): a table is inert, and it
26
+ * is how an agent presents a comparison.
27
+ *
22
28
  * Deliberately absent, and each for a reason: `input` (GFM task lists would
23
- * put a control inside a transcript), `table` and friends (no GFM plugin is
24
- * enabled, so the parser never produces them), `svg`/`math` (an untrusted
29
+ * put a control inside a transcript), `sup`/`section` (footnotes, which a
30
+ * reply does not need), `svg`/`math` (an untrusted
25
31
  * vector is a script host in several browsers' history), `details`/`summary`
26
32
  * (disclosure is a component, not a token stream), and every sectioning or
27
33
  * embedding tag — `iframe`, `object`, `embed`, `script`, `style`, `form`.
@@ -46,6 +52,12 @@ const ALLOWED_TAGS = [
46
52
  "p",
47
53
  "pre",
48
54
  "strong",
55
+ "table",
56
+ "tbody",
57
+ "td",
58
+ "th",
59
+ "thead",
60
+ "tr",
49
61
  "ul"
50
62
  ];
51
63
  /**
@@ -72,6 +84,9 @@ const ALLOWED_PROTOCOLS = [
72
84
  * handler unreachable by construction instead of by deny-list. `code` may
73
85
  * carry a `className`, and only one matching `language-…`, because that is how
74
86
  * a fenced block's info string reaches {@link MarkdownTextProps.renderCodeBlock}.
87
+ * A table cell may carry `align`, and only one of the three values the
88
+ * delimiter row can write; the renderer turns it into `text-align`, which is
89
+ * the one inline style anything in this surface ever carries.
75
90
  */
76
91
  const SCHEMA = {
77
92
  tagNames: [...ALLOWED_TAGS],
@@ -84,6 +99,18 @@ const SCHEMA = {
84
99
  ],
85
100
  code: [["className", /^language-[\w+#.-]+$/]],
86
101
  ol: ["start"],
102
+ th: [[
103
+ "align",
104
+ "left",
105
+ "center",
106
+ "right"
107
+ ]],
108
+ td: [[
109
+ "align",
110
+ "left",
111
+ "center",
112
+ "right"
113
+ ]],
87
114
  "*": []
88
115
  },
89
116
  protocols: {
@@ -304,6 +331,20 @@ function sealStream(source) {
304
331
  for (let index = 0; index < tail.length; index += 1) if (!erase.has(index)) sealed += tail[index];
305
332
  return head + sealed;
306
333
  }
334
+ /**
335
+ * GitHub's table syntax, as a remark plugin, and nothing else from GFM.
336
+ *
337
+ * `remark-gfm` would bring task lists (a checkbox in a transcript), footnotes
338
+ * and autolink literals along with tables. This registers the table tokenizer
339
+ * and its tree handlers alone, which is what `remark-gfm` itself does for each
340
+ * of its five extensions, one at a time. The sanitizer still decides what a
341
+ * table may render as: the six elements and a cell's `align`.
342
+ */
343
+ function remarkTables() {
344
+ const data = this.data();
345
+ (data.micromarkExtensions ??= []).push(gfmTable());
346
+ (data.fromMarkdownExtensions ??= []).push(gfmTableFromMarkdown());
347
+ }
307
348
  /** The text and info string of a fenced block, read off its hast node. */
308
349
  function readCodeBlock(node) {
309
350
  const code = node?.children?.find((child) => child.tagName === "code");
@@ -347,9 +388,11 @@ function readCodeBlock(node) {
347
388
  * - **Attributes are enumerated, not filtered.** An element that is not in the
348
389
  * schema renders nothing; an element that is renders only the attributes
349
390
  * named beside it — `href`/`title` on a link, `src`/`alt`/`title` on an
350
- * image, `language-…` on a `code`. No element in the subset accepts `id`,
391
+ * image, `language-…` on a `code`, and `align` on a table cell, held to
392
+ * `left`, `center` or `right`. No element in the subset accepts `id`,
351
393
  * `style`, `srcset`, `formaction` or any `on*` handler, so none of them has
352
- * an attribute to arrive on.
394
+ * an attribute to arrive on; the one inline style anything carries is a
395
+ * cell's `text-align`, which the renderer writes from that `align`.
353
396
  * - **URLs are allow-listed twice.** `http`, `https` and `mailto` on both
354
397
  * `href` and `src`; a relative URL stays relative; everything else — a
355
398
  * `javascript:` link, a `data:text/html` image, `vbscript:`, `blob:`,
@@ -361,10 +404,14 @@ function readCodeBlock(node) {
361
404
  *
362
405
  * **The subset.** Paragraphs, ATX and setext headings, bullet and ordered
363
406
  * lists, block quotes, thematic breaks, fenced and indented code, inline code,
364
- * emphasis, strong, strikethrough, links, images, and hard breaks. No GFM
365
- * plugin is enabled, so no tables, task lists, footnotes or autolink literals —
366
- * an explicit `<https://…>` autolink still works. Fenced blocks are plain text
367
- * unless {@link MarkdownTextProps.renderCodeBlock} is given.
407
+ * emphasis, strong, strikethrough, links, images, hard breaks, and GitHub
408
+ * tables. Tables are the one part of GFM the parser reads, each column aligned
409
+ * as its delimiter row says and wrapped in a named region that scrolls
410
+ * sideways; task lists, footnotes and autolink literals stay out — an explicit
411
+ * `<https://…>` autolink still works. Fenced blocks are plain text unless
412
+ * {@link MarkdownTextProps.renderCodeBlock} is given, and
413
+ * {@link MarkdownTextProps.remarkPlugins} adds parser plugins without widening
414
+ * the subset.
368
415
  *
369
416
  * **Streaming.** Pass `streaming` while the turn is arriving and the token the
370
417
  * model is halfway through typing is hidden rather than rendered as
@@ -373,10 +420,11 @@ function readCodeBlock(node) {
373
420
  * words appear as they arrive; only their formatting waits for the closing
374
421
  * token.
375
422
  *
376
- * `react-markdown` and `rehype-sanitize` are optional peers of this subpath
377
- * alone, which is why this component is not re-exported from
423
+ * `react-markdown`, `rehype-sanitize` and GFM's table pair
424
+ * (`micromark-extension-gfm-table`, `mdast-util-gfm-table`) are optional peers
425
+ * of this subpath alone, which is why this component is not re-exported from
378
426
  * `@cueplusplus/ui/elements`: a console that renders a transcript without
379
- * markdown should install neither.
427
+ * markdown should install none of them.
380
428
  *
381
429
  * @example
382
430
  * <Message agent="researcher">
@@ -388,8 +436,10 @@ function readCodeBlock(node) {
388
436
  * <MarkdownText renderCodeBlock={({ language, code }) => <Highlight lang={language} code={code} />}>
389
437
  * {answer}
390
438
  * </MarkdownText>
439
+ * @example
440
+ * <MarkdownText remarkPlugins={[remarkMentions]}>{turn.text}</MarkdownText>
391
441
  */
392
- const MarkdownText = React.forwardRef(function MarkdownText({ className, children, streaming = false, target = "_blank", headingLevel = 2, renderCodeBlock, ...elementProps }, ref) {
442
+ const MarkdownText = React.forwardRef(function MarkdownText({ className, children, streaming = false, target = "_blank", headingLevel = 2, renderCodeBlock, remarkPlugins, ...elementProps }, ref) {
393
443
  const source = streaming ? sealStream(children) : children;
394
444
  const components = React.useMemo(() => markdownComponents({
395
445
  target,
@@ -408,6 +458,7 @@ const MarkdownText = React.forwardRef(function MarkdownText({ className, childre
408
458
  ...elementProps,
409
459
  children: /* @__PURE__ */ jsx(Markdown, {
410
460
  components,
461
+ remarkPlugins: [remarkTables, ...remarkPlugins ?? []],
411
462
  rehypePlugins: [[rehypeSanitize, SCHEMA]],
412
463
  skipHtml: true,
413
464
  urlTransform: safeUrl,
@@ -522,7 +573,45 @@ function markdownComponents(options) {
522
573
  children: renderCodeBlock(block)
523
574
  })
524
575
  });
525
- }
576
+ },
577
+ table: ({ children }) => /* @__PURE__ */ jsx("div", {
578
+ role: "region",
579
+ "aria-label": "Table",
580
+ tabIndex: 0,
581
+ "data-slot": "markdown-table-scroll",
582
+ className: cn("rounded-control border border-border outline-none", codeScroll, FOCUS_RING),
583
+ children: /* @__PURE__ */ jsx("table", {
584
+ "data-slot": "markdown-table",
585
+ className: "w-full border-collapse text-(length:--cue-text-ui)",
586
+ children
587
+ })
588
+ }),
589
+ thead: ({ children }) => /* @__PURE__ */ jsx("thead", {
590
+ "data-slot": "markdown-table-head",
591
+ className: "bg-surface-2",
592
+ children
593
+ }),
594
+ tbody: ({ children }) => /* @__PURE__ */ jsx("tbody", {
595
+ "data-slot": "markdown-table-body",
596
+ children
597
+ }),
598
+ tr: ({ children }) => /* @__PURE__ */ jsx("tr", {
599
+ "data-slot": "markdown-table-row",
600
+ className: "border-b border-border last:border-b-0",
601
+ children
602
+ }),
603
+ th: ({ children, style }) => /* @__PURE__ */ jsx("th", {
604
+ "data-slot": "markdown-table-header",
605
+ style,
606
+ className: "px-(--cue-space-3) py-(--cue-space-2) text-start align-bottom font-medium text-fg",
607
+ children
608
+ }),
609
+ td: ({ children, style }) => /* @__PURE__ */ jsx("td", {
610
+ "data-slot": "markdown-table-cell",
611
+ style,
612
+ className: "px-(--cue-space-3) py-(--cue-space-2) align-top text-fg",
613
+ children
614
+ })
526
615
  };
527
616
  }
528
617
  //#endregion
@@ -35,6 +35,15 @@ import { FormControlSize } from "./_chassis-size.js";
35
35
  * as it does on Tab. Only the non-typing controls that ride this chassis — the
36
36
  * `Select` trigger, the date triggers — actually distinguish the two.
37
37
  *
38
+ * **And quiet means faint** (the owner's call, 2026-10-09). At full strength a
39
+ * white `--cue-focus` ring around every clicked field read as a glaring box, so
40
+ * the ring is `--cue-focus` mixed with transparent to
41
+ * `--cue-style-field-focus-mix`: 15% unless a style set, `:root` or any subtree
42
+ * names another share (`_focus-ring-field.ts`). The rim's lift to
43
+ * `border-strong` carries the rest of the signal. The 3:1 paragraphs below
44
+ * hold for the `--cue-focus` role itself, and so for the ring at 100%, which
45
+ * is what `data-field-edge="contrast"` restores for a reader who needs it.
46
+ *
38
47
  * `--cue-focus`, not `--cue-accent`. Every theme ships both roles, and keeping
39
48
  * them separable is what WCAG 1.4.11 needs: one role whose job is "read at 3:1
40
49
  * against the surface behind the ring", independent of the role whose job is
@@ -77,9 +86,10 @@ import { FormControlSize } from "./_chassis-size.js";
77
86
  * while focused. Pushing the ring off the rim would mean either floating it on
78
87
  * a gap (WCAG 2.4.13 then wants 3px, and the halo is back) or insetting it
79
88
  * further (which buries it deeper). So the ring takes the danger colour instead
80
- * — `data-[invalid]:focus-visible:outline-danger` — which is the ruling shadcn
81
- * and Radix both reached: the border stays danger, the ring communicates focus,
82
- * and red communicates invalid whichever of the two is painting.
89
+ * — `--cue-danger` at the same faint share as the focus ring — which is the
90
+ * ruling shadcn and Radix both reached: the border stays danger, the ring
91
+ * communicates focus, and red communicates invalid whichever of the two is
92
+ * painting. At the faint share the danger rim shows through the ring.
83
93
  *
84
94
  * The compound modifier outranks the plain one on specificity — an attribute
85
95
  * selector and a pseudo-class against one pseudo-class — so its position in
@@ -105,7 +115,10 @@ import { FormControlSize } from "./_chassis-size.js";
105
115
  * **`field-ground`** is `styles.css`'s opt-in relative fill (spec
106
116
  * 2026-10-08-field-ground R3): inside `[data-field-ground="relative"]` it
107
117
  * re-declares this field's own `--cue-sunken` one step off the ground it stands
108
- * on; anywhere else it compiles to nothing that applies.
118
+ * on; anywhere else it compiles to nothing that applies. The same hook carries
119
+ * the contrast edge (cue-ui-60): inside `[data-field-edge="contrast"]` this
120
+ * field's resting stroke reads `--cue-field-edge`, its theme's border moved to
121
+ * 3:1 on `surface-1`.
109
122
  *
110
123
  * **The resting shade** ({@link REST_SHADE_CLASS}, spec §3.10) is the one
111
124
  * shadow this recipe draws: the stroke paints a border and a ground and no
@@ -115,7 +128,7 @@ import { FormControlSize } from "./_chassis-size.js";
115
128
  * own `shadow-` utility replaces it through `twMerge`, as it would have
116
129
  * replaced nothing before.
117
130
  */
118
- declare const controlSurface = "w-full field-ground rounded-[var(--cue-style-field-radius,calc(var(--cue-style-control-radius,var(--cue-radius-control))*var(--cue-radius-scale)))] border-transparent border-(length:--cue-paint-stroke-width) bg-[linear-gradient(var(--cue-style-field-fill,var(--cue-sunken)),var(--cue-style-field-fill,var(--cue-sunken))),linear-gradient(var(--cue-paint-stroke-angle),var(--cue-paint-stroke-from,var(--cue-border)),var(--cue-paint-stroke-to,var(--cue-border)))] bg-origin-border [background-clip:padding-box,border-box] shadow-[shadow:0_calc(2px*var(--cue-paint-shadow-scale))_calc(4px*var(--cue-paint-shadow-scale))_var(--cue-paint-rest-shade,transparent)] font-[family-name:var(--cue-style-input-face,var(--cue-font-mono))] font-[number:var(--cue-style-body-weight,inherit)] text-fg placeholder:text-fg-subtle outline-none hover:border-[color:var(--cue-style-field-hover-edge,transparent)] focus:border-[color:var(--cue-style-focus-edge,var(--cue-border-strong))] focus-visible:outline-solid focus-visible:outline-[length:var(--cue-style-focus-width,var(--cue-focus-ring))] focus-visible:outline-(--cue-focus) focus-visible:outline-offset-[var(--cue-style-field-focus-offset,-1px)] disabled:opacity-[var(--cue-style-disabled-opacity,0.4)] data-[disabled]:opacity-[var(--cue-style-disabled-opacity,0.4)] disabled:bg-[linear-gradient(var(--cue-style-disabled-fill,var(--cue-style-field-fill,var(--cue-sunken))),var(--cue-style-disabled-fill,var(--cue-style-field-fill,var(--cue-sunken)))),linear-gradient(var(--cue-paint-stroke-angle),var(--cue-paint-stroke-from,var(--cue-border)),var(--cue-paint-stroke-to,var(--cue-border)))] data-[disabled]:bg-[linear-gradient(var(--cue-style-disabled-fill,var(--cue-style-field-fill,var(--cue-sunken))),var(--cue-style-disabled-fill,var(--cue-style-field-fill,var(--cue-sunken)))),linear-gradient(var(--cue-paint-stroke-angle),var(--cue-paint-stroke-from,var(--cue-border)),var(--cue-paint-stroke-to,var(--cue-border)))] data-[invalid]:border-danger data-[invalid]:focus-visible:outline-danger";
131
+ declare const controlSurface = "w-full field-ground rounded-[var(--cue-style-field-radius,calc(var(--cue-style-control-radius,var(--cue-radius-control))*var(--cue-radius-scale)))] border-transparent border-(length:--cue-paint-stroke-width) bg-[linear-gradient(var(--cue-style-field-fill,var(--cue-sunken)),var(--cue-style-field-fill,var(--cue-sunken))),linear-gradient(var(--cue-paint-stroke-angle),var(--cue-paint-stroke-from,var(--cue-border)),var(--cue-paint-stroke-to,var(--cue-border)))] bg-origin-border [background-clip:padding-box,border-box] shadow-[shadow:0_calc(2px*var(--cue-paint-shadow-scale))_calc(4px*var(--cue-paint-shadow-scale))_var(--cue-paint-rest-shade,transparent)] font-[family-name:var(--cue-style-input-face,var(--cue-font-mono))] font-[number:var(--cue-style-body-weight,inherit)] text-fg placeholder:text-fg-subtle outline-none hover:border-[color:var(--cue-style-field-hover-edge,transparent)] focus:border-[color:var(--cue-style-focus-edge,var(--cue-border-strong))] focus-visible:outline-solid focus-visible:outline-[length:var(--cue-style-focus-width,var(--cue-focus-ring))] focus-visible:outline-[color:color-mix(in_oklab,var(--cue-focus)_var(--cue-style-field-focus-mix,15%),transparent)] focus-visible:outline-offset-[var(--cue-style-field-focus-offset,-1px)] disabled:opacity-[var(--cue-style-disabled-opacity,0.4)] data-[disabled]:opacity-[var(--cue-style-disabled-opacity,0.4)] disabled:bg-[linear-gradient(var(--cue-style-disabled-fill,var(--cue-style-field-fill,var(--cue-sunken))),var(--cue-style-disabled-fill,var(--cue-style-field-fill,var(--cue-sunken)))),linear-gradient(var(--cue-paint-stroke-angle),var(--cue-paint-stroke-from,var(--cue-border)),var(--cue-paint-stroke-to,var(--cue-border)))] data-[disabled]:bg-[linear-gradient(var(--cue-style-disabled-fill,var(--cue-style-field-fill,var(--cue-sunken))),var(--cue-style-disabled-fill,var(--cue-style-field-fill,var(--cue-sunken)))),linear-gradient(var(--cue-paint-stroke-angle),var(--cue-paint-stroke-from,var(--cue-border)),var(--cue-paint-stroke-to,var(--cue-border)))] data-[invalid]:border-danger data-[invalid]:focus-visible:outline-[color:color-mix(in_oklab,var(--cue-danger)_var(--cue-style-field-focus-mix,15%),transparent)]";
119
132
  /**
120
133
  * The full recessed-control recipe, one variant per rung of the control ladder.
121
134
  *
@@ -185,6 +198,6 @@ declare const bareControlClasses = "min-w-0 flex-1 border-0 bg-transparent p-0 f
185
198
  * pass's browser run caught it (`form-focus.spec.ts`, a grouped field). The
186
199
  * wrapper never takes focus itself, so `:has(:focus)` is the same test.
187
200
  */
188
- declare const controlGroupFocusClasses = "has-focus:border-border-strong has-focus-visible:outline-solid has-focus-visible:outline-[length:var(--cue-style-focus-width,var(--cue-focus-ring))] has-focus-visible:outline-(--cue-focus) has-focus-visible:outline-offset-[var(--cue-style-field-focus-offset,-1px)] data-[invalid]:has-focus-visible:outline-danger";
201
+ declare const controlGroupFocusClasses = "has-focus:border-border-strong has-focus-visible:outline-solid has-focus-visible:outline-[length:var(--cue-style-focus-width,var(--cue-focus-ring))] has-focus-visible:outline-[color:color-mix(in_oklab,var(--cue-focus)_var(--cue-style-field-focus-mix,15%),transparent)] has-focus-visible:outline-offset-[var(--cue-style-field-focus-offset,-1px)] data-[invalid]:has-focus-visible:outline-[color:color-mix(in_oklab,var(--cue-danger)_var(--cue-style-field-focus-mix,15%),transparent)]";
189
202
  //#endregion
190
203
  export { ControlVariantProps, type FormControlSize, bareControlClasses, controlClasses, controlGroupFocusClasses, controlSurface, controlVariants };
@@ -42,6 +42,15 @@ import { INPUT_FACE } from "../system/_style-input-face.js";
42
42
  * as it does on Tab. Only the non-typing controls that ride this chassis — the
43
43
  * `Select` trigger, the date triggers — actually distinguish the two.
44
44
  *
45
+ * **And quiet means faint** (the owner's call, 2026-10-09). At full strength a
46
+ * white `--cue-focus` ring around every clicked field read as a glaring box, so
47
+ * the ring is `--cue-focus` mixed with transparent to
48
+ * `--cue-style-field-focus-mix`: 15% unless a style set, `:root` or any subtree
49
+ * names another share (`_focus-ring-field.ts`). The rim's lift to
50
+ * `border-strong` carries the rest of the signal. The 3:1 paragraphs below
51
+ * hold for the `--cue-focus` role itself, and so for the ring at 100%, which
52
+ * is what `data-field-edge="contrast"` restores for a reader who needs it.
53
+ *
45
54
  * `--cue-focus`, not `--cue-accent`. Every theme ships both roles, and keeping
46
55
  * them separable is what WCAG 1.4.11 needs: one role whose job is "read at 3:1
47
56
  * against the surface behind the ring", independent of the role whose job is
@@ -84,9 +93,10 @@ import { INPUT_FACE } from "../system/_style-input-face.js";
84
93
  * while focused. Pushing the ring off the rim would mean either floating it on
85
94
  * a gap (WCAG 2.4.13 then wants 3px, and the halo is back) or insetting it
86
95
  * further (which buries it deeper). So the ring takes the danger colour instead
87
- * — `data-[invalid]:focus-visible:outline-danger` — which is the ruling shadcn
88
- * and Radix both reached: the border stays danger, the ring communicates focus,
89
- * and red communicates invalid whichever of the two is painting.
96
+ * — `--cue-danger` at the same faint share as the focus ring — which is the
97
+ * ruling shadcn and Radix both reached: the border stays danger, the ring
98
+ * communicates focus, and red communicates invalid whichever of the two is
99
+ * painting. At the faint share the danger rim shows through the ring.
90
100
  *
91
101
  * The compound modifier outranks the plain one on specificity — an attribute
92
102
  * selector and a pseudo-class against one pseudo-class — so its position in
@@ -112,7 +122,10 @@ import { INPUT_FACE } from "../system/_style-input-face.js";
112
122
  * **`field-ground`** is `styles.css`'s opt-in relative fill (spec
113
123
  * 2026-10-08-field-ground R3): inside `[data-field-ground="relative"]` it
114
124
  * re-declares this field's own `--cue-sunken` one step off the ground it stands
115
- * on; anywhere else it compiles to nothing that applies.
125
+ * on; anywhere else it compiles to nothing that applies. The same hook carries
126
+ * the contrast edge (cue-ui-60): inside `[data-field-edge="contrast"]` this
127
+ * field's resting stroke reads `--cue-field-edge`, its theme's border moved to
128
+ * 3:1 on `surface-1`.
116
129
  *
117
130
  * **The resting shade** ({@link REST_SHADE_CLASS}, spec §3.10) is the one
118
131
  * shadow this recipe draws: the stroke paints a border and a ground and no
@@ -122,7 +135,7 @@ import { INPUT_FACE } from "../system/_style-input-face.js";
122
135
  * own `shadow-` utility replaces it through `twMerge`, as it would have
123
136
  * replaced nothing before.
124
137
  */
125
- const controlSurface = `w-full field-ground ${FIELD_RADIUS} ${STROKE_SUNKEN} ${REST_SHADE_CLASS} ${INPUT_FACE} ${BODY_WEIGHT} text-fg placeholder:text-fg-subtle outline-none ${FIELD_HOVER_EDGE} focus:border-[color:var(--cue-style-focus-edge,var(--cue-border-strong))] ${FOCUS_RING_FIELD} ${DISABLED_OPACITY} ${DATA_DISABLED_OPACITY} ${FIELD_DISABLED_FILL} ${INVALID_EDGE} data-[invalid]:focus-visible:outline-danger`;
138
+ const controlSurface = `w-full field-ground ${FIELD_RADIUS} ${STROKE_SUNKEN} ${REST_SHADE_CLASS} ${INPUT_FACE} ${BODY_WEIGHT} text-fg placeholder:text-fg-subtle outline-none ${FIELD_HOVER_EDGE} focus:border-[color:var(--cue-style-focus-edge,var(--cue-border-strong))] ${FOCUS_RING_FIELD} ${DISABLED_OPACITY} ${DATA_DISABLED_OPACITY} ${FIELD_DISABLED_FILL} ${INVALID_EDGE} data-[invalid]:focus-visible:outline-[color:color-mix(in_oklab,var(--cue-danger)_var(--cue-style-field-focus-mix,15%),transparent)]`;
126
139
  /**
127
140
  * The full recessed-control recipe, one variant per rung of the control ladder.
128
141
  *
@@ -195,6 +208,6 @@ const bareControlClasses = `min-w-0 flex-1 border-0 bg-transparent p-0 ${INPUT_F
195
208
  * pass's browser run caught it (`form-focus.spec.ts`, a grouped field). The
196
209
  * wrapper never takes focus itself, so `:has(:focus)` is the same test.
197
210
  */
198
- const controlGroupFocusClasses = `has-focus:border-border-strong ${FOCUS_RING_GROUP_FIELD} data-[invalid]:has-focus-visible:outline-danger`;
211
+ const controlGroupFocusClasses = `has-focus:border-border-strong ${FOCUS_RING_GROUP_FIELD} data-[invalid]:has-focus-visible:outline-[color:color-mix(in_oklab,var(--cue-danger)_var(--cue-style-field-focus-mix,15%),transparent)]`;
199
212
  //#endregion
200
213
  export { bareControlClasses, controlClasses, controlGroupFocusClasses, controlSurface, controlVariants };
@@ -2,7 +2,7 @@ import { ClassProp, VariantProps } from "../lib/cva.js";
2
2
  import * as React from "react";
3
3
  //#region src/primitives/chip.d.ts
4
4
  declare const chipVariants: (props?: ({
5
- tone?: "danger" | "info" | "warn" | "busy" | "accent" | "ok" | "neutral" | null | undefined;
5
+ tone?: "danger" | "info" | "warn" | "accent" | "ok" | "busy" | "neutral" | null | undefined;
6
6
  variant?: "outline" | "tinted" | "tag" | null | undefined;
7
7
  interactive?: boolean | null | undefined;
8
8
  } & ClassProp) | undefined) => string;
package/dist/styles.css CHANGED
@@ -179,9 +179,28 @@
179
179
  * the chain the stroke already reads, `var(--cue-style-field-fill,
180
180
  * var(--cue-sunken))`: a named fill still wins, a disabled field follows, and
181
181
  * no field's class grows by more than this utility's name. A consumer's own
182
- * field can wear `field-ground` too. */
182
+ * field can wear `field-ground` too.
183
+ *
184
+ * The contrast edge (cue-ui-60), and opt-in too. A theme's `--cue-border` is a
185
+ * box edge, quiet on purpose, and against `surface-1` it measured 1.07:1 to
186
+ * 2.02:1 in every shipped theme: under WCAG 1.4.11's 3:1 for the line that
187
+ * marks out where to type. `--cue-field-edge` is that border moved toward the
188
+ * ink until it clears 3:1 (each theme writes it per block, as a literal), and
189
+ * inside an ancestor carrying `data-field-edge="contrast"` a field's resting
190
+ * stroke reads it. Declared on the field for the reason the fill is: resolved
191
+ * here, it is the theme the field stands in, island or not. It sets both ends
192
+ * of the stroke, so it wins over a paint pack's gradient edge — the opt-in is
193
+ * a reader's need, and a pack is a look. The hover, focus and invalid edges are
194
+ * their own colours and do not move. Same hook as the relative fill, so no
195
+ * field's class grows for it. */
183
196
  @utility field-ground {
184
197
  :where([data-field-ground="relative"]) & {
185
198
  --cue-sunken: color-mix(in oklab, var(--cue-ground, var(--cue-bg)), var(--cue-style-field-toward, var(--cue-fg)) var(--cue-style-field-step, 6%));
186
199
  }
200
+ :where([data-field-edge="contrast"]) & {
201
+ --cue-paint-stroke-from: var(--cue-field-edge, var(--cue-border));
202
+ --cue-paint-stroke-to: var(--cue-field-edge, var(--cue-border));
203
+ /* The full focus ring too: a field's is faint by default (`_focus-ring-field.ts`). */
204
+ --cue-style-field-focus-mix: 100%;
205
+ }
187
206
  }
@@ -11,19 +11,31 @@
11
11
  * would move with the buttons). `FOCUS_RING_INSET` keeps its fixed offset
12
12
  * for its ten other consumers.
13
13
  *
14
+ * **Faint, and configurable.** A text field matches `:focus-visible` on a
15
+ * mouse click as well as on Tab, so its ring is the one every click lights up.
16
+ * Drawn at full strength (a white `--cue-focus` on the default theme) it read
17
+ * as a glaring box, so a field paints `--cue-focus` mixed with transparent to
18
+ * `--cue-style-field-focus-mix`: 15% unless a style set, `:root` or any
19
+ * subtree names another share (0% hides it, 100% is the full ring). The
20
+ * border's lift to `border-strong` on `:focus` carries the rest of the signal.
21
+ * Inside `data-field-edge="contrast"` (`styles.css`) the share is 100%, so the
22
+ * reader who opted into the 3:1 edge gets the full ring back. Every other
23
+ * control's keyboard ring (`_focus-ring.ts`) is unchanged: it only ever shows
24
+ * to a keyboard.
25
+ *
14
26
  * Its own module, not a constant in `_focus-ring.ts`: docgen attributes a
15
27
  * token to a component by the file its class string lives in, and every
16
28
  * focusable component imports that module.
17
29
  *
18
30
  * @internal Not part of the public API.
19
31
  */
20
- const FOCUS_RING_FIELD = "focus-visible:outline-solid focus-visible:outline-[length:var(--cue-style-focus-width,var(--cue-focus-ring))] focus-visible:outline-(--cue-focus) focus-visible:outline-offset-[var(--cue-style-field-focus-offset,-1px)]";
32
+ const FOCUS_RING_FIELD = "focus-visible:outline-solid focus-visible:outline-[length:var(--cue-style-focus-width,var(--cue-focus-ring))] focus-visible:outline-[color:color-mix(in_oklab,var(--cue-focus)_var(--cue-style-field-focus-mix,15%),transparent)] focus-visible:outline-offset-[var(--cue-style-field-focus-offset,-1px)]";
21
33
  /**
22
34
  * `FOCUS_RING_GROUP_INSET` with {@link FOCUS_RING_FIELD}'s offset: the
23
35
  * grouped field's ring reads `style-field-focus-offset` as the field it wraps
24
36
  * does (plan V.2, T25), so a grouped field and a plain one keep one focus
25
37
  * geometry under every Look.
26
38
  */
27
- const FOCUS_RING_GROUP_FIELD = "has-focus-visible:outline-solid has-focus-visible:outline-[length:var(--cue-style-focus-width,var(--cue-focus-ring))] has-focus-visible:outline-(--cue-focus) has-focus-visible:outline-offset-[var(--cue-style-field-focus-offset,-1px)]";
39
+ const FOCUS_RING_GROUP_FIELD = "has-focus-visible:outline-solid has-focus-visible:outline-[length:var(--cue-style-focus-width,var(--cue-focus-ring))] has-focus-visible:outline-[color:color-mix(in_oklab,var(--cue-focus)_var(--cue-style-field-focus-mix,15%),transparent)] has-focus-visible:outline-offset-[var(--cue-style-field-focus-offset,-1px)]";
28
40
  //#endregion
29
41
  export { FOCUS_RING_FIELD, FOCUS_RING_GROUP_FIELD };
@@ -1,2 +1,2 @@
1
- import { CONTRAST_REQUIREMENTS, ContrastCheck, ContrastInput, ContrastReport, ContrastRequirement, WCAG_AAA_TEXT, WCAG_AA_NON_TEXT, WCAG_AA_TEXT, contrastRatio, contrastReport as contrastReport$1, relativeLuminance } from "@cueplusplus/theme-base/contrast";
2
- export { CONTRAST_REQUIREMENTS, type ContrastCheck, type ContrastInput, type ContrastReport, type ContrastRequirement, WCAG_AAA_TEXT, WCAG_AA_NON_TEXT, WCAG_AA_TEXT, contrastRatio, contrastReport$1 as contrastReport, relativeLuminance };
1
+ import { CONTRAST_REQUIREMENTS, ContrastCheck, ContrastInput, ContrastReport, ContrastRequirement, WCAG_AAA_TEXT, WCAG_AA_NON_TEXT, WCAG_AA_TEXT, contrastRatio, contrastReport as contrastReport$1, edgeToContrast, relativeLuminance } from "@cueplusplus/theme-base/contrast";
2
+ export { CONTRAST_REQUIREMENTS, type ContrastCheck, type ContrastInput, type ContrastReport, type ContrastRequirement, WCAG_AAA_TEXT, WCAG_AA_NON_TEXT, WCAG_AA_TEXT, contrastRatio, contrastReport$1 as contrastReport, edgeToContrast, relativeLuminance };
@@ -1,2 +1,2 @@
1
- import { CONTRAST_REQUIREMENTS, WCAG_AAA_TEXT, WCAG_AA_NON_TEXT, WCAG_AA_TEXT, contrastRatio, contrastReport as contrastReport$1, relativeLuminance } from "@cueplusplus/theme-base/contrast";
2
- export { CONTRAST_REQUIREMENTS, WCAG_AAA_TEXT, WCAG_AA_NON_TEXT, WCAG_AA_TEXT, contrastRatio, contrastReport$1 as contrastReport, relativeLuminance };
1
+ import { CONTRAST_REQUIREMENTS, WCAG_AAA_TEXT, WCAG_AA_NON_TEXT, WCAG_AA_TEXT, contrastRatio, contrastReport as contrastReport$1, edgeToContrast, relativeLuminance } from "@cueplusplus/theme-base/contrast";
2
+ export { CONTRAST_REQUIREMENTS, WCAG_AAA_TEXT, WCAG_AA_NON_TEXT, WCAG_AA_TEXT, contrastRatio, contrastReport$1 as contrastReport, edgeToContrast, relativeLuminance };
@@ -1,5 +1,5 @@
1
1
  import { formatColorString } from "../color/_convert.js";
2
- import { WCAG_AA_NON_TEXT, WCAG_AA_TEXT, contrastRatio, contrastReport } from "./contrast.js";
2
+ import { WCAG_AA_NON_TEXT, WCAG_AA_TEXT, contrastRatio, contrastReport, edgeToContrast } from "./contrast.js";
3
3
  import { RESOLVED_MONO, assertCssValue, assertThemeName, derivedTokens, serializeSystemModeAliases, serializeThemeCss, themeSelector, tokenProperty } from "./serialize.js";
4
4
  import { COLOR_CONTRACT, resolve } from "@cueplusplus/theme-base";
5
5
  import { clampChroma, converter, formatHex, inGamut, parse } from "culori";
@@ -462,7 +462,10 @@ function createTheme(anchors) {
462
462
  if (anchors.radiusScale !== void 0 && !(anchors.radiusScale > 0)) throw new TypeError(`radiusScale must be a positive number, got ${anchors.radiusScale}`);
463
463
  const dark = resolveDark(anchors, preset.dark);
464
464
  const light = anchors.supportsLight ?? preset.light !== null ? resolveLight(dark, preset.light) : null;
465
- const derived = Object.entries(derivedTokens());
465
+ const fieldEdge = (colors) => edgeToContrast(colors["border"], colors["surface-1"], colors["fg"]);
466
+ const derivedFor = (colors) => Object.entries(derivedTokens()).map(([property, value]) => property === tokenProperty("field-edge") ? [property, fieldEdge(colors)] : [property, value]);
467
+ const derived = derivedFor(dark.colors);
468
+ const derivedLight = light === null ? null : derivedFor(light.colors);
466
469
  const fonts = resolveFonts$1(anchors, preset.fonts);
467
470
  const radius = anchors.radiusScale === void 0 ? null : radiusBlocks(name, anchors.radiusScale);
468
471
  const darkPaletteSections = [
@@ -482,7 +485,7 @@ function createTheme(anchors) {
482
485
  }]];
483
486
  const lightSections = light === null ? null : [{ declarations: colorDeclarations(light.colors) }, {
484
487
  comment: DERIVED_COMMENT,
485
- declarations: derived
488
+ declarations: derivedLight ?? derived
486
489
  }];
487
490
  const blocks = [
488
491
  {
@@ -502,10 +505,16 @@ function createTheme(anchors) {
502
505
  ];
503
506
  const inputs = [{
504
507
  mode: "dark",
505
- tokens: dark.colors
508
+ tokens: {
509
+ ...dark.colors,
510
+ "field-edge": fieldEdge(dark.colors)
511
+ }
506
512
  }, ...light === null ? [] : [{
507
513
  mode: "light",
508
- tokens: light.colors,
514
+ tokens: {
515
+ ...light.colors,
516
+ "field-edge": fieldEdge(light.colors)
517
+ },
509
518
  provisional: light.provisional
510
519
  }]];
511
520
  const explicitCss = serializeThemeCss(blocks, { banner: `@cueplusplus/ui — theme "${name}"` }).trimEnd();
@@ -522,7 +531,7 @@ function createTheme(anchors) {
522
531
  sections: lightSections ?? darkPaletteSections
523
532
  }])}\n`,
524
533
  tokens: Object.fromEntries(darkSections.flatMap((section) => section.declarations)),
525
- light: light === null ? null : Object.fromEntries([...colorDeclarations(light.colors), ...derived]),
534
+ light: light === null ? null : Object.fromEntries([...colorDeclarations(light.colors), ...derivedLight ?? derived]),
526
535
  report: contrastReport(inputs)
527
536
  };
528
537
  }