From af5fbde76126402cd2b80169a15046b017ee3b6d Mon Sep 17 00:00:00 2001 From: Gregor Jarisch Date: Tue, 11 Aug 2026 16:40:37 +0200 Subject: [PATCH 1/9] docs: add Claude Code UI docs and extend design-sync to the app chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documentation and design-sync build config only. No application source is modified and no UI behavior changes. Docs: CLAUDE.md (loaded every session) defers to AGENTS.md and routes to three on-demand skills — eddi-ui, eddi-screens, eddi-data. Verified the docs against the code rather than trusting them, and corrected: - shared/ has 13 components, not 14 (view-mode.ts is a helper), so the surface is 24, not 25; - the page skeleton documented channels.tsx, which is the outlier — the real convention is space-y-6 (29 of 40 pages, vs 2) with a text-3xl heading and an h-8 w-8 icon, so the reference is now agents.tsx; - eddi-data recommended tsc --noEmit, the exact no-op AGENTS.md warns about; pre-commit runs npm run typecheck. Design-sync surface 21 -> 29: adds the chrome (Sidebar, TopBar, PlatformStatus, PageLoader, MockDataBanner) plus three components that were always in the declared ui/+shared/ surface but never exported (DropdownMenu, ModeSwitcher, RefetchErrorNotice). AppLayout and ConfigEditorLayout stay excluded — both pull Monaco into the bundle. The sidebar-token bug described in the patch notes does not reproduce: :root carries the same 4 of 5 sidebar tokens with and without the layout @source line, and a control build scanning nothing emits the same 4, because Tailwind v4 emits this project's @theme into :root regardless of usage. The @source line is kept for the real reason — it emits the layout utilities (.fill-sidebar-accent, .border-s-2, ~8.7 KB; 45.4 -> 54.1 KB) without which a synced Sidebar/TopBar renders unstyled. --color-sidebar-accent-foreground is genuinely absent from :root in every configuration and ships only in .dark. Nothing uses that utility, so nothing renders wrong; fixing it means touching src/index.css, so it is recorded in .design-sync/NOTES.md instead. A design-system re-sync is required for the new components to appear, and the new viewport overrides need a full package-build, not a preview rebuild. --- .claude/skills/eddi-data/SKILL.md | 72 +++++++++ .claude/skills/eddi-screens/SKILL.md | 145 +++++++++++++++++++ .claude/skills/eddi-ui/SKILL.md | 131 +++++++++++++++++ .design-sync/NOTES.md | 52 ++++++- .design-sync/build-css.mjs | 11 +- .design-sync/config.json | 70 +++++++-- .design-sync/ds-entry.tsx | 28 +++- .design-sync/previews/DropdownMenu.tsx | 29 ++++ .design-sync/previews/MockDataBanner.tsx | 11 ++ .design-sync/previews/ModeSwitcher.tsx | 22 +++ .design-sync/previews/PageLoader.tsx | 7 + .design-sync/previews/PlatformStatus.tsx | 9 ++ .design-sync/previews/RefetchErrorNotice.tsx | 16 ++ .design-sync/previews/Sidebar.tsx | 20 +++ .design-sync/previews/TopBar.tsx | 9 ++ CLAUDE.md | 45 ++++++ HANDOFF.md | 7 + 17 files changed, 663 insertions(+), 21 deletions(-) create mode 100644 .claude/skills/eddi-data/SKILL.md create mode 100644 .claude/skills/eddi-screens/SKILL.md create mode 100644 .claude/skills/eddi-ui/SKILL.md create mode 100644 .design-sync/previews/DropdownMenu.tsx create mode 100644 .design-sync/previews/MockDataBanner.tsx create mode 100644 .design-sync/previews/ModeSwitcher.tsx create mode 100644 .design-sync/previews/PageLoader.tsx create mode 100644 .design-sync/previews/PlatformStatus.tsx create mode 100644 .design-sync/previews/RefetchErrorNotice.tsx create mode 100644 .design-sync/previews/Sidebar.tsx create mode 100644 .design-sync/previews/TopBar.tsx create mode 100644 CLAUDE.md diff --git a/.claude/skills/eddi-data/SKILL.md b/.claude/skills/eddi-data/SKILL.md new file mode 100644 index 000000000..20faaa33c --- /dev/null +++ b/.claude/skills/eddi-data/SKILL.md @@ -0,0 +1,72 @@ +--- +name: eddi-data +description: EDDI Manager's data and plumbing conventions — routing, TanStack Query hooks, ApiClient, i18n propagation, MSW mocks and tests. Load when wiring a screen to real data or adding a route. +--- + +# EDDI Manager data & plumbing + +AGENTS.md is the authority; this is the working summary for building a screen. + +## Routing + +React Router v7, declarative mode (no data router). Routes are declared in `src/app.tsx`; +pages render through `AppLayout`'s ``. Resource detail routes carry the version as +a query param: `/manage/channels/:id?version=2`. `src/__tests__/route-integrity.test.ts` +asserts every nav target resolves — add the route and the sidebar entry together. + +## Server state — TanStack Query v5 + +One hook file per domain in `src/hooks/` (`use-channels.ts`, `use-agents.ts`, …). Pages +never call the API directly: + +```tsx +const { data: channels, isLoading, error, refetch } = useEnrichedChannelDescriptors(); +const deleteMutation = useDeleteChannel(); +await deleteMutation.mutateAsync({ id, version }); +``` + +Filtering/search is client-side `useMemo` over the query result when the list is small — +that is the existing pattern, not a server round-trip per keystroke. + +## API calls + +Use `ApiClient` (`src/lib/api-client.ts`) for ordinary JSON — it injects the Keycloak auth +header. Base URL is always `window.location.origin`; never hardcode. Raw `fetch` is only +justified for SSE, binary/blob and `text/plain` bodies, and **must** spread +`api.getAuthHeader()` itself — a missing header is a 401 that only shows up once OIDC is on. +`secrets.ts` uses raw fetch for historical reasons; it is debt, not a pattern to copy. + +## UI state + +Zustand for chat/debug stores; `useState`/`useCallback` everywhere else. No Redux. + +## i18n — blocking requirement + +Every string: `t("namespace.key", "English fallback")`. Add the key to +`src/i18n/locales/en.json`, then propagate translations to all 10 other locales +(de, fr, es, ar, zh, th, ja, ko, pt, hi) **in the same commit**. Each editor gets its own +namespace (`llmEditor.*`, `rulesEditor.*`). Because Arabic ships, every layout must use +logical properties. + +## Tests + +- Unit: Vitest + RTL in `src/pages/__tests__/`, `renderPage(type)` helper wraps + `MemoryRouter` + `QueryClient` + `ThemeProvider`. Assert on `data-testid`. +- Mocks: MSW handlers in `src/test/mocks/handlers.ts`. Specific GET handlers must be + registered **before** the generic `createResourceHandlers` block. Mock data should match + the backend Java model. +- E2E: Playwright in `e2e/` — including `rtl.spec.ts` and `theme.spec.ts`, so a new screen + must survive both Arabic and dark mode. + +## Gates before you call it done + +```bash +npm run test # Vitest +npm run build # includes tsc -b +``` + +Pre-commit runs `eslint --max-warnings 0` on staged files, then `npm run typecheck`. +Use `npm run typecheck` (`tsc -b`), never `tsc --noEmit` — `tsconfig.json` is a solution +file (`"files": []` plus two project references), so `--noEmit` resolves zero inputs and +exits 0 without checking anything. Never commit to `main`; branch first. Update +`HANDOFF.md` when a phase completes. diff --git a/.claude/skills/eddi-screens/SKILL.md b/.claude/skills/eddi-screens/SKILL.md new file mode 100644 index 000000000..b023beec9 --- /dev/null +++ b/.claude/skills/eddi-screens/SKILL.md @@ -0,0 +1,145 @@ +--- +name: eddi-screens +description: How an EDDI Manager page is actually built — the app shell, the list-page pattern, detail pages, the config-editor chrome, wizards, and loading/empty/error states. Load before creating or restructuring a page. +--- + +# EDDI Manager screen patterns + +Pages live in `src/pages/` and render inside `AppLayout` (sidebar + top bar). The layout +already supplies `p-6`, `max-w-screen-2xl`, scroll, and a `@container/main` context — a +page renders its own content only, no shell, no width cap, no page background. + +## Page skeleton + +Every page is a single `space-y-6` column (29 of 40 pages). Reference: `src/pages/agents.tsx`. + +```tsx +
+ {/* 1. Header: icon + title + subtitle, actions right */} +
+
+

+ + {t("pages.agents.title")} +

+

{t("pages.agents.subtitle")}

+
+
+ + +
+
+ + {/* 2. Optional guidance banner */} + {/* 3. Toolbar: search + ViewToggle */} + {/* 4. Content: loading → error → empty → card grid or table */} + {/* 5. Dialogs last */} +
+``` + +Rules that hold across pages: + +- **Do not add `p-6` to the page root.** `AppLayout`'s `
` already applies + `p-6` inside `@container/main mx-auto max-w-screen-2xl`. Seven pages + (`channels`, `channel-detail`, `coordinator`, `orphans`, `schedules`, `secrets`, + `variables`) still self-pad and end up double-padded — don't copy them. +- `h1` is `flex items-center gap-2 text-3xl font-bold text-foreground` with an + `h-8 w-8 text-primary` Lucide icon. (`channels.tsx` uses a smaller `text-2xl` / + `h-6 w-6` variant; it is the outlier, not the rule.) +- The page's primary action is a `primary` Button top-right; secondary actions sit beside it + in a `flex flex-wrap items-center gap-2` group. Buttons already supply `gap-2` from `cva`. +- Toolbar: search on `flex-1`, `ViewToggle` after it. Persist the choice with + `getStoredViewMode(page)` / `setStoredViewMode(page, mode)` from + `src/components/shared/view-mode.ts`. Where a result count is shown, it is a + `text-xs font-semibold uppercase tracking-wider text-muted-foreground` line above the grid. +- Inline guidance banners: `rounded-xl border border-primary/20 bg-primary/5 p-4`. + +## The three content states, in this order + +Loading → error → empty → content, as guarded blocks (the `agents.tsx` form): + +```tsx +{isLoading && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ ))} +
+)} + +{isError && ( + refetch()} retryLabel={t("common.retry")} /> +)} + +{!isLoading && !isError && items.length === 0 && ( + setCreateOpen(true) : undefined} /> +)} +``` + +Use the `Skeleton` primitive inside a card-shaped wrapper, not a bare `animate-pulse` div. +Always render an error branch — `ErrorState` is in 16 pages, `EmptyState` in 8; the pages +that hand-roll these (`channels.tsx` builds its own empty state inline) are the ones to fix, +not to copy. Empty state distinguishes "no results for this search" from "nothing exists +yet", and only the latter offers the create action. + +## Card grid vs table + +Card grid: `grid gap-4 sm:grid-cols-2 lg:grid-cols-3`. When the page can sit next to the +open chat drawer, prefer the container-query classes from `src/index.css` — `cq-card-grid` +(1→2→3→4) or `cq-stat-grid` (1→2→4) — which respond to the content area, not the viewport. + +Table: wrap in `rounded-xl border border-border/50 overflow-hidden`, header row +`border-b bg-muted/50` with `text-start px-4 py-3 font-medium` cells, body rows +`border-b border-border/30 hover:bg-muted/30 cursor-pointer transition-colors`, whole row +navigates, IDs in `font-mono text-xs text-muted-foreground`, numeric/version columns +`text-end`. Give each row `data-testid={\`thing-row-\${id}\`}`. + +## Detail pages + +`BackLink` at the top, then the same header block (title + subtitle + actions), then +content in `Card`s. Deletes go through `AlertDialog` with `isPending` bound to the +mutation. Resource routes carry the version: `/manage/channels/:id?version=N`. + +## Config editors + +Never build editor chrome. `ConfigEditorLayout` +(`src/components/editors/config-editor-layout.tsx`) owns the Form↔JSON tabs, version +picker, compare, dirty indicator, Save / Discard / Save & Test, and the unsaved-changes +guard. A new editor supplies only the form body: + +```tsx + ( + + )} +/> +``` + +Then register it in `EDITOR_MAP` (`src/components/editors/editor-registry.tsx`), add an MSW +handler, add i18n keys, add a test — the four steps in AGENTS.md §3. + +## Wizards + +`agent-wizard.tsx` and `group-wizard.tsx` are the reference. Entry is +`CreateOrWizardDialog` (quick create vs. guided). Keep step state local; only commit on +finish. + +## Accessibility baked into the patterns + +Tab bars and toggles are `role="tablist"`/`radiogroup` with arrow-key handling and roving +`tabIndex` — copy the handler from `ConfigEditorLayout` or `ViewToggle` rather than writing +plain buttons. Icons that repeat a visible label get `aria-hidden="true"`; icon-only +controls get `aria-label` via `t()`. The global focus ring is already defined in +`src/index.css` — don't remove outlines. diff --git a/.claude/skills/eddi-ui/SKILL.md b/.claude/skills/eddi-ui/SKILL.md new file mode 100644 index 000000000..cfcd41aef --- /dev/null +++ b/.claude/skills/eddi-ui/SKILL.md @@ -0,0 +1,131 @@ +--- +name: eddi-ui +description: EDDI Manager's UI vocabulary — the 24 existing components, their variant props, the brand tokens, and the styling rules. Load before writing or editing any component that renders. +--- + +# EDDI Manager UI + +Black & gold admin dashboard. React 19 + Tailwind CSS v4 + CSS-variable tokens. +Style with Tailwind utilities that read tokens. Never hand-write CSS, never invent a +class system, never use raw hex. + +## What already exists — import it + +### `src/components/ui/` — primitives + +| Component | Notes | +|---|---| +| `Button` | `variant`: primary (default) · secondary · destructive · outline · ghost · link. `size`: sm · md (default) · lg · icon. `asChild` for link buttons. Lucide icon as a child auto-sizes to 16px. | +| `Badge` | `variant`: default (gold) · secondary · success · warning · destructive · outline. Pill, `text-xs font-semibold`. | +| `Card` | Compose `Card` > `CardHeader` (`CardTitle`, `CardDescription`) + `CardContent` + `CardFooter`. `rounded-xl border bg-card shadow-sm`; header is `p-5 pb-0`, content `p-5`. | +| `Input` | `h-10 rounded-lg`, gold focus ring. Plain `InputHTMLAttributes`. | +| `Skeleton` | Loading placeholder. | +| `AccessibleDialog` | Focus-trapped modal. | +| `AlertDialog` | Confirm/destructive prompt: `title`, `description`, `onConfirm`, `confirmLabel`, `cancelLabel`, `isPending`. Use for every delete. | +| `UnsavedChangesDialog` | Discard-changes confirm. Pair with `useUnsavedChangesGuard`. | +| `DropdownMenu` | Radix wrapper. | +| `ErrorBoundary` | Wrap risky subtrees. | +| `StreamBadge` | Live/streaming indicator. | + +### `src/components/shared/` — app-level + +| Component | Use it for | +|---|---| +| `EmptyState` | `icon` (Lucide), `title`, `description?`, `actionLabel?`, `onAction?`. Dashed-border box, `py-16`. | +| `ErrorState` | `message`, `onRetry?`, `retryLabel?`. Destructive-tinted box. | +| `ViewToggle` | Card ↔ list switch. Already keyboard-accessible (arrow keys, radiogroup). | +| `BackLink` | Detail-page back nav. Takes only `to` / `label` — no `className`. | +| `AgentPicker`, `SecretKeyPicker` | Async pickers, already wired to react-query. | +| `ResourceTypeBadge` | `type` slug → per-type color chip (rules amber, apicalls green, llm pink, …). | +| `ActionBadge` | Diff actions: CREATE · UPDATE · SKIP · CONFLICT. | +| `InfiniteScrollSentinel` | Intersection-observer load-more trigger. | +| `CommandPalette` | Global Ctrl+K. Opens via its store, not props. | +| `CreateOrWizardDialog` | "Quick create or launch the wizard" fork. | +| `ModeSwitcher`, `RefetchErrorNotice` | Mode switch; background-refetch failure notice. | + +### `src/components/layout/` — the shell + +| Component | Notes | +|---|---| +| `AppLayout` | Owns the whole frame: sidebar, top bar, drawers, onboarding, and `
` with `p-6`, `max-w-screen-2xl` and an `@container/main` context. A page renders its body only. | +| `Sidebar` | `collapsed` / `onToggle`. Four collapsible nav sections (persisted to `eddi-sidebar-sections`), Manager/Workforce switch, approvals count badge, external links, help menu, version footer. Uses the `sidebar-*` tokens, not the page tokens. | +| `TopBar` | `onMenuClick` / `sidebarVisible`. Breadcrumb, command-palette trigger, theme and chat controls. | +| `PlatformStatus` | Backend connectivity pill with a click-to-expand popover (instance, latency, last checked). | +| `PageLoader` | Route-level skeleton. Use it for lazy-route fallbacks, not for in-page loading. | +| `MockDataBanner` | Demo-mode strip; self-hides unless MSW is active. | +| `ThemeProvider` | Light/dark; toggles the `dark` class. Wrap tests and previews in it. | + +## Tokens + +Declared in `@theme` in `src/index.css`; `.dark` overrides them. Use the semantic name. + +| Class suffix | Light | Meaning | +|---|---|---| +| `primary` / `primary-foreground` | `#f59e0b` / `#0c0a09` | brand gold + text on gold | +| `background` / `foreground` | `#fafaf9` / `#1c1917` | page + body text | +| `card` / `card-foreground` | `#ffffff` / `#1c1917` | card surface | +| `secondary` / `secondary-foreground` | `#f5f5f4` / `#1c1917` | muted surface, hover fills | +| `muted` / `muted-foreground` | `#f5f5f4` / `#78716c` | subtle surface, secondary text | +| `border`, `input` | `#e7e5e4` | hairlines, field borders | +| `destructive` / `destructive-foreground` | `#dc2626` / `#fff` | danger | +| `accent` | `#fbbf24` | brighter gold | +| `sidebar` / `sidebar-foreground` | `#ffffff` / `#44403c` | sidebar surface + text | +| `sidebar-border` | `#e7e5e4` | sidebar hairlines | +| `sidebar-accent` / `-foreground` | `#b45309` / `#ffffff` | active nav; dark mode restores bright gold `#f59e0b` on `#0c0a09` | + +Other constants: `--radius: 0.5rem` (`rounded-lg` fields/buttons, `rounded-xl` cards and +containers, `rounded-full` badges), `--font-sans` is Noto Sans Variable with per-script +fallbacks — do not set another font. + +Status colors outside the token set (emerald for success/deploy, amber for dirty state, +blue for update) exist in a few places. Reuse the existing pattern rather than inventing +a new palette: `text-emerald-600 dark:text-emerald-400`, `bg-amber-100 … dark:bg-amber-900/30`. + +## Examples + +Card with status: + +```tsx + + +
+ {agent.name} + {t("agents.deployed", "Deployed")} +
+ {agent.description} +
+ + + + + +
+``` + +Search field (note `ps-9` and `start-3`, not `pl-9`/`left-3`): + +```tsx +
+ + setQ(e.target.value)} data-testid="x-search" /> +
+``` + +## Do not + +- **Do not add a shadcn/ui component** because it "looks the same". If the primitive is + missing, write it into `src/components/ui/` in the house style (`cva` + `cn()`, tokens, + `forwardRef`, `displayName`) so it is reusable and syncable. +- **Do not use raw hex or Tailwind's default palette for brand color** — `bg-amber-500` is + not `bg-primary`. (Third-party brand marks like Slack's `#4A154B` are the exception.) +- **Do not restyle a variant at the call site.** Add the variant to the `cva` config. +- **Do not use directional spacing** (`pl-`, `pr-`, `ml-`, `mr-`, `left-`, `right-`, + `text-left`, `text-right`). +- **Do not write a `.css` file** for component styling. `src/index.css` holds tokens, base + styles and a few genuinely global patterns (spotlight, container-query grids) — that is + the only place raw CSS belongs. +- **Do not hardcode a font-family, shadow scale, or radius** outside the tokens. +- **Do not mix component and utility exports in one file** — `react-refresh/only-export-components` + fails the build. +- **Do not ship an untranslated string.** diff --git a/.design-sync/NOTES.md b/.design-sync/NOTES.md index 7183cc2be..c95886000 100644 --- a/.design-sync/NOTES.md +++ b/.design-sync/NOTES.md @@ -2,31 +2,38 @@ EDDI-Manager is an **application**, not a published component library. The synced "design system" is the presentational surface: `src/components/ui/` + `src/components/shared/` -(21 components). Synced via the **package shape with a dedicated re-export entry**. ++ the app chrome from `src/components/layout/` (29 components). Synced via the +**package shape with a dedicated re-export entry**. Claude Design project: `408b967f-5a31-4a9f-85f7-f8794639218b` ("Design System"). +`AppLayout`, `ConfigEditorLayout` and `ThemeProvider` are deliberately **not** synced — +the first two pull Monaco and the operator tool-scope graph into the bundle, and +`ThemeProvider` is a provider rather than a visual component (it is already used by +`ds-providers`). Compose `Sidebar` + `TopBar` by hand instead of reaching for `AppLayout`. + ## How the build is wired (non-obvious — read before re-syncing) - **No library `dist/`.** `npm run build` produces an *app* bundle. So `cfg.entry` points at `.design-sync/ds-entry.tsx`, a hand-authored file that re-exports exactly - the 21 scoped components (+ the preview provider). The converter esbuilds from it, so + the 29 scoped components (+ the preview provider). The converter esbuilds from it, so only this surface + its deps land in `_ds_bundle.js` — not Monaco/editors/etc. **To add/remove a synced component: edit ds-entry.tsx AND `cfg.componentSrcMap`.** -- `componentSrcMap` pins all 21 src paths because there is no shipped `.d.ts`. +- `componentSrcMap` pins all 29 src paths because there is no shipped `.d.ts`. - `cfg.tsconfig = tsconfig.app.json` so esbuild resolves the `@/* → ./src/*` alias. - **Provider chain** `.design-sync/ds-providers.tsx` (`DesignSyncProvider`, used as `cfg.provider`): MemoryRouter + QueryClientProvider + ThemeProvider(light) + Radix - TooltipProvider, and a side-effect `import "@/i18n/config"`. It also calls - `i18n.changeLanguage("en")` so previews are deterministic English (LanguageDetector - otherwise renders labels in the host machine's locale — we caught German). + TooltipProvider, and a side-effect `import "@/i18n/config"`. It previews through an + isolated `i18n.cloneInstance({ lng: "en", detection: { caches: [] } })` so previews are + deterministic English without mutating the app's shared instance or persisting a locale + (LanguageDetector otherwise renders labels in the host machine's locale — we caught German). ## Styling = scoped Tailwind v4 compile (`cfg.buildCmd`) - Tokens live in `@theme` in `src/index.css` (EDDI brand: black & gold, primary `#f59e0b`). - `cfg.cssEntry = .design-sync/.cache/compiled.css`, produced by `cfg.buildCmd` (`node .design-sync/build-css.mjs`) which runs the Tailwind v4 CLI scoped (`source(none)` - + `@source` for ui/, shared/, ds-entry, previews) over `src/index.css`. This gives a - **stable, lean (~40 KB)** stylesheet with NO Monaco/VSCode bleed (the whole-app + + `@source` for ui/, shared/, **layout/**, ds-entry, previews) over `src/index.css`. This gives a + **stable, lean (~53 KB)** stylesheet with NO Monaco/VSCode bleed (the whole-app `dist/assets/index-*.css` pulled in ~217 `--vscode-*` tokens, Ubuntu Mono/Segoe fonts, and codicon — all gone now). `build-css.mjs` needs `@tailwindcss/cli` in `.ds-sync/node_modules` (install it alongside the other converter deps). @@ -49,11 +56,40 @@ Claude Design project: `408b967f-5a31-4a9f-85f7-f8794639218b` ("Design System"). in preview they show the empty/closed state — previews are authored around that. - Layout glue in previews is inline styles (not Tailwind classes) so targeted preview-rebuilds don't require regenerating compiled.css. +- **`Sidebar` needs a pinned height** — it fills its parent, so the preview wraps it in a + fixed-height flex frame. It also writes `eddi-sidebar-sections` to localStorage when a + nav section is collapsed; harmless in preview, but it is a real write. +- **`TopBar`'s breadcrumb derives from the router.** `ds-providers`' `MemoryRouter` starts + at `/`, so the breadcrumb renders its root state unless the preview supplies + `initialEntries`. +- `Sidebar` / `TopBar` call `useAuth()`. There is no auth provider in the chain, but + `AuthContext` has a real `GUEST_CONTEXT` default, so they render the signed-out state + rather than throwing. ## Known render warns - None. (Earlier GRID_OVERFLOW on the two pickers resolved via `cardMode: column`; earlier FONT_MISSING/TOKENS_MISSING resolved by the scoped cssEntry.) +## Why `layout/` is in the `@source` list (measured, not assumed) + +Adding `@source "../../src/components/layout"` is required for the **utilities** the chrome +uses: without it the compiled sheet has no `.fill-sidebar-accent`, no `.border-s-2`, and +~8.7 KB of other layout utilities (45.4 KB → 54.1 KB), so a synced `Sidebar`/`TopBar` +renders unstyled. + +It is **not** what puts the `--color-sidebar*` tokens in `:root`, contrary to what an +earlier note claimed. Tailwind v4 emits this project's `@theme` block into `:root` +regardless of scanned usage — a control build scanning *nothing at all* still emits +`--color-sidebar`, `--color-sidebar-foreground`, `--color-sidebar-border` and +`--color-sidebar-accent`. Before and after the `layout` line, `:root` carries the same +four. So there was no sidebar-token bug to fix here. + +The one genuine gap: **`--color-sidebar-accent-foreground` is absent from `:root`** in every +configuration, including the zero-scan control, and ships only in the `.dark` block (which +is plain CSS). Nothing in the app uses a `*-sidebar-accent-foreground` utility, so nothing +renders wrong today — but the light-mode value declared in `src/index.css` (`#ffffff`) does +not reach the bundle. Fixing that means touching `src/index.css`, so it is left alone here. + ## Re-sync risks / watch-list - **`build-css.mjs` depends on `src/index.css`'s `@import 'tailwindcss';` line** and on `@source`-able component dirs. If the app migrates Tailwind config or moves index.css, diff --git a/.design-sync/build-css.mjs b/.design-sync/build-css.mjs index dd69a3b86..435093481 100644 --- a/.design-sync/build-css.mjs +++ b/.design-sync/build-css.mjs @@ -3,8 +3,9 @@ // EDDI-Manager has no library dist; its app CSS (dist/assets/index-.css) // is hash-named (not reproducible) and pulls in Monaco/VSCode CSS the synced // components never use. This regenerates src/index.css's tokens + ONLY the -// Tailwind utilities used by src/components/ui + src/components/shared + the -// authored previews, to a fixed path: .design-sync/.cache/compiled.css. +// Tailwind utilities used by src/components/ui + src/components/shared + +// src/components/layout + the authored previews, to a fixed path: +// .design-sync/.cache/compiled.css. // // Wired as cfg.buildCmd so re-sync regenerates it before the converter runs. // Requires @tailwindcss/cli in .ds-sync/node_modules (staged converter deps). @@ -26,6 +27,12 @@ const scoped = [ '@import "tailwindcss" source(none);', '@source "../../src/components/ui";', '@source "../../src/components/shared";', + // Required, not cosmetic: Tailwind v4 only emits an @theme token if a scanned + // file uses it. Nothing in ui/ or shared/ references --color-sidebar*, so + // without this line :root ships zero sidebar tokens and the sidebar's brand + // gold resolves to nothing. (The .dark overrides are plain CSS and ship + // regardless, which is why the gap only shows in light mode.) + '@source "../../src/components/layout";', '@source "../ds-entry.tsx";', '@source "../previews";', ].join("\n"); diff --git a/.design-sync/config.json b/.design-sync/config.json index 773f0726d..626cb335a 100644 --- a/.design-sync/config.json +++ b/.design-sync/config.json @@ -7,16 +7,60 @@ "tsconfig": "tsconfig.app.json", "buildCmd": "node .design-sync/build-css.mjs", "cssEntry": ".design-sync/.cache/compiled.css", - "extraFonts": ["node_modules/@fontsource-variable/noto-sans/index.css"], + "extraFonts": [ + "node_modules/@fontsource-variable/noto-sans/index.css" + ], "readmeHeader": ".design-sync/conventions.md", - "provider": { "component": "DesignSyncProvider" }, + "provider": { + "component": "DesignSyncProvider" + }, "overrides": { - "AccessibleDialog": { "cardMode": "single", "viewport": "560x560" }, - "AlertDialog": { "cardMode": "single", "viewport": "560x440" }, - "UnsavedChangesDialog": { "cardMode": "single", "viewport": "520x480" }, - "CreateOrWizardDialog": { "cardMode": "single", "viewport": "760x620" }, - "AgentPicker": { "cardMode": "column" }, - "SecretKeyPicker": { "cardMode": "column" } + "AccessibleDialog": { + "cardMode": "single", + "viewport": "560x560" + }, + "AlertDialog": { + "cardMode": "single", + "viewport": "560x440" + }, + "UnsavedChangesDialog": { + "cardMode": "single", + "viewport": "520x480" + }, + "CreateOrWizardDialog": { + "cardMode": "single", + "viewport": "760x620" + }, + "AgentPicker": { + "cardMode": "column" + }, + "SecretKeyPicker": { + "cardMode": "column" + }, + "DropdownMenu": { + "cardMode": "single", + "viewport": "440x380" + }, + "MockDataBanner": { + "cardMode": "single", + "viewport": "760x120" + }, + "PageLoader": { + "cardMode": "single", + "viewport": "860x460" + }, + "PlatformStatus": { + "cardMode": "single", + "viewport": "440x320" + }, + "Sidebar": { + "cardMode": "single", + "viewport": "340x800" + }, + "TopBar": { + "cardMode": "single", + "viewport": "960x160" + } }, "componentSrcMap": { "AccessibleDialog": "src/components/ui/accessible-dialog.tsx", @@ -24,6 +68,7 @@ "Badge": "src/components/ui/badge.tsx", "Button": "src/components/ui/button.tsx", "Card": "src/components/ui/card.tsx", + "DropdownMenu": "src/components/ui/dropdown-menu.tsx", "ErrorBoundary": "src/components/ui/error-boundary.tsx", "Input": "src/components/ui/input.tsx", "Skeleton": "src/components/ui/skeleton.tsx", @@ -37,8 +82,15 @@ "EmptyState": "src/components/shared/empty-state.tsx", "ErrorState": "src/components/shared/error-state.tsx", "InfiniteScrollSentinel": "src/components/shared/infinite-scroll-sentinel.tsx", + "ModeSwitcher": "src/components/shared/mode-switcher.tsx", + "RefetchErrorNotice": "src/components/shared/refetch-error-notice.tsx", "ResourceTypeBadge": "src/components/shared/resource-type-badge.tsx", "SecretKeyPicker": "src/components/shared/secret-key-picker.tsx", - "ViewToggle": "src/components/shared/view-toggle.tsx" + "ViewToggle": "src/components/shared/view-toggle.tsx", + "MockDataBanner": "src/components/layout/mock-data-banner.tsx", + "PageLoader": "src/components/layout/page-loader.tsx", + "PlatformStatus": "src/components/layout/platform-status.tsx", + "Sidebar": "src/components/layout/sidebar.tsx", + "TopBar": "src/components/layout/top-bar.tsx" } } diff --git a/.design-sync/ds-entry.tsx b/.design-sync/ds-entry.tsx index d05b380f5..078e0d179 100644 --- a/.design-sync/ds-entry.tsx +++ b/.design-sync/ds-entry.tsx @@ -1,6 +1,7 @@ // Design-system entry for /design-sync. Re-exports the scoped presentational -// components (src/components/ui + src/components/shared) so the converter bundles -// exactly this surface — not the whole app. Authored input; safe to commit. +// components (src/components/ui + src/components/shared + src/components/layout) +// so the converter bundles exactly this surface — not the whole app. +// Authored input; safe to commit. // ── ui/ ──────────────────────────────────────────────────────────────────── export { AccessibleDialog } from "@/components/ui/accessible-dialog"; @@ -15,6 +16,16 @@ export { CardDescription, CardContent, } from "@/components/ui/card"; +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuLabel, + DropdownMenuGroup, + DropdownMenuPortal, +} from "@/components/ui/dropdown-menu"; export { ErrorBoundary } from "@/components/ui/error-boundary"; export { Input } from "@/components/ui/input"; export { Skeleton } from "@/components/ui/skeleton"; @@ -30,9 +41,22 @@ export { CreateOrWizardDialog } from "@/components/shared/create-or-wizard-dialo export { EmptyState } from "@/components/shared/empty-state"; export { ErrorState } from "@/components/shared/error-state"; export { InfiniteScrollSentinel } from "@/components/shared/infinite-scroll-sentinel"; +export { ModeSwitcher } from "@/components/shared/mode-switcher"; +export { RefetchErrorNotice } from "@/components/shared/refetch-error-notice"; export { ResourceTypeBadge } from "@/components/shared/resource-type-badge"; export { SecretKeyPicker } from "@/components/shared/secret-key-picker"; export { ViewToggle } from "@/components/shared/view-toggle"; +// ── layout/ ────────────────────────────────────────────────────────────────── +// The app chrome. AppLayout itself is deliberately NOT exported: it mounts the +// chat drawer, operator drawer and the onboarding tour, which drags Monaco and +// the operator tool-scope graph into the bundle. Sidebar + TopBar are the parts +// a design needs; compose them by hand around a page body. +export { MockDataBanner } from "@/components/layout/mock-data-banner"; +export { PageLoader } from "@/components/layout/page-loader"; +export { PlatformStatus } from "@/components/layout/platform-status"; +export { Sidebar } from "@/components/layout/sidebar"; +export { TopBar } from "@/components/layout/top-bar"; + // ── preview provider (not a card; used as cfg.provider) ────────────────────── export { DesignSyncProvider } from "./ds-providers"; diff --git a/.design-sync/previews/DropdownMenu.tsx b/.design-sync/previews/DropdownMenu.tsx new file mode 100644 index 000000000..dd8cc421b --- /dev/null +++ b/.design-sync/previews/DropdownMenu.tsx @@ -0,0 +1,29 @@ +import { + Button, + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuLabel, +} from "eddi-manager"; +import { Copy, Download, Trash2 } from "lucide-react"; + +// Radix portals the content to , so this card uses cardMode: single with +// a viewport — the same treatment AlertDialog needs. +export const Open = () => ( +
+ + + + + + Agent + Duplicate + Export + + Delete + + +
+); diff --git a/.design-sync/previews/MockDataBanner.tsx b/.design-sync/previews/MockDataBanner.tsx new file mode 100644 index 000000000..6bf29e864 --- /dev/null +++ b/.design-sync/previews/MockDataBanner.tsx @@ -0,0 +1,11 @@ +import { MockDataBanner } from "eddi-manager"; + +// The banner self-hides unless MSW is active. Previews run without MSW, so the +// flag is set here — a preview-only side effect, scoped to this module. +(window as unknown as Record).__EDDI_MOCK_ACTIVE__ = true; + +export const Active = () => ( +
+ +
+); diff --git a/.design-sync/previews/ModeSwitcher.tsx b/.design-sync/previews/ModeSwitcher.tsx new file mode 100644 index 000000000..e088542fc --- /dev/null +++ b/.design-sync/previews/ModeSwitcher.tsx @@ -0,0 +1,22 @@ +import { ModeSwitcher } from "eddi-manager"; + +// Reads the sidebar tokens, so preview it on the sidebar surface. +const shell = { + padding: 12, + width: 240, + background: "var(--color-sidebar)", + border: "1px solid var(--color-sidebar-border)", + borderRadius: 12, +} as const; + +export const Expanded = () => ( +
+ +
+); + +export const Collapsed = () => ( +
+ +
+); diff --git a/.design-sync/previews/PageLoader.tsx b/.design-sync/previews/PageLoader.tsx new file mode 100644 index 000000000..4276bcfbf --- /dev/null +++ b/.design-sync/previews/PageLoader.tsx @@ -0,0 +1,7 @@ +import { PageLoader } from "eddi-manager"; + +export const Default = () => ( +
+ +
+); diff --git a/.design-sync/previews/PlatformStatus.tsx b/.design-sync/previews/PlatformStatus.tsx new file mode 100644 index 000000000..595d40903 --- /dev/null +++ b/.design-sync/previews/PlatformStatus.tsx @@ -0,0 +1,9 @@ +import { PlatformStatus } from "eddi-manager"; + +// No backend in preview, so the pill settles on its offline state after the +// first probe — that is the state worth documenting anyway. +export const Pill = () => ( +
+ +
+); diff --git a/.design-sync/previews/RefetchErrorNotice.tsx b/.design-sync/previews/RefetchErrorNotice.tsx new file mode 100644 index 000000000..714f821fb --- /dev/null +++ b/.design-sync/previews/RefetchErrorNotice.tsx @@ -0,0 +1,16 @@ +import { RefetchErrorNotice } from "eddi-manager"; + +export const Default = () => ( +
+ {}} /> +
+); + +export const CustomMessage = () => ( +
+ {}} + message="Schedules could not refresh — showing the last poll." + /> +
+); diff --git a/.design-sync/previews/Sidebar.tsx b/.design-sync/previews/Sidebar.tsx new file mode 100644 index 000000000..1dbf2ab52 --- /dev/null +++ b/.design-sync/previews/Sidebar.tsx @@ -0,0 +1,20 @@ +import { useState } from "react"; +import { Sidebar } from "eddi-manager"; + +// The sidebar fills its parent's height, so the preview pins one. +const frame = { height: 720, display: "flex" } as const; + +export const Expanded = () => { + const [collapsed, setCollapsed] = useState(false); + return ( +
+ setCollapsed((c) => !c)} /> +
+ ); +}; + +export const Collapsed = () => ( +
+ {}} /> +
+); diff --git a/.design-sync/previews/TopBar.tsx b/.design-sync/previews/TopBar.tsx new file mode 100644 index 000000000..7e7ce8dfe --- /dev/null +++ b/.design-sync/previews/TopBar.tsx @@ -0,0 +1,9 @@ +import { TopBar } from "eddi-manager"; + +// Breadcrumbs come from the router; the provider's MemoryRouter starts at "/", +// so the preview shows the root crumb. Verify after the first sync. +export const Default = () => ( +
+ {}} sidebarVisible={false} /> +
+); diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..a90b0f11a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,45 @@ +# CLAUDE.md — EDDI Manager + +Start with **[AGENTS.md](AGENTS.md)**. It owns workflow, branch policy, quality gates, the +i18n mandate, architecture, API conventions and constraints — all of it applies. This file +adds the layer AGENTS.md does not cover: **what UI should be built from and what it should +look like.** + +## Load a skill before writing UI + +| Task | Skill | +|---|---| +| Anything that renders — a component, a card, a dialog | `.claude/skills/eddi-ui` | +| A whole page — list, detail, config editor, wizard | `.claude/skills/eddi-screens` | +| Data, routes, forms, i18n, tests | `.claude/skills/eddi-data` | + +## The five rules that catch most mistakes + +1. **Compose, don't recreate.** `src/components/ui/` (11 primitives) and + `src/components/shared/` (13 shared components) already exist. Import them. Do not + pull in a fresh shadcn/ui component, and do not hand-roll a button, badge, card, + dialog, empty state or error state. +2. **Colors come from tokens, never hex.** `bg-primary`, `text-muted-foreground`, + `border-border`, `bg-card`, `text-destructive`. Tokens are declared in `@theme` in + `src/index.css` and flip in dark mode. A literal `#f59e0b` in a component is a bug. +3. **Logical properties only.** `ps-*` / `pe-*` / `ms-*` / `me-*` / `start-*` / `end-*` / + `text-start` / `text-end`. Never `pl-`, `pr-`, `ml-`, `mr-`, `left-`, `right-`. The app + ships Arabic; `e2e/rtl.spec.ts` will catch you. +4. **Every user-visible string goes through `t("key", "Fallback")`** — then into + `en.json` and all 10 other locales in the same commit (AGENTS.md §2). +5. **`data-testid` on anything a test asserts on** — rows, buttons, inputs, states. + Existing naming: `channel-row-${id}`, `create-channel-btn`, `view-toggle-card`. + +## Variant props, not restyling + +`