feat(desktop): port the 15 design-system primitives (#14)
Verbatim port of the handoff primitives into components/ui/ — Icon, Button, IconButton, Badge, Tag, Card, Tabs (core); Input, Select, Checkbox, Radio, Switch (forms); Dialog, Toast, Tooltip (feedback). Each keeps its injected token-referencing CSS byte-for-byte; the handoff .d.ts contracts become the exported prop interfaces. Barrel at components/ui/index.ts. Adds a PrimitivesGallery (app root for now; real shell is P3-2) that exercises every primitive with a light/dark toggle. Smoke suite asserts the gallery, section coverage, theme flip, and dialog open/Escape; screenshots captured for both themes. typecheck + 5 e2e green. Closes P3-1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,33 @@
|
|||||||
import { expect, test } from './fixtures.js'
|
import { expect, test } from './fixtures.js'
|
||||||
|
|
||||||
test('boots and renders the CommiTea shell', async ({ app }) => {
|
test('boots and renders the primitives gallery', async ({ app, window }) => {
|
||||||
await app.expectLoaded()
|
await expect(window.getByRole('heading', { name: 'Primitives' })).toBeVisible()
|
||||||
await app.screenshot('boot')
|
await expect(window.getByRole('button', { name: 'Brew plan' })).toBeVisible()
|
||||||
|
await app.screenshot('primitives-light')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('renders the label-parse check from @commitea/core', async ({ window }) => {
|
test('primitives cover core, forms, and feedback', async ({ window }) => {
|
||||||
// Proves the core package is bundled + running in the renderer, not just
|
// section headings prove each cluster ported and rendered
|
||||||
// that the window opened. Updates as the scaffold gives way to real screens.
|
for (const section of ['Button', 'Badge', 'Tag', 'Card', 'Input & Select', 'Feedback']) {
|
||||||
await expect(window.getByText(/label parse check/i)).toBeVisible()
|
await expect(window.getByRole('heading', { name: section, exact: true })).toBeVisible()
|
||||||
await expect(window.getByText(/est\/3d · p\/2 · hard true/)).toBeVisible()
|
}
|
||||||
|
// a mono label chip renders verbatim
|
||||||
|
await expect(window.getByText('deadline/hard')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('dark theme toggle flips the document theme', async ({ app, window }) => {
|
||||||
|
await window.getByRole('button', { name: 'Switch to dark' }).click()
|
||||||
|
await expect(window.locator('html')).toHaveAttribute('data-theme', 'dark')
|
||||||
|
await app.screenshot('primitives-dark')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('dialog opens on demand and closes on Escape', async ({ window }) => {
|
||||||
|
await window.getByRole('button', { name: 'Open dialog' }).click()
|
||||||
|
const dialog = window.getByRole('dialog')
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
await expect(dialog.getByText('Withdraw directive?')).toBeVisible()
|
||||||
|
await window.keyboard.press('Escape')
|
||||||
|
await expect(dialog).toBeHidden()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('exposes the commitea preload API', async ({ app }) => {
|
test('exposes the commitea preload API', async ({ app }) => {
|
||||||
|
|||||||
@@ -1,26 +1,5 @@
|
|||||||
import { extractLabelFacts } from '@commitea/core'
|
import { PrimitivesGallery } from './components/gallery.js'
|
||||||
|
|
||||||
const facts = extractLabelFacts(['est/3d', 'p/2', 'deadline/hard'])
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return <PrimitivesGallery />
|
||||||
<main className="min-h-screen bg-paper-0 font-sans text-ink-1">
|
|
||||||
<div className="mx-auto max-w-[1120px] px-7 py-16">
|
|
||||||
<header className="border-b-[3px] border-double border-ink-1 pb-4">
|
|
||||||
<h1 className="font-display text-5xl">CommiTea</h1>
|
|
||||||
<p className="mt-2 font-mono text-sm text-ink-3">
|
|
||||||
scaffold · phase 0 · gitea connection pending
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
<p className="mt-8 max-w-[60ch] font-agent text-lg leading-relaxed">
|
|
||||||
Good morning. The scaffold stands and the kettle is on, but I have nothing to manage
|
|
||||||
yet. Connect me to gitea and we shall put the pot to work.
|
|
||||||
</p>
|
|
||||||
<p className="mt-6 font-mono text-sm text-ink-2">
|
|
||||||
label parse check: est/{facts.estimateDays}d · p/{facts.priority} · hard{' '}
|
|
||||||
{String(facts.hardDeadline)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
222
apps/desktop/src/renderer/src/components/gallery.tsx
Normal file
222
apps/desktop/src/renderer/src/components/gallery.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Checkbox,
|
||||||
|
Dialog,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Radio,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
Toast,
|
||||||
|
Tooltip,
|
||||||
|
} from './ui/index.js'
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section className="mt-9">
|
||||||
|
<h2 className="mb-3 font-mono text-xs uppercase tracking-wider text-ink-3">{title}</h2>
|
||||||
|
<div className="flex flex-wrap items-start gap-3">{children}</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visual + behavioral proof for the ported primitives (P3-1). Not a product
|
||||||
|
* screen — the real shell lands in P3-2. Exercises every primitive in both
|
||||||
|
* themes via the toggle.
|
||||||
|
*/
|
||||||
|
export function PrimitivesGallery() {
|
||||||
|
const [dark, setDark] = useState(false)
|
||||||
|
const [tab, setTab] = useState('board')
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [checked, setChecked] = useState(true)
|
||||||
|
const [radio, setRadio] = useState('a')
|
||||||
|
const [on, setOn] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
|
||||||
|
}, [dark])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-paper-0 font-sans text-ink-1">
|
||||||
|
<div className="mx-auto max-w-[1120px] px-7 py-10">
|
||||||
|
<header className="flex items-baseline justify-between border-b-[3px] border-double border-ink-1 pb-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-4xl">Primitives</h1>
|
||||||
|
<p className="mt-1 font-mono text-sm text-ink-3">15 components · light + dark</p>
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
icon={dark ? 'sun' : 'moon'}
|
||||||
|
label={dark ? 'Switch to light' : 'Switch to dark'}
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDark((d) => !d)}
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Section title="Button">
|
||||||
|
<Button variant="primary" icon="sparkles">
|
||||||
|
Brew plan
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary">Amend</Button>
|
||||||
|
<Button variant="ghost" icon="clock">
|
||||||
|
Defer
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" icon="trash-2">
|
||||||
|
Withdraw
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="sm" iconRight="arrow-right">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" disabled>
|
||||||
|
Disabled
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="IconButton">
|
||||||
|
<IconButton icon="pencil" label="Edit" />
|
||||||
|
<IconButton icon="copy" label="Copy" />
|
||||||
|
<IconButton icon="ellipsis" label="More" variant="outline" />
|
||||||
|
<IconButton icon="x" label="Close" size="sm" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Badge">
|
||||||
|
<Badge tone="ok" dot>
|
||||||
|
ahead
|
||||||
|
</Badge>
|
||||||
|
<Badge tone="warn" dot>
|
||||||
|
at risk
|
||||||
|
</Badge>
|
||||||
|
<Badge tone="danger" dot>
|
||||||
|
behind
|
||||||
|
</Badge>
|
||||||
|
<Badge tone="info">steeping</Badge>
|
||||||
|
<Badge tone="neutral">triage</Badge>
|
||||||
|
<Badge tone="jade">on track</Badge>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Tag">
|
||||||
|
<Tag label="est/3d" />
|
||||||
|
<Tag label="est/8d" />
|
||||||
|
<Tag label="p/1" />
|
||||||
|
<Tag label="p/2" />
|
||||||
|
<Tag label="p/3" />
|
||||||
|
<Tag label="p/4" />
|
||||||
|
<Tag label="deadline/hard" />
|
||||||
|
<Tag label="p/1" onRemove={() => {}} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Card">
|
||||||
|
<Card
|
||||||
|
overline="Now"
|
||||||
|
title="Gitea read client"
|
||||||
|
jade
|
||||||
|
actions={<IconButton icon="external-link" label="Open" size="sm" />}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="primary">
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost">
|
||||||
|
Defer
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
style={{ width: 340 }}
|
||||||
|
>
|
||||||
|
<p className="font-agent text-base leading-relaxed">
|
||||||
|
The tap-root. Everything sinks into it — grab it first.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
<Card title="Plain card" style={{ width: 260 }}>
|
||||||
|
<p className="text-sm text-ink-2">Hairline border, whispered shadow.</p>
|
||||||
|
</Card>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Tabs">
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{ id: 'board', label: 'Board', icon: 'square-kanban', count: 34 },
|
||||||
|
{ id: 'gantt', label: 'Gantt', icon: 'chart-no-axes-gantt' },
|
||||||
|
{ id: 'deps', label: 'Dependencies', icon: 'network', count: 51 },
|
||||||
|
]}
|
||||||
|
active={tab}
|
||||||
|
onChange={setTab}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Icon">
|
||||||
|
<Icon name="coffee" size={20} />
|
||||||
|
<Icon name="leaf" size={20} />
|
||||||
|
<Icon name="git-branch" size={20} />
|
||||||
|
<Icon name="milestone" size={20} />
|
||||||
|
<Icon name="sparkles" size={20} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Input & Select">
|
||||||
|
<Input label="Repository" placeholder="christian/commitea" style={{ width: 240 }} />
|
||||||
|
<Input label="Search" icon="search" placeholder="Filter issues" style={{ width: 220 }} />
|
||||||
|
<Input label="Token" mono error="Connection failed" placeholder="tok_xxx" style={{ width: 220 }} />
|
||||||
|
<Select
|
||||||
|
label="Priority"
|
||||||
|
options={[
|
||||||
|
{ value: 'p/1', label: 'p/1 — urgent' },
|
||||||
|
{ value: 'p/2', label: 'p/2' },
|
||||||
|
{ value: 'p/3', label: 'p/3' },
|
||||||
|
]}
|
||||||
|
style={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Checkbox · Radio · Switch">
|
||||||
|
<Checkbox label="Hard deadline" checked={checked} onChange={(e) => setChecked(e.target.checked)} />
|
||||||
|
<Checkbox label="Disabled" checked disabled onChange={() => {}} />
|
||||||
|
<Radio label="Option A" name="g" value="a" checked={radio === 'a'} onChange={() => setRadio('a')} />
|
||||||
|
<Radio label="Option B" name="g" value="b" checked={radio === 'b'} onChange={() => setRadio('b')} />
|
||||||
|
<Switch label="Live sync" checked={on} onChange={(e) => setOn(e.target.checked)} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Feedback">
|
||||||
|
<Toast tone="ok" title="Plan brewed" onDismiss={() => {}}>
|
||||||
|
Milestone P2 lands Mar 3–12.
|
||||||
|
</Toast>
|
||||||
|
<Toast tone="warn" title="Stale blocker">
|
||||||
|
#9 has waited 6 days.
|
||||||
|
</Toast>
|
||||||
|
<Tooltip content="Writes to gitea, as you">
|
||||||
|
<Button variant="secondary" icon="info">
|
||||||
|
Hover me
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Button variant="primary" onClick={() => setDialogOpen(true)}>
|
||||||
|
Open dialog
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onClose={() => setDialogOpen(false)}
|
||||||
|
title="Withdraw directive?"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={() => setDialogOpen(false)}>
|
||||||
|
Withdraw
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p>This removes the directive from the ledger. The schedule reverts to its prior state.</p>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
apps/desktop/src/renderer/src/components/ui/badge.tsx
Normal file
44
apps/desktop/src/renderer/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status pill — muted tint background, colored text. For state words like "ahead", "behind", "steeping".
|
||||||
|
*/
|
||||||
|
export interface BadgeProps {
|
||||||
|
/** @default 'neutral' */
|
||||||
|
tone?: 'ok' | 'warn' | 'danger' | 'info' | 'neutral' | 'jade';
|
||||||
|
/** Leading status dot @default false */
|
||||||
|
dot?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
.ct-badge {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
font: 500 12px/1 var(--font-sans);
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: var(--radius-round);
|
||||||
|
}
|
||||||
|
.ct-badge__dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||||
|
.ct-badge--ok { background: var(--ok-tint); color: var(--ok); }
|
||||||
|
.ct-badge--warn { background: var(--warn-tint); color: var(--warn); }
|
||||||
|
.ct-badge--danger { background: var(--danger-tint); color: var(--danger); }
|
||||||
|
.ct-badge--info { background: var(--info-tint); color: var(--info); }
|
||||||
|
.ct-badge--neutral { background: var(--paper-2); color: var(--ink-2); }
|
||||||
|
.ct-badge--jade { background: var(--jade-tint); color: var(--jade-7); }
|
||||||
|
`;
|
||||||
|
(function inject() {
|
||||||
|
if (typeof document !== 'undefined' && !document.getElementById('ct-badge-css')) {
|
||||||
|
const s = document.createElement('style'); s.id = 'ct-badge-css'; s.textContent = CSS;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
export function Badge({ tone = 'neutral', dot = false, children, style, ...rest }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<span className={`ct-badge ct-badge--${tone}`} style={style} {...rest}>
|
||||||
|
{dot ? <span className="ct-badge__dot"></span> : null}
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
apps/desktop/src/renderer/src/components/ui/button.tsx
Normal file
81
apps/desktop/src/renderer/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { Icon } from './icon.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CommiTea button. Plain verbs only ("Approve", "Brew plan", "Defer") — never witty.
|
||||||
|
*/
|
||||||
|
export interface ButtonProps {
|
||||||
|
/** @default 'primary' */
|
||||||
|
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||||
|
/** @default 'md' */
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
/** Lucide icon name rendered before the label */
|
||||||
|
icon?: string;
|
||||||
|
/** Lucide icon name rendered after the label */
|
||||||
|
iconRight?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
.ct-btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
|
font: 600 14px/1 var(--font-sans);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background var(--duration-fast) var(--ease-out),
|
||||||
|
border-color var(--duration-fast) var(--ease-out),
|
||||||
|
color var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
.ct-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
.ct-btn--md { height: 34px; padding: 0 14px; }
|
||||||
|
.ct-btn--sm { height: 28px; padding: 0 10px; font-size: 13px; }
|
||||||
|
.ct-btn--primary { background: var(--accent); color: var(--text-on-accent); }
|
||||||
|
.ct-btn--primary:hover:not(:disabled) { background: var(--accent-hover); }
|
||||||
|
.ct-btn--primary:active:not(:disabled) { background: var(--accent-pressed); }
|
||||||
|
.ct-btn--secondary { background: var(--surface-card); border-color: var(--line-2); color: var(--ink-1); }
|
||||||
|
.ct-btn--secondary:hover:not(:disabled) { background: var(--paper-2); }
|
||||||
|
.ct-btn--secondary:active:not(:disabled) { background: var(--paper-3); }
|
||||||
|
.ct-btn--ghost { background: transparent; color: var(--ink-2); }
|
||||||
|
.ct-btn--ghost:hover:not(:disabled) { background: var(--paper-2); color: var(--ink-1); }
|
||||||
|
.ct-btn--ghost:active:not(:disabled) { background: var(--paper-3); }
|
||||||
|
.ct-btn--danger { background: var(--danger); color: var(--ink-inverse); }
|
||||||
|
.ct-btn--danger:hover:not(:disabled) { filter: brightness(0.92); }
|
||||||
|
`;
|
||||||
|
(function inject() {
|
||||||
|
if (typeof document !== 'undefined' && !document.getElementById('ct-button-css')) {
|
||||||
|
const s = document.createElement('style'); s.id = 'ct-button-css'; s.textContent = CSS;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
icon,
|
||||||
|
iconRight,
|
||||||
|
disabled = false,
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
...rest
|
||||||
|
}: ButtonProps) {
|
||||||
|
const iconSize = size === 'sm' ? 15 : 17;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ct-btn ct-btn--${size} ct-btn--${variant}`}
|
||||||
|
disabled={disabled}
|
||||||
|
style={style}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{icon ? <Icon name={icon} size={iconSize} /> : null}
|
||||||
|
{children}
|
||||||
|
{iconRight ? <Icon name={iconRight} size={iconSize} /> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
apps/desktop/src/renderer/src/components/ui/card.tsx
Normal file
79
apps/desktop/src/renderer/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paper card — hairline border, 10px radius, whispered shadow. `jade` adds the jade top rule
|
||||||
|
* reserved for the view's key card (e.g. the Focus card).
|
||||||
|
*/
|
||||||
|
export interface CardProps {
|
||||||
|
/** Caslon title */
|
||||||
|
title?: React.ReactNode;
|
||||||
|
/** Small uppercase label above the title */
|
||||||
|
overline?: string;
|
||||||
|
/** Right-aligned header actions (IconButtons) */
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
/** Footer row below a hairline */
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
/** Jade top rule — one per view @default false */
|
||||||
|
jade?: boolean;
|
||||||
|
/** Remove body padding (tables, charts) @default false */
|
||||||
|
flush?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
.ct-card {
|
||||||
|
background: var(--surface-card);
|
||||||
|
border: 1px solid var(--border-hairline);
|
||||||
|
border-radius: var(--radius-3);
|
||||||
|
box-shadow: var(--shadow-1);
|
||||||
|
}
|
||||||
|
.ct-card--jade { box-shadow: var(--shadow-jade-line), var(--shadow-1); }
|
||||||
|
.ct-card__header {
|
||||||
|
display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
|
||||||
|
padding: 16px 20px 0;
|
||||||
|
}
|
||||||
|
.ct-card__title {
|
||||||
|
font: var(--text-title);
|
||||||
|
color: var(--ink-1);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.ct-card__overline {
|
||||||
|
font: var(--text-overline);
|
||||||
|
letter-spacing: var(--letter-spacing-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-3);
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
.ct-card__body { padding: 14px 20px 18px; }
|
||||||
|
.ct-card__body--flush { padding: 0; }
|
||||||
|
.ct-card__footer {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-top: 1px solid var(--border-hairline);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
(function inject() {
|
||||||
|
if (typeof document !== 'undefined' && !document.getElementById('ct-card-css')) {
|
||||||
|
const s = document.createElement('style'); s.id = 'ct-card-css'; s.textContent = CSS;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
export function Card({ title, overline, actions, footer, jade = false, flush = false, children, style, ...rest }: CardProps) {
|
||||||
|
return (
|
||||||
|
<section className={`ct-card${jade ? ' ct-card--jade' : ''}`} style={style} {...rest}>
|
||||||
|
{(title || overline || actions) ? (
|
||||||
|
<header className="ct-card__header">
|
||||||
|
<div>
|
||||||
|
{overline ? <p className="ct-card__overline">{overline}</p> : null}
|
||||||
|
{title ? <h2 className="ct-card__title">{title}</h2> : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div style={{ display: 'flex', gap: '4px', flexShrink: 0 }}>{actions}</div> : null}
|
||||||
|
</header>
|
||||||
|
) : null}
|
||||||
|
<div className={`ct-card__body${flush ? ' ct-card__body--flush' : ''}`}>{children}</div>
|
||||||
|
{footer ? <footer className="ct-card__footer">{footer}</footer> : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
apps/desktop/src/renderer/src/components/ui/checkbox.tsx
Normal file
54
apps/desktop/src/renderer/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Icon } from './icon.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checkbox with label; 16px box, spruce when checked.
|
||||||
|
*/
|
||||||
|
export interface CheckboxProps {
|
||||||
|
label?: React.ReactNode;
|
||||||
|
checked?: boolean;
|
||||||
|
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
.ct-check { display: inline-flex; align-items: center; gap: 9px; cursor: pointer; font: var(--text-body); color: var(--ink-1); }
|
||||||
|
.ct-check--disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.ct-check__input { position: absolute; opacity: 0; width: 0; height: 0; }
|
||||||
|
.ct-check__box {
|
||||||
|
width: 16px; height: 16px; flex-shrink: 0;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--surface-card);
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
border-radius: var(--radius-1);
|
||||||
|
color: transparent;
|
||||||
|
transition: background var(--duration-fast) var(--ease-out), border-color var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
.ct-check:hover:not(.ct-check--disabled) .ct-check__box { border-color: var(--border-strong); }
|
||||||
|
.ct-check__input:checked + .ct-check__box { background: var(--accent); border-color: var(--accent); color: var(--ink-inverse); }
|
||||||
|
.ct-check__input:focus-visible + .ct-check__box { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
|
||||||
|
`;
|
||||||
|
(function inject() {
|
||||||
|
if (typeof document !== 'undefined' && !document.getElementById('ct-check-css')) {
|
||||||
|
const s = document.createElement('style'); s.id = 'ct-check-css'; s.textContent = CSS;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
export function Checkbox({ label, checked, onChange, disabled = false, style, ...rest }: CheckboxProps) {
|
||||||
|
return (
|
||||||
|
<label className={`ct-check${disabled ? ' ct-check--disabled' : ''}`} style={style}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="ct-check__input"
|
||||||
|
checked={checked}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
<span className="ct-check__box"><Icon name="check" size={12} strokeWidth={2.5} /></span>
|
||||||
|
{label ? <span>{label}</span> : null}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
apps/desktop/src/renderer/src/components/ui/dialog.tsx
Normal file
82
apps/desktop/src/renderer/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { IconButton } from './icon-button.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal dialog — Caslon title over a double stationery rule; used for propose-approve
|
||||||
|
* moments and destructive confirms.
|
||||||
|
*/
|
||||||
|
export interface DialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
title: React.ReactNode;
|
||||||
|
/** Right-aligned action row (Buttons) */
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
.ct-dialog-scrim {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(32, 38, 29, 0.4);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
animation: ct-dialog-fade var(--duration-base) var(--ease-out);
|
||||||
|
}
|
||||||
|
.ct-dialog {
|
||||||
|
background: var(--surface-card);
|
||||||
|
border: 1px solid var(--border-hairline);
|
||||||
|
border-radius: var(--radius-3);
|
||||||
|
box-shadow: var(--shadow-3);
|
||||||
|
width: min(480px, calc(100vw - 48px));
|
||||||
|
max-height: calc(100vh - 96px);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
animation: ct-dialog-rise var(--duration-base) var(--ease-out);
|
||||||
|
}
|
||||||
|
.ct-dialog__header {
|
||||||
|
display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;
|
||||||
|
padding: 20px 20px 12px;
|
||||||
|
border-bottom: 3px double var(--border-strong);
|
||||||
|
margin: 0 20px; padding-left: 0; padding-right: 0;
|
||||||
|
}
|
||||||
|
.ct-dialog__title { font: var(--text-title); color: var(--ink-1); margin: 0; }
|
||||||
|
.ct-dialog__body { padding: 16px 20px; overflow-y: auto; font: var(--text-body); color: var(--ink-1); }
|
||||||
|
.ct-dialog__footer {
|
||||||
|
display: flex; justify-content: flex-end; gap: 8px;
|
||||||
|
padding: 12px 20px 20px;
|
||||||
|
}
|
||||||
|
@keyframes ct-dialog-fade { from { opacity: 0; } }
|
||||||
|
@keyframes ct-dialog-rise { from { opacity: 0; transform: translateY(8px); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ct-dialog-scrim, .ct-dialog { animation: none; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
(function inject() {
|
||||||
|
if (typeof document !== 'undefined' && !document.getElementById('ct-dialog-css')) {
|
||||||
|
const s = document.createElement('style'); s.id = 'ct-dialog-css'; s.textContent = CSS;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
export function Dialog({ open, onClose, title, footer, children, style }: DialogProps) {
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && onClose) onClose(); };
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<div className="ct-dialog-scrim" onClick={(e: React.MouseEvent) => { if (e.target === e.currentTarget && onClose) onClose(); }}>
|
||||||
|
<div className="ct-dialog" role="dialog" aria-modal="true" style={style}>
|
||||||
|
<header className="ct-dialog__header">
|
||||||
|
<h2 className="ct-dialog__title">{title}</h2>
|
||||||
|
{onClose ? <IconButton icon="x" label="Close" size="sm" onClick={onClose} /> : null}
|
||||||
|
</header>
|
||||||
|
<div className="ct-dialog__body">{children}</div>
|
||||||
|
{footer ? <footer className="ct-dialog__footer">{footer}</footer> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
apps/desktop/src/renderer/src/components/ui/icon-button.tsx
Normal file
68
apps/desktop/src/renderer/src/components/ui/icon-button.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Icon } from './icon.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Square icon-only button; label is mandatory (becomes aria-label + title).
|
||||||
|
*/
|
||||||
|
export interface IconButtonProps {
|
||||||
|
/** Lucide icon name */
|
||||||
|
icon: string;
|
||||||
|
/** Required accessible label (also the tooltip) */
|
||||||
|
label: string;
|
||||||
|
/** @default 'ghost' */
|
||||||
|
variant?: 'ghost' | 'outline';
|
||||||
|
/** @default 'md' */
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
.ct-iconbtn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--duration-fast) var(--ease-out), color var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
.ct-iconbtn:hover:not(:disabled) { background: var(--paper-2); color: var(--ink-1); }
|
||||||
|
.ct-iconbtn:active:not(:disabled) { background: var(--paper-3); }
|
||||||
|
.ct-iconbtn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
.ct-iconbtn--md { width: 34px; height: 34px; }
|
||||||
|
.ct-iconbtn--sm { width: 28px; height: 28px; }
|
||||||
|
.ct-iconbtn--outline { border-color: var(--line-2); background: var(--surface-card); }
|
||||||
|
.ct-iconbtn--outline:hover:not(:disabled) { background: var(--paper-2); }
|
||||||
|
`;
|
||||||
|
(function inject() {
|
||||||
|
if (typeof document !== 'undefined' && !document.getElementById('ct-iconbtn-css')) {
|
||||||
|
const s = document.createElement('style'); s.id = 'ct-iconbtn-css'; s.textContent = CSS;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
export function IconButton({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
variant = 'ghost',
|
||||||
|
size = 'md',
|
||||||
|
disabled = false,
|
||||||
|
style,
|
||||||
|
...rest
|
||||||
|
}: IconButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ct-iconbtn ct-iconbtn--${size}${variant === 'outline' ? ' ct-iconbtn--outline' : ''}`}
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
disabled={disabled}
|
||||||
|
style={style}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<Icon name={icon} size={size === 'sm' ? 15 : 17} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
apps/desktop/src/renderer/src/components/ui/icon.tsx
Normal file
47
apps/desktop/src/renderer/src/components/ui/icon.tsx
Normal file
File diff suppressed because one or more lines are too long
21
apps/desktop/src/renderer/src/components/ui/index.ts
Normal file
21
apps/desktop/src/renderer/src/components/ui/index.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* CommiTea design-system primitives, ported verbatim from the design handoff
|
||||||
|
* (docs/design/components/). Each keeps its injected token-referencing CSS; the
|
||||||
|
* contracts in the handoff's `.d.ts` files are reproduced here as the exported
|
||||||
|
* prop interfaces. Import from `#~/components/ui` — never reach into a file.
|
||||||
|
*/
|
||||||
|
export * from './icon.js'
|
||||||
|
export * from './button.js'
|
||||||
|
export * from './icon-button.js'
|
||||||
|
export * from './badge.js'
|
||||||
|
export * from './tag.js'
|
||||||
|
export * from './card.js'
|
||||||
|
export * from './tabs.js'
|
||||||
|
export * from './input.js'
|
||||||
|
export * from './checkbox.js'
|
||||||
|
export * from './radio.js'
|
||||||
|
export * from './switch.js'
|
||||||
|
export * from './select.js'
|
||||||
|
export * from './dialog.js'
|
||||||
|
export * from './toast.js'
|
||||||
|
export * from './tooltip.js'
|
||||||
75
apps/desktop/src/renderer/src/components/ui/input.tsx
Normal file
75
apps/desktop/src/renderer/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Icon } from './icon.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-line text input with optional label, hint/error and leading icon.
|
||||||
|
*/
|
||||||
|
export interface InputProps {
|
||||||
|
label?: string;
|
||||||
|
/** Small gray helper under the field */
|
||||||
|
hint?: string;
|
||||||
|
/** Replaces hint; turns the border madder red */
|
||||||
|
error?: string;
|
||||||
|
/** Lucide icon name, leading */
|
||||||
|
icon?: string;
|
||||||
|
/** Mono text for machine values (urls, tokens) @default false */
|
||||||
|
mono?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
value?: string;
|
||||||
|
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
type?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
.ct-field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.ct-field__label { font: 600 13px/1.2 var(--font-sans); color: var(--ink-1); }
|
||||||
|
.ct-field__wrap { position: relative; display: flex; align-items: center; }
|
||||||
|
.ct-field__icon { position: absolute; left: 10px; color: var(--ink-3); pointer-events: none; display: flex; }
|
||||||
|
.ct-input {
|
||||||
|
width: 100%; height: 34px;
|
||||||
|
font: var(--text-body);
|
||||||
|
color: var(--ink-1);
|
||||||
|
background: var(--surface-card);
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
padding: 0 12px;
|
||||||
|
transition: border-color var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
.ct-input::placeholder { color: var(--ink-3); }
|
||||||
|
.ct-input:hover:not(:disabled):not(:focus) { border-color: var(--border-strong); background: var(--paper-1); }
|
||||||
|
.ct-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||||
|
.ct-input:disabled { opacity: 0.5; background: var(--paper-2); cursor: not-allowed; }
|
||||||
|
.ct-input--icon { padding-left: 32px; }
|
||||||
|
.ct-input--error { border-color: var(--danger); }
|
||||||
|
.ct-input--error:focus { border-color: var(--danger); box-shadow: 0 0 0 1px var(--danger); }
|
||||||
|
.ct-input--mono { font: var(--text-data); }
|
||||||
|
.ct-field__hint { font: var(--text-caption); color: var(--ink-3); margin: 0; }
|
||||||
|
.ct-field__error { font: var(--text-caption); color: var(--danger); margin: 0; }
|
||||||
|
`;
|
||||||
|
(function inject() {
|
||||||
|
if (typeof document !== 'undefined' && !document.getElementById('ct-input-css')) {
|
||||||
|
const s = document.createElement('style'); s.id = 'ct-input-css'; s.textContent = CSS;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
export function Input({ label, hint, error, icon, mono = false, style, ...rest }: InputProps) {
|
||||||
|
const cls = [
|
||||||
|
'ct-input',
|
||||||
|
icon ? 'ct-input--icon' : '',
|
||||||
|
error ? 'ct-input--error' : '',
|
||||||
|
mono ? 'ct-input--mono' : '',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
return (
|
||||||
|
<div className="ct-field" style={style}>
|
||||||
|
{label ? <label className="ct-field__label">{label}</label> : null}
|
||||||
|
<div className="ct-field__wrap">
|
||||||
|
{icon ? <span className="ct-field__icon"><Icon name={icon} size={15} /></span> : null}
|
||||||
|
<input className={cls} {...rest} />
|
||||||
|
</div>
|
||||||
|
{error ? <p className="ct-field__error">{error}</p> : hint ? <p className="ct-field__hint">{hint}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
apps/desktop/src/renderer/src/components/ui/radio.tsx
Normal file
63
apps/desktop/src/renderer/src/components/ui/radio.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Radio button with label; group by `name`.
|
||||||
|
*/
|
||||||
|
export interface RadioProps {
|
||||||
|
label?: React.ReactNode;
|
||||||
|
checked?: boolean;
|
||||||
|
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
name?: string;
|
||||||
|
value?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
.ct-radio { display: inline-flex; align-items: center; gap: 9px; cursor: pointer; font: var(--text-body); color: var(--ink-1); }
|
||||||
|
.ct-radio--disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.ct-radio__input { position: absolute; opacity: 0; width: 0; height: 0; }
|
||||||
|
.ct-radio__dot {
|
||||||
|
width: 16px; height: 16px; flex-shrink: 0;
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--surface-card);
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
transition: border-color var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
.ct-radio__dot::after {
|
||||||
|
content: '';
|
||||||
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
transition: background var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
.ct-radio:hover:not(.ct-radio--disabled) .ct-radio__dot { border-color: var(--border-strong); }
|
||||||
|
.ct-radio__input:checked + .ct-radio__dot { border-color: var(--accent); }
|
||||||
|
.ct-radio__input:checked + .ct-radio__dot::after { background: var(--accent); }
|
||||||
|
.ct-radio__input:focus-visible + .ct-radio__dot { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
|
||||||
|
`;
|
||||||
|
(function inject() {
|
||||||
|
if (typeof document !== 'undefined' && !document.getElementById('ct-radio-css')) {
|
||||||
|
const s = document.createElement('style'); s.id = 'ct-radio-css'; s.textContent = CSS;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
export function Radio({ label, checked, onChange, name, value, disabled = false, style, ...rest }: RadioProps) {
|
||||||
|
return (
|
||||||
|
<label className={`ct-radio${disabled ? ' ct-radio--disabled' : ''}`} style={style}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
className="ct-radio__input"
|
||||||
|
checked={checked}
|
||||||
|
onChange={onChange}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
<span className="ct-radio__dot"></span>
|
||||||
|
{label ? <span>{label}</span> : null}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
apps/desktop/src/renderer/src/components/ui/select.tsx
Normal file
63
apps/desktop/src/renderer/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { Icon } from './icon.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Styled native select with Lucide chevron.
|
||||||
|
*/
|
||||||
|
export interface SelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
export interface SelectProps {
|
||||||
|
label?: string;
|
||||||
|
options: SelectOption[];
|
||||||
|
value?: string;
|
||||||
|
onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
.ct-select-field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.ct-select-field__label { font: 600 13px/1.2 var(--font-sans); color: var(--ink-1); }
|
||||||
|
.ct-select__wrap { position: relative; display: flex; align-items: center; }
|
||||||
|
.ct-select {
|
||||||
|
width: 100%; height: 34px;
|
||||||
|
font: var(--text-body);
|
||||||
|
color: var(--ink-1);
|
||||||
|
background: var(--surface-card);
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
padding: 0 30px 0 12px;
|
||||||
|
appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
.ct-select:hover:not(:disabled):not(:focus) { border-color: var(--border-strong); }
|
||||||
|
.ct-select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||||
|
.ct-select:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.ct-select__chevron { position: absolute; right: 10px; color: var(--ink-3); pointer-events: none; display: flex; }
|
||||||
|
`;
|
||||||
|
(function inject() {
|
||||||
|
if (typeof document !== 'undefined' && !document.getElementById('ct-select-css')) {
|
||||||
|
const s = document.createElement('style'); s.id = 'ct-select-css'; s.textContent = CSS;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
export function Select({ label, options, style, ...rest }: SelectProps) {
|
||||||
|
return (
|
||||||
|
<div className="ct-select-field" style={style}>
|
||||||
|
{label ? <label className="ct-select-field__label">{label}</label> : null}
|
||||||
|
<div className="ct-select__wrap">
|
||||||
|
<select className="ct-select" {...rest}>
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="ct-select__chevron"><Icon name="chevron-down" size={15} /></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
apps/desktop/src/renderer/src/components/ui/switch.tsx
Normal file
61
apps/desktop/src/renderer/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On/off switch; spruce when on. For live state, not form options.
|
||||||
|
*/
|
||||||
|
export interface SwitchProps {
|
||||||
|
label?: React.ReactNode;
|
||||||
|
checked?: boolean;
|
||||||
|
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
.ct-switch { display: inline-flex; align-items: center; gap: 9px; cursor: pointer; font: var(--text-body); color: var(--ink-1); }
|
||||||
|
.ct-switch--disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.ct-switch__input { position: absolute; opacity: 0; width: 0; height: 0; }
|
||||||
|
.ct-switch__track {
|
||||||
|
width: 34px; height: 20px; flex-shrink: 0;
|
||||||
|
border-radius: var(--radius-round);
|
||||||
|
background: var(--paper-3);
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
position: relative;
|
||||||
|
transition: background var(--duration-base) var(--ease-out), border-color var(--duration-base) var(--ease-out);
|
||||||
|
}
|
||||||
|
.ct-switch__track::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute; top: 2px; left: 2px;
|
||||||
|
width: 14px; height: 14px; border-radius: 50%;
|
||||||
|
background: var(--surface-card);
|
||||||
|
box-shadow: var(--shadow-1);
|
||||||
|
transition: transform var(--duration-base) var(--ease-out);
|
||||||
|
}
|
||||||
|
.ct-switch__input:checked + .ct-switch__track { background: var(--accent); border-color: var(--accent); }
|
||||||
|
.ct-switch__input:checked + .ct-switch__track::after { transform: translateX(14px); }
|
||||||
|
.ct-switch__input:focus-visible + .ct-switch__track { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
|
||||||
|
`;
|
||||||
|
(function inject() {
|
||||||
|
if (typeof document !== 'undefined' && !document.getElementById('ct-switch-css')) {
|
||||||
|
const s = document.createElement('style'); s.id = 'ct-switch-css'; s.textContent = CSS;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
export function Switch({ label, checked, onChange, disabled = false, style, ...rest }: SwitchProps) {
|
||||||
|
return (
|
||||||
|
<label className={`ct-switch${disabled ? ' ct-switch--disabled' : ''}`} style={style}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
role="switch"
|
||||||
|
className="ct-switch__input"
|
||||||
|
checked={checked}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
<span className="ct-switch__track"></span>
|
||||||
|
{label ? <span>{label}</span> : null}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
apps/desktop/src/renderer/src/components/ui/tabs.tsx
Normal file
73
apps/desktop/src/renderer/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Icon } from './icon.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Underline tabs for view switching (Board / Gantt / Dependencies).
|
||||||
|
*/
|
||||||
|
export interface TabItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
/** Lucide icon name */
|
||||||
|
icon?: string;
|
||||||
|
/** Mono count rendered after the label */
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
export interface TabsProps {
|
||||||
|
items: TabItem[];
|
||||||
|
/** id of the active tab */
|
||||||
|
active: string;
|
||||||
|
onChange?: (id: string) => void;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
.ct-tabs {
|
||||||
|
display: flex; gap: 2px;
|
||||||
|
border-bottom: 1px solid var(--border-hairline);
|
||||||
|
}
|
||||||
|
.ct-tab {
|
||||||
|
display: inline-flex; align-items: center; gap: 7px;
|
||||||
|
font: 500 13.5px/1 var(--font-sans);
|
||||||
|
color: var(--ink-2);
|
||||||
|
background: none; border: none;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
.ct-tab:hover { color: var(--ink-1); }
|
||||||
|
.ct-tab--active {
|
||||||
|
color: var(--ink-1);
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
.ct-tab__count { font: 400 11.5px/1 var(--font-mono); color: var(--ink-3); }
|
||||||
|
`;
|
||||||
|
(function inject() {
|
||||||
|
if (typeof document !== 'undefined' && !document.getElementById('ct-tabs-css')) {
|
||||||
|
const s = document.createElement('style'); s.id = 'ct-tabs-css'; s.textContent = CSS;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
export function Tabs({ items, active, onChange, style, ...rest }: TabsProps) {
|
||||||
|
return (
|
||||||
|
<div className="ct-tabs" role="tablist" style={style} {...rest}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={item.id === active}
|
||||||
|
className={`ct-tab${item.id === active ? ' ct-tab--active' : ''}`}
|
||||||
|
onClick={() => onChange && onChange(item.id)}
|
||||||
|
>
|
||||||
|
{item.icon ? <Icon name={item.icon} size={15} /> : null}
|
||||||
|
{item.label}
|
||||||
|
{item.count != null ? <span className="ct-tab__count">{item.count}</span> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
apps/desktop/src/renderer/src/components/ui/tag.tsx
Normal file
66
apps/desktop/src/renderer/src/components/ui/tag.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Icon } from './icon.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gitea label chip — renders label text verbatim in mono; tone derives from the label itself
|
||||||
|
* (est/[1d 2d 3d 5d 8d], p/1..4, deadline/hard).
|
||||||
|
*/
|
||||||
|
export interface TagProps {
|
||||||
|
/** Verbatim gitea label, e.g. "est/3d", "p/1", "deadline/hard" */
|
||||||
|
label: string;
|
||||||
|
/** Renders a small remove button when provided */
|
||||||
|
onRemove?: (e: React.MouseEvent) => void;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
.ct-tag {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
font: 500 11.5px/1 var(--font-mono);
|
||||||
|
letter-spacing: var(--letter-spacing-label);
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: var(--radius-round);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ct-tag--est { background: var(--label-est-bg); color: var(--label-est-text); }
|
||||||
|
.ct-tag--p1 { background: var(--danger-tint); color: var(--label-p1); }
|
||||||
|
.ct-tag--p2 { background: var(--warn-tint); color: var(--label-p2); }
|
||||||
|
.ct-tag--p3 { background: var(--info-tint); color: var(--label-p3); }
|
||||||
|
.ct-tag--p4 { background: var(--paper-2); color: var(--label-p4); }
|
||||||
|
.ct-tag--hard { background: var(--label-hard); color: var(--ink-inverse); }
|
||||||
|
.ct-tag--plain { background: var(--paper-2); color: var(--ink-2); }
|
||||||
|
.ct-tag__x {
|
||||||
|
display: inline-flex; padding: 0; margin: 0 -3px 0 0;
|
||||||
|
background: none; border: none; color: inherit; cursor: pointer; opacity: 0.6;
|
||||||
|
}
|
||||||
|
.ct-tag__x:hover { opacity: 1; }
|
||||||
|
`;
|
||||||
|
(function inject() {
|
||||||
|
if (typeof document !== 'undefined' && !document.getElementById('ct-tag-css')) {
|
||||||
|
const s = document.createElement('style'); s.id = 'ct-tag-css'; s.textContent = CSS;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
function toneFor(label: string) {
|
||||||
|
if (label.startsWith('est/')) return 'est';
|
||||||
|
if (label === 'p/1') return 'p1';
|
||||||
|
if (label === 'p/2') return 'p2';
|
||||||
|
if (label === 'p/3') return 'p3';
|
||||||
|
if (label === 'p/4') return 'p4';
|
||||||
|
if (label === 'deadline/hard') return 'hard';
|
||||||
|
return 'plain';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tag({ label, onRemove, style, ...rest }: TagProps) {
|
||||||
|
return (
|
||||||
|
<span className={`ct-tag ct-tag--${toneFor(label)}`} style={style} {...rest}>
|
||||||
|
{label}
|
||||||
|
{onRemove ? (
|
||||||
|
<button type="button" className="ct-tag__x" aria-label={`Remove ${label}`} onClick={onRemove}>
|
||||||
|
<Icon name="x" size={11} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
apps/desktop/src/renderer/src/components/ui/toast.tsx
Normal file
65
apps/desktop/src/renderer/src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Icon } from './icon.js';
|
||||||
|
import { IconButton } from './icon-button.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transient notification card. Presentational — the consumer owns positioning/stacking.
|
||||||
|
*/
|
||||||
|
export interface ToastProps {
|
||||||
|
/** @default 'info' */
|
||||||
|
tone?: 'ok' | 'warn' | 'danger' | 'info';
|
||||||
|
title?: React.ReactNode;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
.ct-toast {
|
||||||
|
display: flex; align-items: flex-start; gap: 10px;
|
||||||
|
background: var(--surface-card);
|
||||||
|
border: 1px solid var(--border-hairline);
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
box-shadow: var(--shadow-2);
|
||||||
|
padding: 12px 14px;
|
||||||
|
max-width: 420px;
|
||||||
|
font: var(--text-body);
|
||||||
|
color: var(--ink-1);
|
||||||
|
animation: ct-toast-in var(--duration-slow) var(--ease-out);
|
||||||
|
}
|
||||||
|
.ct-toast__icon { display: flex; margin-top: 1px; }
|
||||||
|
.ct-toast--ok .ct-toast__icon { color: var(--ok); }
|
||||||
|
.ct-toast--warn .ct-toast__icon { color: var(--warn); }
|
||||||
|
.ct-toast--danger .ct-toast__icon { color: var(--danger); }
|
||||||
|
.ct-toast--info .ct-toast__icon { color: var(--info); }
|
||||||
|
.ct-toast__content { flex: 1; min-width: 0; }
|
||||||
|
.ct-toast__title { font: var(--text-body-strong); margin: 0 0 2px; }
|
||||||
|
@keyframes ct-toast-in { from { opacity: 0; transform: translateY(6px); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .ct-toast { animation: none; } }
|
||||||
|
`;
|
||||||
|
(function inject() {
|
||||||
|
if (typeof document !== 'undefined' && !document.getElementById('ct-toast-css')) {
|
||||||
|
const s = document.createElement('style'); s.id = 'ct-toast-css'; s.textContent = CSS;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const TOAST_ICONS: Record<string, string> = {
|
||||||
|
ok: 'circle-check',
|
||||||
|
warn: 'triangle-alert',
|
||||||
|
danger: 'circle-alert',
|
||||||
|
info: 'info',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Toast({ tone = 'info', title, onDismiss, children, style, ...rest }: ToastProps) {
|
||||||
|
return (
|
||||||
|
<div className={`ct-toast ct-toast--${tone}`} role="status" style={style} {...rest}>
|
||||||
|
<span className="ct-toast__icon"><Icon name={TOAST_ICONS[tone]} size={16} /></span>
|
||||||
|
<div className="ct-toast__content">
|
||||||
|
{title ? <p className="ct-toast__title">{title}</p> : null}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{onDismiss ? <IconButton icon="x" label="Dismiss" size="sm" onClick={onDismiss} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
apps/desktop/src/renderer/src/components/ui/tooltip.tsx
Normal file
55
apps/desktop/src/renderer/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hover/focus tooltip — ink capsule, plain facts only.
|
||||||
|
*/
|
||||||
|
export interface TooltipProps {
|
||||||
|
content: React.ReactNode;
|
||||||
|
/** @default 'top' */
|
||||||
|
side?: 'top' | 'bottom';
|
||||||
|
children?: React.ReactNode;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
.ct-tooltip-wrap { position: relative; display: inline-flex; }
|
||||||
|
.ct-tooltip {
|
||||||
|
position: absolute; bottom: calc(100% + 7px); left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(2px);
|
||||||
|
background: var(--ink-1);
|
||||||
|
color: var(--ink-inverse);
|
||||||
|
font: 500 12px/1.4 var(--font-sans);
|
||||||
|
padding: 5px 9px;
|
||||||
|
border-radius: var(--radius-1);
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--duration-fast) var(--ease-out), transform var(--duration-fast) var(--ease-out);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
.ct-tooltip--bottom { bottom: auto; top: calc(100% + 7px); transform: translateX(-50%) translateY(-2px); }
|
||||||
|
.ct-tooltip-wrap:hover .ct-tooltip,
|
||||||
|
.ct-tooltip-wrap:focus-within .ct-tooltip {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
.ct-tooltip code { font: 500 11px var(--font-mono); }
|
||||||
|
`;
|
||||||
|
|
||||||
|
(function inject() {
|
||||||
|
if (typeof document !== 'undefined' && !document.getElementById('ct-tooltip-css')) {
|
||||||
|
const s = document.createElement('style'); s.id = 'ct-tooltip-css'; s.textContent = CSS;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
export function Tooltip({ content, side = 'top', children, style }: TooltipProps) {
|
||||||
|
return (
|
||||||
|
<span className="ct-tooltip-wrap" style={style}>
|
||||||
|
{children}
|
||||||
|
<span className={`ct-tooltip${side === 'bottom' ? ' ct-tooltip--bottom' : ''}`} role="tooltip">
|
||||||
|
{content}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user