From 635025113cf081b181a2ca521a50489e5035be11 Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Wed, 8 Jul 2026 12:12:00 -0400 Subject: [PATCH] feat(desktop): port the 15 design-system primitives (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/desktop/e2e/smoke.spec.ts | 34 ++- apps/desktop/src/renderer/src/app.tsx | 25 +- .../src/renderer/src/components/gallery.tsx | 222 ++++++++++++++++++ .../src/renderer/src/components/ui/badge.tsx | 44 ++++ .../src/renderer/src/components/ui/button.tsx | 81 +++++++ .../src/renderer/src/components/ui/card.tsx | 79 +++++++ .../renderer/src/components/ui/checkbox.tsx | 54 +++++ .../src/renderer/src/components/ui/dialog.tsx | 82 +++++++ .../src/components/ui/icon-button.tsx | 68 ++++++ .../src/renderer/src/components/ui/icon.tsx | 47 ++++ .../src/renderer/src/components/ui/index.ts | 21 ++ .../src/renderer/src/components/ui/input.tsx | 75 ++++++ .../src/renderer/src/components/ui/radio.tsx | 63 +++++ .../src/renderer/src/components/ui/select.tsx | 63 +++++ .../src/renderer/src/components/ui/switch.tsx | 61 +++++ .../src/renderer/src/components/ui/tabs.tsx | 73 ++++++ .../src/renderer/src/components/ui/tag.tsx | 66 ++++++ .../src/renderer/src/components/ui/toast.tsx | 65 +++++ .../renderer/src/components/ui/tooltip.tsx | 55 +++++ 19 files changed, 1247 insertions(+), 31 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/gallery.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/badge.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/button.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/card.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/checkbox.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/dialog.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/icon-button.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/icon.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/index.ts create mode 100644 apps/desktop/src/renderer/src/components/ui/input.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/radio.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/select.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/switch.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/tabs.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/tag.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/toast.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/tooltip.tsx diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts index 5c22a72..5854c87 100644 --- a/apps/desktop/e2e/smoke.spec.ts +++ b/apps/desktop/e2e/smoke.spec.ts @@ -1,15 +1,33 @@ import { expect, test } from './fixtures.js' -test('boots and renders the CommiTea shell', async ({ app }) => { - await app.expectLoaded() - await app.screenshot('boot') +test('boots and renders the primitives gallery', async ({ app, window }) => { + await expect(window.getByRole('heading', { name: 'Primitives' })).toBeVisible() + 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 }) => { - // Proves the core package is bundled + running in the renderer, not just - // that the window opened. Updates as the scaffold gives way to real screens. - await expect(window.getByText(/label parse check/i)).toBeVisible() - await expect(window.getByText(/est\/3d · p\/2 · hard true/)).toBeVisible() +test('primitives cover core, forms, and feedback', async ({ window }) => { + // section headings prove each cluster ported and rendered + for (const section of ['Button', 'Badge', 'Tag', 'Card', 'Input & Select', 'Feedback']) { + await expect(window.getByRole('heading', { name: section, exact: 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 }) => { diff --git a/apps/desktop/src/renderer/src/app.tsx b/apps/desktop/src/renderer/src/app.tsx index ef35ec2..0434ef1 100644 --- a/apps/desktop/src/renderer/src/app.tsx +++ b/apps/desktop/src/renderer/src/app.tsx @@ -1,26 +1,5 @@ -import { extractLabelFacts } from '@commitea/core' - -const facts = extractLabelFacts(['est/3d', 'p/2', 'deadline/hard']) +import { PrimitivesGallery } from './components/gallery.js' export function App() { - return ( -
-
-
-

CommiTea

-

- scaffold · phase 0 · gitea connection pending -

-
-

- 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. -

-

- label parse check: est/{facts.estimateDays}d · p/{facts.priority} · hard{' '} - {String(facts.hardDeadline)} -

-
-
- ) + return } diff --git a/apps/desktop/src/renderer/src/components/gallery.tsx b/apps/desktop/src/renderer/src/components/gallery.tsx new file mode 100644 index 0000000..bd5c814 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/gallery.tsx @@ -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 ( +
+

{title}

+
{children}
+
+ ) +} + +/** + * 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 ( +
+
+
+
+

Primitives

+

15 components · light + dark

+
+ setDark((d) => !d)} + /> +
+ +
+ + + + + + +
+ +
+ + + + +
+ +
+ + ahead + + + at risk + + + behind + + steeping + triage + on track +
+ +
+ + + + + + + + {}} /> +
+ +
+ } + footer={ + <> + + + + } + style={{ width: 340 }} + > +

+ The tap-root. Everything sinks into it — grab it first. +

+
+ +

Hairline border, whispered shadow.

+
+
+ +
+ +
+ +
+ + + + + +
+ +
+ + + + + + {label ? {label} : null} + + ); +} diff --git a/apps/desktop/src/renderer/src/components/ui/dialog.tsx b/apps/desktop/src/renderer/src/components/ui/dialog.tsx new file mode 100644 index 0000000..301e5d9 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/dialog.tsx @@ -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 ( +
{ if (e.target === e.currentTarget && onClose) onClose(); }}> +
+
+

{title}

+ {onClose ? : null} +
+
{children}
+ {footer ?
{footer}
: null} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/ui/icon-button.tsx b/apps/desktop/src/renderer/src/components/ui/icon-button.tsx new file mode 100644 index 0000000..06b8a4c --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/icon-button.tsx @@ -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 ( + + ); +} diff --git a/apps/desktop/src/renderer/src/components/ui/icon.tsx b/apps/desktop/src/renderer/src/components/ui/icon.tsx new file mode 100644 index 0000000..3d53115 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/icon.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +/** + * Inline Lucide icon (1.5px stroke), tinted by currentColor. + */ +export interface IconProps { + /** Lucide icon name, e.g. "circle-dot", "sparkles", "git-branch" */ + name: string; + /** Square size in px. 16 inline, 18–20 in buttons/nav. @default 16 */ + size?: number; + /** @default 1.5 */ + strokeWidth?: number; + /** Accessible label; omit for decorative icons */ + title?: string; + style?: React.CSSProperties; +} + +/* Generated from assets/icons/*.svg (lucide-static v0.462.0, ISC). Do not edit paths by hand. */ +const ICONS: Record = {"activity":"","arrow-left":"\n ","arrow-right":"\n ","arrow-up-right":"\n ","bell":"\n ","calendar":"\n \n \n ","chart-line":"\n ","chart-no-axes-gantt":"\n \n ","check":"","chevron-down":"","chevron-left":"","chevron-right":"","chevron-up":"","circle-alert":"\n \n ","circle-check":"\n ","circle-dashed":"\n \n \n \n \n \n \n ","circle-dot":"\n ","circle":"","clock-3":"\n ","clock":"\n ","coffee":"\n \n \n ","copy":"\n ","ellipsis":"\n \n ","external-link":"\n \n ","eye":"\n ","filter":"","flag":"\n ","gauge":"\n ","git-branch":"\n \n \n ","git-commit-horizontal":"\n \n ","git-merge":"\n \n ","git-pull-request":"\n \n \n ","history":"\n \n ","inbox":"\n ","info":"\n \n ","keyboard":"\n \n \n \n \n \n \n \n ","layers":"\n \n ","leaf":"\n ","link":"\n ","list-filter":"\n \n ","list":"\n \n \n \n \n ","loader-circle":"","message-square":"","milestone":"\n \n ","minus":"","moon":"","network":"\n \n \n \n ","panel-left":"\n ","pause":"\n ","pencil":"\n ","play":"","plus":"\n ","refresh-cw":"\n \n \n ","search":"\n ","send":"\n ","settings-2":"\n \n \n ","settings":"\n ","sparkles":"\n \n \n \n ","square-kanban":"\n \n \n ","sun":"\n \n \n \n \n \n \n \n ","tag":"\n ","target":"\n \n ","trash-2":"\n \n \n \n ","trending-up":"\n ","triangle-alert":"\n \n ","user":"\n ","users":"\n \n \n ","x":"\n ","zap":""}; + +export function Icon({ name, size = 16, strokeWidth = 1.5, title, style, ...rest }: IconProps) { + const inner = ICONS[name]; + if (!inner) { + console.warn('[CommiTea Icon] unknown icon: ' + name); + return null; + } + return ( + + ); +} + +export const ICON_NAMES = Object.keys(ICONS); diff --git a/apps/desktop/src/renderer/src/components/ui/index.ts b/apps/desktop/src/renderer/src/components/ui/index.ts new file mode 100644 index 0000000..9d0eea1 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/index.ts @@ -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' diff --git a/apps/desktop/src/renderer/src/components/ui/input.tsx b/apps/desktop/src/renderer/src/components/ui/input.tsx new file mode 100644 index 0000000..5f72dc1 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/input.tsx @@ -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) => 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 ( +
+ {label ? : null} +
+ {icon ? : null} + +
+ {error ?

{error}

: hint ?

{hint}

: null} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/ui/radio.tsx b/apps/desktop/src/renderer/src/components/ui/radio.tsx new file mode 100644 index 0000000..7ec3d3f --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/radio.tsx @@ -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) => 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 ( + + ); +} diff --git a/apps/desktop/src/renderer/src/components/ui/select.tsx b/apps/desktop/src/renderer/src/components/ui/select.tsx new file mode 100644 index 0000000..3f66f74 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/select.tsx @@ -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) => 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 ( +
+ {label ? : null} +
+ + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/ui/switch.tsx b/apps/desktop/src/renderer/src/components/ui/switch.tsx new file mode 100644 index 0000000..7282025 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/switch.tsx @@ -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) => 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 ( + + ); +} diff --git a/apps/desktop/src/renderer/src/components/ui/tabs.tsx b/apps/desktop/src/renderer/src/components/ui/tabs.tsx new file mode 100644 index 0000000..f14757e --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/tabs.tsx @@ -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 ( +
+ {items.map((item) => ( + + ))} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/ui/tag.tsx b/apps/desktop/src/renderer/src/components/ui/tag.tsx new file mode 100644 index 0000000..e0f6bbc --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/tag.tsx @@ -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 ( + + {label} + {onRemove ? ( + + ) : null} + + ); +} diff --git a/apps/desktop/src/renderer/src/components/ui/toast.tsx b/apps/desktop/src/renderer/src/components/ui/toast.tsx new file mode 100644 index 0000000..be72874 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/toast.tsx @@ -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 = { + ok: 'circle-check', + warn: 'triangle-alert', + danger: 'circle-alert', + info: 'info', +}; + +export function Toast({ tone = 'info', title, onDismiss, children, style, ...rest }: ToastProps) { + return ( +
+ +
+ {title ?

{title}

: null} + {children} +
+ {onDismiss ? : null} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/ui/tooltip.tsx b/apps/desktop/src/renderer/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..2d9d3da --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/tooltip.tsx @@ -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 ( + + {children} + + {content} + + + ); +}