diff --git a/.claude/skills/ui-components/SKILL.md b/.claude/skills/ui-components/SKILL.md
index 0f0ea19c55..ebedcf8f48 100644
--- a/.claude/skills/ui-components/SKILL.md
+++ b/.claude/skills/ui-components/SKILL.md
@@ -60,6 +60,115 @@ skeleton rules but lives next to its feature.
- **No hardcoded user-facing strings** — even in stories/tests. Route through the i18n hook (`useT`);
wording conventions are the [`translation`](../translation/SKILL.md) skill.
+## Composing pages — use the set, don't hand-roll
+
+The rules above build primitives; this builds _pages_ from them. **Feature pages and routes
+(`services/platform/app/**`) compose design-system components — they do not emit raw layout HTML.** A
+raw `
` on a page is the defect this prevents.
+
+- **Layout primitives** ([`@tale/ui/layout`](../../../packages/ui/src/components/layout/layout.tsx)):
+ vertical → `Stack`, horizontal → `Row`, responsive grid → `Grid`. They share one `gap` scale and take
+ `align`/`justify`/`wrap`, plus an `as` prop for semantic elements (`
`,
+ ``, ``) and `asChild` to merge onto a single child. `HStack`/`VStack`
+ are **deprecated aliases** of `Row`/`Stack`. A cluster of action buttons is the semantic `ActionRow`;
+ a titled section is `PageSection` (or `SettingsSection`). There is **no `Box`** — a neutral wrapper is
+ ``.
+- **One spacing scale** — `gapScale` in
+ [`layout.tsx`](../../../packages/ui/src/components/layout/layout.tsx). Recommended steps: **`2`** field
+ group (label → control → hint), **`4`** within a section (the default), **`6`** loose grouping, **`8`**
+ between sections (the settings rhythm). Never a raw `gap-[…]` or `space-y-*` for layout.
+- **Button size by context**
+ ([`button.tsx`](../../../packages/ui/src/components/primitives/button.tsx)): one height fits all —
+ **`default`** (`h-9`) for nearly everything (page/dialog/form/CTA actions) · **`sm`** (`h-8`) ONLY
+ for dense bars/toolbars (page-header action bars, table/card-header toolbars, the chat composer,
+ filter bars). There is **no `lg`**. Icon-only buttons are the same two heights, square —
+ **`icon`** (`size-9`) / **`icon-sm`** (`size-8`); prefer
+ [`IconButton`](../../../packages/ui/src/components/primitives/icon-button.tsx) (forces `aria-label`,
+ takes the same `size` axis) over a bare ``. A `size="icon"` button must hold a
+ **single icon** — the square clips text, so an icon+label control uses `default`/`sm` instead.
+- **Escape hatch.** Genuinely bespoke layout — chat/canvas, virtualization, geometry-measured
+ containers, responsive direction flips (`flex-col sm:flex-row`) — may stay raw with a one-line
+ `// raw layout: ` so it reads as deliberate, not an oversight.
+
+## Concept → component catalog
+
+One concept, one component. Find it here before writing layout markup; import via `@tale/ui/`
+unless noted.
+
+| Concept | Component | Concept | Component |
+| ----------------------- | ----------------------------------- | --------------------------- | -------------------------- |
+| Vertical group | `Stack` | Horizontal group | `Row` |
+| Responsive grid | `Grid` | Action-button cluster | `ActionRow` |
+| Center content | `Center` | Flex spacer | `Spacer` |
+| Page-width wrapper | `Container` / `NarrowContainer` | Titled section | `PageSection` |
+| Settings page / section | `SettingsPage` / `SettingsSection`¹ | Section header | `SectionHeader` |
+| Card | `Card` | Bordered subsection | `BorderedSection` |
+| Heading (h1–h6) | `Heading` | Body / muted / label text | `Text` |
+| Button / link-button | `Button` / `LinkButton` | Icon-only button | `IconButton` |
+| Text / multiline input | `Input` / `Textarea` | Field (label+control+error) | `Field` |
+| Select / searchable | `Select` / `SearchableSelect`² | Toggle | `Switch`² |
+| Checkbox | `Checkbox` | Slider | `Slider` |
+| List / table | `DataTable` (+ `useListPage`)¹ | Empty state | `EmptyState` |
+| Badge / status dot | `Badge` / `StatusIndicator` | Alert / callout | `Alert` |
+| Dialog / sheet | `ResponsiveDialog` | Popover / menu | `Popover` / `DropdownMenu` |
+| Tooltip | `Tooltip` | Tabs | `Tabs` |
+| Stat / stat group | `StatItem` / `StatGrid` | Code (inline / block) | `InlineCode` / `CodeBlock` |
+| Image | `Image` | Loading | `Skeletonize` / `Spinner` |
+
+¹ App-level (not `@tale/ui`): `SettingsPage`/`SettingsSection`, `DataTable`, `useListPage` live under
+`services/platform/app/components/` — import from `@/app/...`. ² `Select`, `SearchableSelect`, `Switch`,
+`RadioGroup` live in `app/components/ui/forms/` (app-level canonical — no `@tale/ui` rival); promote to
+`@tale/ui` only when a non-platform workspace (`web`/`docs`) needs them.
+
+Missing a concept? Extend the closest primitive or add a new one in `packages/ui` (story + a11y +
+skeleton-aware) — never a one-off in a feature folder.
+
+## One implementation per concept — the `@tale/ui` ↔ app layering
+
+There are two component layers, and **the same concept must never be implemented twice.** The split:
+
+- **`@tale/ui` owns the bare, shared primitive** (the control + its styling, once). It is consumed by
+ every workspace — `web` and `docs` included — and those **cannot import** `services/platform` code.
+ So any primitive a non-platform surface needs lives in `@tale/ui`, never in the app.
+- **The platform app composes that primitive; it never re-implements its styling.** The app layer adds
+ platform-only UX (label/description/error, password toggle, skeleton, i18n) _around_ the `@tale/ui`
+ control. Reference pattern:
+ [`app/.../data-display/image.tsx`](../../../services/platform/app/components/ui/data-display/image.tsx)
+ wraps `@tale/ui/image` and only adds the `BASE_PATH` fallback.
+
+**Rule:** before building a control under `app/components/ui/`, find the `@tale/ui` primitive and
+**compose it**. A second, divergent implementation (its own CVA/styling for an input, checkbox,
+tooltip, …) is a defect — it drifts. One canonical per concept.
+
+**Canonical per cross-layer concept** (use these; never fork a rival):
+
+| Concept | Canonical (use this) | Composes |
+| -------------------------------- | --------------------------------------------- | --------------------------------------------------------- |
+| Text input / textarea / checkbox | app `forms/{input,textarea,checkbox}` | `@tale/ui/{input,textarea,checkbox}` (bare control) |
+| Label | app `forms/label` (required/info/error) | `@tale/ui/label` |
+| Tooltip (simple) | app `overlays/tooltip` (`content`+`children`) | `@tale/ui/tooltip` (`TooltipContent`; raw parts for adv.) |
+| Settings section | app `SettingsSection` | `@tale/ui/page-section` |
+| Image | app `data-display/image` | `@tale/ui/image` |
+| Pagination | `data-table/data-table-pagination` | — (standalone `navigation/pagination` was dead, removed) |
+
+**Genuinely distinct — do NOT merge** (similar names, different concepts): `StatCard`/`StatCardGrid`
+(bordered headline-metric strip) vs `StatItem`/`StatGrid` (borderless `` key/value); `EmptyState`
+(full-height) vs `EmptyPlaceholder` (inline dashed); `@tale/ui/Field` (form wrapper) vs app `Field`
+(read-only display); the `Dialog → ConfirmDialog → DeleteDialog` / `FormDialog` / `ViewDialog`
+composition ladder; `InlineCode` vs `CodeBlock`; `Popover` (interactive) vs `Tooltip` (passive).
+
+**Known styling drift to reconcile** (the app control re-implements the primitive's look instead of
+composing it — route it through the `@tale/ui` primitive, reconcile to one token set, verify visually):
+`forms/input`, `forms/textarea`, `forms/checkbox` (border/focus tokens), `overlays/tooltip` (content
+background). Until reconciled, use the canonical above — don't add a third.
+
+## When to create a new primitive
+
+Reuse → extend (add a `variant`/prop) → compose → only then create. A new shared primitive lives in
+`packages/ui/src/components//`, ships a `*.stories.tsx` (all variants) and a
+`checkAccessibility()` block, is skeleton-aware if it's a leaf, and gets a `@tale/ui/` subpath
+export.
+
## Patterns (show, don't tell)
CVA base + named `variant`, then `cn(...)` so `className` overrides
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index a0ceb25468..b2d22eb1e6 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -39,32 +39,34 @@ concurrency:
jobs:
e2e:
- name: Playwright (platform ${{ matrix.shard }}/8)
+ name: Playwright (platform ${{ matrix.shard }}/16)
if: github.event_name != 'pull_request' || github.event.pull_request.draft != true
runs-on: ubuntu-latest
- timeout-minutes: 25
- # The suite outgrew a single serial 30-min run (workers:1 against one
- # backend). Shard the specs across parallel runners so each shard finishes
- # well inside the timeout instead of being cancelled mid-run. Each shard is
- # an independent job: it boots its own hermetic stack and runs the `setup`
- # auth project before its slice. fail-fast:false so one shard's failure
- # doesn't cancel the others — we want every shard's result.
+ timeout-minutes: 30
+ # The suite outgrew a single serial run (workers:1 against one backend).
+ # Shard the specs across parallel runners so each shard finishes well inside
+ # the timeout instead of being cancelled mid-run. Each shard is an
+ # independent job: it boots its own hermetic stack and runs the `setup` auth
+ # project before its slice. fail-fast:false so one shard's failure doesn't
+ # cancel the others — we want every shard's result, and a re-run of just the
+ # red shards (`gh run rerun --failed`) converges to green.
#
- # Shard count: 8. Each shard's cost is the FIXED per-shard boot (Chromium +
+ # Shard count: 16. Each shard's cost is the FIXED per-shard boot (Chromium +
# Convex + Vite preview + @tale/mocks) plus its serial test slice; a single
# worker per shard is mandatory (two starve the local Convex backend on the
# 4-vCPU runner — see the run step). Parallelism therefore comes only from
- # shards, so the lever for wall-clock is the test slice: 8 shards puts ~9 of
- # the ~74 tests per job (vs ~18 at 4) and halves the serial test time. We stop
- # at 8 rather than going higher because the boot floor dominates below ~9
- # tests — more shards would multiply CI minutes for a negligible wall-clock
- # gain. The prod build that used to sit on every shard's critical path is now
- # cached across shards/re-runs (see the dist cache step), so test-only e2e PRs
- # skip it entirely.
+ # shards, so the lever for wall-clock is the test slice: 16 shards puts ~5 of
+ # the ~74 tests per job. The boot floor dominates at this slice size, so this
+ # trades extra CI minutes for the shortest serial test time per shard —
+ # chosen deliberately so a shard is far less likely to hit the 30-min timeout
+ # when the local Convex backend runs slow under runner CPU contention. The
+ # prod build that used to sit on every shard's critical path is now cached
+ # across shards/re-runs (see the dist cache step), so test-only e2e PRs skip
+ # it entirely.
strategy:
fail-fast: false
matrix:
- shard: [1, 2, 3, 4, 5, 6, 7, 8]
+ shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
permissions:
contents: read
@@ -130,14 +132,14 @@ jobs:
working-directory: services/platform
run: bun run build
- - name: Run E2E suite (shard ${{ matrix.shard }}/8)
+ - name: Run E2E suite (shard ${{ matrix.shard }}/16)
working-directory: services/platform
# One worker per shard. Two workers over-subscribed the 4-vCPU runner
# (Vite + Convex + mock + the browser workers all share it): the local
# Convex backend starved and queries blew its hard 1s function-execution
# timeout in floods (100s+ per shard), stranding the WS unauthenticated
- # and failing tests non-deterministically. Parallelism comes from the 4
- # shards; each single-worker shard still finishes well inside the 25-min
+ # and failing tests non-deterministically. Parallelism comes from the 16
+ # shards; each single-worker shard still finishes well inside the 30-min
# budget. Tune via E2E_WORKERS.
env:
E2E_WORKERS: '1'
@@ -149,7 +151,7 @@ jobs:
# starve interactive queries past the backend's hard 1s timeout
# mid-test. Passed through to the deployment env by sync-convex-env.
TALE_E2E: '1'
- run: bunx playwright test --shard=${{ matrix.shard }}/8
+ run: bunx playwright test --shard=${{ matrix.shard }}/16
- name: Upload Playwright report
if: failure()
diff --git a/.github/workflows/update-models.yml b/.github/workflows/update-models.yml
index d42574e52c..08a9cd3f09 100644
--- a/.github/workflows/update-models.yml
+++ b/.github/workflows/update-models.yml
@@ -48,7 +48,7 @@ jobs:
- name: Detect changes
id: diff
run: |
- if [ -n "$(git status --porcelain builtin-configs/providers)" ]; then
+ if [ -n "$(git status --porcelain builtin-configs/providers docs/en/platform/models.md docs/de/platform/models.md docs/fr/platform/models.md)" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
@@ -69,7 +69,7 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "$BRANCH"
- git add builtin-configs/providers
+ git add builtin-configs/providers docs/en/platform/models.md docs/de/platform/models.md docs/fr/platform/models.md
# --no-verify: skip any husky hooks `bun install` may have registered.
git commit --no-verify -m "$TITLE"
# Bot-owned branch, recreated from HEAD each run → a plain force push is
diff --git a/AGENTS.md b/AGENTS.md
index 9da70a0e5d..c4052a06b6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,11 +5,7 @@ Cursor, Codex, Copilot, Gemini CLI). Read it in full before your first change. D
on-demand guides under [`.claude/skills/`](.claude/skills/) — this file is the contract; the skills
are the how-to. The index is at the bottom.
-Tale is a monorepo on Bun workspaces. Every workspace script runs through the filter:
-
-```bash
-bun run --filter @tale/