This commit is contained in:
Marc Chan 2026-05-31 13:23:27 +08:00 committed by GitHub
commit 0b551c8afe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 802 additions and 463 deletions

View file

@ -61,7 +61,7 @@ jobs:
if [[ "$file" == "apps/daemon/"* || "$file" == "packages/contracts/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then
daemon_tests_required=true
fi
if [[ "$file" == "apps/web/"* || "$file" == "packages/contracts/"* || "$file" == "packages/host/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then
if [[ "$file" == "apps/web/"* || "$file" == "packages/components/"* || "$file" == "packages/contracts/"* || "$file" == "packages/host/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then
web_tests_required=true
fi
if [[ "$file" == "scripts/"* || "$file" == "assets/"* || "$file" == "skills/"* || "$file" == "prompt-templates/"* || "$file" == "design-systems/"* || "$file" == "design-templates/"* || "$file" == "craft/"* ]]; then
@ -81,7 +81,7 @@ jobs:
tools_pack_tests_required=true
fi
# Keep this filter in sync with flake.nix daemonWorkspacePaths / webWorkspacePaths.
if [[ "$file" == "package.json" || "$file" == "pnpm-lock.yaml" || "$file" == "pnpm-workspace.yaml" || "$file" == "flake.nix" || "$file" == "flake.lock" || "$file" == "nix/"* || "$file" == ".github/workflows/ci.yml" || "$file" == ".github/workflows/nix-check.yml" || "$file" == ".github/workflows/nix-hash-autofix.yml" || "$file" == "apps/daemon/"* || "$file" == "apps/web/"* || "$file" == "packages/contracts/"* || "$file" == "packages/registry-protocol/"* || "$file" == "packages/agui-adapter/"* || "$file" == "packages/plugin-runtime/"* || "$file" == "packages/sidecar-proto/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/platform/"* || "$file" == "packages/diagnostics/"* || "$file" == "packages/host/"* || "$file" == "assets/"* || "$file" == "plugins/"* || "$file" == "skills/"* || "$file" == "design-systems/"* || "$file" == "design-templates/"* || "$file" == "craft/"* || "$file" == "prompt-templates/"* || "$file" == "scripts/update-nix-pnpm-deps-hash.ts" ]]; then
if [[ "$file" == "package.json" || "$file" == "pnpm-lock.yaml" || "$file" == "pnpm-workspace.yaml" || "$file" == "flake.nix" || "$file" == "flake.lock" || "$file" == "nix/"* || "$file" == ".github/workflows/ci.yml" || "$file" == ".github/workflows/nix-check.yml" || "$file" == ".github/workflows/nix-hash-autofix.yml" || "$file" == "apps/daemon/"* || "$file" == "apps/web/"* || "$file" == "packages/components/"* || "$file" == "packages/contracts/"* || "$file" == "packages/registry-protocol/"* || "$file" == "packages/agui-adapter/"* || "$file" == "packages/plugin-runtime/"* || "$file" == "packages/sidecar-proto/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/platform/"* || "$file" == "packages/diagnostics/"* || "$file" == "packages/host/"* || "$file" == "assets/"* || "$file" == "plugins/"* || "$file" == "skills/"* || "$file" == "design-systems/"* || "$file" == "design-templates/"* || "$file" == "craft/"* || "$file" == "prompt-templates/"* || "$file" == "scripts/update-nix-pnpm-deps-hash.ts" ]]; then
nix_validation_required=true
fi
case "$file" in

View file

@ -152,6 +152,14 @@ root `pnpm tools-pr` script without a new explicit maintainer decision.
- Keep global class names only for deliberate shared contracts: reusable primitives, theme hooks, third-party/content styling, cross-component layout, or selectors that rely on global cascade/specificity. Document any new global selector group with its owning feature.
- CSS refactors must preserve cascade semantics. For mechanical splits, verify expanded import content/order matches the previous stylesheet; for CSS Module migrations, validate the affected UI path with `pnpm --filter @open-design/web typecheck` and a focused build/test or visual check when practical.
## Web component reuse
- New `apps/web` UI should reuse shared primitives from `@open-design/components` when one exists instead of styling plain HTML elements directly. For example, use `Button` for app buttons and `VisuallyHidden` for screen-reader-only text/status content.
- Do not add new raw primitive classes such as `primary`, `primary-ghost`, `ghost`, `subtle`, `icon-btn`, or `sr-only` for new UI. Those classes are legacy compatibility surface for existing markup until it is migrated.
- If a needed primitive is missing, prefer adding a small focused primitive to `packages/components` with colocated CSS Modules, then consume it from the app. Keep product-specific layout and workflow styling in the app, not in `packages/components`.
- Keep semantic plain HTML when it is content markup or a specialized control that the shared package does not model yet; do not force a migration that would hide native behavior or make a custom widget harder to reason about.
- `apps/web` transpiles `@open-design/components` from source during dev, so component and CSS Module edits should work through the normal web dev loop without rebuilding the package.
## i18n keys
- `apps/web/src/i18n/types.ts` is the typed `Dict`; every key must be defined in all 18 locale files under `apps/web/src/i18n/locales/*.ts` (`ar`, `de`, `en`, `es-ES`, `fa`, `fr`, `hu`, `id`, `ja`, `ko`, `pl`, `pt-BR`, `ru`, `th`, `tr`, `uk`, `zh-CN`, `zh-TW`). Add the key to `types.ts` first; missing translations produce a typecheck error.

View file

@ -164,6 +164,7 @@ const nextConfig: NextConfig = {
// to inject chunk IDs, upload to PostHog, and ALWAYS delete the .map files
// before packaging so source never ships inside an installer.
productionBrowserSourceMaps: true,
transpilePackages: ['@open-design/components'],
turbopack: {
root: WORKSPACE_ROOT,
},

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

@ -1,3 +1,4 @@
import { Button, Select } from '@open-design/components';
import { useT } from '../i18n';
import type { AgentInfo, ExecMode } from '../types';
@ -27,7 +28,7 @@ export function AgentPicker({
return (
<div className="picker agent-picker">
<span className="picker-label">{t('agentPicker.label')}</span>
<select
<Select
value={mode}
onChange={(e) => onModeChange(e.target.value as ExecMode)}
title={t('agentPicker.modeChoose')}
@ -36,10 +37,10 @@ export function AgentPicker({
{t('agentPicker.localCli')} {daemonLive ? '' : `· ${t('agentPicker.daemonOff')}`}
</option>
<option value="api">{t('agentPicker.byok')}</option>
</select>
</Select>
{mode === 'daemon' ? (
<>
<select
<Select
value={agentId ?? ''}
onChange={(e) => onAgentChange(e.target.value)}
disabled={available.length === 0}
@ -58,14 +59,14 @@ export function AgentPicker({
{a.available ? '' : ` · ${t('agentPicker.notInstalled')}`}
</option>
))}
</select>
<button
</Select>
<Button
size="icon"
onClick={onRefresh}
title={t('agentPicker.rescan')}
className="icon-btn"
>
</button>
</Button>
</>
) : null}
</div>

View file

@ -1,4 +1,5 @@
import type { CSSProperties } from 'react';
import { Button, Textarea } from '@open-design/components';
import { useRef } from 'react';
import type { PreviewCommentSnapshot } from '../comments';
@ -325,14 +326,14 @@ export function BoardComposerPopover({
{notes.map((note, index) => (
<div key={`${target.elementId}-${index}`} className="board-note-item">
<span>{note}</span>
<button type="button" className="ghost" onClick={() => onRemoveQueuedNote(index)}>
<Button variant="ghost" onClick={() => onRemoveQueuedNote(index)}>
{t('chat.comments.remove')}
</button>
</Button>
</div>
))}
</div>
) : null}
<textarea
<Textarea
data-testid="comment-popover-input"
value={draft}
autoFocus
@ -385,35 +386,32 @@ export function BoardComposerPopover({
</div>
<div className="comment-popover-actions-end">
{target.selectionKind === 'pod' ? (
<button
type="button"
className="ghost"
<Button
variant="ghost"
data-testid="comment-popover-add-note"
disabled={!draft.trim()}
onClick={onAddDraft}
>
{t('chat.comments.addNote')}
</button>
</Button>
) : (
<button
type="button"
className="ghost"
<Button
variant="ghost"
data-testid="comment-popover-save"
disabled={!draft.trim() || !hasCommentChange}
onClick={() => void onSaveComment()}
>
{t('chat.comments.comment')}
</button>
</Button>
)}
<button
type="button"
className="primary"
<Button
variant="primary"
data-testid="comment-add-send"
disabled={sendDisabled}
onClick={() => void onSendBatch()}
>
{sending ? t('chat.comments.sending') : t('chat.comments.sendToChat')}
</button>
</Button>
</div>
</div>
</section>

View file

@ -9,6 +9,7 @@ import {
type ReactNode,
} from "react";
import { createPortal } from 'react-dom';
import { Button } from '@open-design/components';
import { useI18n, useT } from '../i18n';
import type { Dict } from '../i18n/types';
import {
@ -2206,8 +2207,8 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
) : null}
</div>
) : null}
<button
className="icon-btn"
<Button
size="icon"
data-testid="chat-attach"
onClick={() => {
trackChatPanelClick(analytics.track, {
@ -2226,7 +2227,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
) : (
<Icon name="attach" size={15} />
)}
</button>
</Button>
{footerAccessory}
<span className="composer-spacer" />
{showStopButton ? (

View file

@ -7,6 +7,7 @@ import {
type KeyboardEvent as ReactKeyboardEvent,
type SyntheticEvent,
} from 'react';
import { VisuallyHidden } from '@open-design/components';
import type { ConnectorConnectResponse, ConnectorDetail, ConnectorStatusResponse } from '@open-design/contracts';
import { useT } from '../i18n';
import type { Dict } from '../i18n/types';
@ -900,7 +901,7 @@ export function ConnectorsBrowser({
>
<p className="connector-panel-alert-copy" role="status">
<strong title={alert.connectorName}>{alert.connectorName}</strong>
<span className="sr-only">: </span>
<VisuallyHidden>: </VisuallyHidden>
<span title={alert.message}>{alert.message}</span>
</p>
<button

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from '@open-design/components';
import { useAnalytics } from '../analytics/provider';
import { trackFileManagerClick } from '../analytics/events';
import { useT } from '../i18n';
@ -1657,10 +1658,10 @@ function DfPreview({
</div>
<div className="df-preview-meta" data-testid="design-file-preview">
<div className="df-preview-actions">
<button type="button" className="ghost" onClick={onOpen}>
<Button variant="ghost" onClick={onOpen}>
<Icon name="eye" size={13} />
<span>{t('designFiles.previewOpen')}</span>
</button>
</Button>
<a
className="ghost-link"
href={url}

View file

@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from 'react';
import { Button, Textarea } from '@open-design/components';
import type { ConnectorConnectResponse, ConnectorDetail, ConnectorStatusResponse } from '@open-design/contracts';
import { streamViaDaemon } from '../providers/daemon';
import {
@ -651,19 +652,18 @@ export function DesignSystemCreationFlow({
<h1>It will take about 5 minutes to generate your design system.</h1>
<p>You can step away. Keep the tab open in the background.</p>
<div className="ds-setup-actions">
<button type="button" className="ghost" onClick={() => setStep('setup')}>
<Button variant="ghost" onClick={() => setStep('setup')}>
<Icon name="arrow-left" />
Back
</button>
<button
type="button"
className="primary"
</Button>
<Button
variant="primary"
disabled={generationStarting}
onClick={() => void generate()}
>
<Icon name="sparkles" />
{generationStarting ? 'Opening project...' : 'Generate'}
</button>
</Button>
</div>
</div>
</div>
@ -687,16 +687,15 @@ export function DesignSystemCreationFlow({
) : null}
{embedded ? null : (
<header className="ds-setup-topbar">
<button type="button" className="ghost" onClick={onBack}>
<Button variant="ghost" onClick={onBack}>
<Icon name="arrow-left" />
Back
</button>
</Button>
<span className="ds-setup-mark">
<Icon name="blocks" />
</span>
<button
type="button"
className="primary"
<Button
variant="primary"
disabled={!state.company.trim()}
onClick={() => {
if (!state.company.trim()) {
@ -708,7 +707,7 @@ export function DesignSystemCreationFlow({
>
Continue to generation
<Icon name="chevron-right" />
</button>
</Button>
</header>
)}
@ -842,7 +841,7 @@ export function DesignSystemCreationFlow({
{embedded ? null : (
<label className="ds-setup-field">
<span>Notes</span>
<textarea
<Textarea
rows={4}
value={state.notes}
onChange={(event) => setState((curr) => ({ ...curr, notes: event.target.value }))}
@ -853,13 +852,12 @@ export function DesignSystemCreationFlow({
{error ? <div className="ds-editor-error">{error}</div> : null}
{embedded ? (
<div className="ds-setup-actions ds-setup-actions--embedded">
<button type="button" className="ghost" onClick={onBack}>
<Button variant="ghost" onClick={onBack}>
<Icon name="arrow-left" />
Back
</button>
<button
type="button"
className="primary"
</Button>
<Button
variant="primary"
disabled={!state.company.trim()}
onClick={() => {
if (!state.company.trim()) {
@ -871,7 +869,7 @@ export function DesignSystemCreationFlow({
>
Generate
<Icon name="chevron-right" />
</button>
</Button>
</div>
) : null}
</main>
@ -1786,10 +1784,10 @@ export function DesignSystemDetailView({
<main className="ds-review-main">
<header className="ds-review-tabs">
<button type="button" className="ghost" onClick={onBack}>
<Button variant="ghost" onClick={onBack}>
<Icon name="arrow-left" />
Back
</button>
</Button>
<div className="segmented">
<button
type="button"
@ -1806,9 +1804,9 @@ export function DesignSystemDetailView({
Design Files
</button>
</div>
<button type="button" className="ghost">
<Button variant="ghost">
Share
</button>
</Button>
</header>
{tab === 'system' ? (
@ -1834,9 +1832,9 @@ export function DesignSystemDetailView({
Published
</label>
{selectedId !== system.id ? (
<button
type="button"
className="ghost compact"
<Button
variant="ghost"
className="compact"
onClick={() => {
const statusBefore = mapDsStatusToTracking(system.status);
onSetDefault(system.id);
@ -1856,7 +1854,7 @@ export function DesignSystemDetailView({
}}
>
Make default
</button>
</Button>
) : null}
</div>
<DesignSystemPackageCard system={system} />
@ -1866,10 +1864,10 @@ export function DesignSystemDetailView({
<strong>Missing brand fonts</strong>
Open Design is rendering typography with substitute web fonts.
</span>
<button type="button" className="ghost compact">
<Button variant="ghost" className="compact">
<Icon name="upload" />
Upload fonts
</button>
</Button>
</div>
{statusLine ? <div className="ds-status-line">{statusLine}</div> : null}
<WorkspaceActivityCard message={workspaceActivityMessage} active={chatStreaming} />
@ -1939,16 +1937,16 @@ export function DesignSystemDetailView({
</div>
<label className="ds-body-editor">
DESIGN.md
<textarea
<Textarea
value={body}
onChange={(event) => setBody(event.target.value)}
rows={16}
disabled={!editable}
/>
</label>
<button type="button" className="primary" disabled={!editable || saving} onClick={() => void saveBody()}>
<Button variant="primary" disabled={!editable || saving} onClick={() => void saveBody()}>
Save DESIGN.md
</button>
</Button>
{recentRevisions.length > 0 ? <RevisionHistoryList revisions={recentRevisions} /> : null}
</div>
) : (
@ -2734,9 +2732,9 @@ function DropZone({
<span>{names.length > 0 && !onRemoveName ? names.join(', ') : prompt}</span>
</label>
{onBrowseFolder ? (
<button type="button" className="ghost" onClick={onBrowseFolder}>
<Button variant="ghost" onClick={onBrowseFolder}>
Browse folder
</button>
</Button>
) : null}
</div>
{names.length > 0 && onRemoveName ? (
@ -2923,24 +2921,24 @@ function GitHubRepositoryAccessPanel({
}
const composioAction = !composioConfigured ? (
<button type="button" className="ghost" onClick={onOpenConnectorsTab}>
<Button variant="ghost" onClick={onOpenConnectorsTab}>
Configure Composio
</button>
</Button>
) : connected || authorizationPending ? (
<>
{authorizationPending && authorizationUrl ? (
<button type="button" className="ghost" disabled={busy} onClick={onOpenAuthorization}>
<Button variant="ghost" disabled={busy} onClick={onOpenAuthorization}>
Open authorization
</button>
</Button>
) : null}
<button type="button" className="ghost" disabled={busy} onClick={onDisconnect}>
<Button variant="ghost" disabled={busy} onClick={onDisconnect}>
{action === 'disconnect' ? 'Disconnecting...' : 'Disconnect'}
</button>
</Button>
</>
) : (
<button type="button" className="ghost" disabled={busy} onClick={onConnect}>
<Button variant="ghost" disabled={busy} onClick={onConnect}>
{action === 'connect' ? 'Connecting...' : 'Connect via Composio'}
</button>
</Button>
);
const methods: GitHubAccessMethod[] = [

View file

@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from '@open-design/components';
import { useAnalytics } from '../analytics/provider';
import {
trackDesignSystemsTemplateCardClick,
@ -543,14 +544,13 @@ export function DesignSystemsTab({
<i aria-hidden />
</button>
{onOpenSystem ? (
<button
type="button"
className="icon-btn"
<Button
size="icon"
aria-label={`Open ${system.title}`}
onClick={() => onOpenSystem(system.id)}
>
<Icon name="external-link" />
</button>
</Button>
) : null}
<button
type="button"

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { Button, Input, Select } from '@open-design/components';
import { APP_CHROME_FILE_ACTIONS_ID, APP_CHROME_FILE_ACTIONS_SELECTOR } from './AppChromeHeader';
import {
anonymizeArtifactId,
@ -2309,18 +2310,17 @@ export function CommentSidePanel({
{selectedCount > 0 ? (
<div className="comment-side-selectbar" data-testid="comment-side-selectbar">
<span className="comment-side-selectcount">{t('chat.comments.nSelected', { n: selectedCount })}</span>
<button type="button" className="ghost" onClick={onClearSelection}>
<Button variant="ghost" onClick={onClearSelection}>
{t('chat.comments.clear')}
</button>
<button
type="button"
className="primary"
</Button>
<Button
variant="primary"
data-testid="comment-side-send-claude"
disabled={sending}
onClick={() => void onSendSelected()}
>
{sending ? t('chat.comments.sending') : t('chat.comments.sendToChat')}
</button>
</Button>
</div>
) : null}
{composer ? <div className="comment-side-composer">{composer}</div> : null}
@ -2563,9 +2563,9 @@ function InspectPanel({
<strong title={target.label || target.elementId}>{target.label || target.elementId}</strong>
<code title={target.selector}>{target.elementId}</code>
</div>
<button type="button" className="ghost" onClick={onClose} aria-label="Close inspect">
<Button variant="ghost" onClick={onClose} aria-label="Close inspect">
×
</button>
</Button>
</header>
{target.clickedDescendant ? (
@ -2588,14 +2588,14 @@ function InspectPanel({
<div className="inspect-section-label">Colors</div>
<div className="inspect-row">
<label htmlFor="ip-color">Text</label>
<input
<Input
id="ip-color"
data-testid="inspect-color"
type="color"
value={colorHex}
onChange={(e) => setVal('color', e.target.value)}
/>
<input
<Input
type="text"
value={colorHex}
onChange={(e) => setVal('color', e.target.value)}
@ -2604,14 +2604,14 @@ function InspectPanel({
</div>
<div className="inspect-row">
<label htmlFor="ip-bg">Background</label>
<input
<Input
id="ip-bg"
data-testid="inspect-bg"
type="color"
value={bgHex}
onChange={(e) => setVal('background-color', e.target.value)}
/>
<input
<Input
type="text"
value={bgHex}
onChange={(e) => setVal('background-color', e.target.value)}
@ -2638,7 +2638,7 @@ function InspectPanel({
</div>
<div className="inspect-row">
<label htmlFor="ip-fw">Weight</label>
<select
<Select
id="ip-fw"
value={fontWeight}
onChange={(e) => setVal('font-weight', e.target.value)}
@ -2646,11 +2646,11 @@ function InspectPanel({
{['100', '300', '400', '500', '600', '700', '800', '900'].map((w) => (
<option key={w} value={w}>{w}</option>
))}
</select>
</Select>
</div>
<div className="inspect-row">
<label htmlFor="ip-ta">Align</label>
<select
<Select
id="ip-ta"
value={textAlign}
onChange={(e) => setVal('text-align', e.target.value)}
@ -2658,7 +2658,7 @@ function InspectPanel({
{['left', 'center', 'right', 'justify'].map((a) => (
<option key={a} value={a}>{a}</option>
))}
</select>
</Select>
</div>
</section>
@ -2695,25 +2695,23 @@ function InspectPanel({
</section>
<footer className="inspect-panel-footer">
<button
type="button"
className="ghost"
<Button
variant="ghost"
onClick={() => {
setDraft({});
onResetElement(target.elementId);
}}
>
Reset element
</button>
<button
type="button"
className="primary"
</Button>
<Button
variant="primary"
data-testid="inspect-save"
disabled={saving}
onClick={onSaveToSource}
>
{saving ? 'Saving…' : justSaved ? 'Saved ✓' : 'Save to source'}
</button>
</Button>
</footer>
{error ? <div className="inspect-panel-error">{error}</div> : null}
</aside>

View file

@ -5,6 +5,7 @@ import {
useState,
type DragEvent as ReactDragEvent,
} from 'react';
import { Button } from '@open-design/components';
import type { TrackingProjectKind } from '@open-design/contracts/analytics';
import { useAnalytics } from '../analytics/provider';
import {
@ -1598,14 +1599,14 @@ function DesignSystemProjectPanel({
{published ? (
<div className="ds-project-use-row">
<span>Use this system</span>
<button
type="button"
className="ghost compact"
<Button
variant="ghost"
className="compact"
onClick={() => onUseDesignSystem?.(system.id, system.title)}
>
<Icon name="external-link" size={13} />
New design
</button>
</Button>
</div>
) : null}
</div>
@ -1618,20 +1619,20 @@ function DesignSystemProjectPanel({
<small>{repoConnectCopy(githubConnected).bannerBody}</small>
</span>
{onConnectRepo ? (
<button
type="button"
className="ghost compact"
<Button
variant="ghost"
className="compact"
disabled={githubConnected === undefined}
onClick={onConnectRepo}
>
<Icon name="github" size={13} />
{repoConnectCopy(githubConnected).buttonLabel}
</button>
</Button>
) : githubEvidence.hasSourceManifest ? (
<button type="button" className="ghost compact" onClick={() => onOpenFile('context/source-context.md')}>
<Button variant="ghost" className="compact" onClick={() => onOpenFile('context/source-context.md')}>
<Icon name="file" size={13} />
Open source context
</button>
</Button>
) : null}
</div>
) : null}

View file

@ -13,6 +13,7 @@ import {
useRef,
useState,
} from 'react';
import { Button } from '@open-design/components';
import { useAnalytics } from '../analytics/provider';
import { trackIntegrationsMcpTabClick } from '../analytics/events';
import {
@ -780,33 +781,32 @@ function McpRow({ row, idx, total, template, onChange, onRemove, onMoveUp, onMov
</span>
<div className="mcp-row-actions">
{onMoveUp ? (
<button type="button" className="icon-btn" onClick={onMoveUp} title="Move up">
<Button size="icon" onClick={onMoveUp} title="Move up">
</button>
</Button>
) : null}
{onMoveDown ? (
<button type="button" className="icon-btn" onClick={onMoveDown} title="Move down">
<Button size="icon" onClick={onMoveDown} title="Move down">
</button>
</Button>
) : null}
<button
type="button"
className="icon-btn"
<Button
size="icon"
onClick={onRemove}
title="Remove this MCP server"
>
×
</button>
<button
type="button"
className="icon-btn mcp-row-toggle-btn"
</Button>
<Button
size="icon"
className="mcp-row-toggle-btn"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-label={expanded ? 'Collapse this MCP server' : 'Expand this MCP server'}
title={expanded ? 'Collapse' : 'Expand'}
>
<Icon name="chevron-down" size={13} />
</button>
</Button>
</div>
</div>

View file

@ -6,6 +6,7 @@ import {
useState,
type CSSProperties,
} from 'react';
import { Button } from '@open-design/components';
import { Icon, type IconName } from './Icon';
import { ConnectorLogo, useResolvedTheme } from './ConnectorLogo';
import { useT } from '../i18n';
@ -1837,17 +1838,16 @@ export function MemorySection({
{t('settings.memorySaveHint')}
</span>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="ghost" onClick={cancelEdit}>
<Button variant="ghost" onClick={cancelEdit}>
{t('common.cancel')}
</button>
<button
type="button"
className="primary"
</Button>
<Button
variant="primary"
onClick={onSave}
disabled={busy || !editing.name.trim()}
>
{editing.id ? t('common.save') : t('common.create')}
</button>
</Button>
</div>
</div>
</div>

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { Button } from '@open-design/components';
import { Icon } from './Icon';
@ -69,14 +70,14 @@ export function MissingBrandFontsBanner({
</span>
<div className="ds-warning-card-actions">
{onUploadAssets ? (
<button type="button" className="ghost compact" onClick={onUploadAssets}>
<Button variant="ghost" className="compact" onClick={onUploadAssets}>
<Icon name="upload" size={13} />
Upload fonts
</button>
</Button>
) : null}
<button type="button" className="ghost compact" onClick={useSystemFonts}>
<Button variant="ghost" className="compact" onClick={useSystemFonts}>
Use system fonts
</button>
</Button>
</div>
</div>
);

View file

@ -1,4 +1,5 @@
import { useState } from 'react';
import { Button, Input, Textarea } from '@open-design/components';
import { useT } from '../i18n';
interface Props {
@ -25,7 +26,7 @@ export function PasteTextDialog({ onSave, onClose }: Props) {
<p className="hint">{t('pasteDialog.hint')}</p>
<label>
{t('pasteDialog.fileNameLabel')}
<input
<Input
type="text"
value={name}
placeholder={t('pasteDialog.namePlaceholder')}
@ -35,7 +36,7 @@ export function PasteTextDialog({ onSave, onClose }: Props) {
</label>
<label>
{t('pasteDialog.contentLabel')}
<textarea
<Textarea
rows={10}
value={content}
placeholder={t('pasteDialog.contentPlaceholder')}
@ -43,10 +44,10 @@ export function PasteTextDialog({ onSave, onClose }: Props) {
/>
</label>
<div className="row">
<button onClick={onClose}>{t('pasteDialog.cancel')}</button>
<button className="primary" onClick={commit} disabled={!content.trim()}>
<Button onClick={onClose}>{t('pasteDialog.cancel')}</Button>
<Button variant="primary" onClick={commit} disabled={!content.trim()}>
{t('pasteDialog.save')}
</button>
</Button>
</div>
</div>
</div>

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties, Dispatch, SetStateAction } from 'react';
import { Button, VisuallyHidden } from '@open-design/components';
import { validateBaseUrl } from '@open-design/contracts/api/connectionTest';
import {
agentIdToTracking,
@ -5280,9 +5281,9 @@ function MediaProvidersSection({
// Off-screen announcement so assistive tech still hears the
// success state even though the visible feedback collapses
// into a transient button label change.
<span className="sr-only" role="status">
<VisuallyHidden role="status">
{reloadNotice.message}
</span>
</VisuallyHidden>
) : null}
{onReloadMediaProviders ? (
<div className="media-provider-reload-row">
@ -6580,7 +6581,7 @@ function NotificationsSection({
) : null}
{notif.desktopEnabled && permission === 'granted' ? (
<>
<button type="button" className="ghost" onClick={() => {
<Button variant="ghost" onClick={() => {
trackSettingsNotificationsClick(analytics.track, {
page_name: 'settings',
area: 'notifications',
@ -6589,7 +6590,7 @@ function NotificationsSection({
void sendTestNotification();
}}>
{t('settings.notifyTest')}
</button>
</Button>
{testStatus ? <p className="hint" role="status">{t(testStatus)}</p> : null}
</>
) : null}

View file

@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button, Input } from '@open-design/components';
import { useT } from '../i18n';
import { Icon } from './Icon';
import { readDefaultSketchToolColor } from './sketch-colors';
@ -245,30 +246,30 @@ export function SketchEditor({
className="sketch-size"
/>
<span className="sketch-divider" />
<button className="ghost" onClick={handleUndo} disabled={items.length === 0}>
<Button variant="ghost" onClick={handleUndo} disabled={items.length === 0}>
{t('sketch.undo')}
</button>
<button className="ghost" onClick={handleClear} disabled={!canClear}>
</Button>
<Button variant="ghost" onClick={handleClear} disabled={!canClear}>
{t('sketch.clear')}
</button>
</Button>
<span className="sketch-spacer" />
<span className="sketch-name" title={fileName}>
{fileName}
{dirty ? ' •' : ''}
</span>
{onCancel ? (
<button className="ghost" onClick={onCancel}>
<Button variant="ghost" onClick={onCancel}>
{t('sketch.close')}
</button>
</Button>
) : null}
<button
className="primary"
<Button
variant="primary"
onClick={handleSave}
disabled={saving || !canSave}
aria-label={saving ? t('sketch.saving') : showSaved ? t('sketch.saved') : t('common.save')}
>
{saving ? t('sketch.saving') : showSaved ? <Icon name="check" size={14} /> : t('common.save')}
</button>
</Button>
</div>
<div className="sketch-canvas-wrap" ref={wrapRef}>
<canvas
@ -288,7 +289,7 @@ export function SketchEditor({
</div>
<label>
<span>{t('sketch.textPrompt')}</span>
<input
<Input
type="text"
value={textModalValue}
autoFocus
@ -305,17 +306,16 @@ export function SketchEditor({
/>
</label>
<div className="modal-foot">
<button type="button" className="ghost" onClick={cancelTextModal}>
<Button variant="ghost" onClick={cancelTextModal}>
{t('common.cancel')}
</button>
<button
type="button"
className="primary"
</Button>
<Button
variant="primary"
disabled={!textModalValue.trim()}
onClick={submitTextModal}
>
{t('common.save')}
</button>
</Button>
</div>
</div>
</div>

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { Button } from '@open-design/components';
import { useI18n, useT } from '../i18n';
import {
localizeSkillDescription,
@ -647,25 +648,23 @@ function SkillRow({
</span>
) : (
<>
<button
type="button"
className="icon-btn"
<Button
size="icon"
onClick={onStartEdit}
title={t('settings.skillsEdit')}
data-testid="skills-edit"
>
<Icon name="edit" size={13} />
</button>
</Button>
{canDelete ? (
<button
type="button"
className="icon-btn"
<Button
size="icon"
onClick={onArmDelete}
title={t('settings.skillsDelete')}
data-testid="skills-delete"
>
<Icon name="close" size={13} />
</button>
</Button>
) : null}
</>
)}

View file

@ -13,6 +13,7 @@
// needs to commit.
import { useMemo, useState } from 'react';
import { VisuallyHidden } from '@open-design/components';
import type { InstalledPluginRecord } from '@open-design/contracts';
import { useI18n } from '../../i18n';
import type { PluginShareAction } from '../../state/projects';
@ -261,7 +262,7 @@ export function PluginCard({
data-testid={`plugins-home-save-${record.id}`}
>
<Icon name={isSaved ? 'check' : 'star'} size={12} />
<span className="sr-only">{isSaved ? 'Saved' : 'Save'}</span>
<VisuallyHidden>{isSaved ? 'Saved' : 'Save'}</VisuallyHidden>
</button>
<span className="plugins-home__card-title" title={title}>
<span className="plugins-home__card-title-text">{title}</span>

View file

@ -3,6 +3,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

@ -357,8 +357,8 @@
}
.picker select {
border: none;
background: transparent;
padding: 4px 6px;
background-color: transparent;
padding: 4px 26px 4px 6px;
width: auto;
min-width: 120px;
box-shadow: none;

View file

@ -1912,13 +1912,16 @@ button.ghost.mcp-copy-btn:hover:not(:disabled) {
.inspect-row input[type='text'],
.inspect-row select {
min-width: 0;
padding: 3px 6px;
padding: 3px 26px 3px 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
background-color: var(--bg);
font-size: 11px;
font-family: inherit;
}
.inspect-row input[type='text'] {
padding-right: 6px;
}
.inspect-row input[type='range'] {
width: 100%;
}

View file

@ -1,6 +1,10 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
const packageCssImports = new Map([
['@open-design/components/styles.css', join(process.cwd(), '../../packages/components/src/styles.css')],
]);
function expandCssFile(filePath: string, seen = new Set<string>()): string {
const key = filePath;
if (seen.has(key)) {
@ -12,7 +16,8 @@ function expandCssFile(filePath: string, seen = new Set<string>()): string {
return css.replace(/@import\s+(?:url\(([^)]+)\)|(['"])([^'"]+)\2);/g, (_match, urlImport, _quote, quotedImport) => {
const specifier = (quotedImport ?? urlImport ?? '').trim().replace(/^['"]|['"]$/g, '');
if (!specifier.startsWith('./') && !specifier.startsWith('../')) {
return '';
const packageCssPath = packageCssImports.get(specifier);
return packageCssPath == null ? '' : expandCssFile(packageCssPath, seen);
}
return expandCssFile(join(dirname(filePath), specifier), seen);
});

View file

@ -142,6 +142,7 @@ async function runToolsDevSuite(
break;
} catch (error) {
if (attempt === 3 || !toolsDev.isToolsDevPortConflict(error)) throw error;
await toolsDev.stopToolsDevWeb(suite).catch(() => undefined);
runtime = await toolsDev.allocateToolsDevRuntime();
}
}

View file

@ -56,6 +56,7 @@
# Keep in sync with .github/workflows/ci.yml change_scopes
# nix_validation_required filter.
webWorkspacePaths = [
"packages/components"
"packages/contracts"
"packages/host"
"packages/platform"

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,17 @@
import { rmSync } from 'node:fs';
import { build } from 'esbuild';
rmSync('./dist', { force: true, recursive: true });
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,37 @@
{
"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",
"development": "./src/index.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,75 @@
.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;
}
.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);
}
.primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.primaryGhost {
background: var(--bg-panel);
border-color: var(--accent);
color: var(--accent);
font-weight: 500;
}
.primaryGhost:hover:not(:disabled) {
background: var(--accent-tint);
}
.ghost {
background: transparent;
border-color: var(--border);
color: var(--text);
}
.ghost:hover:not(:disabled) {
background: var(--bg-subtle);
border-color: var(--border-strong);
}
.subtle {
background: var(--bg-subtle);
border-color: transparent;
color: var(--text);
}
.subtle:hover:not(:disabled) {
background: var(--bg-muted);
}
.icon {
padding: 6px 10px;
font-size: 13px;
}
.button:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View file

@ -0,0 +1,45 @@
import { forwardRef } from 'react';
import type { ButtonHTMLAttributes } from 'react';
import { joinClassNames } from './class-names';
import styles from './button.module.css';
export type ButtonVariant = 'default' | 'primary' | 'primary-ghost' | 'ghost' | 'subtle';
export type ButtonSize = 'default' | 'icon';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
}
const variantClassNames: Record<ButtonVariant, string | undefined> = {
default: undefined,
primary: joinClassNames(styles.primary, 'primary'),
'primary-ghost': joinClassNames(styles.primaryGhost, 'primary-ghost'),
ghost: joinClassNames(styles.ghost, 'ghost'),
subtle: joinClassNames(styles.subtle, 'subtle'),
};
const sizeClassNames: Record<ButtonSize, string | undefined> = {
default: undefined,
icon: joinClassNames(styles.icon, 'icon-btn'),
};
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(
styles.button,
variantClassNames[variant],
sizeClassNames[size],
className,
)}
{...props}
/>
);
});

View file

@ -0,0 +1,3 @@
export function joinClassNames(...classNames: Array<string | false | null | undefined>) {
return classNames.filter(Boolean).join(' ') || undefined;
}

View file

@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: Record<string, string>;
export default classes;
}

View file

@ -0,0 +1,49 @@
.control {
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;
}
.control::placeholder {
color: var(--text-faint);
}
.control:focus {
outline: none;
border-color: var(--selected);
box-shadow: 0 0 0 3px var(--selected-soft);
}
.textarea {
resize: vertical;
font-family: inherit;
}
.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");
}
@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");
}
}

View file

@ -0,0 +1,32 @@
import { forwardRef } from 'react';
import type { InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes } from 'react';
import { joinClassNames } from './class-names';
import styles from './form-controls.module.css';
export type InputProps = InputHTMLAttributes<HTMLInputElement>;
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ className, ...props },
ref,
) {
return <input ref={ref} className={joinClassNames(styles.control, className)} {...props} />;
});
export type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
{ className, ...props },
ref,
) {
return <textarea ref={ref} className={joinClassNames(styles.control, styles.textarea, className)} {...props} />;
});
export type SelectProps = SelectHTMLAttributes<HTMLSelectElement>;
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
{ className, ...props },
ref,
) {
return <select ref={ref} className={joinClassNames(styles.control, styles.select, className)} {...props} />;
});

View file

@ -0,0 +1,6 @@
export { Button } from './button';
export type { ButtonProps, ButtonSize, ButtonVariant } from './button';
export { Input, Select, Textarea } from './form-controls';
export type { InputProps, SelectProps, TextareaProps } from './form-controls';
export { VisuallyHidden } from './visually-hidden';
export type { VisuallyHiddenProps } from './visually-hidden';

View file

@ -0,0 +1,6 @@
export { Button } from './button';
export type { ButtonProps, ButtonSize, ButtonVariant } from './button';
export { Input, Select, Textarea } from './form-controls';
export type { InputProps, SelectProps, TextareaProps } from './form-controls';
export { VisuallyHidden } from './visually-hidden';
export type { VisuallyHiddenProps } from './visually-hidden';

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,11 @@
import type { HTMLAttributes, ReactNode } from 'react';
import { joinClassNames } from './class-names';
export interface VisuallyHiddenProps extends HTMLAttributes<HTMLSpanElement> {
children: ReactNode;
}
export function VisuallyHidden({ children, className, ...props }: VisuallyHiddenProps) {
return <span className={joinClassNames('sr-only', className)} {...props}>{children}</span>;
}

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

@ -263,6 +263,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
@ -383,6 +386,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",

View file

@ -47,6 +47,7 @@ const CONTAINER_NODE_VERSION = "24.14.1";
const CONTAINER_TOOLS_PACK_CLI_PATH = "tools/pack/bin/tools-pack.mjs";
export const INTERNAL_PACKAGES = [
{ directory: "packages/components", name: "@open-design/components" },
{ directory: "packages/contracts", name: "@open-design/contracts" },
{ directory: "packages/registry-protocol", name: "@open-design/registry-protocol" },
{ directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" },

View file

@ -1,6 +1,7 @@
export const PRODUCT_NAME = "Open Design";
export const INTERNAL_PACKAGES = [
{ directory: "packages/components", name: "@open-design/components" },
{ directory: "packages/contracts", name: "@open-design/contracts" },
{ directory: "packages/registry-protocol", name: "@open-design/registry-protocol" },
{ directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" },

View file

@ -30,6 +30,7 @@ export const NSIS_INSTALLER_LANGUAGE_BY_WEB_LOCALE = {
"zh-TW": "zh_TW",
} as const;
export const INTERNAL_PACKAGES = [
{ directory: "packages/components", name: "@open-design/components" },
{ directory: "packages/contracts", name: "@open-design/contracts" },
{ directory: "packages/registry-protocol", name: "@open-design/registry-protocol" },
{ directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" },

View file

@ -5,24 +5,10 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { ToolPackConfig } from "../src/config.js";
import { INTERNAL_PACKAGES } from "../src/win/constants.js";
import { createWorkspaceTarballsCacheKey } from "../src/win/app.js";
const PACKAGE_DIRS = [
"packages/contracts",
"packages/registry-protocol",
"packages/sidecar-proto",
"packages/sidecar",
"packages/platform",
"packages/download",
"packages/host",
"packages/agui-adapter",
"packages/plugin-runtime",
"packages/diagnostics",
"apps/daemon",
"apps/web",
"apps/desktop",
"apps/packaged",
] as const;
const PACKAGE_DIRS = INTERNAL_PACKAGES.map((packageInfo) => packageInfo.directory);
async function writeWorkspace(root: string): Promise<void> {
await writeFile(join(root, "package.json"), `${JSON.stringify({ packageManager: "pnpm@10.33.2" }, null, 2)}\n`, "utf8");