From a8d21a6c01fffd74420845797b5d51c9fdf6b955 Mon Sep 17 00:00:00 2001 From: mrcfps Date: Mon, 25 May 2026 15:05:46 +0800 Subject: [PATCH 01/22] refactor(web): extract shared UI primitives --- apps/web/package.json | 1 + .../src/components/ContinueInCliButton.tsx | 11 +- .../src/components/FinalizeDesignButton.tsx | 11 +- apps/web/src/index.css | 1 + apps/web/src/styles/primitives.css | 276 ------------------ packages/AGENTS.md | 1 + packages/components/esbuild.config.ts | 13 + packages/components/package.json | 36 +++ packages/components/src/index.ts | 4 + packages/components/src/primitives.tsx | 66 +++++ packages/components/src/styles.css | 275 +++++++++++++++++ packages/components/tsconfig.json | 19 ++ pnpm-lock.yaml | 22 ++ scripts/postinstall.mjs | 1 + 14 files changed, 449 insertions(+), 288 deletions(-) create mode 100644 packages/components/esbuild.config.ts create mode 100644 packages/components/package.json create mode 100644 packages/components/src/index.ts create mode 100644 packages/components/src/primitives.tsx create mode 100644 packages/components/src/styles.css create mode 100644 packages/components/tsconfig.json diff --git a/apps/web/package.json b/apps/web/package.json index b64106e1a..6123f3f25 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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:*", diff --git a/apps/web/src/components/ContinueInCliButton.tsx b/apps/web/src/components/ContinueInCliButton.tsx index 7a557ac33..fd2074529 100644 --- a/apps/web/src/components/ContinueInCliButton.tsx +++ b/apps/web/src/components/ContinueInCliButton.tsx @@ -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 ( - + - + {designMdState.isStale ? ( {chipTextForReason(designMdState.staleReason)} diff --git a/apps/web/src/components/FinalizeDesignButton.tsx b/apps/web/src/components/FinalizeDesignButton.tsx index 2ea8cba4c..b00cefa79 100644 --- a/apps/web/src/components/FinalizeDesignButton.tsx +++ b/apps/web/src/components/FinalizeDesignButton.tsx @@ -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; @@ -31,14 +32,13 @@ export function FinalizeDesignButton({
); } @@ -57,12 +57,11 @@ export function FinalizeDesignButton({ } return ( - + ); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 875e9638c..00b71c053 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -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'; diff --git a/apps/web/src/styles/primitives.css b/apps/web/src/styles/primitives.css index 7b7d4748f..7a8a08987 100644 --- a/apps/web/src/styles/primitives.css +++ b/apps/web/src/styles/primitives.css @@ -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 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); diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 2b1c12353..ea13a9e30 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -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. diff --git a/packages/components/esbuild.config.ts b/packages/components/esbuild.config.ts new file mode 100644 index 000000000..e63dab55e --- /dev/null +++ b/packages/components/esbuild.config.ts @@ -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', +}); diff --git a/packages/components/package.json b/packages/components/package.json new file mode 100644 index 000000000..422e9d3d2 --- /dev/null +++ b/packages/components/package.json @@ -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" + } +} diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts new file mode 100644 index 000000000..9d2d547f6 --- /dev/null +++ b/packages/components/src/index.ts @@ -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'; diff --git a/packages/components/src/primitives.tsx b/packages/components/src/primitives.tsx new file mode 100644 index 000000000..f03e50061 --- /dev/null +++ b/packages/components/src/primitives.tsx @@ -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 { + variant?: ButtonVariant; + size?: ButtonSize; +} + +function joinClassNames(...classNames: Array) { + return classNames.filter(Boolean).join(' ') || undefined; +} + +export const Button = forwardRef(function Button( + { className, type = 'button', variant = 'default', size = 'default', ...props }, + ref, +) { + return ( +