refactor(web): extract shared UI primitives

This commit is contained in:
mrcfps 2026-05-25 15:05:46 +08:00
parent c7f55388a9
commit a8d21a6c01
14 changed files with 449 additions and 288 deletions

View file

@ -29,6 +29,7 @@
},
"dependencies": {
"@anthropic-ai/sdk": "0.32.1",
"@open-design/components": "workspace:*",
"@open-design/contracts": "workspace:*",
"@open-design/host": "workspace:*",
"@open-design/platform": "workspace:*",

View file

@ -17,6 +17,7 @@
// component itself.
import type { DesignMdState, DesignMdStaleReason } from '../hooks/useDesignMdState';
import { Button } from '@open-design/components';
const STALE_CHIP_TEXT = 'Spec is stale — regenerate?';
// Round 7 (mrcfps @ useDesignMdState.ts:160): malformed provenance
@ -46,14 +47,13 @@ export function ContinueInCliButton({ designMdState, onClick }: ContinueInCliBut
// explanation when the disabled button gets focused.
return (
<span className="project-actions-button-group">
<button
type="button"
<Button
className="project-actions-button project-actions-button-secondary"
disabled
aria-describedby="continue-in-cli-disabled-hint"
>
Continue in CLI
</button>
</Button>
<span
id="continue-in-cli-disabled-hint"
className="project-actions-disabled-hint"
@ -67,15 +67,14 @@ export function ContinueInCliButton({ designMdState, onClick }: ContinueInCliBut
return (
<span className="project-actions-button-group">
<button
type="button"
<Button
className="project-actions-button project-actions-button-secondary"
onClick={() => {
void onClick();
}}
>
Continue in CLI
</button>
</Button>
{designMdState.isStale ? (
<span className="project-actions-chip" role="note" aria-label="Spec staleness">
{chipTextForReason(designMdState.staleReason)}

View file

@ -12,6 +12,7 @@
import type { DesignMdState } from '../hooks/useDesignMdState';
import type { FinalizeStatus } from '../hooks/useFinalizeProject';
import { Button } from '@open-design/components';
export interface FinalizeDesignButtonProps {
designMdState: Pick<DesignMdState, 'exists' | 'isStale'>;
@ -31,14 +32,13 @@ export function FinalizeDesignButton({
<div className="project-actions-button project-actions-button-pending" role="group">
<span className="project-actions-spinner" aria-hidden="true" />
<span className="project-actions-label">Finalizing</span>
<button
type="button"
<Button
className="project-actions-link"
onClick={onCancel}
aria-label="Cancel finalize"
>
Cancel
</button>
</Button>
</div>
);
}
@ -57,12 +57,11 @@ export function FinalizeDesignButton({
}
return (
<button
type="button"
<Button
className={`project-actions-button ${variantClass}`}
onClick={onFinalize}
>
{label}
</button>
</Button>
);
}

View file

@ -2,6 +2,7 @@
@import './styles/design-system-flow.css';
@import './styles/tokens.css';
@import './styles/base.css';
@import '@open-design/components/styles.css';
@import './styles/primitives.css';
@import './styles/shell.css';
@import './styles/chat.css';

View file

@ -1,279 +1,3 @@
/* -------- Buttons --------------------------------------------------- */
button {
font: inherit;
color: var(--text);
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 12px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
}
button:hover:not(:disabled) { background: var(--bg-subtle); border-color: var(--border-strong); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button.primary {
background: var(--accent);
border-color: var(--accent);
color: white;
font-weight: 500;
box-shadow: 0 1px 0 rgba(180, 90, 59, 0.18) inset, var(--shadow-xs);
}
button.primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
button.primary-ghost {
background: var(--bg-panel);
border-color: var(--accent);
color: var(--accent);
font-weight: 500;
}
button.primary-ghost:hover:not(:disabled) { background: var(--accent-tint); }
button.ghost {
background: transparent;
border-color: var(--border);
color: var(--text);
}
button.ghost:hover:not(:disabled) { background: var(--bg-subtle); border-color: var(--border-strong); }
/*
* Transient success state for ghost buttons. Used by the Media
* Providers "Reload from daemon" button after a successful reload
* the button briefly turns green with a check icon for ~2s then snaps
* back to its idle treatment, replacing what used to be a permanent
* success paragraph under the section header. Reusable on any
* ghost button that wants the same "did it" pulse.
*/
button.ghost.is-success-flash {
border-color: var(--green-border, color-mix(in srgb, #1f9d55 32%, var(--border)));
background: var(--green-bg, color-mix(in srgb, #1f9d55 8%, transparent));
color: var(--green, #137a3d);
}
button.ghost.is-success-flash:hover:not(:disabled) {
background: var(--green-bg, color-mix(in srgb, #1f9d55 14%, transparent));
}
button.ghost.is-success-flash svg {
color: currentColor;
}
/*
* Visually-hidden but assistive-tech accessible. We use it to keep
* a `role="status"` live-region for actions whose visible feedback
* is too transient (e.g. a 2s button-label flash) for a screen
* reader to catch reliably. Borrowed from Tailwind's `sr-only` so
* it reads as the same primitive everyone already knows.
*/
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
button.subtle {
background: var(--bg-subtle);
border-color: transparent;
color: var(--text);
}
button.subtle:hover:not(:disabled) { background: var(--bg-muted); }
button.icon-btn { padding: 6px 10px; font-size: 13px; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
/* -------- Inputs ---------------------------------------------------- */
input, textarea, select {
font: inherit;
color: var(--text);
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 7px 10px;
width: 100%;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
input::placeholder, textarea::placeholder { color: var(--text-faint); }
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--selected);
box-shadow: 0 0 0 3px var(--selected-soft);
}
/* Entry sidebar form fields keep a quieter, neutral focus so the orange
"Create" CTA stays the loudest thing in the panel. A low-opacity black
halo reads as "focused" without re-introducing the global blue/accent
ring. Using rgba directly because the Next CSS pipeline collapses
`color-mix(in srgb, var(--text) 8%, transparent)` to a solid var(--text)
ring (would render a 3px black band too loud). */
.entry-side input:focus,
.entry-side textarea:focus,
.entry-side select:focus {
border-color: var(--text);
box-shadow: 0 0 0 3px rgba(28, 27, 26, 0.08);
}
/* Native <select> on macOS uses its own intrinsic min-height that ends up
shorter than <input> at the same `padding: 7px 10px` rule above, so any
form that flex-aligns an input next to a select (e.g. the memory editor's
Title + Type row) renders with mismatched heights. Stripping the native
chrome lets the shared padding and inherited line-height compute the
same box dimensions as the input, then we paint our own chevron via a
background SVG. The chevron color is a per-theme override so it stays
readable against the panel in both light and dark. */
select {
padding-right: 32px;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%2374716b' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3 5 6 8 9 5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 12px 12px;
}
select::-ms-expand { display: none; }
[data-theme='dark'] select {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%239a9690' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3 5 6 8 9 5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 12px 12px;
}
@media (prefers-color-scheme: dark) {
html:not([data-theme]) select {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%239a9690' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3 5 6 8 9 5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 12px 12px;
}
}
textarea { resize: vertical; font-family: inherit; }
.od-select {
position: relative;
width: 100%;
min-width: 0;
}
.od-select-trigger {
width: 100%;
min-width: 0;
min-height: 36px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 7px 10px;
font: inherit;
color: var(--text);
text-align: left;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: border-color 120ms ease, box-shadow 120ms ease, background 120ms ease;
}
.od-select-trigger:hover:not(:disabled),
.od-select-trigger[aria-expanded='true'] {
border-color: var(--border-strong);
background: var(--bg-subtle);
}
.od-select-trigger:focus-visible,
.od-select-trigger[aria-expanded='true'] {
outline: none;
border-color: var(--selected);
box-shadow: 0 0 0 3px var(--selected-soft);
}
.od-select-trigger:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.od-select-trigger svg {
color: var(--text-muted);
transition: transform 120ms ease;
}
.od-select-trigger[aria-expanded='true'] svg {
transform: rotate(180deg);
}
.od-select-value,
.od-select-option-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.od-select-menu {
z-index: 9000;
padding: 4px;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
max-width: calc(100vw - 24px);
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
}
.od-select-menu.portal {
position: fixed;
}
.od-select-menu.inline {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: min(280px, 48vh);
}
.od-select-option {
width: 100%;
min-width: 0;
min-height: 30px;
display: grid;
grid-template-columns: minmax(0, 1fr) 16px;
align-items: center;
gap: 8px;
padding: 6px 8px;
color: var(--text);
background: transparent;
border: 0;
border-radius: 6px;
font: inherit;
font-size: 12.5px;
text-align: left;
cursor: pointer;
}
.od-select-option:hover:not(:disabled),
.od-select-option.active:not(:disabled) {
background: var(--bg-subtle);
}
.od-select-option.selected {
color: var(--text);
font-weight: 600;
}
.od-select-option:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.od-select-option-check {
display: inline-flex;
justify-content: center;
color: var(--selected);
opacity: 0;
}
.od-select-option.selected .od-select-option-check {
opacity: 1;
}
.od-select-group + .od-select-group {
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid var(--border-soft);
}
.od-select-group-label {
padding: 6px 8px 4px;
color: var(--text-faint);
font-size: 11px;
font-weight: 600;
}
code {
font-family: var(--mono);
background: var(--bg-subtle);

View file

@ -5,6 +5,7 @@ Follow the root `AGENTS.md` first. This file only records module-level boundarie
## Package responsibilities
- `packages/contracts`: web/daemon app contract layer. Keep it pure TypeScript; it must not depend on Next.js, Express, Node filesystem/process APIs, browser APIs, SQLite, daemon internals, or the sidecar control-plane protocol.
- `packages/components`: shared React UI primitives and primitive CSS. It may depend on React types/runtime only; keep product workflows and app-specific layout/styling in the apps.
- `packages/host`: web/desktop host bridge contract. It models renderer-facing host capabilities and helpers while keeping `window.__od__` access out of app UI code.
- `packages/sidecar-proto`: Open Design sidecar business protocol. Owns app/mode/source constants, namespace validation, stamp descriptor/fields/flags, IPC message schema, status shapes, error semantics, and default product path constants.
- `packages/sidecar`: generic sidecar runtime primitives. Includes bootstrap, IPC transport, path/runtime resolution, launch env, and JSON runtime file helpers; it must not hard-code Open Design app keys or IPC business messages.

View file

@ -0,0 +1,13 @@
import { build } from 'esbuild';
await build({
bundle: true,
entryPoints: ['./src/index.ts'],
format: 'esm',
outbase: './src',
outdir: './dist',
outExtension: { '.js': '.mjs' },
packages: 'external',
platform: 'browser',
target: 'es2022',
});

View file

@ -0,0 +1,36 @@
{
"name": "@open-design/components",
"version": "0.8.0",
"private": true,
"type": "module",
"description": "Shared Open Design React UI primitives.",
"main": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"dist",
"src/styles.css"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
},
"./styles.css": "./src/styles.css"
},
"scripts": {
"build": "tsx ./esbuild.config.ts && tsc -p tsconfig.json --emitDeclarationOnly",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"peerDependencies": {
"react": "18.3.1"
},
"devDependencies": {
"@types/react": "18.3.28",
"esbuild": "0.28.0",
"tsx": "4.22.3",
"typescript": "5.9.3"
},
"engines": {
"node": "~24"
}
}

View file

@ -0,0 +1,4 @@
export { Button } from './primitives.js';
export type { ButtonProps, ButtonSize, ButtonVariant } from './primitives.js';
export { Input, Select, Textarea, VisuallyHidden } from './primitives.js';
export type { InputProps, SelectProps, TextareaProps, VisuallyHiddenProps } from './primitives.js';

View file

@ -0,0 +1,66 @@
import { forwardRef } from 'react';
import type {
ButtonHTMLAttributes,
InputHTMLAttributes,
ReactNode,
SelectHTMLAttributes,
TextareaHTMLAttributes,
} from 'react';
export type ButtonVariant = 'default' | 'primary' | 'primary-ghost' | 'ghost' | 'subtle';
export type ButtonSize = 'default' | 'compact' | 'icon';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
}
function joinClassNames(...classNames: Array<string | false | null | undefined>) {
return classNames.filter(Boolean).join(' ') || undefined;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ className, type = 'button', variant = 'default', size = 'default', ...props },
ref,
) {
return (
<button
ref={ref}
type={type}
className={joinClassNames(
variant !== 'default' && variant,
size === 'compact' && 'compact',
size === 'icon' && 'icon-btn',
className,
)}
{...props}
/>
);
});
export type InputProps = InputHTMLAttributes<HTMLInputElement>;
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(props, ref) {
return <input ref={ref} {...props} />;
});
export type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(props, ref) {
return <textarea ref={ref} {...props} />;
});
export type SelectProps = SelectHTMLAttributes<HTMLSelectElement>;
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(props, ref) {
return <select ref={ref} {...props} />;
});
export interface VisuallyHiddenProps {
children: ReactNode;
className?: string;
}
export function VisuallyHidden({ children, className }: VisuallyHiddenProps) {
return <span className={joinClassNames('sr-only', className)}>{children}</span>;
}

View file

@ -0,0 +1,275 @@
/* -------- Buttons --------------------------------------------------- */
button {
font: inherit;
color: var(--text);
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 12px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
}
button:hover:not(:disabled) { background: var(--bg-subtle); border-color: var(--border-strong); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button.primary {
background: var(--accent);
border-color: var(--accent);
color: white;
font-weight: 500;
box-shadow: 0 1px 0 rgba(180, 90, 59, 0.18) inset, var(--shadow-xs);
}
button.primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
button.primary-ghost {
background: var(--bg-panel);
border-color: var(--accent);
color: var(--accent);
font-weight: 500;
}
button.primary-ghost:hover:not(:disabled) { background: var(--accent-tint); }
button.ghost {
background: transparent;
border-color: var(--border);
color: var(--text);
}
button.ghost:hover:not(:disabled) { background: var(--bg-subtle); border-color: var(--border-strong); }
/*
* Transient success state for ghost buttons. Used by the Media
* Providers "Reload from daemon" button after a successful reload
* the button briefly turns green with a check icon for ~2s then snaps
* back to its idle treatment, replacing what used to be a permanent
* success paragraph under the section header. Reusable on any
* ghost button that wants the same "did it" pulse.
*/
button.ghost.is-success-flash {
border-color: var(--green-border, color-mix(in srgb, #1f9d55 32%, var(--border)));
background: var(--green-bg, color-mix(in srgb, #1f9d55 8%, transparent));
color: var(--green, #137a3d);
}
button.ghost.is-success-flash:hover:not(:disabled) {
background: var(--green-bg, color-mix(in srgb, #1f9d55 14%, transparent));
}
button.ghost.is-success-flash svg {
color: currentColor;
}
/*
* Visually-hidden but assistive-tech accessible. We use it to keep
* a `role="status"` live-region for actions whose visible feedback
* is too transient (e.g. a 2s button-label flash) for a screen
* reader to catch reliably. Borrowed from Tailwind's `sr-only` so
* it reads as the same primitive everyone already knows.
*/
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
button.subtle {
background: var(--bg-subtle);
border-color: transparent;
color: var(--text);
}
button.subtle:hover:not(:disabled) { background: var(--bg-muted); }
button.icon-btn { padding: 6px 10px; font-size: 13px; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
/* -------- Inputs ---------------------------------------------------- */
input, textarea, select {
font: inherit;
color: var(--text);
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 7px 10px;
width: 100%;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
input::placeholder, textarea::placeholder { color: var(--text-faint); }
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--selected);
box-shadow: 0 0 0 3px var(--selected-soft);
}
/* Entry sidebar form fields keep a quieter, neutral focus so the orange
"Create" CTA stays the loudest thing in the panel. A low-opacity black
halo reads as "focused" without re-introducing the global blue/accent
ring. Using rgba directly because the Next CSS pipeline collapses
`color-mix(in srgb, var(--text) 8%, transparent)` to a solid var(--text)
ring (would render a 3px black band too loud). */
.entry-side input:focus,
.entry-side textarea:focus,
.entry-side select:focus {
border-color: var(--text);
box-shadow: 0 0 0 3px rgba(28, 27, 26, 0.08);
}
/* Native <select> on macOS uses its own intrinsic min-height that ends up
shorter than <input> at the same `padding: 7px 10px` rule above, so any
form that flex-aligns an input next to a select (e.g. the memory editor's
Title + Type row) renders with mismatched heights. Stripping the native
chrome lets the shared padding and inherited line-height compute the
same box dimensions as the input, then we paint our own chevron via a
background SVG. The chevron color is a per-theme override so it stays
readable against the panel in both light and dark. */
select {
padding-right: 32px;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%2374716b' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3 5 6 8 9 5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 12px 12px;
}
select::-ms-expand { display: none; }
[data-theme='dark'] select {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%239a9690' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3 5 6 8 9 5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 12px 12px;
}
@media (prefers-color-scheme: dark) {
html:not([data-theme]) select {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%239a9690' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3 5 6 8 9 5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 12px 12px;
}
}
textarea { resize: vertical; font-family: inherit; }
.od-select {
position: relative;
width: 100%;
min-width: 0;
}
.od-select-trigger {
width: 100%;
min-width: 0;
min-height: 36px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 7px 10px;
font: inherit;
color: var(--text);
text-align: left;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: border-color 120ms ease, box-shadow 120ms ease, background 120ms ease;
}
.od-select-trigger:hover:not(:disabled),
.od-select-trigger[aria-expanded='true'] {
border-color: var(--border-strong);
background: var(--bg-subtle);
}
.od-select-trigger:focus-visible,
.od-select-trigger[aria-expanded='true'] {
outline: none;
border-color: var(--selected);
box-shadow: 0 0 0 3px var(--selected-soft);
}
.od-select-trigger:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.od-select-trigger svg {
color: var(--text-muted);
transition: transform 120ms ease;
}
.od-select-trigger[aria-expanded='true'] svg {
transform: rotate(180deg);
}
.od-select-value,
.od-select-option-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.od-select-menu {
z-index: 9000;
padding: 4px;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
max-width: calc(100vw - 24px);
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
}
.od-select-menu.portal {
position: fixed;
}
.od-select-menu.inline {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: min(280px, 48vh);
}
.od-select-option {
width: 100%;
min-width: 0;
min-height: 30px;
display: grid;
grid-template-columns: minmax(0, 1fr) 16px;
align-items: center;
gap: 8px;
padding: 6px 8px;
color: var(--text);
background: transparent;
border: 0;
border-radius: 6px;
font: inherit;
font-size: 12.5px;
text-align: left;
cursor: pointer;
}
.od-select-option:hover:not(:disabled),
.od-select-option.active:not(:disabled) {
background: var(--bg-subtle);
}
.od-select-option.selected {
color: var(--text);
font-weight: 600;
}
.od-select-option:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.od-select-option-check {
display: inline-flex;
justify-content: center;
color: var(--selected);
opacity: 0;
}
.od-select-option.selected .od-select-option-check {
opacity: 1;
}
.od-select-group + .od-select-group {
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid var(--border-soft);
}
.od-select-group-label {
padding: 6px 8px 4px;
color: var(--text-faint);
font-size: 11px;
font-weight: 600;
}

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}

View file

@ -260,6 +260,9 @@ importers:
'@anthropic-ai/sdk':
specifier: 0.32.1
version: 0.32.1
'@open-design/components':
specifier: workspace:*
version: link:../../packages/components
'@open-design/contracts':
specifier: workspace:*
version: link:../../packages/contracts
@ -377,6 +380,25 @@ importers:
specifier: 4.1.6
version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.39)(jsdom@29.1.1)(vite@7.3.3(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0))
packages/components:
dependencies:
react:
specifier: 18.3.1
version: 18.3.1
devDependencies:
'@types/react':
specifier: 18.3.28
version: 18.3.28
esbuild:
specifier: 0.28.0
version: 0.28.0
tsx:
specifier: 4.22.3
version: 4.22.3
typescript:
specifier: 5.9.3
version: 5.9.3
packages/contracts:
dependencies:
zod:

View file

@ -8,6 +8,7 @@ const repoRoot = resolve(scriptDir, "..");
const buildTargets = [
"packages/contracts",
"packages/components",
"packages/platform",
"packages/download",
"packages/host",