Skip to content
This repository was archived by the owner on Sep 17, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .claude/skills/eddi-data/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 `<Outlet />`. 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.
185 changes: 185 additions & 0 deletions .claude/skills/eddi-screens/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
---
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

Most pages live in `src/pages/` and render inside `AppLayout` (sidebar + top bar) via its
`<Outlet />`. The layout already supplies `p-6`, `max-w-screen-2xl`, scroll, and a
`@container/main` context — such a page renders its own content only, no shell, no width cap,
no page background.

**Three surfaces deliberately render outside `AppLayout`** and own their full frame:
`landing-page.tsx` (`/welcome`), `agent-studio.tsx` (`/manage/studio/:agentId`, a full-screen
breakout), and everything under `src/pages/workforce/` (mounted on `WorkforceLayout`, a
standalone app with no Manager chrome). Check `src/app.tsx` before assuming the shell is
there — those pages *do* supply their own padding, and the rules below are about the
`AppLayout` ones.

## Page skeleton

Most pages are a single `space-y-6` column — 29 of the 40 files in `src/pages/`.
Reference: `src/pages/agents.tsx`.

```tsx
<div className="space-y-6">
{/* 1. Header: icon + title + subtitle, actions right */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="flex items-center gap-2 text-3xl font-bold text-foreground">
<Bot className="h-8 w-8 text-primary" />
{t("pages.agents.title", "Agents")}
</h1>
<p className="mt-1 text-muted-foreground">
{t("pages.agents.subtitle", "Build and deploy conversational agents")}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" data-testid="import-agent-btn">…</Button>
<Button onClick={() => setCreateOpen(true)} data-testid="create-agent-btn">
<Plus className="h-4 w-4" />
{t("createOrWizard.newAgent", "New Agent")}
</Button>
</div>
</div>

{/* 2. Optional guidance banner */}
{/* 3. Toolbar: search + ViewToggle */}
{/* 4. Content: loading → error → empty → card grid or table */}
{/* 5. Dialogs last */}
</div>
```

Rules that hold across pages:

- **Do not add `p-6` to the page root.** `AppLayout`'s `<main>` already applies
`p-6` inside `@container/main mx-auto max-w-screen-2xl`, so a page that pads itself
renders at 48px. Every page currently complies; keep it that way.
- `h1` is `flex items-center gap-2 text-3xl font-bold text-foreground` with an
`h-8 w-8 text-primary` Lucide icon. **Heading size is genuinely mixed** — 22 `text-3xl`
against 18 `text-2xl` (plus 3 `text-xl` on dense detail headers). `text-3xl` is the one to
reach for on a new top-level page, but match the neighbouring screens rather than treating
either as absolute.
- 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` — all 6 pages with a `ViewToggle` do.
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
// A failed *background* refetch keeps the last good data (see the gating rule
// below), so only a failed initial load has nothing left to show.
const loadFailed = isError && !data;

{isLoading && (
<div className="cq-card-grid" data-testid="agents-loading">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-xl border border-border bg-card p-5 space-y-3">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
))}
</div>
)}

{loadFailed && (
<ErrorState message={t("common.error", "Something went wrong")}
onRetry={() => refetch()} retryLabel={t("common.retry", "Retry")} />
)}

{!isLoading && !loadFailed && items.length === 0 && (
<EmptyState icon={Bot}
title={search ? t("common.noResults", "No results found")
: t("agents.empty", "No agents yet")}
description={!search ? t("agents.emptyDescription", "Use the wizard to create one.") : undefined}
actionLabel={!search ? t("agents.createAgent", "Create Agent") : undefined}
onAction={!search ? () => setCreateOpen(true) : undefined} />
)}
```

Use the `Skeleton` primitive inside a card-shaped wrapper, not a bare `animate-pulse` div.
`ErrorState` is in 19 pages, `EmptyState` in 9. Empty state distinguishes "no results for
this search" from "nothing exists yet", and only the latter offers the create action.

**Every page that loads data has an error branch — keep it that way.** Falling through to
the empty state on a failed fetch is the recurring bug here: it tells the user "nothing
exists yet" or "data will appear automatically" when the truth is "we could not reach the
backend". Pick the shape by surface:

- `ErrorState` — replaces the container. For a page or panel with nothing else to show.
Carries `data-testid="error-state"` and `data-testid="error-state-retry"`; assert on those
rather than on the translated copy, which changes with the locale.
**Gate it on the data being absent** (`isError && !data`), not on `isError` alone:
TanStack Query keeps the last good result when a background refetch fails (and
`refetchOnWindowFocus` is on by default), so an ungated branch replaces a usable page
with an error over a focus blip. `isError` alone is only correct when there can be no
cached data.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- `RefetchErrorNotice` — a compact amber strip that keeps the last good data on screen.
For a *background* refetch failure on a polling page, or an inline control (a picker
inside a form) where a full error box would be out of scale. Pass an explicit `message`
when the initial load failed — its default wording says data is merely stale.
- A neutral "unknown" state — when the value drives a decision. `gdpr.tsx` renders a grey
"status unavailable" chip rather than its green "Processing Active" badge, and disables
the toggle, because reporting an unknown restriction state as a known-safe one is worse
than showing nothing.

## 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}\`}`.
Comment on lines +140 to +144

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use logical padding in the table example.

Line [125] documents px-4, which uses physical horizontal padding. Replace it with logical utilities so the example follows the RTL rules.

Proposed correction
- header row `text-start px-4 py-3 font-medium`
+ header row `text-start ps-4 pe-4 py-3 font-medium`

As per coding guidelines: Use logical properties and utilities for RTL support.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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}\`}`.
Table: wrap in `rounded-xl border border-border/50 overflow-hidden`, header row
`border-b bg-muted/50` with `text-start ps-4 pe-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}\`}`.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/skills/eddi-screens/SKILL.md around lines 124 - 128, Update the
table example’s header cell styling to replace the physical horizontal padding
utility px-4 with the equivalent logical inline-padding utility, while
preserving the existing vertical padding and other classes.

Source: Coding guidelines


## 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
<ConfigEditorLayout
typeName={t("rulesEditor.title", "Behavior Rules")} typeIcon={GitBranch}
resourceId={id} data={json} versions={versions} currentVersion={version}
onVersionChange={setVersion} onSave={save} onSaveAndDeploy={saveAndDeploy}
renderFormEditor={(parsed, onChange, readOnly) => (
<RulesEditor data={parsed} onChange={onChange} readOnly={readOnly} />
)}
/>
```

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.
133 changes: 133 additions & 0 deletions .claude/skills/eddi-ui/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
---
name: eddi-ui
description: EDDI Manager's UI vocabulary — the 25 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: `open`, `onOpenChange`, `title`, `description`, `onConfirm`, `confirmLabel`, `cancelLabel`, `isPending`, `variant` (`destructive` \| `warning`), plus `children` for extra controls between description and buttons (e.g. a "permanently delete" checkbox). 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. |
| `UpdateCheckCard` | "Is a newer EDDI released?" panel for the dashboard. No props. Opt-in — issues no request until the operator asks. |

### `src/components/layout/` — the shell

| Component | Notes |
|---|---|
| `AppLayout` | Owns the whole frame: sidebar, top bar, drawers, onboarding, and `<main>` 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. |
| `UpdateBanner` | New-release strip, mounted by `AppLayout`. No props; self-hides unless the opt-in check is on and an update is available. |
| `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
<Card className="max-w-md">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{agent.name}</CardTitle>
<Badge variant="success">{t("agents.deployed", "Deployed")}</Badge>
</div>
<CardDescription>{agent.description}</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">…</CardContent>
<CardFooter className="gap-2">
<Button size="sm">{t("common.open", "Open")}</Button>
<Button size="sm" variant="outline">{t("common.configure", "Configure")}</Button>
</CardFooter>
</Card>
```

Search field (note `ps-9` and `start-3`, not `pl-9`/`left-3`):

```tsx
<div className="relative flex-1 max-w-sm">
<Search className="absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input className="ps-9" placeholder={t("x.search", "Search…")} value={q}
onChange={(e) => setQ(e.target.value)} data-testid="x-search" />
</div>
```

## 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.**
Loading