mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
refactor(web): extract shared UI primitives
This commit is contained in:
parent
c7f55388a9
commit
a8d21a6c01
14 changed files with 449 additions and 288 deletions
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
13
packages/components/esbuild.config.ts
Normal file
13
packages/components/esbuild.config.ts
Normal 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',
|
||||
});
|
||||
36
packages/components/package.json
Normal file
36
packages/components/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
4
packages/components/src/index.ts
Normal file
4
packages/components/src/index.ts
Normal 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';
|
||||
66
packages/components/src/primitives.tsx
Normal file
66
packages/components/src/primitives.tsx
Normal 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>;
|
||||
}
|
||||
275
packages/components/src/styles.css
Normal file
275
packages/components/src/styles.css
Normal 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;
|
||||
}
|
||||
19
packages/components/tsconfig.json
Normal file
19
packages/components/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const repoRoot = resolve(scriptDir, "..");
|
|||
|
||||
const buildTargets = [
|
||||
"packages/contracts",
|
||||
"packages/components",
|
||||
"packages/platform",
|
||||
"packages/download",
|
||||
"packages/host",
|
||||
|
|
|
|||
Loading…
Reference in a new issue