diff --git a/AGENTS.md b/AGENTS.md
index 6728c91..f7c5d23 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,39 +1,63 @@
# Agent instructions
-## Previewing the authenticated dashboard
-
-When checking UI changes through a preview or browser, start the app with agent mode enabled:
+What the app is and how to run it: [`README.md`](README.md). UI rules: [`docs/design.md`](docs/design.md).
+
+## Architecture
+
+- **Routes** (`src/routes/`). `/dashboard` (`dashboard.tsx`) resolves access in `beforeLoad`: signed in, then Telegram
+ account linked, then an admin role, otherwise it redirects to `/login`, `/onboarding/link` or
+ `/onboarding/unauthorized`. It puts `{ session, roles }` in the route context and renders the shell. Each page route's
+ `loader` calls server functions and passes the data to its page component. `/dashboard/web` adds a web-admin gate
+ (`web.tsx`; today the same roles).
+- **Features** (`src/features//`): the page, its dialogs, validation and the `*.functions.ts` server functions.
+- **Server functions** (`createServerFn`) each attach one middleware from `src/server/auth.middleware.ts`, which also
+ provides `context.backend`, a tRPC client for this request that forwards the user's cookies to the backend. Reads use
+ `adminMiddleware` (`webAdminMiddleware` under `/dashboard/web`). Mutations (`method: "POST"`) use their area's write
+ middleware: `writeAdminMiddleware` (Telegram users and grants, Microsoft 365), `groupWriteAdminMiddleware` (Telegram
+ and WhatsApp groups, reports) or `webWriteAdminMiddleware` (web content and labels).
+- **Roles** (`src/server/authorization.ts`): every admin role reads; write roles mutate; `web` also mutates web content
+ and groups. The UI hides controls the server would reject with `useCanWrite()` or `useCanWrite("web")`.
+- **Auth**: `/api/auth/*` proxies Better Auth to the backend (`src/server/auth-proxy*.ts`).
+- **Shared UI**: `src/components/shell` (dashboard chrome and navigation), `src/components/primitives` (page building
+ blocks), `src/components/ui` (shadcn/Base UI base, used through the primitives).
+
+## Conventions
+
+`pnpm test` enforces the items marked (test); its failure messages name what to add.
+
+- **Mutations** are called from event handlers through `useServerFn(fn)`; each new POST server function is listed with
+ its consumer file in `tests/server-security.test.mjs` (test). Afterwards `await router.invalidate({ sync: true })`, so
+ every loader (including the shell's open-reports count) has reloaded before the dialog closes or the toast shows. A
+ failed reload after a successful mutation is not a failed mutation: do not offer to repeat it. Optimistic updates
+ revert on failure and toast the error.
+- **Write scope**: each file calling `useCanWrite` is listed with its scope in the same test (test). The middleware
+ checks cover only the `*.functions.ts` files listed in that test: add a new one there.
+- **Errors**: every `catch` block and `.catch(handler)` logs `console.error(error)` (test). Route errors and not-found
+ states come from the router defaults (`RouteError`, `RouteNotFound`), so routes declare no `errorComponent`, and
+ `notFoundComponent` only for a specific state (Telegram user detail).
+- **New dashboard page**: add its section to `src/components/shell/nav.ts` (`PageMatch` and `matchPath`); the
+ `/dashboard` route derives `document.title` from it, so pages set no title.
+
+## Previewing
+
+Run with a high port (≥ 10000) that won't collide with other previews, and agent mode:
```sh
-PORT=xxxxx AGENT_MODE=true pnpm dev
+PORT=1xxxx AGENT_MODE=true pnpm dev
```
-Setting the `PORT=xxxxx` to an high enough port that is unlikely to hit
-another service or conflict with another workflow in preview (min 10000).
-
-Then open `/dashboard` directly (if not working on a page outside dashboard).
-`AGENT_MODE` bypasses session and role authorization and disables the auth-based
-redirects between `/login` and `/dashboard`, so no real account or login flow is needed.
-You can indipendently check /dashboard and /login for modifying those pages
-
-Use this flag only for local agent-driven development and previews.
-Never enable it in a deployed environment.
-
-When modifing auth-related code or redirects between authed and non-authed contexts,
-always verify that normal behavior still works with `AGENT_MODE=false`.
+`AGENT_MODE` (development only) signs in a fake administrator with every role and disables the auth redirects between
+`/login` and `/dashboard`, so open `/dashboard/...` or `/login` directly. Data still comes from `BACKEND_URL` (default
+`http://localhost:3000`). When changing auth or those redirects, also verify the normal flow with `AGENT_MODE=false`.
> [!IMPORTANT]
-> Do not run destructive actions across multiple rows, unless specific prompt indication or
-> ask for user confirmation ALWAYS.
-
-## Git commits
+> Never run a destructive action on more than one row unless the prompt explicitly asks for it; otherwise ask the user
+> first.
-Use Conventional Commits for every commit message:
+## Before handing back
-```text
-[optional scope][!]:
-```
+Run `pnpm check`, `pnpm typecheck`, `pnpm test` and `pnpm build`.
-Choose an accurate type such as `feat`, `fix`, `docs`, `refactor`, `test`,
-`build`, `ci`, or `chore`. Add a body when the change needs context. Mark
-breaking changes with `!` or a `BREAKING CHANGE:` footer.
+Commit with [Conventional Commits](https://www.conventionalcommits.org): `[scope][!]: `, using an
+accurate type (`feat`, `fix`, `docs`, `refactor`, `test`, `build`, `ci`, `chore`), a body when the change needs context,
+and `!` or a `BREAKING CHANGE:` footer for breaking changes.
diff --git a/README.md b/README.md
index a5747b6..fbdf64d 100644
--- a/README.md
+++ b/README.md
@@ -1,29 +1,33 @@
# PoliNetwork Admin
-The PoliNetwork operations console, rebuilt with TanStack Start, React 19, Vite+, Nitro, Tailwind CSS v4, and shadcn/ui.
+The dashboard PoliNetwork administrators use to manage Telegram and WhatsApp groups, group labels, Telegram users and
+grants, Microsoft 365 groups and members, the website's content (projects, associations, freshman guide, FAQs) and
+student reports.
+
+Built with TanStack Start (React 19), Vite+, Nitro, Tailwind CSS v4 and shadcn/Base UI. The app has no database: it
+reads and writes everything through the [PoliNetwork backend](https://github.com/PoliNetworkOrg/backend) over tRPC,
+and signs users in through the backend's Better Auth.
## Development
```bash
pnpm install
-pnpm dev
+pnpm dev # http://localhost:3001 (PORT overrides)
```
-Open `http://localhost:3001`. The production server is generated with `pnpm build` and starts with `pnpm start`.
-
-The UI uses shadcn's base components with a custom semantic theme in `src/styles.css`. The theme preserves the console's paper canvas, dark navy shell, cobalt `#1156ae` primary, DM Sans body copy, Libre Baskerville headings, and DM Mono metadata while keeping page layout in Tailwind utilities.
+| Variable | Meaning |
+| ------------- | -------------------------------------------------------------------------------------------------------- |
+| `BACKEND_URL` | Backend origin. Defaults to `http://localhost:3000` in development; required in production. |
+| `AGENT_MODE` | `true` signs in a fake administrator so the dashboard opens without a login. Ignored outside `pnpm dev`. |
-## Environment
+Before opening a pull request run `pnpm check`, `pnpm typecheck`, `pnpm test` and `pnpm build`.
-Set `BACKEND_URL` to the PoliNetwork backend origin. TanStack Start proxies Better Auth at `/api/auth/*`, and each server-function request creates its own tRPC client with that request's session cookie. Private dashboard functions also verify the linked Telegram identity and an administrator role before contacting the backend.
+## Production
-`AGENT_MODE=true` provides a fake administrator only while the app runs in development. Production ignores the flag.
+`pnpm build` writes the server to `.output/`; `pnpm start` runs it. Every push to `main` publishes the Docker image
+`ghcr.io/polinetworkorg/admin:latest`.
-## Checks
+## Documentation
-```bash
-pnpm typecheck
-pnpm test
-pnpm check
-pnpm build
-```
+- [`AGENTS.md`](AGENTS.md): architecture and code conventions (for humans and coding agents alike).
+- [`docs/design.md`](docs/design.md): the UI design system.
diff --git a/docs/design.md b/docs/design.md
new file mode 100644
index 0000000..192c0af
--- /dev/null
+++ b/docs/design.md
@@ -0,0 +1,328 @@
+# Design system
+
+The rules every dashboard page follows. Measurements and copy live in the code: token values in `src/styles.css`,
+component props and geometry in each primitive's JSDoc, page copy in the page. This file says which piece to use, where,
+and why. When you change a rule, change it here in the same commit.
+
+Login and onboarding pages use the same tokens but not the shell.
+
+## 1. Principles
+
+1. **Say it once.** Every place, control, label and number appears once per screen. No breadcrumbs, eyebrows, page
+ descriptions or `h1` on section pages; no count that repeats one already visible.
+2. **The chrome is for finding; the content is for doing.** Rail, panel and header bar locate and filter; nothing in
+ them explains. Help lives in empty states, field hints and dialog descriptions.
+3. **One accent, neutral everything else.** Brand blue marks the active location, the one primary action, links and
+ focus. Icons are `--pn-fg-muted`. The only multicolor marks are the Telegram, WhatsApp and Microsoft 365 logos.
+ Status uses the fixed tones, never ad-hoc colors.
+4. **Borders separate; shadows float.** Static surfaces (cards, tables, nav) get a 1px line; only layers above the page
+ (popover, menu, dialog, sheet, toast, drag ghost) cast a shadow.
+5. **Motion confirms, never decorates.** Animate only changes the user caused, mostly on `transform`/`opacity`, short
+ and ease-out. Switching pages, sorting, filtering, paging and keyboard moves never animate; the one exception is the
+ panel sliding in or out (§6).
+
+## 2. Shell and wayfinding
+
+```
+┌──────┬────────────────────┬──────────────────────────────────────────────────────────────────┐
+│ [PN] │ ✈ Telegram │ [🔍 Search by name or tag…] [Labels ▾] [All|Visible|Hidden] … │ header bar
+│ ▦ Ov │────────────────────│ │
+│ 🔍 ⌘K│ Users │ Group Telegram ID Tag Labels ⎘ ↗ 👁 🏷 ⇥│
+│──────│ ▌ Groups │ Analisi Matematica -1001234567 @am1 Didattica › … │
+│▌✈ Tg │ Grants │ … │
+│ ◯ Wa │ │ Showing 1–20 of 1,318 Rows 20 ▾ ‹ 1 2 … 66 › │
+│ ☁ M3 │ │ │
+│ ⊕ We │ │ │
+│ ⚑ Re │ │ │
+│ ☾ (LC) │ │
+└──────┴────────────────────┴──────────────────────────────────────────────────────────────────┘
+ rail panel content column: header bar + scrolling main
+```
+
+Vocabulary, used throughout:
+
+- **Service**: a rail entry (Overview, Telegram, WhatsApp, Microsoft 365, Web, Reports, Account).
+- **Section**: a panel entry inside a service (Telegram › Groups).
+- **Deep page**: a page under a section that is not in the panel (Telegram user, category node, tag page).
+- **Header bar**: the bar on top of the content column, rendered by each page through `PageBar`.
+- **Toolbar**: `lead` (controls that scope the page, such as the FAQ category), search, filters and `Count`; on section
+ pages it is the header bar's left slot.
+
+Services and sections are defined once, in `src/components/shell/nav.ts`, with each section's search placeholder; rail,
+panel, sheet, command palette and Overview read from it. Services are drawn with `ServiceGlyph` (the branded logo for
+Telegram, WhatsApp and Microsoft 365, lucide otherwise): never import the SVGs.
+
+**Where each level is named**
+
+| Level | Named by |
+| ------- | --------------------------------------------------------------------------------------------------- |
+| Service | the panel header (the rail only highlights it; its name is a tooltip) |
+| Section | the active panel item; the shell also renders an sr-only `h1` |
+| Record | the `h1` in `RecordHeader` on deep pages; the header bar shows the parent context and a back button |
+| Page | Overview and Account have no panel, so `PageBar title` renders their `h1` in the header bar |
+
+**Rail.** One tab stop with roving focus (`↑/↓`, `Home/End`). A service opens the last section visited in that service
+this session, else its first. The logo is decoration (Overview right below it leads home). Theme toggle and the account
+avatar sit at the bottom; sign out lives on the Account page.
+
+**Panel.** Rendered only for services with two or more sections (Telegram, Microsoft 365, Web, Reports). WhatsApp,
+Overview and Account have none, and the content column takes the width. On deep pages the parent section stays
+highlighted (`data-ancestor`). The only count in the panel is the open reports next to Reports › Open. (`nav.ts`'s
+`panelServices` are the services that have sections, for rail, sheet and palette; the panel itself needs two.)
+
+**Header bar.** Sits outside the scroll container (`main` scrolls, the document never does), so search and actions never
+scroll away. `/` focuses the page search from anywhere; `Esc` clears it. Left/right slots per template:
+
+| Template | Left | Right |
+| ----------------- | --------------------------------------------------------------------- | --------------------------------------------------- |
+| Section page | `Toolbar`: lead → search → filters → `Count` | one primary action, at most one `outline` secondary |
+| Deep page | `BackButton` → parent context (mono when it is an id) → `ScrollTitle` | record actions; the primary is right-most |
+| Overview, Account | `h1` | — |
+
+`ScrollTitle` fades the record name into the bar only while the content `h1` is scrolled out of view.
+
+**Small screens.** Below 1024px the panel leaves the layout and becomes a left `Sheet` opened from a button at the start
+of the header bar; on section pages the bar gains a first row reading "{Service} › {Section}" and the toolbar wraps
+below it. Below 640px the rail is hidden too and the sheet is the only navigation (it adds Overview, Search, Theme and
+Account rows).
+
+**Command palette** (`⌘K`/`Ctrl+K`): sections and shell actions (theme, account, sign out); records are not searchable
+there.
+
+## 3. Page templates
+
+Wrap every page body in `PageContent` and give `PageBar` the same `width` (both default to `wide`), so bar and content
+share edges.
+
+| Template | `width` | Used by |
+| --------------- | ---------- | ------------------------------------------------------------------------------ |
+| List | `wide` | Telegram users, groups, grants; WhatsApp groups; Members; Freshman guide; FAQs |
+| Queue | `wide` | Reports › Open, Reports › Closed |
+| Card collection | `wide` | Projects, Associations |
+| Grouped | `wide` | Microsoft 365 groups |
+| Browser | `wide` | Categories root and nodes, tag page |
+| Tree | `tree` | Labels |
+| Record | `record` | Telegram user |
+| Settings | `settings` | Account |
+| Overview | `overview` | Overview |
+
+**List.** A `DataTable` with search and filters in the toolbar. One row height (44px), no dense mode. Columns declare a
+priority; the lowest hide first when the table surface narrows, never the first or the actions column. Give columns
+whose content length varies a `width` (the table switches to fixed layout and the title column takes the rest), so
+nothing jumps across search, filters and paging. Pagination shows in the table footer once rows exceed the smallest page
+size (20; options 20/50/100). The page resets to page 1 itself when search, filters or sort change (`DataTable`
+doesn't). List state that other pages link to (`?q=`, `?visibility=`) goes through the route's `validateSearch`. Without
+write access the primary action and mutation controls disappear (read-only actions such as copying an invite link stay).
+
+**Queue.** A list whose rows are worked off: segmented filter with totals, row click opens the reported group or
+category, and resolve/dismiss remove the row optimistically.
+
+**Card collection.** Cards edited in place (`InlineEditCard`, §5). New items start as a draft card on top. Cards in a
+row share their height so footers line up.
+
+**Grouped.** Collapsible surfaces (`Reveal`) with a count in the heading; open state persists in session storage and
+search expands every group.
+
+**Browser.** `NavCard` grids to walk a hierarchy, then the items at this level in a table. On node pages the header
+bar's left slot holds the parent path, so the search sits in the table's `SectionHeading` instead (the one exception).
+
+**Tree.** One bordered surface per section, 44px rows, indentation for depth, a rotating chevron. Expanded nodes persist
+in session storage; search force-expands.
+
+**Record.** `RecordHeader` (avatar, `h1`, meta, chips), then `SectionCard`s in one column, no summary tiles.
+
+**Settings.** `SectionCard`s in one column, each with a title and an action slot. The destructive card goes last,
+further apart, without a red border.
+
+**States.** While a route loads, its `pendingComponent` renders `PageBar`, `PageContent` and the template's skeleton
+(`TableSkeleton`, `CardsSkeleton`, `RecordSkeleton`, `SettingsListSkeleton`, labelled "Loading {things}…"), so nothing
+shifts when data arrives. Empty uses `EmptyState` (page) or `SectionEmpty` (one card or slot), see §7. A failed loader
+renders the route error; pages that load parts independently (Overview, Account) show an inline warning with Retry
+instead.
+
+## 4. Tokens
+
+Use only the `--pn-*` variables (or the Tailwind utilities that resolve to them). Raw hex and Tailwind palette colors
+(`slate-*`, `amber-*`…) are defects; the one exception is label colors, which are user data and go through `LabelChip`.
+shadcn's variables (`--primary`, `--border`…) are remapped to `--pn-*` in `styles.css`, so `@/components/ui/*` re-theme
+without edits. Light borders are alpha so they composite; dark borders are solid so they don't glow.
+
+| Role | Variables |
+| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Canvas and surfaces | `--pn-bg` canvas · `--pn-surface` cards, tables · `--pn-surface-raised` popover, menu, dialog, toast · `--pn-nav` rail and panel |
+| Fills | `--pn-muted` hover, skeleton, neutral fills · `--pn-nav-active` active panel item · `--pn-scrim` backdrop (no blur) |
+| Lines | `--pn-line` static borders · `--pn-line-strong` inputs, dividers on muted fills |
+| Text | `--pn-fg` · `--pn-fg-muted` secondary text and icons · `--pn-fg-subtle` placeholders, disabled, decorative glyphs; never information |
+| Accent | `--pn-accent(-hover)` text, icons, links · `--pn-accent-solid(-hover, -fg)` primary button · `--pn-accent-soft(-hover)` active/selected fills · `--pn-focus` · `--pn-selection` |
+| Status | `--pn-{success,warning,danger}-{fg,bg,solid}`, `--pn-info-{fg,bg}` |
+| Row-action tones | `--pn-action-{blue,green,amber,red}`, `--pn-action-{green,amber}-tint`: only through `IconButton appearance="tinted"` |
+| Elevation | `--pn-shadow-float` popover, menu, toast · `--pn-shadow-modal` dialog, sheet · `--pn-media-ring` images, avatars |
+| Radii | `--pn-r-1` 4 kbd, swatches · `-2` 6 menu/panel items · `-3` 8 `sm` buttons, rail items, search field · `-4` 10 cards, tables, popovers · `-5` 12 dialogs, sheets, toasts · `-full` badges, chips, avatars |
+| Motion | `--pn-ease-out`, `--pn-ease-in`, `--pn-ease-move` |
+
+Nested radii: inner = outer − padding.
+
+**Spacing.** 4px grid (4, 8, 12, 16, 20, 24, 32, 48). Lay out with `gap`, not per-child margins. Card padding 16px (20px
+on Settings).
+
+**Type.** DM Sans, DM Mono for identifiers only (Telegram IDs, chat ids, label paths, versions). Sizes: 12 captions,
+hints, table headers, meta · 13 table cells, rows, panel items, dialog descriptions · 14 body, inputs · 15/600 header
+bar `h1`, dialog and card titles · 18/600 empty-state titles · 20/600 `RecordHeader` `h1` · 28/600 stat numbers. Weights
+400/500/600, and never change on hover, active or selected. No uppercase (no eyebrows, no uppercase table headers; the
+IT/EN language codes are the exception) and no italic. Numbers, counts and dates get `tabular-nums` and never wrap.
+Headings `text-wrap: balance`, body `pretty`. Truncate with an ellipsis plus a `title`. Use `…`, not three dots. Inputs
+are 16px on coarse pointers (no iOS zoom).
+
+**Focus.** One global ring (`:focus-visible`, `--pn-focus`) in `styles.css`; inputs swap it for a border plus soft ring;
+invalid fields show the danger border. Opt out only with `data-focus-ring="none"` when the element draws its own.
+
+## 5. Components
+
+Build from `@/components/shell` and `@/components/primitives`; reach into `@/components/ui` only for what the primitives
+don't wrap. Each primitive's JSDoc states its props and rules. In the dashboard these `ui/` parts are replaced: `card`
+and `alert` by `SectionCard`/surfaces and `InlineAlert` (they remain for login and onboarding); `collapsible` and
+accordion's `AccordionContent` by `Reveal` (or Base UI `Accordion.Panel` with the grid-rows transition, as in FAQs),
+because they animate height; `skeleton` by the primitives' skeletons.
+
+**Tables (`DataTable`).** Sentence-case 12px headers, sticky. Sortable headers are buttons with `aria-sort`; an unsorted
+column shows its sort icon only on hover or focus. Numeric quantities right-aligned; identifiers mono, left-aligned. A
+clickable row is focusable, opens on `Enter`/`Space`, has `aria-label="Open {name}"` and its first cell is a real link
+(`rowHref`; return `null` for rows that go nowhere). Only the first column may carry a 12px secondary line. Chip lists
+never wrap (`ChipOverflow`), so rows stay 44px.
+
+**Row actions** (`RowActions`, `IconButton`). Order: other actions → edit → delete → `⋮` menu, the menu always far
+right. Always visible. Default `ghost` in `--pn-fg-muted`; group tables use `appearance="tinted"` with a tone per action
+(blue visible, gray hidden, green edit, amber labels, red leave/delete). In tables mixing Telegram and WhatsApp rows an
+action one platform lacks keeps an empty 36px slot, so the columns of icons align.
+
+**Badges and chips.** `StatusBadge` (dot + label, tones `neutral`/`brand`/`success`/`warning`/`danger`) only for states:
+grant Active (success), Scheduled (brand); report Pending (warning), Resolved (success), Dismissed (neutral); session
+"Current" and guide edition "Latest" (brand); Draft (warning). Absence of a state shows nothing (no "Visible", "Public"
+or "Published" badges). `Chip` for categorical values (roles, licenses, IT/EN markers). `LabelChip` for labels.
+`CountBadge` for neutral counts such as `×3` duplicates. Platforms are a 14px `PlatformGlyph` (an image with alt text),
+not a text badge.
+
+**Buttons.** Text buttons `size="sm"` (36px), icon buttons `icon-sm` (36px); the 40px `default` size only in dialog
+footers; never `xs`. One `default` (primary) button per region (header bar, dialog footer, form card); secondaries
+`outline`, tertiary `ghost`. Destructive in page content is an `outline` with danger text, solid danger only inside a
+confirm dialog. Every icon button has an `aria-label` and a tooltip with the same text. A raw `ui` `Button` gets
+`className={buttonMotion}`; pending actions use `LoadingButton` (spinner, `disabled`, `aria-busy`, motion included).
+Labels are verb + object in sentence case ("Add group", "Publish 12 groups").
+
+**Dialogs.** Forms use `FormDialog` (`md` one column, `lg` two columns or steppers); confirmations use `ConfirmDialog`
+(400px `AlertDialog`, no close button, no outside-click close, focus on Cancel). Header: title + description, no icon,
+no eyebrow. Footer right-aligned: `Cancel` (`outline`) then the primary. `Enter` submits single-line fields,
+`⌘/Ctrl+Enter` textareas. On error an `InlineAlert` appears above the footer and the dialog stays open. On success a
+toast fires and the dialog closes: `ConfirmDialog` closes itself, `FormDialog` doesn't, so call `onOpenChange(false)`
+after the mutation, and keep the dialog's record in state after closing so its content survives the exit animation. A
+dirty form intercepts every close with the discard confirmation (`FormDialog` does it when the dialog passes `dirty`).
+Key a dialog body with `useOpenGeneration` so every opening starts clean. Autofocus the first field on fine pointers
+only.
+
+**Popover or dialog.** Popover only when all hold: not destructive, at most one input or a picker, at most 320px wide,
+and closing without acting is harmless. Otherwise a dialog. Every delete is a `ConfirmDialog`.
+
+**Inline editing** (`InlineEditCard`, `InlineEditRow`, `useEditSlot`), used by Projects, Associations, FAQs and Labels:
+
+1. `✎` among the actions enters edit mode. One item per page edits at a time; starting another closes a clean one or
+ asks to discard a dirty one.
+2. Each text becomes a field in the same place, the card keeps its width and grid position, and its border turns dashed
+ accent.
+3. Footer: validation message or the shortcut hint (hidden on coarse pointers) left; tinted cancel (`X`) and save
+ (`Check`, enabled when valid and dirty) right. `Esc` cancels, `Enter`/`⌘Enter` saves. A shortcut on an unchanged
+ record closes the editor; on an invalid one it focuses the first invalid field.
+4. Saving shows the spinner in the save button; errors appear in the footer, success closes edit mode with a toast.
+
+**Bilingual text** (`TranslationGroup`/`TranslationPanel`): one panel per language, Italian first, side by side from
+560px container width, stacked below; each panel sets `lang`. In edit mode the panel itself is the field (`bare` inputs
+where the text was), so nothing moves. Single-line list rows use IT/EN tiny chips instead.
+
+**Forms** (`FormField`, `fieldControl`). Label above, real `