diff --git a/web/src/components/Button.tsx b/web/src/components/Button.tsx new file mode 100644 index 0000000..b507029 --- /dev/null +++ b/web/src/components/Button.tsx @@ -0,0 +1,68 @@ +import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from 'react'; + +type Variant = 'primary' | 'secondary' | 'ghost'; +type Size = 'default' | 'sm'; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: Variant; + size?: Size; + children: ReactNode; +} + +const baseStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: 'var(--s-2)', + fontFamily: 'var(--ff-sans)', + fontSize: 'var(--t-body)', + fontWeight: 500, + padding: '8px var(--s-4)', + border: '1px solid transparent', + cursor: 'pointer', + letterSpacing: '0.005em', + transition: 'background-color 0.12s, border-color 0.12s, color 0.12s', + borderRadius: 0, +}; + +const variantStyles: Record = { + primary: { + background: 'var(--ink-1)', + color: 'var(--bg-elev)', + borderColor: 'var(--ink-1)', + }, + secondary: { + background: 'var(--bg-elev)', + color: 'var(--ink-1)', + border: '1px solid var(--hair-strong)', + }, + ghost: { + background: 'transparent', + color: 'var(--ink-3)', + textDecoration: 'underline', + textDecorationColor: 'var(--ink-4)', + textUnderlineOffset: 2, + padding: '8px var(--s-3)', + border: 'none', + }, +}; + +export function Button({ + variant = 'primary', + size = 'default', + children, + style, + ...rest +}: ButtonProps) { + const sizeOverride: CSSProperties = + size === 'sm' ? { padding: '4px var(--s-3)', fontSize: 'var(--t-meta)' } : {}; + return ( + + ); +} diff --git a/web/src/components/Input.tsx b/web/src/components/Input.tsx new file mode 100644 index 0000000..06b8f62 --- /dev/null +++ b/web/src/components/Input.tsx @@ -0,0 +1,48 @@ +import { forwardRef } from 'react'; +import type { InputHTMLAttributes } from 'react'; + +type Size = 'default' | 'sm'; + +interface InputProps extends Omit, 'size'> { + size?: Size; +} + +const baseClass = 'asr-input'; + +export const Input = forwardRef( + ({ size = 'default', style, className, ...rest }, ref) => { + const height = size === 'sm' ? 22 : 28; + return ( + { + e.currentTarget.style.borderColor = 'var(--hair-strong)'; + e.currentTarget.style.boxShadow = '0 0 0 1px var(--acc-soft)'; + rest.onFocus?.(e); + }} + onBlur={(e) => { + e.currentTarget.style.borderColor = 'var(--hair)'; + e.currentTarget.style.boxShadow = 'none'; + rest.onBlur?.(e); + }} + {...rest} + /> + ); + }, +); +Input.displayName = 'Input'; diff --git a/web/src/components/Modal.tsx b/web/src/components/Modal.tsx new file mode 100644 index 0000000..994c2f3 --- /dev/null +++ b/web/src/components/Modal.tsx @@ -0,0 +1,194 @@ +import * as Dialog from '@radix-ui/react-dialog'; +import type { ReactNode } from 'react'; + +interface PrimaryAction { + label: string; + onClick: () => void; + disabled?: boolean; +} + +interface ModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + eyebrow?: string; + children: ReactNode; + preview?: ReactNode; + primaryAction?: PrimaryAction; + cancelLabel?: string; +} + +export function Modal({ + open, + onOpenChange, + title, + eyebrow, + children, + preview, + primaryAction, + cancelLabel = 'Cancel', +}: ModalProps) { + return ( + + + + +
+
+ {eyebrow && ( +
+ {eyebrow} +
+ )} + + {title} + +
+ + × + +
+
{children}
+ {preview && ( +
+ {preview} +
+ )} +
+ + Esc to close + + + + + + {primaryAction && ( + + )} +
+
+
+
+ ); +} diff --git a/web/src/components/Pill.tsx b/web/src/components/Pill.tsx new file mode 100644 index 0000000..2b2afed --- /dev/null +++ b/web/src/components/Pill.tsx @@ -0,0 +1,39 @@ +import type { CSSProperties, ReactNode } from 'react'; + +type Kind = 'running' | 'paused' | 'error' | 'resolved' | 'neutral'; + +interface PillProps { + kind: Kind; + children: ReactNode; +} + +const baseStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: 4, + padding: '2px 8px', + fontFamily: 'var(--ff-sans)', + fontSize: 'var(--t-micro)', + fontWeight: 500, + letterSpacing: '0.08em', + textTransform: 'uppercase', + border: '1px solid var(--hair)', + borderRadius: 0, + whiteSpace: 'nowrap', +}; + +const kindStyles: Record = { + running: { color: 'var(--acc)', borderColor: 'var(--acc-mid)', background: 'var(--acc-soft)' }, + paused: { color: 'var(--warn)', borderColor: 'var(--warn)', background: 'var(--warn-bg)' }, + error: { color: 'var(--danger)', borderColor: 'var(--danger)', background: 'var(--danger-bg)' }, + resolved: { color: 'var(--good)', borderColor: 'var(--good)', background: 'var(--good-bg)' }, + neutral: { color: 'var(--ink-3)', borderColor: 'var(--hair-strong)', background: 'var(--bg-subtle)' }, +}; + +export function Pill({ kind, children }: PillProps) { + return ( + + {children} + + ); +} diff --git a/web/src/components/Select.tsx b/web/src/components/Select.tsx new file mode 100644 index 0000000..f3d67ed --- /dev/null +++ b/web/src/components/Select.tsx @@ -0,0 +1,39 @@ +import { forwardRef } from 'react'; +import type { SelectHTMLAttributes } from 'react'; + +export interface SelectOption { + value: string; + label: string; +} + +interface SelectProps extends Omit, 'children'> { + options: SelectOption[]; + placeholder?: string; +} + +export const Select = forwardRef( + ({ options, placeholder, style, value, ...rest }, ref) => ( + + ), +); +Select.displayName = 'Select'; diff --git a/web/src/components/Tag.tsx b/web/src/components/Tag.tsx new file mode 100644 index 0000000..c2b7b98 --- /dev/null +++ b/web/src/components/Tag.tsx @@ -0,0 +1,33 @@ +import type { CSSProperties, ReactNode } from 'react'; + +type Variant = 'default' | 'mono'; + +interface TagProps { + variant?: Variant; + children: ReactNode; +} + +const baseStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + padding: '1px 6px', + fontSize: 'var(--t-meta)', + color: 'var(--ink-2)', + border: '1px solid var(--hair)', + background: 'var(--bg-subtle)', + borderRadius: 0, + whiteSpace: 'nowrap', +}; + +const variantStyles: Record = { + default: { fontFamily: 'var(--ff-sans)' }, + mono: { fontFamily: 'var(--ff-mono)' }, +}; + +export function Tag({ variant = 'default', children }: TagProps) { + return ( + + {children} + + ); +} diff --git a/web/src/components/Textarea.tsx b/web/src/components/Textarea.tsx new file mode 100644 index 0000000..086ece5 --- /dev/null +++ b/web/src/components/Textarea.tsx @@ -0,0 +1,36 @@ +import { forwardRef } from 'react'; +import type { TextareaHTMLAttributes } from 'react'; + +export const Textarea = forwardRef>( + ({ style, ...rest }, ref) => ( +