Skip to content
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
109 changes: 109 additions & 0 deletions .claude/skills/ui-components/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<div className="flex flex-col gap-4">` 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 (`<Stack as="ul">`,
`<Stack as="form">`, `<Row as="nav">`) 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
`<Stack gap={0}>`.
- **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 `<Button size="icon">`. 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: <reason>` 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/<subpath>`
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 `<dl>` 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/<category>/`, ships a `*.stories.tsx` (all variants) and a
`checkAccessibility()` block, is skeleton-aware if it's a leaf, and gets a `@tale/ui/<name>` subpath
export.

## Patterns (show, don't tell)

CVA base + named `variant`, then `cn(...)` so `className` overrides
Expand Down
44 changes: 23 additions & 21 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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'
Expand All @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/update-models.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
Loading
Loading