From 2701020dfc914c60d99ed4973af0afb6b65639d9 Mon Sep 17 00:00:00 2001 From: Oleg Miagkov Date: Wed, 24 Jun 2026 21:21:29 +0400 Subject: [PATCH 1/4] =?UTF-8?q?feat(ui):=20scaffold=20shared=20libs/ui=20d?= =?UTF-8?q?esign=20system=20=E2=80=94=20tokens,=20theme,=20AppShell=20(U0.?= =?UTF-8?q?1-U0.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of Track D (see docs/strategy/roadmap.md). Adds the @auto-code/ui workspace package (libs/* glob), to be consumed by both the Electron desktop and the web app: - tokens/tokens.css: design tokens ported 1:1 from .lazyweb/_desktop-tokens.css (light + [data-theme=dark]); variables only, so importing it is non-breaking. - theme/ThemeProvider.tsx: ThemeProvider + useTheme(), sets data-theme on , resolves system via prefers-color-scheme, persists override in localStorage (works in Electron + web). - shell/AppShell.tsx: layout primitive (sidebar/topbar/main/statusbar slots). Self-contained and presentational (transport-agnostic); apps are NOT wired yet — that and Storybook are follow-up slices. Verified: tsc --noEmit clean; JSON valid. Co-Authored-By: Claude Opus 4.8 --- libs/ui/README.md | 33 +++++++++ libs/ui/package.json | 29 ++++++++ libs/ui/src/css.d.ts | 2 + libs/ui/src/index.ts | 4 ++ libs/ui/src/shell/AppShell.css | 39 +++++++++++ libs/ui/src/shell/AppShell.tsx | 40 +++++++++++ libs/ui/src/theme/ThemeProvider.tsx | 102 ++++++++++++++++++++++++++++ libs/ui/src/tokens/tokens.css | 85 +++++++++++++++++++++++ libs/ui/tsconfig.json | 17 +++++ 9 files changed, 351 insertions(+) create mode 100644 libs/ui/README.md create mode 100644 libs/ui/package.json create mode 100644 libs/ui/src/css.d.ts create mode 100644 libs/ui/src/index.ts create mode 100644 libs/ui/src/shell/AppShell.css create mode 100644 libs/ui/src/shell/AppShell.tsx create mode 100644 libs/ui/src/theme/ThemeProvider.tsx create mode 100644 libs/ui/src/tokens/tokens.css create mode 100644 libs/ui/tsconfig.json diff --git a/libs/ui/README.md b/libs/ui/README.md new file mode 100644 index 000000000..7e527101c --- /dev/null +++ b/libs/ui/README.md @@ -0,0 +1,33 @@ +# @auto-code/ui + +Shared Auto Code design system and UI components, consumed by both the Electron +desktop app (`apps/frontend`) and the web app (`apps/web-frontend`). Presentational +and transport-agnostic: components take props/callbacks; data is injected per app +(IPC on desktop, REST/WS on web) via the planned `AutoCodeClient` ports-and-adapters +pattern (introduced at the Kanban pilot — see `docs/strategy/roadmap.md`, Track D). + +Source of design truth: `.lazyweb/mockups/` (`_desktop-tokens.css` + screen mockups). + +## Current contents (U0 foundation) + +- `src/tokens/tokens.css` — design tokens (light + `[data-theme="dark"]`). Import once + per app: `import '@auto-code/ui/tokens.css'`. Variables only — non-breaking. +- `src/theme/ThemeProvider.tsx` — `ThemeProvider` + `useTheme()`; sets `data-theme` + on ``, resolves `system` from `prefers-color-scheme`, persists the override. +- `src/shell/AppShell.tsx` — layout primitive (sidebar / topbar / main / statusbar slots). + +## Usage + +```tsx +import '@auto-code/ui/tokens.css'; +import { ThemeProvider, AppShell } from '@auto-code/ui'; + + + } topbar={} main={} /> + +``` + +## Roadmap (Track D) + +U0 foundation (this) → U0.4 Sidebar → U0.5 Storybook → U1 Kanban pilot (introduces +`AutoCodeClient`) → canonical screens → chrome → states → remaining views. diff --git a/libs/ui/package.json b/libs/ui/package.json new file mode 100644 index 000000000..790abd6e4 --- /dev/null +++ b/libs/ui/package.json @@ -0,0 +1,29 @@ +{ + "name": "@auto-code/ui", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Shared Auto Code design system and UI components (desktop + web).", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./tokens.css": "./src/tokens/tokens.css" + }, + "sideEffects": [ + "*.css" + ], + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.0", + "typescript": "^6.0.2" + } +} diff --git a/libs/ui/src/css.d.ts b/libs/ui/src/css.d.ts new file mode 100644 index 000000000..c145e3295 --- /dev/null +++ b/libs/ui/src/css.d.ts @@ -0,0 +1,2 @@ +// Allow importing CSS side-effect files (resolved by the consuming app's bundler). +declare module '*.css'; diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts new file mode 100644 index 000000000..19afc92a3 --- /dev/null +++ b/libs/ui/src/index.ts @@ -0,0 +1,4 @@ +export { ThemeProvider, useTheme } from './theme/ThemeProvider'; +export type { Theme, ResolvedTheme, ThemeProviderProps } from './theme/ThemeProvider'; +export { AppShell } from './shell/AppShell'; +export type { AppShellProps } from './shell/AppShell'; diff --git a/libs/ui/src/shell/AppShell.css b/libs/ui/src/shell/AppShell.css new file mode 100644 index 000000000..dd2e6f20c --- /dev/null +++ b/libs/ui/src/shell/AppShell.css @@ -0,0 +1,39 @@ +.ac-shell { + display: grid; + grid-template-columns: auto 1fr; + height: 100vh; + min-height: 0; + color: var(--ink); + background: var(--bg); + font-family: var(--font-sans); +} + +.ac-shell__sidebar { + min-height: 0; + overflow: hidden; + border-right: 1px solid var(--line); + background: var(--rail); +} + +.ac-shell__content { + display: grid; + grid-template-rows: auto 1fr auto; + min-width: 0; + min-height: 0; +} + +.ac-shell__topbar { + border-bottom: 1px solid var(--line); + background: var(--panel); +} + +.ac-shell__main { + min-height: 0; + overflow: auto; +} + +.ac-shell__statusbar { + border-top: 1px solid var(--line); + background: var(--panel); + color: var(--muted); +} diff --git a/libs/ui/src/shell/AppShell.tsx b/libs/ui/src/shell/AppShell.tsx new file mode 100644 index 000000000..2778ddbbb --- /dev/null +++ b/libs/ui/src/shell/AppShell.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from 'react'; +import './AppShell.css'; + +export interface AppShellProps { + /** Left column — typically the Sidebar (icon rail + context column). */ + sidebar?: ReactNode; + /** Top bar of the content column. */ + topbar?: ReactNode; + /** Main scrollable region. Falls back to `children`. */ + main?: ReactNode; + /** Bottom status bar of the content column. */ + statusbar?: ReactNode; + children?: ReactNode; +} + +/** + * Layout primitive: a sidebar column plus a content column laid out as + * topbar / main / statusbar. Pure layout — no business logic, no data. + * Each slot renders only when provided. + */ +export function AppShell({ + sidebar, + topbar, + main, + statusbar, + children, +}: AppShellProps) { + return ( +
+ {sidebar != null &&
{sidebar}
} +
+ {topbar != null &&
{topbar}
} +
{main ?? children}
+ {statusbar != null && ( +
{statusbar}
+ )} +
+
+ ); +} diff --git a/libs/ui/src/theme/ThemeProvider.tsx b/libs/ui/src/theme/ThemeProvider.tsx new file mode 100644 index 000000000..412b09ae1 --- /dev/null +++ b/libs/ui/src/theme/ThemeProvider.tsx @@ -0,0 +1,102 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import type { ReactNode } from 'react'; + +export type Theme = 'light' | 'dark' | 'system'; +export type ResolvedTheme = 'light' | 'dark'; + +interface ThemeContextValue { + /** The user's selection (may be "system"). */ + theme: Theme; + /** The concrete theme applied to the DOM ("light" | "dark"). */ + resolvedTheme: ResolvedTheme; + setTheme: (theme: Theme) => void; +} + +const ThemeContext = createContext(null); + +function getSystemTheme(): ResolvedTheme { + if (typeof window === 'undefined' || !window.matchMedia) return 'light'; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +function readStoredTheme(storageKey: string, fallback: Theme): Theme { + if (typeof localStorage === 'undefined') return fallback; + const stored = localStorage.getItem(storageKey); + return stored === 'light' || stored === 'dark' || stored === 'system' + ? stored + : fallback; +} + +export interface ThemeProviderProps { + children: ReactNode; + /** Theme used before any user override is stored. Defaults to "system". */ + defaultTheme?: Theme; + /** localStorage key for persisting the override. */ + storageKey?: string; +} + +/** + * Applies the Auto Code design tokens' theme by setting `data-theme` on + * ``. Resolves "system" from `prefers-color-scheme` and reacts to OS + * changes; the user's override persists in localStorage (works in both the + * Electron renderer and the web app). + */ +export function ThemeProvider({ + children, + defaultTheme = 'system', + storageKey = 'auto-code-theme', +}: ThemeProviderProps) { + const [theme, setThemeState] = useState(() => + readStoredTheme(storageKey, defaultTheme) + ); + const [systemTheme, setSystemTheme] = useState(getSystemTheme); + + // Track OS preference so "system" stays in sync without a reload. + useEffect(() => { + if (typeof window === 'undefined' || !window.matchMedia) return; + const query = window.matchMedia('(prefers-color-scheme: dark)'); + const onChange = () => setSystemTheme(query.matches ? 'dark' : 'light'); + query.addEventListener('change', onChange); + return () => query.removeEventListener('change', onChange); + }, []); + + const resolvedTheme: ResolvedTheme = theme === 'system' ? systemTheme : theme; + + // Apply to so the token overrides take effect. + useEffect(() => { + if (typeof document === 'undefined') return; + document.documentElement.setAttribute('data-theme', resolvedTheme); + }, [resolvedTheme]); + + const setTheme = useCallback( + (next: Theme) => { + setThemeState(next); + if (typeof localStorage !== 'undefined') { + localStorage.setItem(storageKey, next); + } + }, + [storageKey] + ); + + const value = useMemo( + () => ({ theme, resolvedTheme, setTheme }), + [theme, resolvedTheme, setTheme] + ); + + return {children}; +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + if (!ctx) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return ctx; +} diff --git a/libs/ui/src/tokens/tokens.css b/libs/ui/src/tokens/tokens.css new file mode 100644 index 000000000..3a81e288c --- /dev/null +++ b/libs/ui/src/tokens/tokens.css @@ -0,0 +1,85 @@ +/* Auto Code design tokens — ported 1:1 from .lazyweb/mockups/_desktop-tokens.css. + Light is the default; ThemeProvider sets [data-theme="dark"] on . + Variables only (no global reset / font application), so importing this file + is non-breaking — components opt in via var(--token). */ + +:root { + /* surfaces */ + --bg: #f5f5f2; + --panel: #ffffff; + --soft: #fafaf8; + --rail: #f0f0ec; + --sidebar-rail: #ececea; + + /* text */ + --ink: #151515; + --muted: #666762; + --quiet: #8c8d86; + + /* lines */ + --line: #deded8; + --line-strong: #c7c8bf; + + /* status */ + --green: #178a52; + --green-soft: #e7f4ee; + --blue: #2864d8; + --blue-soft: #e9efff; + --amber: #9d6408; + --amber-soft: #fff1d6; + --red: #ba2f2f; + --red-soft: #fce7e7; + + /* terminal */ + --term-bg: #0f0f0e; + --term-fg: #d8d8d2; + + /* effects */ + --shadow: 0 24px 80px rgba(20, 20, 20, 0.12); + --bg-gradient: + linear-gradient(135deg, rgba(40, 100, 216, 0.08), transparent 34%), + linear-gradient(315deg, rgba(23, 138, 82, 0.08), transparent 38%); + + /* typography */ + --font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +[data-theme="dark"] { + /* surfaces — warm dark, not pure black */ + --bg: #141413; + --panel: #1c1c1a; + --soft: #232321; + --rail: #1a1a18; + --sidebar-rail: #181816; + + /* text */ + --ink: #ebebe7; + --muted: #a0a09a; + --quiet: #6f706a; + + /* lines */ + --line: #2e2e2b; + --line-strong: #3d3d39; + + /* status — lifted so they read on dark */ + --green: #5cba85; + --green-soft: #1c2e23; + --blue: #7aa3ee; + --blue-soft: #1c2538; + --amber: #d9a655; + --amber-soft: #2f2618; + --red: #e57676; + --red-soft: #2f1d1d; + + /* terminal stays nearly the same */ + --term-bg: #0a0a09; + --term-fg: #e2e2dc; + + /* deeper shadow */ + --shadow: 0 24px 80px rgba(0, 0, 0, 0.55); + + /* dim ambient gradient */ + --bg-gradient: + linear-gradient(135deg, rgba(122, 163, 238, 0.06), transparent 34%), + linear-gradient(315deg, rgba(92, 186, 133, 0.06), transparent 38%); +} diff --git a/libs/ui/tsconfig.json b/libs/ui/tsconfig.json new file mode 100644 index 000000000..ef2bc612d --- /dev/null +++ b/libs/ui/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": [] + }, + "include": ["src"] +} From 19d68d4269c9a6e4a4b914f4c191e0dd12089683 Mon Sep 17 00:00:00 2001 From: Oleg Miagkov Date: Wed, 24 Jun 2026 21:28:55 +0400 Subject: [PATCH 2/4] feat(ui): wire @auto-code/ui tokens into desktop and web apps (U0 integration) Add @auto-code/ui as a workspace dependency of apps/frontend and apps/web-frontend, sync package-lock.json, and import the design tokens once per app entry (import '@auto-code/ui/tokens.css'). Tokens are variables only, so this is non-breaking; it validates that the shared workspace package resolves and builds in both targets. (Also drops the unused @types/react-dom devDep from libs/ui.) Co-Authored-By: Claude Opus 4.8 --- apps/frontend/package.json | 1 + apps/frontend/src/renderer/main.tsx | 1 + apps/web-frontend/package.json | 3 ++- apps/web-frontend/src/main.tsx | 1 + libs/ui/package.json | 1 - package-lock.json | 34 ++++++++++++++++++++++++++++- 6 files changed, 38 insertions(+), 3 deletions(-) diff --git a/apps/frontend/package.json b/apps/frontend/package.json index f7adec35d..a74763ae3 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -50,6 +50,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.104.1", + "@auto-code/ui": "*", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.11", "@codemirror/lang-javascript": "^6.2.4", diff --git a/apps/frontend/src/renderer/main.tsx b/apps/frontend/src/renderer/main.tsx index 56339747a..2e481e5ce 100644 --- a/apps/frontend/src/renderer/main.tsx +++ b/apps/frontend/src/renderer/main.tsx @@ -14,6 +14,7 @@ initSentryRenderer().catch((err) => { import React from 'react'; import ReactDOM from 'react-dom/client'; import { App } from './App'; +import '@auto-code/ui/tokens.css'; import './styles/globals.css'; ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/apps/web-frontend/package.json b/apps/web-frontend/package.json index 9a1c3a83c..ae0742bd5 100644 --- a/apps/web-frontend/package.json +++ b/apps/web-frontend/package.json @@ -35,10 +35,11 @@ "test:e2e:report": "playwright show-report" }, "dependencies": { - "@monaco-editor/react": "^4.7.0", + "@auto-code/ui": "*", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@monaco-editor/react": "^4.7.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.3", diff --git a/apps/web-frontend/src/main.tsx b/apps/web-frontend/src/main.tsx index 6cc621322..b9fd256e7 100644 --- a/apps/web-frontend/src/main.tsx +++ b/apps/web-frontend/src/main.tsx @@ -4,6 +4,7 @@ import "./i18n"; import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; +import "@auto-code/ui/tokens.css"; import "./index.css"; ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/libs/ui/package.json b/libs/ui/package.json index 790abd6e4..6983c96a9 100644 --- a/libs/ui/package.json +++ b/libs/ui/package.json @@ -23,7 +23,6 @@ }, "devDependencies": { "@types/react": "^19.2.7", - "@types/react-dom": "^19.2.0", "typescript": "^6.0.2" } } diff --git a/package-lock.json b/package-lock.json index 9deb2d270..acb76e58a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "license": "AGPL-3.0", "dependencies": { "@anthropic-ai/sdk": "^0.104.1", + "@auto-code/ui": "*", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.11", "@codemirror/lang-javascript": "^6.2.4", @@ -126,7 +127,7 @@ "rollup-plugin-visualizer": "^7.0.0", "tailwindcss": "^4.1.17", "typescript": "^6.0.2", - "vite": "^8.1.0", + "vite": "^7.3.5", "vitest": "^4.0.16" }, "engines": { @@ -912,6 +913,7 @@ "version": "2.7.5", "license": "AGPL-3.0", "dependencies": { + "@auto-code/ui": "*", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -1457,6 +1459,32 @@ "node": ">=18" } }, + "libs/ui": { + "name": "@auto-code/ui", + "version": "0.0.0", + "devDependencies": { + "@types/react": "^19.2.7", + "typescript": "^6.0.2" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "libs/ui/node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/@acemir/cssom": { "version": "0.9.31", "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", @@ -1599,6 +1627,10 @@ "dev": true, "license": "MIT" }, + "node_modules/@auto-code/ui": { + "resolved": "libs/ui", + "link": true + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", From 8397931709cc9b9defe920740ee57950ec7b60f5 Mon Sep 17 00:00:00 2001 From: Oleg Miagkov Date: Wed, 24 Jun 2026 21:42:35 +0400 Subject: [PATCH 3/4] feat(ui): add two-column Sidebar (icon rail + context column) to libs/ui (U0.4) Presentational, data-driven Sidebar mirroring the .lazyweb two-column variant: a 48px icon rail of sections + a context column listing the active section's items (with optional shortcut/badge). nav-config.ts defines SidebarSection/SidebarItem; the app supplies localized labels, icon nodes, and selection handlers. Active section/item highlighted; collapse hides the context column. Verified: libs/ui tsc --noEmit clean. Co-Authored-By: Claude Opus 4.8 --- libs/ui/src/index.ts | 3 + libs/ui/src/shell/Sidebar.css | 116 ++++++++++++++++++++++++++++++++ libs/ui/src/shell/Sidebar.tsx | 93 +++++++++++++++++++++++++ libs/ui/src/shell/nav-config.ts | 22 ++++++ 4 files changed, 234 insertions(+) create mode 100644 libs/ui/src/shell/Sidebar.css create mode 100644 libs/ui/src/shell/Sidebar.tsx create mode 100644 libs/ui/src/shell/nav-config.ts diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index 19afc92a3..a9e748852 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -2,3 +2,6 @@ export { ThemeProvider, useTheme } from './theme/ThemeProvider'; export type { Theme, ResolvedTheme, ThemeProviderProps } from './theme/ThemeProvider'; export { AppShell } from './shell/AppShell'; export type { AppShellProps } from './shell/AppShell'; +export { Sidebar } from './shell/Sidebar'; +export type { SidebarProps } from './shell/Sidebar'; +export type { SidebarSection, SidebarItem } from './shell/nav-config'; diff --git a/libs/ui/src/shell/Sidebar.css b/libs/ui/src/shell/Sidebar.css new file mode 100644 index 000000000..37cff7b84 --- /dev/null +++ b/libs/ui/src/shell/Sidebar.css @@ -0,0 +1,116 @@ +.ac-sidebar { + display: flex; + height: 100%; + min-height: 0; + font-family: var(--font-sans); +} + +.ac-sidebar__rail { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + width: 48px; + padding: 12px 0; + border-right: 1px solid var(--line); + background: var(--rail); +} + +.ac-sidebar__brand { + display: grid; + place-items: center; + width: 28px; + height: 28px; + margin-bottom: 8px; + border-radius: 8px; + background: #111; + color: #fff; + font-size: 10px; + font-weight: 800; +} + +.ac-sidebar__icon { + display: grid; + place-items: center; + width: 32px; + height: 32px; + padding: 0; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--muted); + font-size: 13px; + cursor: pointer; +} + +.ac-sidebar__icon--active { + color: var(--ink); + background: var(--panel); + box-shadow: inset 0 0 0 1px var(--line-strong); +} + +.ac-sidebar__list { + display: flex; + flex-direction: column; + gap: 2px; + width: 200px; + min-height: 0; + padding: 12px 10px; + overflow: auto; + border-right: 1px solid var(--line); + background: var(--soft); +} + +.ac-sidebar__group { + padding: 0 6px 8px; + color: var(--quiet); + font-size: 10px; + font-weight: 780; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.ac-sidebar__item { + display: flex; + align-items: center; + gap: 8px; + min-height: 28px; + padding: 0 8px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--muted); + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.ac-sidebar__item--active { + color: var(--ink); + background: var(--panel); + box-shadow: inset 0 0 0 1px var(--line); + font-weight: 720; +} + +.ac-sidebar__item-label { + flex: 1; + min-width: 0; +} + +.ac-sidebar__shortcut { + color: var(--quiet); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 9.5px; +} + +.ac-sidebar__badge { + display: inline-flex; + align-items: center; + min-height: 17px; + padding: 0 6px; + border-radius: 5px; + background: var(--blue-soft); + color: var(--blue); + font-size: 9.5px; + font-weight: 780; +} diff --git a/libs/ui/src/shell/Sidebar.tsx b/libs/ui/src/shell/Sidebar.tsx new file mode 100644 index 000000000..11beab77d --- /dev/null +++ b/libs/ui/src/shell/Sidebar.tsx @@ -0,0 +1,93 @@ +import type { ReactNode } from 'react'; +import type { SidebarSection } from './nav-config'; +import './Sidebar.css'; + +export interface SidebarProps { + sections: SidebarSection[]; + activeSectionId: string; + activeItemId?: string; + onSelectSection: (sectionId: string) => void; + onSelectItem: (sectionId: string, itemId: string) => void; + /** Collapse to the icon rail only (hide the context column). */ + collapsed?: boolean; + /** Brand mark shown at the top of the rail. */ + brand?: ReactNode; +} + +/** + * Two-column navigation: a 48px icon rail of sections plus a context column + * listing the active section's items. Presentational and data-driven (see + * SidebarSection) — the app supplies localized labels, icons, and selection + * handlers. Mirrors the `.lazyweb` two-column sidebar variant. + */ +export function Sidebar({ + sections, + activeSectionId, + activeItemId, + onSelectSection, + onSelectItem, + collapsed = false, + brand = 'AC', +}: SidebarProps) { + const activeSection = sections.find((section) => section.id === activeSectionId); + + return ( + + ); +} diff --git a/libs/ui/src/shell/nav-config.ts b/libs/ui/src/shell/nav-config.ts new file mode 100644 index 000000000..4d4391f0a --- /dev/null +++ b/libs/ui/src/shell/nav-config.ts @@ -0,0 +1,22 @@ +import type { ReactNode } from 'react'; + +/** A single navigable entry inside a sidebar section's context column. */ +export interface SidebarItem { + id: string; + /** Display label (already localized by the app). */ + label: string; + /** Optional keyboard-shortcut hint, e.g. "K" or "⌘N". */ + shortcut?: string; + /** Optional badge — a count or short status text. */ + badge?: string | number; +} + +/** A top-level section shown as an icon in the rail; owns a list of items. */ +export interface SidebarSection { + id: string; + /** Section label — used as the icon's accessible name and the group header. */ + label: string; + /** Section icon node shown in the rail (the app supplies the icon). */ + icon: ReactNode; + items: SidebarItem[]; +} From f64634dc1678324aa4e0be3bb6073874d0e03c12 Mon Sep 17 00:00:00 2001 From: Oleg Miagkov Date: Wed, 24 Jun 2026 21:48:54 +0400 Subject: [PATCH 4/4] =?UTF-8?q?feat(ui):=20Kanban=20pilot=20=E2=80=94=20Au?= =?UTF-8?q?toCodeClient=20port=20+=20useTasks=20+=20KanbanBoard=20(U1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the ports-and-adapters data seam for libs/ui: - client/AutoCodeClient: the AutoCodeClient port (listTasks + optional subscribeTasks), AutoCodeClientProvider/useAutoCodeClient (Context injection), and a shared useTasks() hook (loading/error/reload + live updates). - client/types: UiTask — the lowest-common task shape each app maps into via its adapter. - screens/KanbanBoard: presentational board (status columns + cards, keyboard-accessible), data-agnostic; mirrors the .lazyweb kanban layout. Apps stay decoupled: desktop provides an IPC adapter, web a REST/WS adapter (next slice). Verified: libs/ui tsc --noEmit clean. Runtime validation (Storybook/app adapter) follows. Co-Authored-By: Claude Opus 4.8 --- libs/ui/src/client/AutoCodeClient.tsx | 97 ++++++++++++++++++ libs/ui/src/client/types.ts | 23 +++++ libs/ui/src/index.ts | 15 +++ libs/ui/src/screens/KanbanBoard.css | 139 ++++++++++++++++++++++++++ libs/ui/src/screens/KanbanBoard.tsx | 106 ++++++++++++++++++++ 5 files changed, 380 insertions(+) create mode 100644 libs/ui/src/client/AutoCodeClient.tsx create mode 100644 libs/ui/src/client/types.ts create mode 100644 libs/ui/src/screens/KanbanBoard.css create mode 100644 libs/ui/src/screens/KanbanBoard.tsx diff --git a/libs/ui/src/client/AutoCodeClient.tsx b/libs/ui/src/client/AutoCodeClient.tsx new file mode 100644 index 000000000..8c083d337 --- /dev/null +++ b/libs/ui/src/client/AutoCodeClient.tsx @@ -0,0 +1,97 @@ +import { createContext, useCallback, useContext, useEffect, useState } from 'react'; +import type { ReactNode } from 'react'; +import type { UiTask } from './types'; + +/** + * The data port the shared UI depends on. Each app provides a concrete adapter + * (Electron → IPC, web → REST/WS) so `libs/ui` stays transport-agnostic — this + * is the "ports and adapters" seam introduced at the Kanban pilot (U1). + */ +export interface AutoCodeClient { + /** Fetch the current task/spec list for the active workspace. */ + listTasks(): Promise; + /** + * Optional realtime subscription. Returns an unsubscribe function; when + * provided, useTasks() refreshes from the pushed snapshots. + */ + subscribeTasks?(onChange: (tasks: UiTask[]) => void): () => void; +} + +const AutoCodeClientContext = createContext(null); + +export interface AutoCodeClientProviderProps { + client: AutoCodeClient; + children: ReactNode; +} + +export function AutoCodeClientProvider({ + client, + children, +}: AutoCodeClientProviderProps) { + return ( + + {children} + + ); +} + +export function useAutoCodeClient(): AutoCodeClient { + const client = useContext(AutoCodeClientContext); + if (!client) { + throw new Error('useAutoCodeClient must be used within an AutoCodeClientProvider'); + } + return client; +} + +export interface UseTasksResult { + tasks: UiTask[]; + loading: boolean; + error: Error | null; + reload: () => void; +} + +/** + * Loads tasks from the injected AutoCodeClient and, when the adapter supports + * it, keeps them live via subscribeTasks. Presentational components (e.g. + * KanbanBoard) receive the result as props. + */ +export function useTasks(): UseTasksResult { + const client = useAutoCodeClient(); + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [reloadKey, setReloadKey] = useState(0); + + const reload = useCallback(() => setReloadKey((key) => key + 1), []); + + useEffect(() => { + let active = true; + setLoading(true); + setError(null); + + client + .listTasks() + .then((next) => { + if (active) setTasks(next); + }) + .catch((err: unknown) => { + if (active) { + setError(err instanceof Error ? err : new Error(String(err))); + } + }) + .finally(() => { + if (active) setLoading(false); + }); + + const unsubscribe = client.subscribeTasks?.((next) => { + if (active) setTasks(next); + }); + + return () => { + active = false; + unsubscribe?.(); + }; + }, [client, reloadKey]); + + return { tasks, loading, error, reload }; +} diff --git a/libs/ui/src/client/types.ts b/libs/ui/src/client/types.ts new file mode 100644 index 000000000..301560426 --- /dev/null +++ b/libs/ui/src/client/types.ts @@ -0,0 +1,23 @@ +export type TaskStatus = 'draft' | 'running' | 'review' | 'done'; + +export type BadgeTone = 'good' | 'info' | 'warn' | 'bad' | 'neutral'; + +export interface UiTaskBadge { + label: string; + tone?: BadgeTone; +} + +/** + * The lowest-common task shape the Kanban board renders. Each app maps its own + * model (desktop spec/task over IPC, web task over REST) into this shape via its + * AutoCodeClient adapter, so the board stays transport- and source-agnostic. + */ +export interface UiTask { + id: string; + title: string; + status: TaskStatus; + description?: string; + badges?: UiTaskBadge[]; + /** 0–100 progress, typically for running tasks. */ + progress?: number; +} diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index a9e748852..b2dc6a397 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -5,3 +5,18 @@ export type { AppShellProps } from './shell/AppShell'; export { Sidebar } from './shell/Sidebar'; export type { SidebarProps } from './shell/Sidebar'; export type { SidebarSection, SidebarItem } from './shell/nav-config'; + +// Data layer (ports & adapters) + Kanban pilot (U1) +export { + AutoCodeClientProvider, + useAutoCodeClient, + useTasks, +} from './client/AutoCodeClient'; +export type { + AutoCodeClient, + AutoCodeClientProviderProps, + UseTasksResult, +} from './client/AutoCodeClient'; +export type { UiTask, UiTaskBadge, TaskStatus, BadgeTone } from './client/types'; +export { KanbanBoard, DEFAULT_KANBAN_COLUMNS } from './screens/KanbanBoard'; +export type { KanbanBoardProps, KanbanColumn } from './screens/KanbanBoard'; diff --git a/libs/ui/src/screens/KanbanBoard.css b/libs/ui/src/screens/KanbanBoard.css new file mode 100644 index 000000000..8065fde2a --- /dev/null +++ b/libs/ui/src/screens/KanbanBoard.css @@ -0,0 +1,139 @@ +.ac-kanban { + display: grid; + grid-template-columns: repeat(4, minmax(220px, 1fr)); + gap: 14px; + align-items: start; + padding: 18px 28px 28px; + font-family: var(--font-sans); +} + +.ac-kanban__column { + min-width: 0; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--soft); +} + +.ac-kanban__column-head { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px; + border-bottom: 1px solid var(--line); +} + +.ac-kanban__column-title { + margin: 0; + font-size: 13px; + font-weight: 780; +} + +.ac-kanban__count { + color: var(--quiet); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; +} + +.ac-kanban__column--draft .ac-kanban__column-head { + color: var(--muted); +} +.ac-kanban__column--running .ac-kanban__column-head { + color: var(--blue); +} +.ac-kanban__column--review .ac-kanban__column-head { + color: var(--amber); +} +.ac-kanban__column--done .ac-kanban__column-head { + color: var(--green); +} + +.ac-kanban__column-body { + display: grid; + gap: 10px; + padding: 10px; +} + +.ac-kanban__card { + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel); + text-align: left; +} + +.ac-kanban__card[role='button'] { + cursor: pointer; +} + +.ac-kanban__card-id { + color: var(--quiet); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; +} + +.ac-kanban__card-title { + margin: 0; + color: var(--ink); + font-size: 13.5px; + line-height: 1.3; + font-weight: 760; +} + +.ac-kanban__card-desc { + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.4; +} + +.ac-kanban__card-badges { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.ac-kanban__badge { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 0 7px; + border-radius: 6px; + font-size: 10.5px; + font-weight: 780; + white-space: nowrap; +} + +.ac-kanban__badge--good { color: var(--green); background: var(--green-soft); } +.ac-kanban__badge--info { color: var(--blue); background: var(--blue-soft); } +.ac-kanban__badge--warn { color: var(--amber); background: var(--amber-soft); } +.ac-kanban__badge--bad { color: var(--red); background: var(--red-soft); } +.ac-kanban__badge--neutral { color: var(--muted); background: var(--rail); } + +.ac-kanban__progress { + height: 5px; + overflow: hidden; + border-radius: 999px; + background: #e6e6df; +} + +.ac-kanban__progress > span { + display: block; + height: 100%; + background: var(--blue); +} + +@media (max-width: 1180px) { + .ac-kanban { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .ac-kanban { + grid-template-columns: 1fr; + padding: 14px; + } +} diff --git a/libs/ui/src/screens/KanbanBoard.tsx b/libs/ui/src/screens/KanbanBoard.tsx new file mode 100644 index 000000000..24790b758 --- /dev/null +++ b/libs/ui/src/screens/KanbanBoard.tsx @@ -0,0 +1,106 @@ +import type { KeyboardEvent } from 'react'; +import type { TaskStatus, UiTask } from '../client/types'; +import './KanbanBoard.css'; + +export interface KanbanColumn { + status: TaskStatus; + label: string; +} + +export const DEFAULT_KANBAN_COLUMNS: KanbanColumn[] = [ + { status: 'draft', label: 'Draft' }, + { status: 'running', label: 'Running' }, + { status: 'review', label: 'Review' }, + { status: 'done', label: 'Done' }, +]; + +export interface KanbanBoardProps { + tasks: UiTask[]; + columns?: KanbanColumn[]; + onSelectTask?: (task: UiTask) => void; +} + +/** + * Presentational Kanban board: groups tasks into status columns and renders + * cards. Data-agnostic — pair with `useTasks()` + an AutoCodeClient adapter. + * Mirrors the `.lazyweb` desktop-kanban-board-v2 layout. + */ +export function KanbanBoard({ + tasks, + columns = DEFAULT_KANBAN_COLUMNS, + onSelectTask, +}: KanbanBoardProps) { + return ( +
+ {columns.map((column) => { + const columnTasks = tasks.filter((task) => task.status === column.status); + return ( +
+
+

{column.label}

+ {columnTasks.length} +
+
+ {columnTasks.map((task) => ( + + ))} +
+
+ ); + })} +
+ ); +} + +interface KanbanCardProps { + task: UiTask; + onSelect?: (task: UiTask) => void; +} + +function KanbanCard({ task, onSelect }: KanbanCardProps) { + const handleClick = () => onSelect?.(task); + const handleKeyDown = (event: KeyboardEvent) => { + if (onSelect && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault(); + onSelect(task); + } + }; + + return ( +
+
+ {task.id} +
+

{task.title}

+ {task.description != null && ( +

{task.description}

+ )} + {task.badges != null && task.badges.length > 0 && ( +
+ {task.badges.map((badge) => ( + + {badge.label} + + ))} +
+ )} + {task.progress != null && ( +
+ +
+ )} +
+ ); +}