From 26dadb7e197db66e3ea195658f3ad9518e272681 Mon Sep 17 00:00:00 2001
From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com>
Date: Thu, 24 Sep 2026 15:23:15 +0100
Subject: [PATCH 1/4] Add shared Select and polish shell controls
---
apps/web/src/design-audit/controls.css | 20 +--
apps/web/src/design-audit/controls.tsx | 27 +++-
apps/web/src/design-audit/foundations.tsx | 6 +-
apps/web/src/design-audit/specimens.test.tsx | 17 +++
apps/web/src/design-audit/surfaces.tsx | 2 +-
apps/web/src/theme.css | 33 ++++-
apps/web/src/tokens.test.ts | 32 ++++-
bun.lock | 1 +
packages/visuals/package.json | 1 +
packages/visuals/src/index.ts | 1 +
packages/visuals/src/styles.css | 1 +
packages/visuals/src/ui/public-api.test.ts | 7 +
packages/visuals/src/ui/select.css | 142 +++++++++++++++++++
packages/visuals/src/ui/select.test.tsx | 67 +++++++++
packages/visuals/src/ui/select.tsx | 111 +++++++++++++++
packages/visuals/theme.css | 1 +
16 files changed, 441 insertions(+), 28 deletions(-)
create mode 100644 packages/visuals/src/ui/select.css
create mode 100644 packages/visuals/src/ui/select.test.tsx
create mode 100644 packages/visuals/src/ui/select.tsx
diff --git a/apps/web/src/design-audit/controls.css b/apps/web/src/design-audit/controls.css
index 899246d5..d69d6c88 100644
--- a/apps/web/src/design-audit/controls.css
+++ b/apps/web/src/design-audit/controls.css
@@ -38,14 +38,6 @@
align-items: center;
}
-.design-audit-busy-mark {
- width: 0.5rem;
- height: 0.5rem;
- margin-inline-end: 0.375rem;
- border-radius: 9999px;
- background: currentColor;
-}
-
.design-audit [data-audit-state="hover"].btn-primary {
background: var(--color-brand-hover);
}
@@ -94,6 +86,11 @@
text-underline-offset: 0.18em;
}
+.design-audit-link:is([data-audit-state="focus"], :focus-visible) {
+ margin-inline: -0.25rem;
+ padding-inline: 0.25rem;
+}
+
.design-audit-link[data-audit-state="hover"] {
color: var(--color-brand-hover);
text-decoration-color: currentColor;
@@ -114,7 +111,8 @@
gap: 1rem;
}
-.design-audit-field-grid label {
+.design-audit-field-grid label,
+.design-audit-field-control {
display: grid;
gap: 0.375rem;
font-size: var(--text-sm);
@@ -122,6 +120,10 @@
color: var(--color-text-secondary);
}
+.design-audit-field-control .cv-select-trigger {
+ width: 100%;
+}
+
.design-audit-field-grid :is(input, select, textarea) {
width: 100%;
min-width: 0;
diff --git a/apps/web/src/design-audit/controls.tsx b/apps/web/src/design-audit/controls.tsx
index f72a64f3..186a2c45 100644
--- a/apps/web/src/design-audit/controls.tsx
+++ b/apps/web/src/design-audit/controls.tsx
@@ -1,4 +1,5 @@
import { CheckIcon, ChevronIcon, LoaderIcon, PlusIcon, WarningIcon } from "@chopin/icons";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@chopin/visuals";
import { AuditPlate, StateLabel } from "./frame";
@@ -70,7 +71,7 @@ export function Controls() {
@@ -157,12 +158,24 @@ export function Controls() {
readOnly
/>
-
+
+ Select
+
+
-
diff --git a/apps/web/src/theme.css b/apps/web/src/theme.css
index 02136dd7..cc7327b7 100644
--- a/apps/web/src/theme.css
+++ b/apps/web/src/theme.css
@@ -37,6 +37,7 @@
display: inline-flex;
align-items: center;
justify-content: center;
+ gap: calc(var(--spacing) * 2);
border: 0;
border-radius: var(--radius-md);
font-size: var(--text-sm);
@@ -47,6 +48,20 @@
cursor: default;
opacity: 0.4;
}
+
+ &[aria-busy="true"]:disabled {
+ opacity: 0.65;
+ }
+
+ &[aria-busy="true"] [data-nucleo-icon] {
+ animation: button-loader-spin 800ms linear infinite;
+ }
+}
+
+@keyframes button-loader-spin {
+ to {
+ transform: rotate(360deg);
+ }
}
@utility btn-md {
@@ -69,8 +84,8 @@
background-color: var(--color-brand);
box-shadow: var(--shadow-resting);
color: var(--color-page);
- outline: var(--edge-width) solid rgb(0 0 0 / 15%);
- outline-offset: calc(-1 * var(--edge-width));
+ outline: var(--button-edge-width) solid var(--color-control-edge);
+ outline-offset: calc(-1 * var(--button-edge-width));
&:hover:not(:disabled) {
background-color: var(--color-brand-hover);
@@ -84,8 +99,8 @@
@utility btn-secondary {
background-color: var(--color-gray-200);
color: var(--color-text-primary);
- outline: var(--edge-width) solid var(--color-edge);
- outline-offset: calc(-1 * var(--edge-width));
+ outline: var(--button-edge-width) solid var(--color-edge);
+ outline-offset: calc(-1 * var(--button-edge-width));
&:hover:not(:disabled) {
background-color: var(--color-gray-300);
@@ -112,8 +127,8 @@
@utility btn-destructive {
background-color: var(--color-destructive);
color: var(--color-page);
- outline: var(--edge-width) solid var(--color-edge);
- outline-offset: calc(-1 * var(--edge-width));
+ outline: var(--button-edge-width) solid var(--color-control-edge);
+ outline-offset: calc(-1 * var(--button-edge-width));
&:hover:not(:disabled) {
background-color: var(--color-destructive-hover);
@@ -128,6 +143,7 @@
border: var(--edge-width) solid var(--color-control-edge);
border-radius: var(--radius-md);
background-color: var(--color-page);
+ box-shadow: var(--shadow-resting);
color: var(--color-text-primary);
&:disabled {
@@ -180,6 +196,7 @@
}
&[type="radio"]:checked {
+ border-width: calc(var(--edge-width) * 2);
box-shadow: inset 0 0 0 3px var(--color-page);
}
@@ -626,6 +643,10 @@ body {
border-radius: 9999px;
}
@media (prefers-reduced-motion: reduce) {
+ .btn[aria-busy="true"] [data-nucleo-icon] {
+ animation: none;
+ }
+
.motion-dropdown,
.motion-popover,
.motion-panel,
diff --git a/apps/web/src/tokens.test.ts b/apps/web/src/tokens.test.ts
index 4bb437d1..bf91ab9b 100644
--- a/apps/web/src/tokens.test.ts
+++ b/apps/web/src/tokens.test.ts
@@ -283,19 +283,43 @@ describe("controls", () => {
let primary = utility("btn-primary").split("&:")[0]!;
let secondary = utility("btn-secondary").split("&:")[0]!;
let destructive = utility("btn-destructive").split("&:")[0]!;
- expect(primary).toContain("outline: var(--edge-width) solid rgb(0 0 0 / 15%)");
- expect(secondary).toContain("outline: var(--edge-width) solid var(--color-edge)");
- expect(destructive).toContain("outline: var(--edge-width) solid var(--color-edge)");
+ expect(declared("--button-edge-width")).toBe("1px");
+ expect(primary).toContain("outline: var(--button-edge-width) solid var(--color-control-edge)");
+ expect(secondary).toContain(
+ "outline: var(--button-edge-width) solid var(--color-edge)",
+ );
+ expect(destructive).toContain(
+ "outline: var(--button-edge-width) solid var(--color-control-edge)",
+ );
for (let rest of [primary, secondary, destructive]) {
- expect(rest).toContain("outline-offset: calc(-1 * var(--edge-width))");
+ expect(rest).toContain("outline-offset: calc(-1 * var(--button-edge-width))");
}
});
+ it("spaces button icons and gives busy actions a readable disabled state", () => {
+ let button = utility("btn");
+ expect(button).toContain("gap: calc(var(--spacing) * 2)");
+ expect(button).toMatch(/&\[aria-busy="true"\]:disabled\s*\{[\s\S]*opacity:\s*0\.65/);
+ expect(button).toMatch(
+ /&\[aria-busy="true"\] \[data-nucleo-icon\]\s*\{[\s\S]*animation:\s*button-loader-spin/,
+ );
+ expect(THEME).toMatch(
+ /@media \(prefers-reduced-motion: reduce\)\s*\{[\s\S]*\.btn\[aria-busy="true"\] \[data-nucleo-icon\]\s*\{[\s\S]*animation:\s*none/,
+ );
+ });
+
it("raises primary buttons with the resting shadow", () => {
let primary = utility("btn-primary").split("&:")[0]!;
expect(primary).toContain("box-shadow: var(--shadow-resting)");
});
+ it("raises fields subtly and strengthens selected radio boundaries", () => {
+ expect(utility("field")).toContain("box-shadow: var(--shadow-resting)");
+ expect(utility("choice-control")).toMatch(
+ /&\[type="radio"\]:checked\s*\{[\s\S]*border-width:\s*calc\(var\(--edge-width\) \* 2\)/,
+ );
+ });
+
it("dims each button's default style when disabled", () => {
let rule = utility("btn");
let disabled = /&:disabled\s*\{([^}]*)\}/.exec(rule)?.[1] ?? "";
diff --git a/bun.lock b/bun.lock
index 231c6879..9ee24d66 100644
--- a/bun.lock
+++ b/bun.lock
@@ -179,6 +179,7 @@
"name": "@chopin/visuals",
"version": "0.0.0",
"dependencies": {
+ "@base-ui/react": "1.7.0",
"@chopin/icons": "workspace:*",
},
"devDependencies": {
diff --git a/packages/visuals/package.json b/packages/visuals/package.json
index 98e90feb..5d282ae4 100644
--- a/packages/visuals/package.json
+++ b/packages/visuals/package.json
@@ -15,6 +15,7 @@
"./theme.css": "./theme.css"
},
"dependencies": {
+ "@base-ui/react": "1.7.0",
"@chopin/icons": "workspace:*"
},
"peerDependencies": {
diff --git a/packages/visuals/src/index.ts b/packages/visuals/src/index.ts
index ff5ea257..9ec56f25 100644
--- a/packages/visuals/src/index.ts
+++ b/packages/visuals/src/index.ts
@@ -6,6 +6,7 @@ export { MiniBars } from "./ui/mini-bars";
export type { MiniBarsProps } from "./ui/mini-bars";
export { ProgressBar } from "./ui/progress-bar";
export type { ProgressBarProps } from "./ui/progress-bar";
+export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
export type { SemanticTone } from "./ui/semantic-tone";
export { Sparkline } from "./ui/sparkline";
export type { SparklineProps } from "./ui/sparkline";
diff --git a/packages/visuals/src/styles.css b/packages/visuals/src/styles.css
index 6380e849..f0211fff 100644
--- a/packages/visuals/src/styles.css
+++ b/packages/visuals/src/styles.css
@@ -3,5 +3,6 @@
@import "./ui/icon-label.css";
@import "./ui/mini-bars.css";
@import "./ui/progress-bar.css";
+@import "./ui/select.css";
@import "./ui/sparkline.css";
@import "./ui/table.css";
diff --git a/packages/visuals/src/ui/public-api.test.ts b/packages/visuals/src/ui/public-api.test.ts
index 04336f6b..a280b4b7 100644
--- a/packages/visuals/src/ui/public-api.test.ts
+++ b/packages/visuals/src/ui/public-api.test.ts
@@ -1,6 +1,13 @@
import { expect, test } from "bun:test";
+import * as Visuals from "@chopin/visuals";
test("publishes the component and stylesheet entry points", () => {
expect(() => Bun.resolveSync("@chopin/visuals", import.meta.dir)).not.toThrow();
expect(() => Bun.resolveSync("@chopin/visuals/styles.css", import.meta.dir)).not.toThrow();
});
+
+test("publishes the complete Select composition", () => {
+ for (let name of ["Select", "SelectContent", "SelectItem", "SelectTrigger", "SelectValue"]) {
+ expect(name in Visuals).toBe(true);
+ }
+});
diff --git a/packages/visuals/src/ui/select.css b/packages/visuals/src/ui/select.css
new file mode 100644
index 00000000..bfdc7146
--- /dev/null
+++ b/packages/visuals/src/ui/select.css
@@ -0,0 +1,142 @@
+.cv-select-trigger {
+ box-sizing: border-box;
+ display: inline-flex;
+ min-width: 0;
+ max-width: 100%;
+ min-height: 2rem;
+ align-items: center;
+ justify-content: space-between;
+ gap: calc(var(--spacing) * 4);
+ padding: var(--spacing) calc(var(--spacing) * 3);
+ border: var(--edge-width) solid var(--color-control-edge);
+ border-radius: var(--radius-md);
+ background: var(--color-page);
+ box-shadow: var(--shadow-resting);
+ font: 500 var(--text-sm) / var(--text-sm--line-height) var(--font-sans);
+ color: var(--color-text-primary);
+ text-align: start;
+}
+
+.cv-select-trigger:hover:not([data-disabled]),
+.cv-select-trigger[data-popup-open] {
+ background: var(--color-hover);
+}
+
+.cv-select-trigger[data-disabled] {
+ border-color: var(--color-edge);
+ background: var(--color-gray-200);
+ box-shadow: none;
+ color: var(--color-gray-600);
+ cursor: default;
+}
+
+.cv-select-value {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.cv-select-chevron,
+.cv-select-check {
+ display: flex;
+ flex-shrink: 0;
+ color: var(--color-text-tertiary);
+}
+
+.cv-select-chevron svg,
+.cv-select-scroll-down svg {
+ transform: rotate(90deg);
+}
+
+.cv-select-positioner {
+ z-index: 50;
+ isolation: isolate;
+}
+
+.cv-select-positioner[data-anchor-hidden] {
+ visibility: hidden;
+}
+
+.cv-select-popup {
+ box-sizing: border-box;
+ width: max-content;
+ min-width: min(var(--anchor-width, 12rem), var(--available-width, calc(100vw - 1rem)));
+ max-width: var(--available-width, calc(100vw - 1rem));
+ max-height: var(--available-height, 80vh);
+ overflow: auto;
+ padding: var(--spacing);
+ border: var(--edge-width) solid var(--color-edge);
+ border-radius: var(--radius-lg);
+ background: var(--color-page);
+ box-shadow: var(--shadow-raised);
+ font: var(--text-sm) / var(--text-sm--line-height) var(--font-sans);
+ color: var(--color-text-primary);
+ transition: opacity var(--duration-fast) var(--ease-out);
+}
+
+.cv-select-popup[data-starting-style],
+.cv-select-popup[data-ending-style] {
+ opacity: 0;
+}
+
+.cv-select-listbox {
+ display: grid;
+}
+
+.cv-select-item {
+ position: relative;
+ display: flex;
+ min-height: 2rem;
+ align-items: center;
+ padding: var(--spacing) calc(var(--spacing) * 8) var(--spacing) calc(var(--spacing) * 2);
+ border-radius: var(--radius-md);
+ color: var(--color-text-secondary);
+ cursor: default;
+ user-select: none;
+ overflow-wrap: anywhere;
+}
+
+.cv-select-item[data-highlighted],
+.cv-select-item[data-selected] {
+ background: var(--color-selected);
+ color: var(--color-text-primary);
+}
+
+.cv-select-item[data-disabled] {
+ opacity: 0.45;
+}
+
+.cv-select-check {
+ position: absolute;
+ right: calc(var(--spacing) * 2);
+ color: var(--color-text-primary);
+}
+
+.cv-select-scroll {
+ position: sticky;
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--spacing);
+ background: var(--color-page);
+}
+
+.cv-select-scroll-up {
+ top: 0;
+}
+
+.cv-select-scroll-up svg {
+ transform: rotate(-90deg);
+}
+
+.cv-select-scroll-down {
+ bottom: 0;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .cv-select-popup {
+ transition: none;
+ }
+}
diff --git a/packages/visuals/src/ui/select.test.tsx b/packages/visuals/src/ui/select.test.tsx
new file mode 100644
index 00000000..b7bfdee6
--- /dev/null
+++ b/packages/visuals/src/ui/select.test.tsx
@@ -0,0 +1,67 @@
+import { Select as SelectPrimitive } from "@base-ui/react/select";
+import { expect, test } from "bun:test";
+import { isValidElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
+
+test("delegates selection callbacks and focus return to the Base UI Select root", () => {
+ expect(Select).toBe(SelectPrimitive.Root);
+});
+
+test("renders an authoritative trigger and value contract with disabled state", () => {
+ let markup = renderToStaticMarkup(
+ ,
+ );
+
+ expect(markup).toContain(" {
+ let content = SelectContent({
+ children: Active documents,
+ });
+ expect(isValidElement(content)).toBe(true);
+
+ let positioner = content.props.children;
+ let popup = positioner.props.children;
+ let listbox = popup.props.children[1];
+ expect(popup.props["data-slot"]).toBe("select-popup");
+ expect(listbox.props["data-slot"]).toBe("select-listbox");
+
+ let item = SelectItem({ children: "Active documents", "data-slot": "ignored", value: "active" });
+ let checkmark = item.props.children[1];
+ expect(item.props["data-slot"]).toBe("select-item");
+ expect(checkmark.props["data-slot"]).toBe("select-checkmark");
+});
+
+test("styles bounded trigger, popup, items, selection, and disabled states", async () => {
+ let css = await Bun.file(new URL("./select.css", import.meta.url)).text();
+ let styles = await Bun.file(new URL("../styles.css", import.meta.url)).text();
+
+ expect(styles).toContain('@import "./ui/select.css";');
+ expect(css).toContain(".cv-select-trigger");
+ expect(css).toContain("max-width: 100%;");
+ expect(css).toContain("max-width: var(--available-width, calc(100vw - 1rem));");
+ expect(css).toContain(".cv-select-popup");
+ expect(css).toContain(".cv-select-item");
+ expect(css).not.toContain("outline: none");
+ expect(css).toContain("[data-selected]");
+ expect(css).toContain("[data-disabled]");
+ expect(css).not.toMatch(/#[\da-f]{3,8}|(?:oklch|rgb|hsl)\(/i);
+});
diff --git a/packages/visuals/src/ui/select.tsx b/packages/visuals/src/ui/select.tsx
new file mode 100644
index 00000000..8ae1aa04
--- /dev/null
+++ b/packages/visuals/src/ui/select.tsx
@@ -0,0 +1,111 @@
+import { Select as SelectPrimitive } from "@base-ui/react/select";
+import { CheckIcon, ChevronIcon } from "@chopin/icons";
+
+export const Select = SelectPrimitive.Root;
+
+export function SelectValue({
+ className = "",
+ "data-slot": _dataSlot,
+ ...props
+}: Omit & {
+ className?: string;
+ "data-slot"?: string;
+}) {
+ return (
+
+ );
+}
+
+export function SelectTrigger({
+ children,
+ className = "",
+ "data-slot": _dataSlot,
+ ...props
+}: Omit & {
+ className?: string;
+ "data-slot"?: string;
+}) {
+ return (
+
+ {children}
+
+
+
+
+ );
+}
+
+type SelectContentProps =
+ & Omit
+ & Pick
+ & { className?: string; "data-slot"?: string };
+
+export function SelectContent({
+ align = "start",
+ children,
+ className = "",
+ "data-slot": _dataSlot,
+ side = "bottom",
+ sideOffset = 4,
+ ...props
+}: SelectContentProps) {
+ return (
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+ );
+}
+
+export function SelectItem({
+ children,
+ className = "",
+ "data-slot": _dataSlot,
+ ...props
+}: Omit & {
+ className?: string;
+ "data-slot"?: string;
+}) {
+ return (
+
+ {children}
+
+
+
+
+ );
+}
diff --git a/packages/visuals/theme.css b/packages/visuals/theme.css
index ea83ae32..7a20c305 100644
--- a/packages/visuals/theme.css
+++ b/packages/visuals/theme.css
@@ -120,6 +120,7 @@
--color-control-edge: oklch(0 0 0 / 20%);
--color-control-boundary: var(--color-gray-500);
--edge-width: 1px;
+ --button-edge-width: 1px;
/*
* Chrome, chat, prose, section and title. Colour carries hierarchy
From 2bd33813a1e8848bc85901ee53a2aef3138ace03 Mon Sep 17 00:00:00 2001
From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com>
Date: Thu, 24 Sep 2026 15:28:49 +0100
Subject: [PATCH 2/4] Harden Select audit contracts
---
apps/web/src/design-audit/specimens.test.tsx | 25 ++++++++++++++++++--
packages/visuals/src/ui/public-api.test.ts | 7 ------
packages/visuals/src/ui/select.test.tsx | 15 +++++++++++-
3 files changed, 37 insertions(+), 10 deletions(-)
diff --git a/apps/web/src/design-audit/specimens.test.tsx b/apps/web/src/design-audit/specimens.test.tsx
index 891ff76a..4e9a4aed 100644
--- a/apps/web/src/design-audit/specimens.test.tsx
+++ b/apps/web/src/design-audit/specimens.test.tsx
@@ -1,5 +1,6 @@
import { describe, expect, it } from "bun:test";
-import { createElement } from "react";
+import { SelectItem } from "@chopin/visuals";
+import { Children, createElement, isValidElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { Controls } from "./controls";
@@ -8,12 +9,27 @@ import { Foundations } from "./foundations";
import { AUDIT_INVENTORY } from "./inventory";
import { Surfaces } from "./surfaces";
+import type { ReactElement, ReactNode } from "react";
+
function plate(markup: string, item: string): string {
let start = markup.indexOf(``);
let end = markup.indexOf("", start);
return start === -1 || end === -1 ? "" : markup.slice(start, end + "".length);
}
+function elementsOfType(
+ node: ReactNode,
+ type: ReactElement["type"],
+ found: ReactElement<{ children?: ReactNode; value?: unknown }>[] = [],
+) {
+ Children.forEach(node, child => {
+ if (!isValidElement<{ children?: ReactNode; value?: unknown }>(child)) return;
+ if (child.type === type) found.push(child);
+ elementsOfType(child.props.children, type, found);
+ });
+ return found;
+}
+
describe("design audit specimens", () => {
it("renders every foundation family with a visible label", () => {
let markup = renderToStaticMarkup(createElement(Foundations));
@@ -201,9 +217,14 @@ describe("design audit specimens", () => {
expect(markup).toContain('role="menu"');
expect(markup).toContain('data-slot="select-trigger"');
expect(markup).toContain('data-slot="select-value"');
- expect(markup).toContain("Archived documents");
expect(markup).not.toContain('