@fungi.computer/caps 0.6.0 → 0.7.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.
@@ -9,6 +9,25 @@ export declare const DataList: import("react").ForwardRefExoticComponent<HTMLAtt
9
9
  } & import("react").RefAttributes<HTMLUListElement>>;
10
10
  /** A list row for a caller-provided label and value. */
11
11
  export declare const DataRow: import("react").ForwardRefExoticComponent<LiHTMLAttributes<HTMLLIElement> & import("react").RefAttributes<HTMLLIElement>>;
12
+ /** Props accepted by FactRow. */
13
+ export type FactRowProps = Omit<LiHTMLAttributes<HTMLLIElement>, "children"> & {
14
+ /** A 16px Sigil before the label; decorative. */
15
+ icon?: ReactNode;
16
+ label: ReactNode;
17
+ /** The fact; long values wrap in their own column. */
18
+ children: ReactNode;
19
+ };
20
+ /**
21
+ * One details-panel fact in a compact DataList: a fixed label column (Sigil
22
+ * and quiet name) and a wrapping value, as in a Print's Image info.
23
+ */
24
+ export declare const FactRow: import("react").ForwardRefExoticComponent<Omit<LiHTMLAttributes<HTMLLIElement>, "children"> & {
25
+ /** A 16px Sigil before the label; decorative. */
26
+ icon?: ReactNode;
27
+ label: ReactNode;
28
+ /** The fact; long values wrap in their own column. */
29
+ children: ReactNode;
30
+ } & import("react").RefAttributes<HTMLLIElement>>;
12
31
  /** Props accepted by DataListHeading. */
13
32
  export type DataListHeadingProps = HTMLAttributes<HTMLHeadingElement> & {
14
33
  /** Heading level; the host keeps its document outline. */
@@ -9,6 +9,13 @@ export const DataList = forwardRef(function DataList({ className, density = "reg
9
9
  export const DataRow = forwardRef(function DataRow({ className, ...props }, ref) {
10
10
  return _jsx("li", { ...props, className: cn("list-row", className), ref: ref });
11
11
  });
12
+ /**
13
+ * One details-panel fact in a compact DataList: a fixed label column (Sigil
14
+ * and quiet name) and a wrapping value, as in a Print's Image info.
15
+ */
16
+ export const FactRow = forwardRef(function FactRow({ className, icon, label, children, ...props }, ref) {
17
+ return (_jsxs("li", { ...props, className: cn("list-row caps-fact", className), ref: ref, children: [_jsxs("span", { className: "caps-fact-label", children: [icon ? (_jsx("span", { className: "caps-fact-icon", "aria-hidden": "true", children: icon })) : null, label] }), _jsx("span", { className: "caps-fact-value", children: children })] }));
18
+ });
12
19
  /** A quiet small-caps section label that groups the rows below it. */
13
20
  export const DataListHeading = forwardRef(function DataListHeading({ aside, children, className, level = 2, ...props }, ref) {
14
21
  const Heading = level === 3 ? "h3" : "h2";
@@ -57,6 +57,11 @@ export declare const FormSection: import("react").ForwardRefExoticComponent<{
57
57
  /** Props accepted by FieldSet. */
58
58
  export type FieldSetProps = {
59
59
  legend: ReactNode;
60
+ /**
61
+ * A decorative Sigil or App icon before the legend, as a Field's `icon`
62
+ * marks its label. Hidden from assistive tech.
63
+ */
64
+ icon?: ReactNode;
60
65
  /** Full legend on hover when a columns form clips it to one line. */
61
66
  legendTitle?: string;
62
67
  description?: ReactNode;
@@ -72,6 +77,11 @@ export type FieldSetProps = {
72
77
  */
73
78
  export declare const FieldSet: import("react").ForwardRefExoticComponent<{
74
79
  legend: ReactNode;
80
+ /**
81
+ * A decorative Sigil or App icon before the legend, as a Field's `icon`
82
+ * marks its label. Hidden from assistive tech.
83
+ */
84
+ icon?: ReactNode;
75
85
  /** Full legend on hover when a columns form clips it to one line. */
76
86
  legendTitle?: string;
77
87
  description?: ReactNode;
@@ -39,7 +39,7 @@ export const FormSection = forwardRef(function FormSection({ children, className
39
39
  * A legend-named group of related choices, such as a list of inline
40
40
  * checkbox Fields. `disabled` disables every control inside.
41
41
  */
42
- export const FieldSet = forwardRef(function FieldSet({ children, className, description, error, layout, legend, legendTitle, ...props }, ref) {
42
+ export const FieldSet = forwardRef(function FieldSet({ children, className, description, error, icon, layout, legend, legendTitle, ...props }, ref) {
43
43
  const id = useId();
44
44
  const describedBy = [
45
45
  description ? `${id}-description` : props["aria-describedby"],
@@ -47,7 +47,7 @@ export const FieldSet = forwardRef(function FieldSet({ children, className, desc
47
47
  ]
48
48
  .filter(Boolean)
49
49
  .join(" ") || undefined;
50
- return (_jsxs("fieldset", { ...props, "aria-describedby": describedBy, "aria-invalid": error ? true : props["aria-invalid"], className: cn("caps-fieldset", className), "data-layout": layout, ref: ref, children: [_jsx("legend", { className: "caps-fieldset-legend", title: legendTitle, children: legend }), description ? (_jsx("p", { className: "caps-fieldset-description", id: `${id}-description`, children: description })) : null, _jsx("div", { className: "caps-fieldset-fields", children: children }), error === undefined ? null : (_jsx(FieldError, { id: `${id}-error`, children: error }))] }));
50
+ return (_jsxs("fieldset", { ...props, "aria-describedby": describedBy, "aria-invalid": error ? true : props["aria-invalid"], className: cn("caps-fieldset", className), "data-layout": layout, ref: ref, children: [_jsxs("legend", { className: "caps-fieldset-legend", title: legendTitle, children: [icon ? (_jsx("span", { className: "caps-field-icon", "aria-hidden": "true", children: icon })) : null, legend] }), description ? (_jsx("p", { className: "caps-fieldset-description", id: `${id}-description`, children: description })) : null, _jsx("div", { className: "caps-fieldset-fields", children: children }), error === undefined ? null : (_jsx(FieldError, { id: `${id}-error`, children: error }))] }));
51
51
  });
52
52
  /** The labelled control inside an InputGroup: its first non-addon element. */
53
53
  function groupControl(group) {
@@ -14,6 +14,7 @@ export const HoldButton = forwardRef(function HoldButton({ children, onConfirm,
14
14
  const fill = useRef(null);
15
15
  const rising = useRef(null);
16
16
  const timer = useRef(null);
17
+ const holder = useRef(null);
17
18
  const confirm = useRef(onConfirm);
18
19
  const hint = useId();
19
20
  useEffect(() => {
@@ -27,6 +28,7 @@ export const HoldButton = forwardRef(function HoldButton({ children, onConfirm,
27
28
  const finish = () => {
28
29
  rising.current = null;
29
30
  timer.current = null;
31
+ holder.current = null;
30
32
  confirm.current();
31
33
  const element = fill.current;
32
34
  if (!element || !canAnimate(element))
@@ -37,7 +39,7 @@ export const HoldButton = forwardRef(function HoldButton({ children, onConfirm,
37
39
  easing: motionEasing(motion, motion.easeExit),
38
40
  });
39
41
  };
40
- const start = () => {
42
+ const start = (by) => {
41
43
  const element = fill.current;
42
44
  if (disabled === true ||
43
45
  loading ||
@@ -45,6 +47,7 @@ export const HoldButton = forwardRef(function HoldButton({ children, onConfirm,
45
47
  rising.current ||
46
48
  timer.current)
47
49
  return;
50
+ holder.current = by;
48
51
  if (!canAnimate(element)) {
49
52
  timer.current = setTimeout(finish, holdMs);
50
53
  return;
@@ -59,7 +62,13 @@ export const HoldButton = forwardRef(function HoldButton({ children, onConfirm,
59
62
  animation.addEventListener("finish", finish);
60
63
  rising.current = animation;
61
64
  };
62
- const release = () => {
65
+ // Each input ends only the hold it started: a mouse leaving the button
66
+ // (or the button moving away from a resting mouse) never cancels a held
67
+ // key. Blur and Escape end any hold.
68
+ const release = (by) => {
69
+ if (by !== undefined && holder.current !== by)
70
+ return;
71
+ holder.current = null;
63
72
  if (timer.current !== null) {
64
73
  clearTimeout(timer.current);
65
74
  timer.current = null;
@@ -86,16 +95,16 @@ export const HoldButton = forwardRef(function HoldButton({ children, onConfirm,
86
95
  }, onPointerDown: (event) => {
87
96
  onPointerDown?.(event);
88
97
  if (event.button === 0)
89
- start();
98
+ start(event.pointerId);
90
99
  }, onPointerUp: (event) => {
91
100
  onPointerUp?.(event);
92
- release();
101
+ release(event.pointerId);
93
102
  }, onPointerLeave: (event) => {
94
103
  onPointerLeave?.(event);
95
- release();
104
+ release(event.pointerId);
96
105
  }, onPointerCancel: (event) => {
97
106
  onPointerCancel?.(event);
98
- release();
107
+ release(event.pointerId);
99
108
  }, onKeyDown: (event) => {
100
109
  onKeyDown?.(event);
101
110
  if (event.key === "Escape")
@@ -104,12 +113,12 @@ export const HoldButton = forwardRef(function HoldButton({ children, onConfirm,
104
113
  return;
105
114
  event.preventDefault();
106
115
  if (!event.repeat)
107
- start();
116
+ start("key");
108
117
  }, onKeyUp: (event) => {
109
118
  onKeyUp?.(event);
110
119
  if (!confirmKeys.has(event.key))
111
120
  return;
112
121
  event.preventDefault();
113
- release();
122
+ release("key");
114
123
  }, children: [_jsx("span", { ref: fill, "aria-hidden": "true", className: "caps-hold-fill" }), children] }), _jsx(VisuallyHidden, { id: hint, children: holdHint })] }));
115
124
  });
@@ -1,4 +1,4 @@
1
- import { type ReactNode } from "react";
1
+ import { type HTMLAttributes, type ReactNode } from "react";
2
2
  import { type CheckboxProps } from "./checkbox.js";
3
3
  /** Props accepted by IconCheckbox. The full label names the native input. */
4
4
  export type IconCheckboxProps = {
@@ -18,3 +18,26 @@ export declare const IconCheckbox: import("react").ForwardRefExoticComponent<{
18
18
  /** Compact instruments show the icon and caption without a tile or lamp. */
19
19
  appearance?: "default" | "compact";
20
20
  } & Omit<CheckboxProps, "children" | "indicator"> & import("react").RefAttributes<HTMLInputElement>>;
21
+ /** Props accepted by CheckKey. The label names the native input. */
22
+ export type CheckKeyProps = {
23
+ label: string;
24
+ /** A Sigil or App icon before the label; decorative. */
25
+ icon?: ReactNode;
26
+ /** One quiet line under the label, read as the input's description. */
27
+ hint?: ReactNode;
28
+ } & Omit<CheckboxProps, "children" | "indicator" | "appearance">;
29
+ /**
30
+ * One choice in a short multi-select, drawn as a wide Caps key: Sigil, label
31
+ * and an optional hint, a lit lamp and selected face when checked. The native
32
+ * checkbox covers the whole key, so it keeps form submission, keyboard and
33
+ * focus behaviour.
34
+ */
35
+ export declare const CheckKey: import("react").ForwardRefExoticComponent<{
36
+ label: string;
37
+ /** A Sigil or App icon before the label; decorative. */
38
+ icon?: ReactNode;
39
+ /** One quiet line under the label, read as the input's description. */
40
+ hint?: ReactNode;
41
+ } & Omit<CheckboxProps, "children" | "appearance" | "indicator"> & import("react").RefAttributes<HTMLInputElement>>;
42
+ /** Lays out a group of CheckKeys: a wrapping grid of equal keys. */
43
+ export declare function CheckKeys({ className, ...props }: HTMLAttributes<HTMLDivElement>): import("react/jsx-runtime").JSX.Element;
@@ -1,5 +1,5 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { forwardRef } from "react";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useId } from "react";
3
3
  import { cn } from "../lib/cn.js";
4
4
  import { Checkbox } from "./checkbox.js";
5
5
  import { IconChoiceMark } from "./icon-choice-mark.js";
@@ -7,3 +7,20 @@ import { IconChoiceMark } from "./icon-choice-mark.js";
7
7
  export const IconCheckbox = forwardRef(function IconCheckbox({ label, icon, caption, appearance, className, ...props }, ref) {
8
8
  return (_jsx("label", { className: "caps-icon-choice", "data-appearance": appearance === "compact" ? appearance : undefined, children: _jsx(Checkbox, { ...props, appearance: appearance ?? "default", "aria-label": label, className: cn("caps-icon-choice-input", className), indicator: _jsx(IconChoiceMark, { icon: icon, caption: caption }), ref: ref }) }));
9
9
  });
10
+ /**
11
+ * One choice in a short multi-select, drawn as a wide Caps key: Sigil, label
12
+ * and an optional hint, a lit lamp and selected face when checked. The native
13
+ * checkbox covers the whole key, so it keeps form submission, keyboard and
14
+ * focus behaviour.
15
+ */
16
+ export const CheckKey = forwardRef(function CheckKey({ label, icon, hint, className, ...props }, ref) {
17
+ const hintId = useId();
18
+ const describedBy = [props["aria-describedby"], hint ? hintId : null]
19
+ .filter(Boolean)
20
+ .join(" ") || undefined;
21
+ return (_jsx("label", { className: "caps-check-key", children: _jsx(Checkbox, { ...props, "aria-label": label, "aria-describedby": describedBy, className: cn("caps-check-key-input", className), indicator: _jsxs("span", { className: "caps-check-key-face", "aria-hidden": "true", children: [icon ? (_jsx("span", { className: "caps-check-key-icon", children: icon })) : null, _jsxs("span", { className: "caps-check-key-copy", children: [_jsx("span", { className: "caps-check-key-label", children: label }), hint ? (_jsx("span", { className: "caps-check-key-hint", id: hintId, children: hint })) : null] }), _jsx("span", { className: "caps-check-key-lamp" })] }), ref: ref }) }));
22
+ });
23
+ /** Lays out a group of CheckKeys: a wrapping grid of equal keys. */
24
+ export function CheckKeys({ className, ...props }) {
25
+ return _jsx("div", { ...props, className: cn("caps-check-keys", className) });
26
+ }
@@ -6,6 +6,11 @@ export type IconRadioProps = {
6
6
  icon: ReactNode;
7
7
  /** A short visible legend; it does not replace the accessible label. */
8
8
  caption?: string;
9
+ /**
10
+ * `color` keeps an off key's art in full colour, for choices whose colour
11
+ * is the information (a character, a palette). Icons dim by default.
12
+ */
13
+ art?: "dim" | "color";
9
14
  } & Omit<RadioGroupItemProps, "children" | "indicator">;
10
15
  /** One icon choice in a native, mutually exclusive Caps RadioGroup. */
11
16
  export declare const IconRadio: import("react").ForwardRefExoticComponent<{
@@ -13,4 +18,26 @@ export declare const IconRadio: import("react").ForwardRefExoticComponent<{
13
18
  icon: ReactNode;
14
19
  /** A short visible legend; it does not replace the accessible label. */
15
20
  caption?: string;
21
+ /**
22
+ * `color` keeps an off key's art in full colour, for choices whose colour
23
+ * is the information (a character, a palette). Icons dim by default.
24
+ */
25
+ art?: "dim" | "color";
26
+ } & Omit<RadioGroupItemProps, "children" | "indicator"> & import("react").RefAttributes<HTMLInputElement>>;
27
+ /** Props accepted by SwatchRadio. Use it inside the existing RadioGroup. */
28
+ export type SwatchRadioProps = {
29
+ /** The colour's name: the accessible label and the hover title. */
30
+ label: string;
31
+ /** Any CSS colour, drawn as the key's dot. */
32
+ color: string;
33
+ } & Omit<RadioGroupItemProps, "children" | "indicator">;
34
+ /**
35
+ * One colour choice: a small icon key whose dot keeps its colour while off,
36
+ * so every choice reads before it is picked. The lamp marks the choice.
37
+ */
38
+ export declare const SwatchRadio: import("react").ForwardRefExoticComponent<{
39
+ /** The colour's name: the accessible label and the hover title. */
40
+ label: string;
41
+ /** Any CSS colour, drawn as the key's dot. */
42
+ color: string;
16
43
  } & Omit<RadioGroupItemProps, "children" | "indicator"> & import("react").RefAttributes<HTMLInputElement>>;
@@ -4,6 +4,13 @@ import { cn } from "../lib/cn.js";
4
4
  import { RadioGroupItem } from "./radio-group.js";
5
5
  import { IconChoiceMark } from "./icon-choice-mark.js";
6
6
  /** One icon choice in a native, mutually exclusive Caps RadioGroup. */
7
- export const IconRadio = forwardRef(function IconRadio({ label, icon, caption, className, ...props }, ref) {
8
- return (_jsx("label", { className: "caps-icon-choice", children: _jsx(RadioGroupItem, { ...props, "aria-label": label, className: cn("caps-icon-choice-input", className), indicator: _jsx(IconChoiceMark, { icon: icon, caption: caption }), ref: ref }) }));
7
+ export const IconRadio = forwardRef(function IconRadio({ label, icon, caption, art = "dim", className, ...props }, ref) {
8
+ return (_jsx("label", { className: "caps-icon-choice", "data-art": art === "color" ? art : undefined, children: _jsx(RadioGroupItem, { ...props, "aria-label": label, className: cn("caps-icon-choice-input", className), indicator: _jsx(IconChoiceMark, { icon: icon, caption: caption }), ref: ref }) }));
9
+ });
10
+ /**
11
+ * One colour choice: a small icon key whose dot keeps its colour while off,
12
+ * so every choice reads before it is picked. The lamp marks the choice.
13
+ */
14
+ export const SwatchRadio = forwardRef(function SwatchRadio({ label, color, className, ...props }, ref) {
15
+ return (_jsx("label", { className: "caps-icon-choice", "data-art": "color", "data-swatch": "", title: label, children: _jsx(RadioGroupItem, { ...props, "aria-label": label, className: cn("caps-icon-choice-input", className), indicator: _jsx(IconChoiceMark, { icon: _jsx("span", { className: "caps-swatch-dot", style: { backgroundColor: color } }) }), ref: ref }) }));
9
16
  });
@@ -37,6 +37,11 @@ export type KeycapButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "c
37
37
  /** Extra readout after the label, such as a PixelNumber count. */
38
38
  readout?: ReactNode;
39
39
  tone?: KeycapTone;
40
+ /**
41
+ * `compact` is the details-panel key: 2.25rem high, centred `sm` label.
42
+ * It sizes to its legend in a row and fills its cell in a grid.
43
+ */
44
+ density?: "regular" | "compact";
40
45
  /**
41
46
  * Toggle state for mode keys. `undefined` is a momentary key; a boolean adds
42
47
  * `aria-pressed` and lights the key's indicator.
@@ -66,6 +71,11 @@ export declare const KeycapButton: import("react").ForwardRefExoticComponent<Omi
66
71
  /** Extra readout after the label, such as a PixelNumber count. */
67
72
  readout?: ReactNode;
68
73
  tone?: KeycapTone;
74
+ /**
75
+ * `compact` is the details-panel key: 2.25rem high, centred `sm` label.
76
+ * It sizes to its legend in a row and fills its cell in a grid.
77
+ */
78
+ density?: "regular" | "compact";
69
79
  /**
70
80
  * Toggle state for mode keys. `undefined` is a momentary key; a boolean adds
71
81
  * `aria-pressed` and lights the key's indicator.
@@ -12,7 +12,7 @@ export const Keycap = forwardRef(function Keycap({ tone = "neutral", size = "md"
12
12
  * A chunky action key for game docks and toy panels. The host owns the action
13
13
  * and the keyboard binding; `shortcut` only labels it.
14
14
  */
15
- export const KeycapButton = forwardRef(function KeycapButton({ label, shortcut, icon, readout, tone = "neutral", pressed, expanded, className, type = "button", ...props }, ref) {
15
+ export const KeycapButton = forwardRef(function KeycapButton({ label, shortcut, icon, readout, tone = "neutral", density = "regular", pressed, expanded, className, type = "button", ...props }, ref) {
16
16
  const lamp = pressed ?? expanded;
17
- return (_jsxs("button", { ...props, ref: ref, type: type, className: cn("caps-keycap-button", className), "data-tone": tone, "aria-pressed": pressed, "aria-expanded": expanded, "aria-keyshortcuts": props["aria-keyshortcuts"] ?? shortcut, children: [lamp === undefined ? null : (_jsx("span", { className: "caps-keycap-led", "data-lit": lamp, "aria-hidden": "true" })), icon ? (_jsx("span", { className: "caps-keycap-button-icon", "aria-hidden": "true", children: icon })) : null, label === null ? null : (_jsx("span", { className: "caps-keycap-button-label", children: label })), readout, shortcut ? (_jsx(Keycap, { size: "sm", tone: "neutral", "aria-hidden": "true", children: shortcut })) : null] }));
17
+ return (_jsxs("button", { ...props, ref: ref, type: type, className: cn("caps-keycap-button", className), "data-tone": tone, "data-density": density === "compact" ? density : undefined, "aria-pressed": pressed, "aria-expanded": expanded, "aria-keyshortcuts": props["aria-keyshortcuts"] ?? shortcut, children: [lamp === undefined ? null : (_jsx("span", { className: "caps-keycap-led", "data-lit": lamp, "aria-hidden": "true" })), icon ? (_jsx("span", { className: "caps-keycap-button-icon", "aria-hidden": "true", children: icon })) : null, label === null ? null : (_jsx("span", { className: "caps-keycap-button-label", children: label })), readout, shortcut ? (_jsx(Keycap, { size: "sm", tone: "neutral", "aria-hidden": "true", children: shortcut })) : null] }));
18
18
  });
@@ -8,7 +8,7 @@ import { UploadQueue } from "./blocks/upload-queue.js";
8
8
  import { Checkbox } from "./components/checkbox.js";
9
9
  import { Combobox, ComboboxTrigger } from "./components/combobox.js";
10
10
  import { Field, FieldSet, FormSection, NumberField, } from "./components/field.js";
11
- import { IconCheckbox } from "./components/icon-checkbox.js";
11
+ import { CheckKey, CheckKeys, IconCheckbox, } from "./components/icon-checkbox.js";
12
12
  import { IconRadio } from "./components/icon-radio.js";
13
13
  import { Input } from "./components/input.js";
14
14
  import { InputGroup, InputGroupAddon } from "./components/input-group.js";
@@ -169,16 +169,18 @@ function SpecGroup({ spec, field, locked, error, layout, }) {
169
169
  ? [...selected, value]
170
170
  : selected.filter((current) => current !== value));
171
171
  };
172
- return (_jsx(FieldSet, { legend: legend, layout: layout, ...(layout === "columns" ? { legendTitle: legend } : {}), description: spec.help, disabled: disabled, error: error ?? null, children: spec.kind === "icon-checkboxes" ? (_jsx("div", { className: "caps-field-icon-choices", children: spec.options.map((option) => (_jsx(IconCheckbox, { name: spec.name, value: option.value, label: option.label, ...(option.caption === undefined
172
+ return (_jsx(FieldSet, { legend: legend, icon: spec.icon, layout: layout, ...(layout === "columns" ? { legendTitle: legend } : {}), description: spec.help, disabled: disabled, error: error ?? null, children: spec.kind === "icon-checkboxes" ? (_jsx("div", { className: "caps-field-icon-choices", children: spec.options.map((option) => (_jsx(IconCheckbox, { name: spec.name, value: option.value, label: option.label, ...(option.caption === undefined
173
173
  ? {}
174
174
  : { caption: option.caption }), icon: option.icon, disabled: option.disabled, checked: selected.includes(option.value), onBlur: field.handleBlur, onChange: (event) => {
175
175
  toggle(option.value, event.currentTarget.checked);
176
- } }, option.value))) })) : (spec.options.map((option) => (_jsx(Field, { label: option.label, description: option.description, orientation: "inline", children: _jsx(Checkbox, { name: spec.name, value: option.value, disabled: option.disabled, checked: selected.includes(option.value), onBlur: field.handleBlur, onChange: (event) => {
176
+ } }, option.value))) })) : (
177
+ // A short multi-select reads as one row of check keys.
178
+ _jsx(CheckKeys, { children: spec.options.map((option) => (_jsx(CheckKey, { name: spec.name, value: option.value, label: option.label, icon: option.icon, hint: option.description, disabled: option.disabled, checked: selected.includes(option.value), onBlur: field.handleBlur, onChange: (event) => {
177
179
  toggle(option.value, event.currentTarget.checked);
178
- } }) }, option.value)))) }));
180
+ } }, option.value))) })) }));
179
181
  }
180
182
  const cards = spec.options.some((option) => option.description !== undefined || option.meta !== undefined);
181
- return (_jsx(FieldSet, { legend: legend, layout: layout, ...(layout === "columns" ? { legendTitle: legend } : {}), description: spec.help, disabled: disabled, error: error ?? null, children: _jsx(RadioGroup, { name: spec.name, "aria-label": legend, value: text(field), onValueChange: (next) => {
183
+ return (_jsx(FieldSet, { legend: legend, icon: spec.icon, layout: layout, ...(layout === "columns" ? { legendTitle: legend } : {}), description: spec.help, disabled: disabled, error: error ?? null, children: _jsx(RadioGroup, { name: spec.name, "aria-label": legend, value: text(field), onValueChange: (next) => {
182
184
  field.handleChange(next);
183
185
  field.handleBlur();
184
186
  },
package/dist/styles.css CHANGED
@@ -12905,6 +12905,19 @@
12905
12905
  .list-row > .caps-field {
12906
12906
  grid-column: 1 / -1;
12907
12907
  }
12908
+ .list.list-sm > .list-row > .caps-field {
12909
+ --caps-field-icon-size: 1rem;
12910
+ }
12911
+ .list.list-sm > .list-row > .caps-field .caps-field-lead {
12912
+ align-items: flex-start;
12913
+ gap: var(--spacing-xs);
12914
+ }
12915
+ .list.list-sm > .list-row > .caps-field .caps-field-icon {
12916
+ height: calc(var(--caps-control-label-size, var(--text-sm)) * 1.5);
12917
+ }
12918
+ .list.list-sm > .list-row > .caps-field:has(> .caps-field-row > .caps-field-lead) > .caps-field-error-reveal {
12919
+ padding-inline-start: calc(var(--caps-field-icon-size) + var(--spacing-xs));
12920
+ }
12908
12921
  .caps-field:has(> .caps-field-row > .caps-field-lead) > .caps-field-error-reveal {
12909
12922
  padding-inline-start: calc(var(--caps-field-icon-size) + var(--spacing-sm));
12910
12923
  }
@@ -12939,6 +12952,15 @@
12939
12952
  line-height: 1.5;
12940
12953
  color: var(--color-base-content);
12941
12954
  }
12955
+ .caps-fieldset-legend:has(> .caps-field-icon) {
12956
+ display: flex;
12957
+ align-items: center;
12958
+ gap: var(--spacing-xs);
12959
+ }
12960
+ .caps-fieldset-legend > .caps-field-icon {
12961
+ width: 1rem;
12962
+ height: 1rem;
12963
+ }
12942
12964
  .caps-fieldset-description {
12943
12965
  margin: 0;
12944
12966
  font-size: var(--text-xs);
@@ -13056,7 +13078,7 @@
13056
13078
  padding-bottom: var(--spacing-sm);
13057
13079
  }
13058
13080
  :is(.caps-form, .caps-form-section, .caps-fieldset)[data-layout="columns"] {
13059
- --caps-form-label-col: 9rem;
13081
+ --caps-form-label-col: 7rem;
13060
13082
  }
13061
13083
  .caps-form[data-layout="columns"] :is(.caps-form-body, .caps-form-section-fields) {
13062
13084
  gap: var(--spacing-sm);
@@ -13135,6 +13157,13 @@
13135
13157
  grid-column: 1;
13136
13158
  grid-row: 1;
13137
13159
  margin: 0;
13160
+ float: left;
13161
+ align-self: start;
13162
+ min-height: var(--caps-fieldset-legend-line, 2.25rem);
13163
+ align-content: center;
13164
+ }
13165
+ .caps-fieldset[data-layout="columns"]:has( .caps-icon-choice:not([data-swatch]) ) {
13166
+ --caps-fieldset-legend-line: 3.25rem;
13138
13167
  }
13139
13168
  .caps-fieldset[data-layout="columns"] > .caps-fieldset-fields {
13140
13169
  grid-column: 2;
@@ -13142,6 +13171,7 @@
13142
13171
  flex-direction: row;
13143
13172
  flex-wrap: wrap;
13144
13173
  margin: 0;
13174
+ max-width: 30rem;
13145
13175
  }
13146
13176
  .caps-fieldset[data-layout="columns"] > :is(.caps-fieldset-description, .caps-field-error-reveal) {
13147
13177
  grid-column: 2;
@@ -13162,7 +13192,38 @@
13162
13192
  max-width: none;
13163
13193
  }
13164
13194
  }
13165
- @container (max-width: 20rem) {
13195
+ :is(.caps-form, .caps-form-section)[data-layout="columns"] :is( .caps-field[data-orientation="vertical"], .caps-field[data-orientation="horizontal"] > .caps-field-row ) > .caps-field-lead {
13196
+ display: contents;
13197
+ }
13198
+ :is(.caps-form, .caps-form-section)[data-layout="columns"] :is( .caps-field[data-orientation="vertical"], .caps-field[data-orientation="horizontal"] > .caps-field-row ) > .caps-field-lead > .caps-field-copy {
13199
+ display: contents;
13200
+ }
13201
+ :is(.caps-form, .caps-form-section)[data-layout="columns"] :is( .caps-field[data-orientation="vertical"], .caps-field[data-orientation="horizontal"] > .caps-field-row ) > .caps-field-lead > .caps-field-icon {
13202
+ grid-column: 1;
13203
+ grid-row: 1;
13204
+ justify-self: start;
13205
+ }
13206
+ :is(.caps-form, .caps-form-section)[data-layout="columns"] :is( .caps-field[data-orientation="vertical"], .caps-field[data-orientation="horizontal"] > .caps-field-row ) > .caps-field-lead > .caps-field-copy > label {
13207
+ grid-column: 1;
13208
+ grid-row: 1;
13209
+ min-width: 0;
13210
+ padding-inline-start: calc(var(--caps-field-icon-size) + var(--spacing-xs));
13211
+ overflow: hidden;
13212
+ text-overflow: ellipsis;
13213
+ white-space: nowrap;
13214
+ }
13215
+ :is(.caps-form, .caps-form-section)[data-layout="columns"] :is( .caps-field[data-orientation="vertical"], .caps-field[data-orientation="horizontal"] > .caps-field-row ) > .caps-field-lead > .caps-field-copy > .caps-field-description {
13216
+ grid-column: 2;
13217
+ grid-row: 2;
13218
+ }
13219
+ :is(.caps-form, .caps-form-section)[data-layout="columns"] .caps-field, .caps-fieldset[data-layout="columns"] {
13220
+ --caps-field-icon-size: 1rem;
13221
+ }
13222
+ :is(.caps-form, .caps-form-section)[data-layout="columns"] .caps-field :is(.caps-field-copy > label, .caps-field-icon), .caps-fieldset[data-layout="columns"] > .caps-fieldset-legend {
13223
+ color: var(--stipe-fg-muted);
13224
+ font-weight: 400;
13225
+ }
13226
+ @container (max-width: 24rem) {
13166
13227
  :is(.caps-form, .caps-form-section)[data-layout="columns"] :is( .caps-field[data-orientation="vertical"], .caps-field[data-orientation="horizontal"] > .caps-field-row ), .caps-fieldset[data-layout="columns"] {
13167
13228
  grid-template-columns: minmax(0, 1fr);
13168
13229
  }
@@ -13179,11 +13240,16 @@
13179
13240
  overflow: visible;
13180
13241
  text-overflow: clip;
13181
13242
  white-space: normal;
13243
+ min-height: 0;
13182
13244
  }
13183
13245
  :is(.caps-form, .caps-form-section)[data-layout="columns"] .caps-field[data-orientation="horizontal"] > .caps-field-error-reveal, .caps-form[data-layout="columns"] .caps-form-footer {
13184
13246
  padding-inline-start: 0;
13185
13247
  max-width: none;
13186
13248
  }
13249
+ :is(.caps-form, .caps-form-section)[data-layout="columns"] :is( .caps-field[data-orientation="vertical"], .caps-field[data-orientation="horizontal"] > .caps-field-row ) > .caps-field-lead > .caps-field-copy > .caps-field-description {
13250
+ grid-column: 1;
13251
+ grid-row: 3;
13252
+ }
13187
13253
  }
13188
13254
  .caps-choice-card {
13189
13255
  display: flex;
@@ -14461,6 +14527,121 @@
14461
14527
  .caps-icon-choice[data-appearance="compact"] .caps-icon-choice-lamp {
14462
14528
  display: none;
14463
14529
  }
14530
+ .caps-icon-choice[data-swatch] {
14531
+ min-width: 2.25rem;
14532
+ min-height: 2.25rem;
14533
+ }
14534
+ .caps-icon-choice[data-swatch] .caps-icon-choice-mark {
14535
+ padding: var(--spacing-xs);
14536
+ }
14537
+ .caps-icon-choice[data-art="color"] .caps-icon-choice-art {
14538
+ filter: none;
14539
+ opacity: 1;
14540
+ }
14541
+ .caps-icon-choice[data-swatch] .caps-icon-choice-art {
14542
+ width: 1rem;
14543
+ height: 1rem;
14544
+ }
14545
+ .caps-swatch-dot {
14546
+ display: block;
14547
+ width: 100%;
14548
+ height: 100%;
14549
+ border-radius: var(--radius-full);
14550
+ outline: var(--border) solid var(--color-base-content);
14551
+ @supports (color: color-mix(in lab, red, red)) {
14552
+ outline: var(--border) solid color-mix(in oklab, var(--color-base-content) 20%, transparent);
14553
+ }
14554
+ outline-offset: -1px;
14555
+ }
14556
+ .caps-check-keys {
14557
+ flex: 1 1 100%;
14558
+ display: grid;
14559
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 9rem), 1fr));
14560
+ gap: var(--spacing-xs);
14561
+ min-width: 0;
14562
+ }
14563
+ .caps-check-keys:has(> :nth-child(7)) {
14564
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 13rem), 1fr));
14565
+ }
14566
+ .caps-check-key {
14567
+ display: grid;
14568
+ min-width: 0;
14569
+ cursor: pointer;
14570
+ }
14571
+ .caps-check-key > .caps-checkbox {
14572
+ min-height: 2.25rem;
14573
+ }
14574
+ .caps-check-key-face {
14575
+ grid-area: control;
14576
+ position: relative;
14577
+ display: flex;
14578
+ align-items: flex-start;
14579
+ gap: var(--spacing-xs);
14580
+ min-width: 0;
14581
+ padding: var(--spacing-xs) var(--spacing-md) var(--spacing-xs) var(--spacing-sm);
14582
+ border: var(--border) solid var(--caps-control-line, var(--color-base-300));
14583
+ border-radius: var(--radius-field);
14584
+ background: var(--caps-control-face, var(--color-base-100));
14585
+ color: var(--caps-control-dim, var(--stipe-fg-muted));
14586
+ pointer-events: none;
14587
+ transition: border-color var(--stipe-motion-fast) var(--stipe-motion-ease), background-color var(--stipe-motion-fast) var(--stipe-motion-ease);
14588
+ }
14589
+ .caps-check-key-icon {
14590
+ display: inline-flex;
14591
+ flex: none;
14592
+ align-items: center;
14593
+ width: 1rem;
14594
+ height: calc(var(--text-sm) * 1.5);
14595
+ filter: var(--caps-control-icon-off, grayscale(1) contrast(0));
14596
+ opacity: 0.55;
14597
+ }
14598
+ .caps-check-key-icon > :is(svg, img) {
14599
+ width: 1rem;
14600
+ height: 1rem;
14601
+ }
14602
+ .caps-check-key-copy {
14603
+ display: grid;
14604
+ min-width: 0;
14605
+ }
14606
+ .caps-check-key-label {
14607
+ font-size: var(--text-sm);
14608
+ font-weight: 600;
14609
+ line-height: 1.5;
14610
+ overflow-wrap: break-word;
14611
+ }
14612
+ .caps-check-key-hint {
14613
+ font-size: var(--text-xs);
14614
+ line-height: 1.4;
14615
+ color: var(--stipe-fg-muted);
14616
+ }
14617
+ .caps-check-key-lamp {
14618
+ position: absolute;
14619
+ top: 6px;
14620
+ right: 6px;
14621
+ width: 4px;
14622
+ height: 4px;
14623
+ border-radius: var(--radius-full);
14624
+ background: currentcolor;
14625
+ opacity: 0;
14626
+ }
14627
+ .caps-check-key-input:checked + .caps-check-key-face {
14628
+ border-color: var(--caps-control-selected-line, var(--color-base-content));
14629
+ background: var(--caps-control-selected-face, var(--color-base-200));
14630
+ color: var(--caps-control-ink, var(--color-base-content));
14631
+ }
14632
+ .caps-check-key-input:checked + .caps-check-key-face .caps-check-key-icon {
14633
+ filter: none;
14634
+ opacity: 1;
14635
+ }
14636
+ .caps-check-key-input:checked + .caps-check-key-face .caps-check-key-lamp {
14637
+ opacity: 1;
14638
+ }
14639
+ .caps-check-key-input:hover:not(:checked, :disabled) + .caps-check-key-face .caps-check-key-icon {
14640
+ opacity: 0.8;
14641
+ }
14642
+ .caps-check-key:has(.caps-check-key-input:disabled) {
14643
+ cursor: not-allowed;
14644
+ }
14464
14645
  .caps-icon-choice:has(.caps-icon-choice-input:disabled) {
14465
14646
  cursor: not-allowed;
14466
14647
  }
@@ -15371,6 +15552,35 @@
15371
15552
  font-size: var(--text-xs);
15372
15553
  font-variant-numeric: tabular-nums;
15373
15554
  }
15555
+ .list-row.caps-fact {
15556
+ --list-grid-cols: 6.5rem minmax(0, 1fr);
15557
+ align-items: start;
15558
+ gap: var(--spacing-sm);
15559
+ }
15560
+ .list-row.caps-fact::after {
15561
+ inset-inline: 0;
15562
+ }
15563
+ .caps-fact-label {
15564
+ display: flex;
15565
+ align-items: center;
15566
+ gap: var(--spacing-xs);
15567
+ min-height: 1lh;
15568
+ color: var(--stipe-fg-muted);
15569
+ }
15570
+ .caps-fact-icon {
15571
+ display: inline-flex;
15572
+ flex: none;
15573
+ width: 1rem;
15574
+ height: 1rem;
15575
+ }
15576
+ .caps-fact-icon > :is(svg, img) {
15577
+ width: 100%;
15578
+ height: 100%;
15579
+ }
15580
+ .caps-fact-value {
15581
+ min-width: 0;
15582
+ overflow-wrap: anywhere;
15583
+ }
15374
15584
  .list.list-sm > .list-row {
15375
15585
  gap: var(--spacing-sm);
15376
15586
  padding: var(--spacing-2xs) 0;
@@ -16150,6 +16360,18 @@
16150
16360
  .caps-game-meter .progress {
16151
16361
  height: 0.625rem;
16152
16362
  }
16363
+ .caps-keycap-button[data-density="compact"] {
16364
+ --caps-keycap-height: 2.25rem;
16365
+ --caps-keycap-width: auto;
16366
+ --caps-keycap-justify: center;
16367
+ --caps-keycap-font-size: var(--text-sm);
16368
+ --caps-keycap-font-weight: 600;
16369
+ }
16370
+ @media (max-width: 640px), (any-pointer: coarse) {
16371
+ .caps-keycap-button[data-density="compact"] {
16372
+ --caps-keycap-height: var(--caps-touch-target);
16373
+ }
16374
+ }
16153
16375
  .caps-keycap-button {
16154
16376
  --caps-key-face: var(--caps-keycap-face, var(--color-base-200));
16155
16377
  --caps-keycap-rest-shadow: inset 0 1px 0 oklch(100% 0 0 / calc(var(--depth) * 0.18)),
@@ -18149,6 +18371,19 @@
18149
18371
  place-self: center stretch;
18150
18372
  height: max(100%, var(--caps-touch-target));
18151
18373
  }
18374
+ .caps-switch-input, .caps-icon-choice[data-swatch] .caps-icon-choice-input {
18375
+ place-self: center;
18376
+ width: max(100%, var(--caps-touch-target));
18377
+ }
18378
+ .caps-check-key > .caps-checkbox {
18379
+ min-height: var(--caps-touch-target);
18380
+ }
18381
+ .caps-check-key .caps-check-key-face {
18382
+ align-items: center;
18383
+ }
18384
+ .caps-icon-choice[data-swatch] .caps-icon-choice-input {
18385
+ height: max(100%, var(--caps-touch-target));
18386
+ }
18152
18387
  .caps-icon-button::after {
18153
18388
  content: "";
18154
18389
  position: absolute;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fungi.computer/caps",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "author": "Levi <levi@technomancy.dev>",
5
5
  "description": "Product-neutral React primitives and blocks for Fungi interfaces",
6
6
  "type": "module",
@@ -493,7 +493,7 @@
493
493
  "typescript": "5.9.3",
494
494
  "vite": "8.2.2",
495
495
  "vitest": "4.1.11",
496
- "@fungi.computer/sigils": "0.2.1",
496
+ "@fungi.computer/sigils": "0.3.0",
497
497
  "postcss-value-parser": "4.2.0",
498
498
  "@tanstack/react-form": "1.33.2",
499
499
  "effect": "4.0.2",
@@ -579,7 +579,7 @@
579
579
  "registry:stranger": "node scripts/registry-stranger.mjs",
580
580
  "workbench:serve": "node scripts/workbench-serve.mjs",
581
581
  "workbench:build": "node scripts/workbench-build.mjs",
582
- "workbench:contract": "node scripts/workbench-contract.mjs",
582
+ "workbench:contract": "bash ../../tooling/proof-runner/owned-loopback.sh -- node scripts/workbench-contract.mjs",
583
583
  "docs": "tsx ../../tooling/repository/src/docs/cli.ts check --root ../..",
584
584
  "docs:write": "tsx ../../tooling/repository/src/docs/cli.ts write --root ../..",
585
585
  "pack": "mkdir -p ../../.botanical/caps-pack && pnpm pack --pack-destination ../../.botanical/caps-pack",
@@ -588,10 +588,7 @@
588
588
  "format:check": "node ../../tooling/repository/scripts/format-package-check.mjs",
589
589
  "lint:anti-slop": "node ../../tooling/repository/scripts/lint-package-anti-slop.mjs",
590
590
  "build:workbench": "node scripts/workbench-build.mjs --prepared",
591
- "test:nightly": "node --import tsx ../../tooling/repository/scripts/workbench-proofs.mjs --output ../../.botanical/caps-workbench --origin-env CAPS_WORKBENCH_URL test/screen-theme.browser.mjs test/scroll-area.browser.mjs test/display-density.browser.mjs test/resize-handle.browser.mjs test/dialog-fill.browser.mjs && node test/scroll-gutter.browser.mjs",
592
- "test:code-input": "node --import tsx ../../tooling/repository/scripts/workbench-proofs.mjs --output ../../.botanical/caps-code-input --origin-env CAPS_WORKBENCH_URL test/code-input.browser.mjs",
593
- "test:icon-choice": "node --import tsx ../../tooling/repository/scripts/workbench-proofs.mjs --output ../../.botanical/caps-icon-choice --origin-env CAPS_WORKBENCH_URL test/icon-choice.browser.mjs",
594
- "test:choice-label-alignment": "node --import tsx ../../tooling/repository/scripts/workbench-proofs.mjs --output ../../.botanical/caps-choice-alignment --origin-env CAPS_WORKBENCH_URL test/choice-label-alignment.browser.mjs",
591
+ "test:nightly": "node --import tsx ../../tooling/repository/scripts/workbench-proofs.mjs --output ../../.botanical/caps-workbench --origin-env CAPS_WORKBENCH_URL test/screen-theme.browser.mjs test/scroll-area.browser.mjs test/display-density.browser.mjs test/resize-handle.browser.mjs test/dialog-fill.browser.mjs test/app-bar-edge.browser.mjs test/overlay-layers.browser.mjs test/code-input.browser.mjs test/icon-choice.browser.mjs test/choice-label-alignment.browser.mjs && node test/scroll-gutter.browser.mjs && node test/card-theme.browser.mjs && node test/game-theme.browser.mjs && node test/disabled-filled.browser.mjs && node test/window-theme.browser.mjs",
595
592
  "lint:motion": "node --test scripts/motion-token-guard.test.mjs && node scripts/motion-token-guard.mjs",
596
593
  "test:card-theme": "node test/card-theme.browser.mjs",
597
594
  "test:window-theme": "node test/window-theme.browser.mjs",
package/src/styles.css CHANGED
@@ -4490,6 +4490,25 @@
4490
4490
  .list-row > .caps-field {
4491
4491
  grid-column: 1 / -1;
4492
4492
  }
4493
+ /* In a compact list a settings row's icon is a 16px Sigil beside its
4494
+ label, the details-panel marker, not a tile. */
4495
+ .list.list-sm > .list-row > .caps-field {
4496
+ --caps-field-icon-size: 1rem;
4497
+ }
4498
+ .list.list-sm > .list-row > .caps-field .caps-field-lead {
4499
+ align-items: flex-start;
4500
+ gap: var(--spacing-xs);
4501
+ }
4502
+ /* The marker sits on the label's line, as in a details panel. */
4503
+ .list.list-sm > .list-row > .caps-field .caps-field-icon {
4504
+ height: calc(var(--caps-control-label-size, var(--text-sm)) * 1.5);
4505
+ }
4506
+ .list.list-sm
4507
+ > .list-row
4508
+ > .caps-field:has(> .caps-field-row > .caps-field-lead)
4509
+ > .caps-field-error-reveal {
4510
+ padding-inline-start: calc(var(--caps-field-icon-size) + var(--spacing-xs));
4511
+ }
4493
4512
  /* An icon row's error lines up with its label, not its icon. */
4494
4513
  .caps-field:has(> .caps-field-row > .caps-field-lead)
4495
4514
  > .caps-field-error-reveal {
@@ -4530,6 +4549,17 @@
4530
4549
  line-height: 1.5;
4531
4550
  color: var(--color-base-content);
4532
4551
  }
4552
+ /* A legend's icon is the inline 16px marker a details panel puts before
4553
+ its labels. */
4554
+ .caps-fieldset-legend:has(> .caps-field-icon) {
4555
+ display: flex;
4556
+ align-items: center;
4557
+ gap: var(--spacing-xs);
4558
+ }
4559
+ .caps-fieldset-legend > .caps-field-icon {
4560
+ width: 1rem;
4561
+ height: 1rem;
4562
+ }
4533
4563
  .caps-fieldset-description {
4534
4564
  margin: 0;
4535
4565
  font-size: var(--text-xs);
@@ -4658,7 +4688,7 @@
4658
4688
  /* The label gutter belongs to an opted-in form. No default Field or Form
4659
4689
  changes geometry, including the signed-off permissions panel. */
4660
4690
  :is(.caps-form, .caps-form-section, .caps-fieldset)[data-layout="columns"] {
4661
- --caps-form-label-col: 9rem;
4691
+ --caps-form-label-col: 7rem;
4662
4692
  }
4663
4693
  .caps-form[data-layout="columns"]
4664
4694
  :is(.caps-form-body, .caps-form-section-fields) {
@@ -4791,6 +4821,19 @@
4791
4821
  grid-column: 1;
4792
4822
  grid-row: 1;
4793
4823
  margin: 0;
4824
+ /* A floated legend is an ordinary grid item rather than the fieldset's
4825
+ rendered legend, so it shares a row with its keys. It centres on the
4826
+ first line of keys, as a field's label centres on its control, and
4827
+ stays on that line when the keys wrap. */
4828
+ float: left;
4829
+ align-self: start;
4830
+ min-height: var(--caps-fieldset-legend-line, 2.25rem);
4831
+ align-content: center;
4832
+ }
4833
+ .caps-fieldset[data-layout="columns"]:has(
4834
+ .caps-icon-choice:not([data-swatch])
4835
+ ) {
4836
+ --caps-fieldset-legend-line: 3.25rem;
4794
4837
  }
4795
4838
  .caps-fieldset[data-layout="columns"] > .caps-fieldset-fields {
4796
4839
  grid-column: 2;
@@ -4798,6 +4841,8 @@
4798
4841
  flex-direction: row;
4799
4842
  flex-wrap: wrap;
4800
4843
  margin: 0;
4844
+ /* Choices share the value column's measure with the form's controls. */
4845
+ max-width: 30rem;
4801
4846
  }
4802
4847
  .caps-fieldset[data-layout="columns"]
4803
4848
  > :is(.caps-fieldset-description, .caps-field-error-reveal) {
@@ -4823,9 +4868,78 @@
4823
4868
  max-width: none;
4824
4869
  }
4825
4870
  }
4826
- /* Decide once per form: the 30rem example still has space for a 9rem label
4827
- and its control, while genuinely narrow phone forms stack every field. */
4828
- @container (max-width: 20rem) {
4871
+ /* The label column reads like a details panel's fact column: a 16px Sigil
4872
+ before a quiet label, the value beside it. The icon shares the label's
4873
+ cell; the description and error stay under the control. */
4874
+ :is(.caps-form, .caps-form-section)[data-layout="columns"]
4875
+ :is(
4876
+ .caps-field[data-orientation="vertical"],
4877
+ .caps-field[data-orientation="horizontal"] > .caps-field-row
4878
+ )
4879
+ > .caps-field-lead {
4880
+ display: contents;
4881
+ }
4882
+ :is(.caps-form, .caps-form-section)[data-layout="columns"]
4883
+ :is(
4884
+ .caps-field[data-orientation="vertical"],
4885
+ .caps-field[data-orientation="horizontal"] > .caps-field-row
4886
+ )
4887
+ > .caps-field-lead
4888
+ > .caps-field-copy {
4889
+ display: contents;
4890
+ }
4891
+ :is(.caps-form, .caps-form-section)[data-layout="columns"]
4892
+ :is(
4893
+ .caps-field[data-orientation="vertical"],
4894
+ .caps-field[data-orientation="horizontal"] > .caps-field-row
4895
+ )
4896
+ > .caps-field-lead
4897
+ > .caps-field-icon {
4898
+ grid-column: 1;
4899
+ grid-row: 1;
4900
+ justify-self: start;
4901
+ }
4902
+ :is(.caps-form, .caps-form-section)[data-layout="columns"]
4903
+ :is(
4904
+ .caps-field[data-orientation="vertical"],
4905
+ .caps-field[data-orientation="horizontal"] > .caps-field-row
4906
+ )
4907
+ > .caps-field-lead
4908
+ > .caps-field-copy
4909
+ > label {
4910
+ grid-column: 1;
4911
+ grid-row: 1;
4912
+ min-width: 0;
4913
+ padding-inline-start: calc(var(--caps-field-icon-size) + var(--spacing-xs));
4914
+ overflow: hidden;
4915
+ text-overflow: ellipsis;
4916
+ white-space: nowrap;
4917
+ }
4918
+ :is(.caps-form, .caps-form-section)[data-layout="columns"]
4919
+ :is(
4920
+ .caps-field[data-orientation="vertical"],
4921
+ .caps-field[data-orientation="horizontal"] > .caps-field-row
4922
+ )
4923
+ > .caps-field-lead
4924
+ > .caps-field-copy
4925
+ > .caps-field-description {
4926
+ grid-column: 2;
4927
+ grid-row: 2;
4928
+ }
4929
+ :is(.caps-form, .caps-form-section)[data-layout="columns"] .caps-field,
4930
+ .caps-fieldset[data-layout="columns"] {
4931
+ --caps-field-icon-size: 1rem;
4932
+ }
4933
+ :is(.caps-form, .caps-form-section)[data-layout="columns"]
4934
+ .caps-field
4935
+ :is(.caps-field-copy > label, .caps-field-icon),
4936
+ .caps-fieldset[data-layout="columns"] > .caps-fieldset-legend {
4937
+ color: var(--stipe-fg-muted);
4938
+ font-weight: 400;
4939
+ }
4940
+ /* Phone-width containers stack every field: label (with its Sigil) above
4941
+ the control, description and error under it. */
4942
+ @container (max-width: 24rem) {
4829
4943
  :is(.caps-form, .caps-form-section)[data-layout="columns"]
4830
4944
  :is(
4831
4945
  .caps-field[data-orientation="vertical"],
@@ -4871,6 +4985,8 @@
4871
4985
  overflow: visible;
4872
4986
  text-overflow: clip;
4873
4987
  white-space: normal;
4988
+ /* Stacked, the legend sits on its own line above the keys. */
4989
+ min-height: 0;
4874
4990
  }
4875
4991
  :is(.caps-form, .caps-form-section)[data-layout="columns"]
4876
4992
  .caps-field[data-orientation="horizontal"]
@@ -4879,6 +4995,17 @@
4879
4995
  padding-inline-start: 0;
4880
4996
  max-width: none;
4881
4997
  }
4998
+ :is(.caps-form, .caps-form-section)[data-layout="columns"]
4999
+ :is(
5000
+ .caps-field[data-orientation="vertical"],
5001
+ .caps-field[data-orientation="horizontal"] > .caps-field-row
5002
+ )
5003
+ > .caps-field-lead
5004
+ > .caps-field-copy
5005
+ > .caps-field-description {
5006
+ grid-column: 1;
5007
+ grid-row: 3;
5008
+ }
4882
5009
  }
4883
5010
 
4884
5011
  /* Choice cards: a RadioGroup option with room for a description and a
@@ -6432,6 +6559,131 @@
6432
6559
  .caps-icon-choice[data-appearance="compact"] .caps-icon-choice-lamp {
6433
6560
  display: none;
6434
6561
  }
6562
+ /* SwatchRadio: a 2.25rem icon key whose colour dot stays lit, so each
6563
+ colour reads before it is chosen; the lamp marks the choice. */
6564
+ .caps-icon-choice[data-swatch] {
6565
+ min-width: 2.25rem;
6566
+ min-height: 2.25rem;
6567
+ }
6568
+ .caps-icon-choice[data-swatch] .caps-icon-choice-mark {
6569
+ padding: var(--spacing-xs);
6570
+ }
6571
+ .caps-icon-choice[data-art="color"] .caps-icon-choice-art {
6572
+ filter: none;
6573
+ opacity: 1;
6574
+ }
6575
+ .caps-icon-choice[data-swatch] .caps-icon-choice-art {
6576
+ width: 1rem;
6577
+ height: 1rem;
6578
+ }
6579
+ .caps-swatch-dot {
6580
+ display: block;
6581
+ width: 100%;
6582
+ height: 100%;
6583
+ border-radius: var(--radius-full);
6584
+ outline: var(--border) solid
6585
+ color-mix(in oklab, var(--color-base-content) 20%, transparent);
6586
+ outline-offset: -1px;
6587
+ }
6588
+ /* CheckKey: a short multi-select as wide icon keys. The same face, line,
6589
+ selected face and lamp as an icon key; Sigil, label and hint read like
6590
+ a details-panel row. The checkbox's own focus ring and disabled ink
6591
+ apply to the whole key. */
6592
+ .caps-check-keys {
6593
+ flex: 1 1 100%;
6594
+ display: grid;
6595
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 9rem), 1fr));
6596
+ gap: var(--spacing-xs);
6597
+ min-width: 0;
6598
+ }
6599
+ /* A long list reads two to a row, so longer names keep whole words. */
6600
+ .caps-check-keys:has(> :nth-child(7)) {
6601
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 13rem), 1fr));
6602
+ }
6603
+ .caps-check-key {
6604
+ display: grid;
6605
+ min-width: 0;
6606
+ cursor: pointer;
6607
+ }
6608
+ .caps-check-key > .caps-checkbox {
6609
+ min-height: 2.25rem;
6610
+ }
6611
+ .caps-check-key-face {
6612
+ grid-area: control;
6613
+ position: relative;
6614
+ display: flex;
6615
+ align-items: flex-start;
6616
+ gap: var(--spacing-xs);
6617
+ min-width: 0;
6618
+ padding: var(--spacing-xs) var(--spacing-md) var(--spacing-xs)
6619
+ var(--spacing-sm);
6620
+ border: var(--border) solid var(--caps-control-line, var(--color-base-300));
6621
+ border-radius: var(--radius-field);
6622
+ background: var(--caps-control-face, var(--color-base-100));
6623
+ color: var(--caps-control-dim, var(--stipe-fg-muted));
6624
+ pointer-events: none;
6625
+ transition:
6626
+ border-color var(--stipe-motion-fast) var(--stipe-motion-ease),
6627
+ background-color var(--stipe-motion-fast) var(--stipe-motion-ease);
6628
+ }
6629
+ .caps-check-key-icon {
6630
+ display: inline-flex;
6631
+ flex: none;
6632
+ align-items: center;
6633
+ width: 1rem;
6634
+ height: calc(var(--text-sm) * 1.5);
6635
+ filter: var(--caps-control-icon-off, grayscale(1) contrast(0));
6636
+ opacity: 0.55;
6637
+ }
6638
+ .caps-check-key-icon > :is(svg, img) {
6639
+ width: 1rem;
6640
+ height: 1rem;
6641
+ }
6642
+ .caps-check-key-copy {
6643
+ display: grid;
6644
+ min-width: 0;
6645
+ }
6646
+ .caps-check-key-label {
6647
+ font-size: var(--text-sm);
6648
+ font-weight: 600;
6649
+ line-height: 1.5;
6650
+ overflow-wrap: break-word;
6651
+ }
6652
+ .caps-check-key-hint {
6653
+ font-size: var(--text-xs);
6654
+ line-height: 1.4;
6655
+ color: var(--stipe-fg-muted);
6656
+ }
6657
+ .caps-check-key-lamp {
6658
+ position: absolute;
6659
+ top: 6px;
6660
+ right: 6px;
6661
+ width: 4px;
6662
+ height: 4px;
6663
+ border-radius: var(--radius-full);
6664
+ background: currentcolor;
6665
+ opacity: 0;
6666
+ }
6667
+ .caps-check-key-input:checked + .caps-check-key-face {
6668
+ border-color: var(--caps-control-selected-line, var(--color-base-content));
6669
+ background: var(--caps-control-selected-face, var(--color-base-200));
6670
+ color: var(--caps-control-ink, var(--color-base-content));
6671
+ }
6672
+ .caps-check-key-input:checked + .caps-check-key-face .caps-check-key-icon {
6673
+ filter: none;
6674
+ opacity: 1;
6675
+ }
6676
+ .caps-check-key-input:checked + .caps-check-key-face .caps-check-key-lamp {
6677
+ opacity: 1;
6678
+ }
6679
+ .caps-check-key-input:hover:not(:checked, :disabled)
6680
+ + .caps-check-key-face
6681
+ .caps-check-key-icon {
6682
+ opacity: 0.8;
6683
+ }
6684
+ .caps-check-key:has(.caps-check-key-input:disabled) {
6685
+ cursor: not-allowed;
6686
+ }
6435
6687
  .caps-icon-choice:has(.caps-icon-choice-input:disabled) {
6436
6688
  cursor: not-allowed;
6437
6689
  }
@@ -7483,6 +7735,37 @@
7483
7735
  font-size: var(--text-xs);
7484
7736
  font-variant-numeric: tabular-nums;
7485
7737
  }
7738
+ /* FactRow: a details-panel fact. A fixed label column (16px Sigil and a
7739
+ quiet name) beside a wrapping value; the row rule spans both. */
7740
+ .list-row.caps-fact {
7741
+ --list-grid-cols: 6.5rem minmax(0, 1fr);
7742
+ align-items: start;
7743
+ gap: var(--spacing-sm);
7744
+ }
7745
+ .list-row.caps-fact::after {
7746
+ inset-inline: 0;
7747
+ }
7748
+ .caps-fact-label {
7749
+ display: flex;
7750
+ align-items: center;
7751
+ gap: var(--spacing-xs);
7752
+ min-height: 1lh;
7753
+ color: var(--stipe-fg-muted);
7754
+ }
7755
+ .caps-fact-icon {
7756
+ display: inline-flex;
7757
+ flex: none;
7758
+ width: 1rem;
7759
+ height: 1rem;
7760
+ }
7761
+ .caps-fact-icon > :is(svg, img) {
7762
+ width: 100%;
7763
+ height: 100%;
7764
+ }
7765
+ .caps-fact-value {
7766
+ min-width: 0;
7767
+ overflow-wrap: anywhere;
7768
+ }
7486
7769
  /* DataList density="compact": rows whose content brings its own inset
7487
7770
  (an ActionRow) sit flush with the list's edge, one control high. */
7488
7771
  .list.list-sm > .list-row {
@@ -8368,6 +8651,21 @@
8368
8651
  .caps-game-meter .progress {
8369
8652
  height: 0.625rem;
8370
8653
  }
8654
+ /* KeycapButton density="compact": the details-panel key. A row of them
8655
+ sizes each key to its legend; a grid still stretches them to its cells. */
8656
+ .caps-keycap-button[data-density="compact"] {
8657
+ --caps-keycap-height: 2.25rem;
8658
+ --caps-keycap-width: auto;
8659
+ --caps-keycap-justify: center;
8660
+ --caps-keycap-font-size: var(--text-sm);
8661
+ --caps-keycap-font-weight: 600;
8662
+ }
8663
+ /* On phones and touch screens the compact key keeps a thumb-sized height. */
8664
+ @media (max-width: 640px), (any-pointer: coarse) {
8665
+ .caps-keycap-button[data-density="compact"] {
8666
+ --caps-keycap-height: var(--caps-touch-target);
8667
+ }
8668
+ }
8371
8669
  /* A key: today's flat key on a 1px lip, a touch richer. The face carries a
8372
8670
  faint ±3% grade, the top edge is 0.18, a small soft drop sits under the
8373
8671
  lip, and the linework edge is softened 20% toward the face. It reads its
@@ -10508,6 +10806,23 @@
10508
10806
  place-self: center stretch;
10509
10807
  height: max(100%, var(--caps-touch-target));
10510
10808
  }
10809
+ /* A switch or a colour key is its own target, not beside a label, so its
10810
+ hit area reaches the touch width too; the visual keeps its size and
10811
+ the extra reach spills evenly into the gap around it. */
10812
+ .caps-switch-input,
10813
+ .caps-icon-choice[data-swatch] .caps-icon-choice-input {
10814
+ place-self: center;
10815
+ width: max(100%, var(--caps-touch-target));
10816
+ }
10817
+ .caps-check-key > .caps-checkbox {
10818
+ min-height: var(--caps-touch-target);
10819
+ }
10820
+ .caps-check-key .caps-check-key-face {
10821
+ align-items: center;
10822
+ }
10823
+ .caps-icon-choice[data-swatch] .caps-icon-choice-input {
10824
+ height: max(100%, var(--caps-touch-target));
10825
+ }
10511
10826
  /* An icon button keeps its grid size and face; an invisible hit area
10512
10827
  centred on it reaches the touch target instead. */
10513
10828
  .caps-icon-button::after {