@vitavision/ui 0.6.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.
@@ -0,0 +1,190 @@
1
+ /*
2
+ * What the screen says when it has something other than data to show.
3
+ *
4
+ * An error explains what happened and what to do about it; it does not apologise and it is
5
+ * never vague. An empty state is an invitation to act, so it carries the action rather than
6
+ * describing it. And loading is a shape, not the word "Loading…" -- a skeleton or a
7
+ * progress bar keeps a slow request from looking identical to a broken one.
8
+ */
9
+
10
+ import { AlertTriangle, CheckCircle2, Info, XCircle } from "lucide-react";
11
+ import type { ReactNode } from "react";
12
+
13
+ import { cn } from "./cn";
14
+
15
+ /** A callout's kind: a fact (`info`), a caveat (`warning`), a failure (`error`) or `success`. */
16
+ export type CalloutTone = "info" | "warning" | "error" | "success";
17
+
18
+ const CALLOUT: Record<CalloutTone, { classes: string; Icon: typeof Info }> = {
19
+ info: { classes: "border-signal/30 bg-signal/8 text-fg", Icon: Info },
20
+ warning: { classes: "border-warn/30 bg-warn/8 text-fg", Icon: AlertTriangle },
21
+ error: { classes: "border-defect/30 bg-defect/8 text-fg", Icon: XCircle },
22
+ success: { classes: "border-normal/30 bg-normal/8 text-fg", Icon: CheckCircle2 },
23
+ };
24
+
25
+ const ICON_TONE: Record<CalloutTone, string> = {
26
+ info: "text-signal",
27
+ warning: "text-warn",
28
+ error: "text-defect",
29
+ success: "text-normal",
30
+ };
31
+
32
+ /**
33
+ * A boxed message with a tone icon, an optional title and optional actions.
34
+ *
35
+ * An `error` callout is a `role="alert"` (announced at once); the others are
36
+ * `role="status"`. The tone is exposed as `data-tone`.
37
+ */
38
+ export function Callout({
39
+ tone = "info",
40
+ title,
41
+ actions,
42
+ className,
43
+ children,
44
+ }: {
45
+ /** Defaults to `info`. */
46
+ tone?: CalloutTone | undefined;
47
+ /** A one-line summary above the message. */
48
+ title?: ReactNode;
49
+ /** Buttons for what to do about it, below the message. */
50
+ actions?: ReactNode;
51
+ /** Merged with the callout's own classes through `cn`. */
52
+ className?: string | undefined;
53
+ /** The message: what happened and what to do about it. */
54
+ children: ReactNode;
55
+ }) {
56
+ const { classes, Icon } = CALLOUT[tone];
57
+ return (
58
+ <div
59
+ role={tone === "error" ? "alert" : "status"}
60
+ data-tone={tone}
61
+ className={cn(
62
+ "flex items-start gap-2.5 rounded-control border px-3 py-2.5 text-sm leading-snug",
63
+ classes,
64
+ className,
65
+ )}
66
+ >
67
+ <Icon className={cn("mt-0.5 size-4 shrink-0", ICON_TONE[tone])} aria-hidden />
68
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
69
+ {title && <p className="font-medium">{title}</p>}
70
+ <div className="min-w-0 text-fg-muted">{children}</div>
71
+ {actions && <div className="mt-1 flex flex-wrap items-center gap-2">{actions}</div>}
72
+ </div>
73
+ </div>
74
+ );
75
+ }
76
+
77
+ /** The common error shape: an `error` `Callout` with only a message. */
78
+ export function ErrorBox({
79
+ className,
80
+ children,
81
+ }: {
82
+ /** Merged with the callout's own classes through `cn`. */
83
+ className?: string | undefined;
84
+ /** What went wrong and what to do about it. */
85
+ children: ReactNode;
86
+ }) {
87
+ return (
88
+ <Callout tone="error" className={className}>
89
+ {children}
90
+ </Callout>
91
+ );
92
+ }
93
+
94
+ /** An empty state: a sentence saying why there is nothing here, and the action that fixes it. */
95
+ export function Empty({
96
+ action,
97
+ className,
98
+ children,
99
+ }: {
100
+ /** The action — usually a primary `Button` — rather than a description of it. */
101
+ action?: ReactNode;
102
+ /** Merged with the empty state's own classes through `cn`. */
103
+ className?: string | undefined;
104
+ /** Why there is nothing to show. */
105
+ children: ReactNode;
106
+ }) {
107
+ return (
108
+ <div className={cn("flex flex-col items-center gap-3 py-10 text-center", className)}>
109
+ <p className="text-sm text-fg-muted">{children}</p>
110
+ {action}
111
+ </div>
112
+ );
113
+ }
114
+
115
+ /** One placeholder bar for content on its way (`aria-hidden`; size it with `className`). */
116
+ export function Skeleton({
117
+ className,
118
+ }: {
119
+ /** Sets the bar's size; merged with its own classes through `cn`. */
120
+ className?: string | undefined;
121
+ }) {
122
+ return <div aria-hidden className={cn("animate-pulse rounded-control bg-line", className)} />;
123
+ }
124
+
125
+ /** A stand-in for a panel's worth of content, so the layout does not jump when it lands. */
126
+ export function SkeletonRows({
127
+ rows = 3,
128
+ className,
129
+ }: {
130
+ /** How many bars. Defaults to 3; the last is shorter. */
131
+ rows?: number | undefined;
132
+ /** Merged with the column's own classes through `cn`. */
133
+ className?: string | undefined;
134
+ }) {
135
+ return (
136
+ <div role="status" aria-label="Loading" className={cn("flex flex-col gap-2", className)}>
137
+ {Array.from({ length: rows }, (_, index) => (
138
+ <Skeleton key={index} className={cn("h-4", index === rows - 1 ? "w-2/3" : "w-full")} />
139
+ ))}
140
+ </div>
141
+ );
142
+ }
143
+
144
+ /**
145
+ * A known fraction done, as a bar and (with `label`) a percentage line.
146
+ *
147
+ * A `role="progressbar"` with `aria-valuenow` in percent. Its accessible name is
148
+ * `aria-label` if given, else `label`, else "Progress" — pass one that names the job when
149
+ * a screen shows more than one bar.
150
+ */
151
+ export function ProgressBar({
152
+ fraction,
153
+ label,
154
+ "aria-label": ariaLabel,
155
+ className,
156
+ }: {
157
+ /** Done, from 0 to 1. Clamped. */
158
+ fraction: number;
159
+ /** What is progressing, printed after the percentage: "42 of 100 images". */
160
+ label?: string | undefined;
161
+ /** The bar's accessible name. Defaults to `label`, then to "Progress". */
162
+ "aria-label"?: string | undefined;
163
+ /** Merged with the wrapper's own classes through `cn`. */
164
+ className?: string | undefined;
165
+ }) {
166
+ const percent = Math.round(Math.min(Math.max(fraction, 0), 1) * 100);
167
+ return (
168
+ <div className={cn("flex flex-col gap-1.5", className)}>
169
+ <div
170
+ role="progressbar"
171
+ aria-label={ariaLabel ?? label ?? "Progress"}
172
+ aria-valuenow={percent}
173
+ aria-valuemin={0}
174
+ aria-valuemax={100}
175
+ data-complete={percent === 100 ? "" : undefined}
176
+ className="h-1.5 overflow-hidden rounded-full bg-line"
177
+ >
178
+ <div
179
+ className="h-full rounded-full bg-signal transition-[width] duration-300"
180
+ style={{ width: `${percent}%` }}
181
+ />
182
+ </div>
183
+ {label && (
184
+ <span className="font-mono text-xs text-fg-muted tabular-nums">
185
+ {percent}% · {label}
186
+ </span>
187
+ )}
188
+ </div>
189
+ );
190
+ }
@@ -0,0 +1,149 @@
1
+ /*
2
+ * A labelled control.
3
+ *
4
+ * Two things a naive label cannot express, both of which the schema form needs. A required
5
+ * field's marker is a separate span rather than text concatenated onto the label string, so
6
+ * it can be styled, read out as a marker, or dropped. And a field's description (and its
7
+ * error) is wired to the control through `aria-describedby`, so a screen reader connects
8
+ * the two.
9
+ *
10
+ * `as="group"` exists because a `<label>` cannot label a set of radios or a segmented
11
+ * control; that case needs a labelled group instead, and getting it wrong is the difference
12
+ * between a control that announces itself and one that announces nothing.
13
+ */
14
+
15
+ import { createContext, use, useId, useMemo, type ReactNode } from "react";
16
+
17
+ import { byDensity, useDensity } from "./Density";
18
+ import { cn } from "./cn";
19
+
20
+ /** What a `Field` tells the control inside it: the ids that describe it, and whether it is invalid. */
21
+ interface FieldControl {
22
+ describedBy: string | undefined;
23
+ invalid: boolean;
24
+ }
25
+
26
+ const FieldContext = createContext<FieldControl>({ describedBy: undefined, invalid: false });
27
+
28
+ /**
29
+ * The `aria-describedby` and `aria-invalid` a control inside a `Field` should carry, merged
30
+ * with the control's own `aria-describedby`. Used by this package's controls; not exported
31
+ * from the package.
32
+ */
33
+ export function useFieldDescription(own?: string): {
34
+ "aria-describedby": string | undefined;
35
+ "aria-invalid": true | undefined;
36
+ } {
37
+ const { describedBy, invalid } = use(FieldContext);
38
+ const ids = [own, describedBy].filter(Boolean).join(" ");
39
+ return { "aria-describedby": ids === "" ? undefined : ids, "aria-invalid": invalid || undefined };
40
+ }
41
+
42
+ /**
43
+ * A label, the control, and optionally a description, an error and a compact annotation.
44
+ *
45
+ * The label text and the annotation name the control (the `<label>` wraps them and the
46
+ * control); the description and the error are outside the label and wired into the
47
+ * control's `aria-describedby` instead (the error also sets `aria-invalid`): directly for
48
+ * this package's controls — `Input`, `NumberInput`, `Textarea`, `Select`, `Slider`,
49
+ * `SegmentedControl`, `Checkbox` — and, with `as="group"`, on the group itself. A control of your own inside a `label` field does not
50
+ * pick them up; give it `aria-describedby` yourself. State is exposed as `data-invalid` and
51
+ * `data-required`.
52
+ */
53
+ export function Field({
54
+ label,
55
+ description,
56
+ error,
57
+ required = false,
58
+ annotation,
59
+ as = "label",
60
+ className,
61
+ children,
62
+ }: {
63
+ /** The field's name, shown above the control. */
64
+ label: string;
65
+ /** What the value means or does. Always visible, and read with the control. */
66
+ description?: ReactNode;
67
+ /** What is wrong with the current value. Shown in the verdict red, announced as an alert. */
68
+ error?: ReactNode;
69
+ /** Marks the field required (a `*` after the label). */
70
+ required?: boolean | undefined;
71
+ /** A compact fact about the accepted values -- a range, a unit -- kept out of the prose. */
72
+ annotation?: ReactNode;
73
+ /**
74
+ * `label` (the default) wraps the control in a `<label>`; `group` renders a
75
+ * `role="group"` named by `label`, for a set of radios or a segmented control.
76
+ */
77
+ as?: "label" | "group";
78
+ /** Merged with the field's own classes through `cn`. */
79
+ className?: string | undefined;
80
+ /** The control. */
81
+ children: ReactNode;
82
+ }) {
83
+ const descriptionId = useId();
84
+ const errorId = useId();
85
+ const density = useDensity();
86
+
87
+ const hasError = error !== undefined && error !== null && error !== false;
88
+ const describedBy =
89
+ [description ? descriptionId : undefined, hasError ? errorId : undefined]
90
+ .filter(Boolean)
91
+ .join(" ") || undefined;
92
+
93
+ // A group carries the description itself; its controls would only repeat it.
94
+ const control = useMemo(
95
+ () => ({ describedBy: as === "label" ? describedBy : undefined, invalid: hasError }),
96
+ [as, describedBy, hasError],
97
+ );
98
+
99
+ const gap = byDensity(density, "gap-1.5", "gap-1");
100
+
101
+ const labelled = (
102
+ <>
103
+ <span className="flex items-baseline gap-2">
104
+ <span className={cn("font-medium text-fg", byDensity(density, "text-xs", "text-[11px]"))}>
105
+ {label}
106
+ {required && (
107
+ <span className="ml-0.5 text-defect" title="Required">
108
+ *
109
+ </span>
110
+ )}
111
+ </span>
112
+ {annotation && (
113
+ <span className="ml-auto font-mono text-[11px] text-fg-subtle">{annotation}</span>
114
+ )}
115
+ </span>
116
+ <FieldContext value={control}>{children}</FieldContext>
117
+ </>
118
+ );
119
+
120
+ return (
121
+ <div
122
+ className={cn("flex min-w-0 flex-col", gap, className)}
123
+ data-invalid={hasError ? "" : undefined}
124
+ data-required={required ? "" : undefined}
125
+ {...(as === "group"
126
+ ? { role: "group", "aria-label": label, "aria-describedby": describedBy }
127
+ : {})}
128
+ >
129
+ {/* The description and the error sit outside the <label>: inside it they would be read
130
+ as part of the control's name as well as its description. */}
131
+ {as === "label" ? (
132
+ <label className={cn("flex min-w-0 flex-col", gap)}>{labelled}</label>
133
+ ) : (
134
+ labelled
135
+ )}
136
+
137
+ {description && (
138
+ <span id={descriptionId} className="text-xs leading-snug text-fg-muted">
139
+ {description}
140
+ </span>
141
+ )}
142
+ {hasError && (
143
+ <span id={errorId} role="alert" className="text-xs leading-snug text-defect">
144
+ {error}
145
+ </span>
146
+ )}
147
+ </div>
148
+ );
149
+ }
@@ -0,0 +1,100 @@
1
+ /*
2
+ * Text entry.
3
+ *
4
+ * `controlClasses` is exported so a native control that isn't one of the wrapped ones below
5
+ * can still pick up the shared palette and focus treatment.
6
+ */
7
+
8
+ import type { ComponentProps } from "react";
9
+
10
+ import { byDensity, useDensity } from "./Density";
11
+ import { useFieldDescription } from "./Field";
12
+ import { cn, focusRingInset } from "./cn";
13
+
14
+ /**
15
+ * The shared look of a text-like control — border, palette, placeholder, hover, disabled and
16
+ * focus — for a native control that is not one of `Input`, `NumberInput` or `Textarea`.
17
+ * Add the height with `useControlHeight()`.
18
+ */
19
+ export const controlClasses = cn(
20
+ "w-full rounded-control border border-line-strong bg-raised px-2.5 text-sm text-fg",
21
+ "placeholder:text-fg-subtle",
22
+ "transition-colors hover:border-fg-subtle",
23
+ "disabled:cursor-not-allowed disabled:opacity-50",
24
+ focusRingInset,
25
+ );
26
+
27
+ /**
28
+ * The former name of `controlClasses`, kept as an alias for existing consumers.
29
+ *
30
+ * @deprecated Use `Input`, `NumberInput`, `Textarea` or `Select`, or `controlClasses`.
31
+ */
32
+ export const inputClasses = controlClasses;
33
+
34
+ /**
35
+ * The control height in force — exported so an app's own native control can match.
36
+ *
37
+ * @returns A Tailwind height class: `h-8` comfortable, `h-7` compact.
38
+ */
39
+ export function useControlHeight(): string {
40
+ return byDensity(useDensity(), "h-8", "h-7");
41
+ }
42
+
43
+ /**
44
+ * A single-line text input. Takes every `<input>` prop, `ref` included; inside a `Field` it
45
+ * is described by the field's description and error.
46
+ */
47
+ export function Input({ className, "aria-describedby": describedBy, ...rest }: ComponentProps<"input">) {
48
+ return (
49
+ <input
50
+ {...useFieldDescription(describedBy)}
51
+ {...rest}
52
+ className={cn(controlClasses, useControlHeight(), className)}
53
+ />
54
+ );
55
+ }
56
+
57
+ /**
58
+ * A number, with the schema's own bounds attached.
59
+ *
60
+ * `font-mono` because these are read as quantities and compared down a column. Takes every
61
+ * `<input>` prop, `ref` included (`type` is always `number`).
62
+ */
63
+ export function NumberInput({
64
+ className,
65
+ "aria-describedby": describedBy,
66
+ ...rest
67
+ }: Omit<ComponentProps<"input">, "min" | "max"> & {
68
+ /** The smallest accepted value. */
69
+ min?: number | undefined;
70
+ /** The largest accepted value. */
71
+ max?: number | undefined;
72
+ }) {
73
+ return (
74
+ <input
75
+ {...useFieldDescription(describedBy)}
76
+ {...rest}
77
+ type="number"
78
+ inputMode="decimal"
79
+ className={cn(controlClasses, useControlHeight(), "font-mono tabular-nums", className)}
80
+ />
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Multi-line text — set in mono, for JSON and lists. Takes every `<textarea>` prop, `ref`
86
+ * included.
87
+ */
88
+ export function Textarea({
89
+ className,
90
+ "aria-describedby": describedBy,
91
+ ...rest
92
+ }: ComponentProps<"textarea">) {
93
+ return (
94
+ <textarea
95
+ {...useFieldDescription(describedBy)}
96
+ {...rest}
97
+ className={cn(controlClasses, "py-1.5 font-mono", className)}
98
+ />
99
+ );
100
+ }
@@ -0,0 +1,248 @@
1
+ /*
2
+ * The page furniture: what holds a screen together above the level of a single control.
3
+ *
4
+ * One heading size and one gap rule, defined once so every route in every consuming app
5
+ * agrees with the others by construction rather than by luck.
6
+ */
7
+
8
+ import { Slot, Slottable } from "@radix-ui/react-slot";
9
+ import { isValidElement, type ReactElement, type ReactNode } from "react";
10
+
11
+ import { byDensity, useDensity } from "./Density";
12
+ import { cn } from "./cn";
13
+
14
+ /**
15
+ * A bordered surface with an optional header (a title and actions) above a padded body.
16
+ * The header and the padding follow the density in force.
17
+ */
18
+ export function Panel({
19
+ title,
20
+ actions,
21
+ className,
22
+ bodyClassName,
23
+ children,
24
+ }: {
25
+ /** The header's heading (an `<h2>`). */
26
+ title?: ReactNode;
27
+ /** Controls at the header's right end. */
28
+ actions?: ReactNode;
29
+ /** Merged with the panel's own classes through `cn`. */
30
+ className?: string | undefined;
31
+ /** Merged with the body's own classes (its padding) through `cn`. */
32
+ bodyClassName?: string | undefined;
33
+ /** The body. */
34
+ children: ReactNode;
35
+ }) {
36
+ const density = useDensity();
37
+
38
+ return (
39
+ <section className={cn("rounded-panel border border-line bg-surface", className)}>
40
+ {(title || actions) && (
41
+ <header
42
+ className={cn(
43
+ "flex items-center justify-between gap-4 border-b border-line",
44
+ byDensity(density, "min-h-11 px-4 py-2.5", "min-h-8 px-2.5 py-1"),
45
+ )}
46
+ >
47
+ <h2
48
+ className={cn(
49
+ "truncate font-semibold text-fg",
50
+ byDensity(
51
+ density,
52
+ "text-sm tracking-tight",
53
+ "text-[11px] tracking-wider uppercase text-fg-muted",
54
+ ),
55
+ )}
56
+ >
57
+ {title}
58
+ </h2>
59
+ {actions && <div className="flex shrink-0 items-center gap-1.5">{actions}</div>}
60
+ </header>
61
+ )}
62
+ <div className={cn(byDensity(density, "p-4", "p-2.5"), bodyClassName)}>{children}</div>
63
+ </section>
64
+ );
65
+ }
66
+
67
+ /**
68
+ * A numbered step inside a form.
69
+ *
70
+ * The number is not decoration: it marks a genuine ordering, where each choice narrows the
71
+ * next. Do not reach for this where the parts are merely adjacent.
72
+ */
73
+ export function Section({
74
+ step,
75
+ title,
76
+ hint,
77
+ actions,
78
+ className,
79
+ children,
80
+ }: {
81
+ /** The step's number, shown zero-padded before the title (decorative, `aria-hidden`). */
82
+ step?: number | undefined;
83
+ /** The step's heading (an `<h3>`). */
84
+ title: string;
85
+ /** A short line after the title. */
86
+ hint?: ReactNode;
87
+ /** Controls at the heading row's right end. */
88
+ actions?: ReactNode;
89
+ /** Merged with the section's own classes through `cn`. */
90
+ className?: string | undefined;
91
+ /** The step's content. */
92
+ children: ReactNode;
93
+ }) {
94
+ const density = useDensity();
95
+
96
+ return (
97
+ <section className={cn("flex flex-col", byDensity(density, "gap-3", "gap-1.5"), className)}>
98
+ <div className={cn("flex items-baseline", byDensity(density, "gap-2.5", "gap-2"))}>
99
+ {step !== undefined && (
100
+ <span
101
+ className="font-mono text-xs text-fg-subtle tabular-nums"
102
+ aria-hidden
103
+ >
104
+ {String(step).padStart(2, "0")}
105
+ </span>
106
+ )}
107
+ <h3 className="text-sm font-semibold tracking-tight text-fg">{title}</h3>
108
+ {hint && <p className="text-xs text-fg-muted">{hint}</p>}
109
+ {actions && <div className="ml-auto flex items-center gap-2">{actions}</div>}
110
+ </div>
111
+ {children}
112
+ </section>
113
+ );
114
+ }
115
+
116
+ /**
117
+ * Where `PageHeader`'s back link goes: `{ href, label }` for a plain URL, or the app's own
118
+ * link element (a router's `<Link>`).
119
+ */
120
+ export type BackLink =
121
+ | { href: string; label: string }
122
+ /** A link element — typically a router's `<Link to="…">Label</Link>` — styled and prefixed with an arrow. */
123
+ | ReactElement;
124
+
125
+ const BACK_CLASSES = "w-fit text-xs text-fg-muted transition-colors hover:text-signal";
126
+
127
+ /**
128
+ * The top of a screen: an optional back link, the `<h1>` title with actions beside it, and
129
+ * a line of meta facts below. Links go through `back`, never a router import.
130
+ */
131
+ export function PageHeader({
132
+ title,
133
+ meta,
134
+ actions,
135
+ back,
136
+ className,
137
+ }: {
138
+ /** The screen's heading (the `<h1>`). */
139
+ title: ReactNode;
140
+ /** Small facts under the title — an id, a date, a status badge. */
141
+ meta?: ReactNode;
142
+ /** The screen's actions, beside the title. */
143
+ actions?: ReactNode;
144
+ /**
145
+ * The way up. `{ href, label }` renders a plain `<a>`; for client-side navigation pass the
146
+ * router's own link — `back={<Link to="/experiments">Experiments</Link>}` — which is
147
+ * rendered with the back-link style and arrow, so this package never imports a router.
148
+ */
149
+ back?: BackLink | undefined;
150
+ /** Merged with the header's own classes through `cn`. */
151
+ className?: string | undefined;
152
+ }) {
153
+ return (
154
+ <header className={cn("flex flex-col gap-1.5", className)}>
155
+ {back &&
156
+ (isHrefLink(back) ? (
157
+ <a href={back.href} className={BACK_CLASSES}>
158
+ ← {back.label}
159
+ </a>
160
+ ) : (
161
+ <Slot className={BACK_CLASSES}>
162
+ {"← "}
163
+ <Slottable>{back}</Slottable>
164
+ </Slot>
165
+ ))}
166
+ <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
167
+ <h1 className="text-xl font-semibold tracking-tight text-fg">{title}</h1>
168
+ {actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
169
+ </div>
170
+ {meta && <div className="flex flex-wrap items-center gap-2 text-xs text-fg-muted">{meta}</div>}
171
+ </header>
172
+ );
173
+ }
174
+
175
+ function isHrefLink(back: BackLink): back is { href: string; label: string } {
176
+ return !isValidElement(back);
177
+ }
178
+
179
+ /** One fact on a `ReadoutStrip`: a value, optionally labelled and optionally a link. */
180
+ export type ReadoutItem = {
181
+ /** A quiet prefix naming what the value is, where the value alone is ambiguous. */
182
+ label?: string | undefined;
183
+ /** The fact. An item whose value is `null` or `undefined` is skipped. */
184
+ value: ReactNode;
185
+ /** Makes the value a plain `<a href>`. */
186
+ href?: string | undefined;
187
+ /**
188
+ * Makes the value a link through the app's own element — a router's `<Link to="…" />`,
189
+ * given without children: the value becomes its content. Takes precedence over `href`.
190
+ */
191
+ link?: ReactElement | undefined;
192
+ };
193
+
194
+ const READOUT_LINK = "text-fg transition-colors hover:text-signal";
195
+
196
+ /**
197
+ * The instrument's display line: the facts about what is on screen, in one fixed slot.
198
+ *
199
+ * Stated in the same place on every screen, in mono, the way a piece of measuring equipment
200
+ * has one readout rather than a label per panel. Rendered as a list because it is one.
201
+ */
202
+ export function ReadoutStrip({
203
+ items,
204
+ className,
205
+ }: {
206
+ /** The facts, in order. Items with no value are skipped. */
207
+ items: ReadoutItem[];
208
+ /** Merged with the list's own classes through `cn`. */
209
+ className?: string | undefined;
210
+ }) {
211
+ const shown = items.filter((item) => item.value !== null && item.value !== undefined);
212
+ if (shown.length === 0) return null;
213
+
214
+ return (
215
+ <ol
216
+ className={cn(
217
+ "flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-xs text-fg-muted",
218
+ className,
219
+ )}
220
+ >
221
+ {shown.map((item, index) => (
222
+ // The items are positional facts with no identity of their own (a label is optional
223
+ // and values repeat), so their place in the strip is the key.
224
+ // eslint-disable-next-line @eslint-react/no-array-index-key -- positional list, see above
225
+ <li key={index} className="flex items-center gap-2">
226
+ {index > 0 && (
227
+ <span className="text-fg-subtle" aria-hidden>
228
+ ·
229
+ </span>
230
+ )}
231
+ {item.label && <span className="text-fg-subtle">{item.label}</span>}
232
+ {item.link ? (
233
+ <Slot className={READOUT_LINK}>
234
+ <Slottable>{item.link}</Slottable>
235
+ {item.value}
236
+ </Slot>
237
+ ) : item.href ? (
238
+ <a href={item.href} className={READOUT_LINK}>
239
+ {item.value}
240
+ </a>
241
+ ) : (
242
+ <span className="text-fg">{item.value}</span>
243
+ )}
244
+ </li>
245
+ ))}
246
+ </ol>
247
+ );
248
+ }