docs(16.3): UI design contract

This commit is contained in:
2026-10-11 00:05:21 +02:00
parent a04ba35271
commit 941ae80b86

View File

@@ -0,0 +1,367 @@
---
phase: "16.3"
slug: "backend-admin-personal-api-tokens-with-scopes"
status: approved
reviewed_at: "2026-10-11"
shadcn_initialized: false
preset: none
created: "2026-10-10"
---
# Phase 16.3 — UI Design Contract
> Visual and interaction contract for frontend phases. Generated by msd-ui-researcher, verified by msd-ui-checker.
This phase adds one surface to the existing admin SPA (`admin/`): an **API tokens** card on the My account page (`AccountView.vue`, route `/account`). The card lists the signed-in user's own tokens, creates a token in a two-step dialog (form, then show-once plaintext), and revokes tokens (D-16). There is no other screen: no superuser view and no backend-user management screen (deferred). The CLI (`admin:token:*`, D-19) has no visual contract beyond its stdout rule in CONTEXT.md.
The SPA already has a complete design system: Direction C v2 (`.planning/phases/10-admin-vue-spa/design/README.md`), implemented as Tailwind 4 `@theme` tokens in `admin/src/styles/main.css` and built on Reka UI primitives. **This contract adds no new tokens, colours, fonts, radii or shadows.** Every value below reuses an existing token or copies a class string from an existing component (`AccountView.vue`, `RelationPickerModal.vue`, `ConfirmDialog.vue`, `PermissionEditorField.vue`, `CheckboxField.vue`, `Button.vue`, `control.ts`).
Sources: 16.3-CONTEXT.md D-06, D-08, D-09, D-11, D-16, D-17, D-18 (locked); Phase 12.2 UI-SPEC (approved house conventions for dialogs, chips, alert blocks, skeletons and copy); the codebase scan listed above; `modules/phrasebook/backend/lang/{en,pl}/lang.yaml` (`myaccount.*`, `form.*`).
---
## Design System
| Property | Value |
|----------|-------|
| Tool | none (no shadcn; Vue 3 SPA with its own Direction C system; the shadcn gate does not apply to a Vue codebase with an established system, same ruling as Phases 10.1, 12.1, 12.2) |
| Preset | not applicable |
| Component library | Reka UI 2.9.10 (headless primitives, the Vue port of Radix) plus the local components in `admin/src/components/` |
| Icon library | `@lucide/vue` 1.17.0 (named imports only; 16px in buttons and inline notices, 14px in error and note lines, 20px in the empty-state circle) |
| Font | DM Sans 400/600/700 (self-hosted `@fontsource/dm-sans`); DM Mono 400 (`font-mono`) for the token plaintext, the token hint and scope names; `tabular-nums` on every date and time |
---
## Component Inventory
Enumerated by `node --input-type=module -e "import * as r from 'reka-ui'; console.log(Object.keys(r).length)"` (run in `admin/`) — 478 exports — reka-ui@2.9.10 — 2026-10-10.
Local components enumerated by `find admin/src/components -name '*.vue' | wc -l` — 51 components — summercms-admin@unversioned (private workspace package with no `version` field in `admin/package.json`; pinned by repo commit a04ba35) — 2026-10-10.
Icon presence verified by `node --input-type=module -e "import * as l from '@lucide/vue'; ..."` (run in `admin/`) against @lucide/vue@1.17.0 on 2026-10-10: `Plus`, `KeyRound`, `Copy`, `Check`, `Ban`, `TriangleAlert`, `CircleAlert`, `Info`, `LoaderCircle`, `X` all exported.
This table is a **non-exhaustive** list of known-good components for this phase, not a closed allowlist. Checking for another Reka export or local component is the expected path.
| Component | Import path | Notes |
|-----------|-------------|-------|
| DialogRoot, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogDescription, DialogClose | `reka-ui` | The create-token dialog (both steps). Same parts and classes as `RelationPickerModal.vue`. Verified exported. |
| RadioGroupRoot, RadioGroupItem | `reka-ui` | The expiry segmented control. Same segmented classes as `PermissionEditorField.vue`. Verified exported. |
| AlertDialog* via ConfirmDialog | `admin/src/components/ui/ConfirmDialog.vue` + `useConfirm` from `confirm.ts` | Revoke confirmation and the "close without copying" confirmation. `AccountView` already mounts one `ConfirmDialog` bound to `useConfirm()`; reuse that instance. Do not build a second confirm. |
| Button | `admin/src/components/ui/Button.vue` | Every text button. Variants `primary` / `outline` / `ghost` / `danger`, sizes `md` (42px) / `sm` (38px). |
| `controlClass` | `admin/src/components/form/control.ts` | Border, background and padding of the name input and the plaintext input. |
| `fieldErrors`, `focusField` | `admin/src/components/form/formState.ts` | Map a 422 `error.details` to field errors and focus the first invalid field (same as `AccountView.onSave`). |
| `showToast` | `admin/src/state/useToasts.ts` | `success` toast after revoke; `danger` toasts for failures. Never carries the plaintext. |
| `t`, `tc` | `admin/src/app/i18n.ts` | All copy. No literal UI strings in the component. |
| `api` (openapi-fetch client) | `admin/src/api/client.ts` | Typed calls to the D-18 endpoints after `npm run gen:api` regenerates `schema.d.ts`; keeps the 401 refresh-and-replay. |
| Native checkbox (`size-5 shrink-0 rounded-[6px] accent-primary`) | class string from `CheckboxField.vue` | Scope picker rows and the "Show revoked tokens" toggle. Not a field-registry control: used directly. |
New code this phase creates (planner names the file): one component, suggested `admin/src/components/account/ApiTokensPanel.vue` (card, list, revoke), with the dialog either inline or in a sibling `ApiTokenCreateDialog.vue`. `AccountView.vue` renders the panel after the profile/password `<form>` and passes it the shared `confirm` from `useConfirm()`.
---
## Spacing Scale
Declared values for new layout in this phase (multiples of 4):
| Token | Value | Tailwind | Usage |
|-------|-------|----------|-------|
| xs | 4px | `gap-1` | Label-to-mono-name gap in a scope row; icon-to-text inside chips |
| sm | 8px | `gap-2` | Scope chip gap, toggle checkbox-to-label gap, row line gap, plaintext input to copy-row gap, empty-state stack gap |
| md | 16px | `gap-4`, `px-4` | Card inner stack gap, row horizontal padding, row column gap, dialog section gap, card header gap |
| lg | 24px | `p-6`, `px-6` | Dialog padding; empty-state horizontal padding |
| xl | 32px | `py-8` | Empty-state vertical padding |
| 2xl | 48px | — | Not used by new components |
| 3xl | 64px | — | Not used by new components |
**Locked user decision (2026-10-11):** these inherited Direction C values are accepted for this phase; any new layout not copied from an existing component uses the 4/8/16/24/32 scale. The user explicitly accepted the 18, 6, 10, 14, 2, 20, 28 and 12px values below (`gap-[18px]`, `gap-1.5`, `gap-2.5`, `py-3.5`, `px-[18px] py-3.5`, `mt-0.5` / `gap-0.5`, `gap-5`, `p-7`, `gap-3` / `py-3`) as locked exceptions. Re-verification treats them as locked, not as defects.
Exceptions (inherited from Direction C, `AccountView.vue` and `ConfirmDialog.vue`, binding, not new):
- card padding 28px (`p-7`) and page stack gap 20px (`gap-5`), copied from the existing account cards;
- field stack gap 18px (`gap-[18px]`) inside the dialog form, copied from the account `fieldset`;
- label-to-control gap 6px (`gap-1.5`);
- footer button gap 10px (`gap-2.5`), copied from `ConfirmDialog`;
- row vertical padding 14px (`py-3.5`), copied from the `CheckboxField` toggle card;
- alert block padding 14×18 (`px-[18px] py-3.5`), copied from the 12.2 alert block;
- segmented control inner padding 4px (`p-1`) and segment height 34px (`h-pager`), copied from `PermissionEditorField`;
- scope-row checkbox-to-text gap and dialog header gap 12px (`gap-3`), the same exception 12.2 approved for picker option rows and dialog headers; scope-row vertical padding 12px (`py-3`), copied from the `PermissionEditorField` option rows;
- 2px nudges (`mt-0.5` on the scope checkbox and notice icon, `gap-0.5` between error lines), copied from the existing error-line markup. No other off-scale spacing is introduced.
Fixed component dimensions (sizes, not spacing): 44px inputs (`h-input`), 42px / 38px buttons, 34px dialog close button, 40px empty-state icon circle, 24px chips (`h-6`), 20px checkboxes, 560px dialog max width (`max-w-[560px]`), 56px minimum scope-row height (`min-h-[56px]`), 12px skeleton bars with `rounded-[6px]`.
---
## Typography
New components use exactly four sizes. All inherit DM Sans and the 14px/1.5 base from `html` in `main.css`.
| Role | Size | Weight | Line Height | Used by |
|------|------|--------|-------------|---------|
| Body | 14px | 400 | 1.5 | Token name input, toggle label, plaintext input (DM Mono), dialog descriptions, segment labels when checked |
| Label | 14px | 600 | 1.5 | Field labels (Name, Expiration, Scopes), token name in a row, scope label in a picker row, empty-state heading, button text (existing `Button`) |
| Meta | 13px | 400 | 1.5 | Card comment, row meta line (dates), token hint (DM Mono), scope mono name in the picker, scope description, expiry preview line, error lines, copy status line, unchecked segment labels |
| Section heading | 18px | 700 | 1.5 | Card heading "API tokens": the existing `AccountView` card heading class `text-[18px] font-bold`, copied verbatim |
| Dialog title | 20px | 700 | 1.2 (`tracking-[-0.01em]`) | Dialog titles: the existing `DialogTitle` class from `RelationPickerModal.vue`, copied verbatim |
**Locked user decision (2026-10-11):** 700 heading weight inherited from Direction C is accepted for this phase; new content uses 400 and 600 only. The 700 weight appears only on the card heading (`text-[18px] font-bold`) and the dialog title (`DialogTitle` class from `RelationPickerModal.vue`), both copied verbatim. Re-verification treats this as a locked system-level exception, not a defect.
Weights: new content uses 400 and 600 only. **700 is the inherited, heading-only weight** (card heading and dialog title, both copied verbatim). Chips use 13px/600, the same as the approved 12.2 "Unsaved" chip; no 12px text is introduced. Every date and time uses `tabular-nums`. DM Mono appears only on the plaintext token, the `g15_…a1b2` hint and scope names (`journal:read`, `*`).
---
## Color
All colours are existing CSS variables; light / dark values from `main.css`. Hard-coded hex values are forbidden in new components.
| Role | Value (light / dark) | Usage |
|------|----------------------|-------|
| Dominant (60%) | `bg` #f4f6f9 / #111726, `surface` #ffffff / #182033 | Page background; the card, the dialog, inputs, list rows |
| Secondary (30%) | `subtle` #f3f5f8 / #1f283d, `border` #e6e9ef / #29334b, `border-strong` #d2d8e2 / #3a4661, `muted` #566175 / #a9b3c6, `skel` #eceff4 / #263049 | Read-only plaintext input fill, segmented control track, scope chips, empty-state circle, row dividers, dashed empty-state border, meta text, skeleton bars |
| Accent (10%) | `primary` #22304d / #fcd34d (action colour); `sel` #fdf3cf / #3a3622; `ring` rgba(252,196,40,.55) / rgba(252,211,77,.45) | See reserved list below |
| Destructive | `danger` #c62828 / #f58a8a, `danger-soft` #fdf0f0 / #3a1d24 | "Revoke token" row buttons (danger outline), the revoke confirm button, the "Expired" chip, invalid input borders, error lines, alert blocks |
Accent reserved for (exhaustive for this phase):
1. `primary` fill: the single primary button of each dialog step, "Create token" (step 1) and "Done, hide token" (step 2). The page's existing footer Save stays the page's only other primary.
2. `primary` checkbox tint (`accent-primary`): checked scope checkboxes and the "Show revoked tokens" checkbox.
3. `sel` tint: the show-once notice block in step 2, and nothing else.
4. `ring` (3px): focus-visible on every new focusable element (base style), never removed.
Accent is never used for: the "Create token" button on the card (outline), scope chips, token names, row hover, the checked expiry segment (uses `bg-surface shadow-tab`), icons, or links.
---
## Copywriting Contract
All copy is keyed in `modules/phrasebook/backend/lang/{en,pl}/lang.yaml` under a new `api_tokens:` block (`backend::lang.api_tokens.*`), next to `myaccount:`. Placeholders use `:name` syntax. Polish is the design language; English values follow. Plugin scope labels and descriptions come from the plugin's own phrasebook keys (D-01) and are rendered as delivered by the server.
### Primary CTAs and key states
| Element | Copy (EN / PL) |
|---------|----------------|
| Card heading | `api_tokens.section` "API tokens" / "Tokeny API" |
| Card comment | `api_tokens.section_comment` "Tokens let agents and integrations call the admin API as you, limited to the scopes you choose. Treat a token like a password." / "Tokeny pozwalają agentom i integracjom wywoływać API panelu w Twoim imieniu, w granicach wybranych zakresów. Traktuj token jak hasło." |
| Card button (opens dialog) | `api_tokens.create` "Create token" / "Utwórz token" (`Plus` icon) |
| Primary CTA, dialog step 1 | `api_tokens.create_submit` "Create token" / "Utwórz token"; while busy `api_tokens.creating` "Creating…" / "Tworzenie…" |
| Primary CTA, dialog step 2 | `api_tokens.done` "Done, hide token" / "Gotowe, ukryj token" |
| Empty state heading | `api_tokens.empty_title` "No API tokens yet" / "Nie masz jeszcze tokenów API" |
| Empty state body | `api_tokens.empty_body` "Create a token to let an agent or integration call the admin API as you." / "Utwórz token, aby agent lub integracja mogły wywoływać API panelu w Twoim imieniu." (the card's "Create token" button is the next step) |
| Error state (list load failed) | `api_tokens.load_failed` "The tokens could not be loaded. Refresh the page to try again." / "Nie udało się wczytać tokenów. Odśwież stronę, aby spróbować ponownie." |
| Error state (scopes load failed, dialog) | `api_tokens.scopes_failed` "The scopes could not be loaded. Close this window and try again." / "Nie udało się wczytać zakresów. Zamknij to okno i spróbuj ponownie." |
| Error state (create, non-422) | `api_tokens.create_failed` "The token could not be created. Please try again." / "Nie udało się utworzyć tokenu. Spróbuj ponownie." (danger toast; dialog stays open with input kept) |
| Error state (create 422, name) | `api_tokens.name_required` "Enter a name for the token." / "Podaj nazwę tokenu." (server message, rendered verbatim) |
| Error state (create 422, scopes empty) | `api_tokens.scopes_required` "Choose at least one scope." / "Wybierz co najmniej jeden zakres." (server message) |
| Error state (create 422, scope refused) | `api_tokens.scope_unavailable` "The scope :name is not available to you." / "Zakres :name nie jest dla Ciebie dostępny." (server message, D-17 overlap rule) |
| Error state (revoke, non-404) | `api_tokens.revoke_failed` "The token could not be revoked. Please try again." / "Nie udało się unieważnić tokenu. Spróbuj ponownie." (danger toast) |
| Error state (revoke 404) | `api_tokens.gone` "This token no longer exists. The list has been refreshed." / "Ten token już nie istnieje. Lista została odświeżona." (danger toast, list reloads) |
| Error state (clipboard refused) | `api_tokens.copy_failed` "Could not copy automatically. The token is selected: press Ctrl+C (Cmd+C on a Mac) to copy it." / "Nie udało się skopiować automatycznie. Token jest zaznaczony: naciśnij Ctrl+C (Cmd+C na Macu), aby go skopiować." |
### Destructive actions
| Action | Confirmation approach | Copy (EN / PL) |
|--------|----------------------|----------------|
| Revoke a token (row button) | ConfirmDialog, `danger`, with the revoke request run as the confirm action (`confirm.ask(request, run)`), so the confirm button shows the busy spinner until it settles | Row button: `api_tokens.revoke` "Revoke token" / "Unieważnij token", aria-label `api_tokens.revoke_named` "Revoke token: :name" / "Unieważnij token: :name". Confirm message: `api_tokens.revoke_confirm` "Revoke the token “:name”? Apps and agents using it lose access at once. This cannot be undone." / "Unieważnić token „:name”? Aplikacje i agenci, którzy go używają, natychmiast stracą dostęp. Tej operacji nie można cofnąć." Confirm button: `api_tokens.revoke_submit` "Revoke token" / "Unieważnij token". Success toast: `api_tokens.revoked` "Token revoked" / "Unieważniono token". |
| Close step 2 before copying (Esc or the close button, while the token was never copied) | ConfirmDialog, `danger`, rendered above the token dialog | `api_tokens.close_uncopied_confirm` "Close without copying? You won't be able to see this token again." / "Zamknąć bez kopiowania? Tego tokenu nie będzie można już zobaczyć." Confirm button: `api_tokens.close_uncopied_submit` "Close and hide token" / "Zamknij i ukryj token". Cancel keeps the dialog open on step 2. |
| Close step 1 with input typed | No dialog: nothing is stored yet and the form is cheap to refill | — |
### Create dialog copy
| Element | Copy (EN / PL) |
|---------|----------------|
| Step 1 title | `api_tokens.create_title` "New API token" / "Nowy token API" |
| Step 1 description | `api_tokens.create_description` "Pick a name, an expiry and the scopes this token may use. It never gets more access than your own account." / "Wybierz nazwę, termin ważności i zakresy tokenu. Token nigdy nie ma szerszego dostępu niż Twoje konto." |
| Name label / placeholder / comment | `api_tokens.name` "Name" / "Nazwa"; `api_tokens.name_placeholder` "e.g. Blog agent" / "np. Agent bloga"; `api_tokens.name_comment` "Helps you recognise the token in the list." / "Pomoże rozpoznać token na liście." |
| Expiration label | `api_tokens.expires` "Expiration" / "Ważność" |
| Expiration segments (D-08) | `api_tokens.expires_30` "30 days" / "30 dni"; `api_tokens.expires_90` "90 days" / "90 dni"; `api_tokens.expires_365` "365 days" / "365 dni"; `api_tokens.expires_never` "Never" / "Bez terminu" |
| Expiry preview (dated choice) | `api_tokens.expires_on` "Expires on :date." / "Wygasa :date." |
| Expiry preview (never) | `api_tokens.expires_never_note` "This token never expires. Revoke it when you no longer need it." / "Ten token nie wygasa. Unieważnij go, gdy przestanie być potrzebny." |
| Scopes label | `api_tokens.scopes` "Scopes" / "Zakresy" |
| Framework scope `*` (D-04, D-17) | `api_tokens.scope_all` "Full access" / "Pełny dostęp"; `api_tokens.scope_all_description` "Everything you can do in the admin. Your account settings and tokens still need a signed-in session." / "Wszystko, co możesz zrobić w panelu. Ustawienia konta i tokeny nadal wymagają zalogowania." |
| Framework scope `admin:read` (D-04) | `api_tokens.scope_admin_read` "Read-only, whole admin" / "Tylko odczyt, cały panel"; `api_tokens.scope_admin_read_description` "View everything you can see in the admin, without making changes." / "Podgląd wszystkiego, co widzisz w panelu, bez wprowadzania zmian." |
| Read-only chip (scope whose methods are only GET/HEAD) | `api_tokens.read_only` "Read-only" / "Tylko odczyt" |
| Full-access note (when `*` is checked) | `api_tokens.scope_all_includes` "Full access already includes every other scope." / "Pełny dostęp obejmuje wszystkie pozostałe zakresy." |
| Step 2 title | `api_tokens.created_title` "Copy your new token" / "Skopiuj nowy token" |
| Step 2 notice | `api_tokens.created_notice` "This is the only time the token is shown. Copy it now and store it somewhere safe. If you lose it, revoke it and create a new one." / "Token jest widoczny tylko teraz. Skopiuj go i przechowuj w bezpiecznym miejscu. Jeśli go zgubisz, unieważnij go i utwórz nowy." |
| Plaintext input label (sr-only) | `api_tokens.token_label` "New token" / "Nowy token" |
| Copy button / copied state | `api_tokens.copy` "Copy token" / "Kopiuj token" (`Copy` icon); after success `api_tokens.copied` "Copied" / "Skopiowano" (`Check` icon) for 2 s |
| Copy announcement (polite live region) | `api_tokens.copied_announce` "Token copied to the clipboard." / "Skopiowano token do schowka." |
| Dialog dismiss button (step 1) | `api_tokens.create_cancel` "Discard token" / "Odrzuć token" (new key; closes the dialog and discards the typed input; the generic `form.cancel` is not used here) |
| Dialog close button aria | existing `form.close` |
### List copy
| Element | Copy (EN / PL) |
|---------|----------------|
| Show-revoked toggle | `api_tokens.show_revoked` "Show revoked tokens" / "Pokaż unieważnione tokeny" |
| Meta: created | `api_tokens.created_at` "Created :date" / "Utworzono :date" |
| Meta: last used / never | `api_tokens.last_used` "Last used :date" / "Ostatnio użyto :date"; `api_tokens.never_used` "Never used" / "Nieużywany" |
| Meta: expiry | `api_tokens.expires_at` "Expires :date" / "Wygasa :date"; `api_tokens.expired_at` "Expired :date" / "Wygasł :date"; `api_tokens.no_expiry` "No expiry" / "Bez terminu ważności" |
| Meta: revoked | `api_tokens.revoked_at` "Revoked :date" / "Unieważniono :date" |
| State chips | `api_tokens.state_expired` "Expired" / "Wygasły"; `api_tokens.state_revoked` "Revoked" / "Unieważniony" |
| Unknown scope chip and tooltip | `api_tokens.unknown_scope` ":name (unknown)" / ":name (nieznany)"; `title` `api_tokens.unknown_scope_hint` "This scope is no longer registered and grants nothing." / "Ten zakres nie jest już zarejestrowany i nic nie przyznaje." |
| Hint (aria-label of the mono hint) | `api_tokens.hint` "Token ending in :last4" / "Token kończący się na :last4" |
### Changed existing key
`myaccount.menu_description` becomes "Edit your name, email, subtitle and password, and manage your API tokens." / "Zmień imię, nazwisko, e-mail, podtytuł i hasło oraz zarządzaj tokenami API."
---
## Component Contracts
### 1. Placement on `/account`
- The API tokens card renders **after** the profile/password `<form>` and **outside** it, as a sibling inside the existing `section` stack (`gap-5`). It is never inside the `<form>`: the fixed footer "Save" saves only the profile and never touches tokens, and every token action is immediate.
- The card uses the existing account card class verbatim: `flex flex-col gap-4 rounded-card border border-border bg-surface p-7 shadow-card`, with `data-api-tokens`.
- The `ConfirmDialog` already mounted by `AccountView` serves revoke and close-without-copying. The panel receives the view's `useConfirm()` instance (prop or provide/inject; planner's choice).
### 2. Card header and toolbar
- Header: `flex flex-wrap items-start justify-between gap-4`. Left column `flex min-w-0 flex-col gap-1`: `h2` `api_tokens.section` (18/700) and `p` `api_tokens.section_comment` (13px muted, `max-w-[640px]`). Right: `Button variant="outline" size="sm" :icon="Plus"` with `api_tokens.create`, `data-action="create-token"`. Disabled while the list is in its first load.
- Visual anchor: the 18/700 heading is read first, then the row names. The card has no primary fill, so it never competes with the page's footer Save.
- Toolbar row (only after a successful load): `label.flex.items-center.gap-2` holding the native checkbox (`size-5 shrink-0 rounded-[6px] accent-primary`, `data-show-revoked`) and `api_tokens.show_revoked` (14/400). Toggling reloads the list with revoked rows included (D-09); the toggle state lives in the component only (not in the URL, not persisted).
### 3. Token list
- Container: `flex flex-col overflow-hidden rounded-inner border border-border`, `role="list"`, `data-token-list`. Rows are ordered newest first (`created_at` desc). No pagination: one user's tokens are a short list; the server returns them all.
- Row (`role="listitem"`, `data-token-row="<id>"`): `flex items-start justify-between gap-4 border-t border-border px-4 py-3.5 first:border-t-0 max-sm:flex-col`.
- Left column `flex min-w-0 flex-1 flex-col gap-2`:
- Line 1, `flex flex-wrap items-center gap-2`: the token name (14/600, `[overflow-wrap:anywhere]`, never truncated, since the name is how the user tells tokens apart); the hint `g15_…a1b2` (13px `font-mono text-muted`, `aria-label` `api_tokens.hint`); the state chip when the token is expired or revoked.
- Line 2, `flex flex-wrap gap-2`, `data-token-scopes`: one chip per stored scope, in stored order.
- Line 3, meta: 13px muted `tabular-nums`, items joined with " · ": created, last used (or never used), then expiry (expires / expired / no expiry), and for revoked rows the revoked date.
- Right: `Button variant="danger" size="sm" :icon="Ban"` with `api_tokens.revoke` "Revoke token", aria-label `api_tokens.revoke_named`, `data-action="revoke-token"`. Shown on active and expired rows, hidden on revoked rows. On `max-sm` it sits under the meta line (`self-start`).
- Chips (all `inline-flex h-6 items-center rounded-pill px-2.5 text-[13px] font-semibold whitespace-nowrap`):
| Chip | Extra classes | Text |
|------|---------------|------|
| Known scope | `bg-subtle text-text` | the scope label (`*` shows `api_tokens.scope_all`); `title` = the scope's mono name |
| Unknown scope (no longer registered, D-12 discretion) | `border border-dashed border-border-strong text-muted font-mono` | `api_tokens.unknown_scope` with the raw name; `title` `api_tokens.unknown_scope_hint` |
| State: expired | `bg-danger-soft text-danger` | `api_tokens.state_expired` |
| State: revoked | `border border-border-strong text-muted` | `api_tokens.state_revoked` |
- Revoked rows: name and meta use `text-muted`; no line-through (the chip states it in text, WCAG 1.4.1).
- Dates: created, expires and expired show `YYYY-MM-DD`; last used and revoked show `YYYY-MM-DD HH:mm`, both in the browser's local time, the same shapes as the list `datetime` cell in `CellValue.vue` (reuse or extract that formatter; do not add a new date format).
- Labels for list chips come from the same scope catalog the server uses (the list payload carries a label and a known flag per scope, or the panel maps names through a catalog that includes every registered scope, not only the ones the picker shows). A name with no label is rendered as unknown. The API shape is the planner's call; the rendering rule is fixed here.
### 4. Create dialog, step 1 (form)
- Shell: the `RelationPickerModal` shell, verbatim: `DialogOverlay` `fixed inset-0 z-40 bg-overlay` with `data-dialog-overlay`; `DialogContent` `fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100vh-32px)] w-[calc(100%-32px)] max-w-[560px] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-modal bg-surface p-6 text-text shadow-pop` with `data-dialog-content` and `data-token-dialog`, using the existing 200 ms fade/scale.
- Header `flex items-start gap-3`: `DialogTitle` `api_tokens.create_title` (20/700 class copied) and `DialogDescription` `api_tokens.create_description` (14px muted) in a `flex min-w-0 flex-1 flex-col gap-1` column; the 34px `DialogClose` button copied from the picker (`X` 16px, aria `form.close`).
- Body: `<form novalidate>` with `fieldset` (`disabled` while busy) holding a `flex min-h-0 flex-col gap-[18px] overflow-y-auto` stack:
1. **Name.** `label` (14/600) + `input type="text"` `h-input` with `controlClass(invalid)`, `maxlength="100"`, `autocomplete="off"`, placeholder `api_tokens.name_placeholder`, comment line `api_tokens.name_comment` (13px muted). The server enforces the same 100-character limit and trims the value.
2. **Expiration.** A `span` label (14/600) naming a Reka `RadioGroupRoot` (`orientation="horizontal"`, `aria-labelledby`) styled as the PermissionEditor segmented control: root `inline-flex flex-wrap gap-1 rounded-inner border border-border bg-subtle p-1`; items `inline-flex h-pager items-center rounded-tab px-4 text-[13px] whitespace-nowrap text-muted transition-colors duration-150 ease-out hover:text-text data-[state=checked]:bg-surface data-[state=checked]:text-text data-[state=checked]:shadow-tab data-[state=checked]:text-[14px] data-[state=checked]:font-semibold`. Values `30d`, `90d`, `365d`, `never` in that order; **default `90d`** (D-08). Under it, a 13px muted `tabular-nums` preview line: `api_tokens.expires_on` with today plus N days as `YYYY-MM-DD` (local), or `api_tokens.expires_never_note` for never. The server computes the stored instant; the preview is informational.
3. **Scopes.** A `span` label (14/600) naming a `role="group"` container `overflow-hidden rounded-inner border` (`border-danger` when the scopes field has an error, else `border-border`), `data-scope-picker`. One row per scope from `GET …/account/tokens/scopes`, in server order (`*` first, then `admin:read`, then plugin scopes). The server already hides scopes that grant the user nothing (D-17); the SPA renders exactly what it receives.
- Row: `label.flex.min-h-[56px].cursor-pointer.items-start.gap-3.border-t.border-border.px-4.py-3.first:border-t-0`, `data-scope="<name>"`. Left: native checkbox `mt-0.5 size-5 shrink-0 rounded-[6px] accent-primary`. Right `flex min-w-0 flex-col`: line 1 `flex flex-wrap items-center gap-1` with the scope label (14/600), the mono name (13px `font-mono text-muted`), and the "Read-only" chip (`bg-subtle text-muted`, chip classes above) when the scope's methods are only GET/HEAD; line 2 the description (13px muted, `[overflow-wrap:anywhere]`).
- Rows are 12px gap (`gap-3`), the same exception 12.2 approved for picker option rows.
- `*` checked: every other row's checkbox is unchecked and `disabled` (row `cursor-not-allowed opacity-60`), and a note shows under the group: `flex items-center gap-2 text-[13px] text-muted`, `Info` 14px, `api_tokens.scope_all_includes`. Only `["*"]` is sent. Unchecking `*` re-enables the rows (previous picks are not restored).
- No scope pre-selected on open.
- Field errors render under each field exactly like `AccountView`: `flex flex-col gap-0.5 text-[13px] text-danger`, one `p.flex.items-center.gap-1.5` per message with `CircleAlert` 14px, linked by `aria-describedby`, the control gets `aria-invalid="true"`. The scopes error sits under the group.
- Footer `flex gap-2.5`: two equal-width buttons (`flex-1`), `Button variant="outline"` `api_tokens.create_cancel` "Discard token" (closes and discards input, `data-action="create-token-cancel"`) and `Button variant="primary" type="submit"` `api_tokens.create_submit`, `data-action="create-token-submit"`. Enter in the name input submits.
- Behaviour:
- On open, the scopes request runs; while it is pending the scopes group shows 3 skeleton rows (`min-h-[56px]` rows holding a 20px `rounded-[6px] bg-skel` square and a `h-3 w-40 rounded-[6px] bg-skel` bar) and the submit is disabled.
- Scopes load failure: the group is replaced by the alert block (`rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger`, `role="alert"`) with `api_tokens.scopes_failed`; submit stays disabled; "Discard token" closes.
- The SPA does no client-side validation beyond `maxlength`; empty name and no scope go to the server and come back as a 422, rendered as above, with focus on the first invalid control (name input, else the first scope checkbox, else the first segment).
- Busy: `fieldset` disabled, the primary label is `api_tokens.creating` with `LoaderCircle` 16px `animate-spin`, "Discard token" and the close button are disabled, Esc and overlay click are ignored. A second submit while busy is a no-op.
- Non-422 failure: `api_tokens.create_failed` danger toast; the dialog stays on step 1 with input kept.
- Closing step 1 any way (Esc, overlay, close button, "Discard token") discards input with no confirm.
- Focus: on open, the name input; on close, back to the card's "Create token" button.
### 5. Create dialog, step 2 (show once)
- Success swaps the dialog content in place (same `DialogContent`, same width); the list reloads in the background so the new row is present when the dialog closes.
- Header: `DialogTitle` `api_tokens.created_title`; the close button stays.
- Notice (the visual anchor of the step): `flex items-start gap-2 rounded-inner bg-sel px-4 py-3.5 text-text`, `role="note"`, `data-token-notice`: `TriangleAlert` 16px (`mt-0.5 shrink-0`, `aria-hidden`) and `api_tokens.created_notice` (14/400). This is also the `DialogDescription`.
- Plaintext: a `label` (`sr-only`) `api_tokens.token_label` and a full-width read-only `input` with `controlClass(false)` plus `h-input bg-subtle font-mono text-[14px]`, `readonly`, `autocomplete="off"`, `spellcheck="false"`, `data-token-plaintext`. Focus or click selects the whole value. The full token is visible, not masked: it is shown once and the user must be able to read and verify it.
- Copy row, 8px below: `flex flex-wrap items-center gap-2`: `Button variant="outline" size="sm" :icon="Copy"` `api_tokens.copy`, `data-action="copy-token"`, then a 13px status line.
- Copy uses `navigator.clipboard.writeText`. On success the button shows `Check` and `api_tokens.copied` for 2 s, a `sr-only` `aria-live="polite"` region announces `api_tokens.copied_announce`, and the dialog records that the token was copied.
- When the Clipboard API is missing or rejects: the input is focused with its value selected, and the status line shows `api_tokens.copy_failed` in 13px `text-danger` with `CircleAlert` 14px (`role="alert"`). A manual copy (Ctrl+C or the context menu) on the input also counts as copied (`copy` event on the input).
- Footer: one `Button variant="primary"` `api_tokens.done`, right-aligned (`flex justify-end`), `data-action="hide-token"`. It closes without a confirm.
- Closing rules:
- Overlay click never closes step 2 (`@pointer-down-outside` and `@interact-outside` prevented).
- Esc and the close button: when the token was copied, close at once; otherwise ask `api_tokens.close_uncopied_confirm` (ConfirmDialog, `danger`) and close only on confirm.
- Focus: on entering step 2, the "Copy token" button; on close, the card's "Create token" button.
- Plaintext handling (security, binding): the plaintext lives only in the dialog component's local state; it is set from the create response and cleared on close (and on unmount). It is never written to a toast, the console, `localStorage`/`sessionStorage`, the URL, the router state or the list rows. Reopening the dialog always starts at step 1.
### 6. Revoke
- The row's "Revoke token" button calls `confirm.ask({ message: t('api_tokens.revoke_confirm', { name }), confirmLabel: t('api_tokens.revoke_submit'), danger: true }, run)`, where `run` sends `DELETE …/account/tokens/{id}`.
- Success: the list reloads, `api_tokens.revoked` success toast. With "Show revoked tokens" off the row leaves the list; with it on the row turns into a revoked row. Focus moves to the card's "Create token" button.
- 404: `api_tokens.gone` danger toast, list reloads. Other failure: `api_tokens.revoke_failed` danger toast, the row stays as it was.
### 7. List states
- **Loading (first load and toggle reload):** the list container shows 3 skeleton rows with the row padding: a `h-3 w-40` bar above a `h-3 w-64` bar, both `rounded-[6px] bg-skel`, `aria-hidden`, and the container gets `aria-busy="true"`.
- **Load failure:** the alert block (`rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger`, `role="alert"`, `data-tokens-error`) with `CircleAlert` 16px and `api_tokens.load_failed` replaces the list; the toolbar toggle is hidden; the "Create token" button stays enabled (creating does not depend on the list).
- **Empty:** in place of the list, `flex flex-col items-center gap-2 rounded-inner border border-dashed border-border-strong px-6 py-8 text-center`, `data-tokens-empty`: a 40px `bg-subtle` circle with `KeyRound` 20px muted, `api_tokens.empty_title` (14/600), `api_tokens.empty_body` (13px muted, `max-w-[420px]`). The same block shows when "Show revoked tokens" is on and the user has no tokens at all.
### 8. Interactive-only and auth
- The SPA always calls the token endpoints with the admin JWT session, so the interactive-only refusal (D-06) never surfaces in the UI. A 401 goes through the existing client refresh-and-replay and, failing that, the existing sign-in redirect. No token-specific auth UI.
- The panel does not show the scope picker's hidden scopes, the owner's permissions, or any permission pattern: scope labels and descriptions are the only explanation of access.
---
## UI Considerations
Applicable state considerations resolved (ui-consideration-probe, run post-verification 2026-10-11: 7 elements — token list, show-revoked toggle, create form, expiry control, scope picker, show-once step, revoke action — 48 element×state pairs, kinds confirmed by the user). Every pair is covered, backstopped or dismissed with a reason; 0 unresolved. Rows group pairs that share a resolution. Copy for empty and error rows lives in the Copywriting Contract.
| Category | Element(s) | Status | Resolution / Reason |
|----------|------------|--------|---------------------|
| empty | Token list | ✅ covered | With no tokens, the dashed empty block shows `api_tokens.empty_title` and `api_tokens.empty_body`; the card's "Create token" button stays the single next step |
| loading | Token list | ✅ covered | First load and the show-revoked reload show 3 skeleton rows with `aria-busy="true"`; the "Create token" button is disabled only during the first load |
| error | Token list | ✅ covered | A failed load replaces the list with the `api_tokens.load_failed` alert block (`role="alert"`) and hides the toggle |
| populated | Token list | ✅ covered | Each row shows name, mono hint, scope chips, the meta line (created, last used or never used, expiry) and a "Revoke token" button; newest first |
| partial | Token list (mixed states) | ✅ covered | Active, expired and revoked rows coexist; expired rows carry the danger-soft "Expired" chip and keep "Revoke token"; revoked rows are muted with the "Revoked" chip and no "Revoke token" button |
| zero-one-many | Token list | ✅ covered | Zero: empty block; one or more: rows separated by `border-t`, no pagination, page scroll only |
| overflow | Token list | ✅ covered | Rows stack vertically and the page scrolls; scope chips wrap with `flex-wrap`; on `max-sm` the "Revoke token" button moves under the meta line |
| long-text | Token name, scope labels | ✅ covered | Names (up to 100 characters) wrap with `[overflow-wrap:anywhere]`, never truncate; scope descriptions wrap the same way; chips never truncate and wrap to new lines |
| partial | Scope chips (unknown scope) | ✅ covered | A stored scope name that no plugin registers any more renders as a dashed muted mono chip `api_tokens.unknown_scope` with the `unknown_scope_hint` title |
| empty | Show-revoked toggle on, no tokens | ✅ covered | Same empty block as above |
| loading | Show-revoked toggle | ✅ covered | Toggling reloads with the list skeleton; the checkbox stays interactive |
| loading | Scope picker | ✅ covered | 3 skeleton scope rows while `GET …/scopes` is pending; submit disabled |
| error | Scope picker | ✅ covered | Load failure shows the `api_tokens.scopes_failed` alert block; submit stays disabled; "Discard token" closes |
| empty | Scope picker | ✅ covered | Cannot be empty: `*` is always offered (D-17). If the server ever returns none, the scopes-failed alert block is shown |
| partial | Scope picker with `*` | ✅ covered | Checking `*` unchecks and disables every other row and shows `api_tokens.scope_all_includes`; only `["*"]` is sent |
| overflow / long-text | Scope picker (many plugin scopes) | ✅ covered | The dialog body scrolls inside `max-h-[calc(100vh-32px)]` while header and footer stay fixed |
| error | Create form (422) | ✅ covered | Server messages render under the name input, the expiry group or the scopes group with `aria-invalid`; focus moves to the first invalid control; the dialog stays on step 1 |
| error | Create form (other failure) | ✅ covered | `api_tokens.create_failed` danger toast; input kept |
| loading | Create submit | ✅ covered | Fieldset disabled, primary shows `api_tokens.creating` with a spinner, "Discard token"/close/Esc/overlay ignored, double submit is a no-op |
| populated | Show-once step | ✅ covered | `bg-sel` notice, full plaintext in a read-only mono input, Copy button with copied state, single "Done, hide token" primary |
| error | Clipboard refused or unavailable | ✅ covered | The input is focused and selected, and `api_tokens.copy_failed` shows in a `role="alert"` line; a manual copy event counts as copied |
| partial | Show-once step closed before copying | ✅ covered | Overlay click is ignored; Esc and the close button ask `api_tokens.close_uncopied_confirm` first; "Done, hide token" closes without a confirm |
| long-text | Plaintext token | ✅ covered | 44 characters of DM Mono 14px fit the full-width input at the 560px dialog width; on narrow screens the input scrolls horizontally and a select-all copy still takes the whole value |
| loading / error | Revoke | ✅ covered | The ConfirmDialog confirm button shows the busy spinner while the request runs; 404 shows `api_tokens.gone` and reloads; other failures show `api_tokens.revoke_failed` and keep the row |
| populated | Plaintext never retained | 🧪 backstop | An SPA unit test asserts that after "Done, hide token" (and after Esc-confirm) the plaintext is absent from the DOM and from the panel state, that reopening starts at step 1, and that no toast text contains the token |
| populated | Copy and live-region flow | 🧪 backstop | An SPA unit test with a stubbed `navigator.clipboard` asserts the copied state, the polite announcement, and that a rejected `writeText` selects the input and shows `api_tokens.copy_failed` |
| partial | Close-without-copy guard | 🧪 backstop | An SPA unit test asserts that Esc on step 2 before copying opens the danger confirm and keeps the dialog on cancel, and that it closes directly after a copy |
| zero-one-many | Scope chips per row | ✅ covered | One chip per stored scope (a token holds one or more, D-03); `*` shows the "Full access" label |
| loading / error / overflow | Expiry segmented control | ➖ dismissed | Four static options with no async data; labels are short and the group wraps (`flex-wrap`) on narrow screens |
| empty / loading / error | Page placement | ➖ dismissed | The card is independent of the profile form: profile load, save and 422 states are unchanged, and the footer Save never includes token data |
| long-text | Revoke confirm message | ✅ covered | `api_tokens.revoke_confirm` interpolates the token name (up to 100 characters, possibly with no spaces). `ConfirmDialog.vue`'s `AlertDialogDescription` currently has no wrap rule, so this phase adds `[overflow-wrap:anywhere]` to it (a one-class change to the shared component, harmless for existing messages) |
| long-text | Create form name input | ✅ covered | The single-line `h-input` scrolls horizontally like every other text input; `maxlength="100"` caps the value, and the server trims and enforces the same limit |
| empty / partial | Create form | ✅ covered | An unfilled form or a partial one (name without scope, scope without name) is submitted as-is; the server's 422 errors render under each field as above. Expiry is never empty (default `90d`) |
| empty / populated / partial / overflow / zero-one-many / long-text | Show-revoked toggle | ➖ dismissed | A single native checkbox with a short static label; its only states are checked/unchecked and the list reload it triggers (covered under Token list); a list load failure hides it |
| empty / partial / long-text | Expiry segmented control | ➖ dismissed | Always has a value (default `90d`), four fixed short labels; the preview line is one short date string |
| empty / loading / overflow | Show-once step | ➖ dismissed | The step exists only after a successful create response, so it is never empty or loading (create loading is under Create submit); its content is fixed-size (notice, one input, one button row) inside the scrolling dialog body |
| empty / populated / partial / overflow / zero-one-many | Revoke action | ➖ dismissed | A per-row button acting on exactly one token; its states are idle, confirming, busy, success and failure (covered under Revoke and the list rows) |
---
## Registry Safety
| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| shadcn official | none (shadcn not used) | not applicable |
| third-party registries | none | not applicable |
| npm dependencies | none added (Reka UI, `@lucide/vue` and Tailwind already installed at the versions above) | not applicable |
---
## Checker Sign-Off
- [x] Dimension 1 Copywriting: PASS
- [x] Dimension 2 Visuals: PASS
- [x] Dimension 3 Color: PASS
- [x] Dimension 4 Typography: FLAG (inherited 700 heading weight, locked user decision 2026-10-11)
- [x] Dimension 5 Spacing: FLAG (inherited Direction C values, locked user decision 2026-10-11)
- [x] Dimension 6 Registry Safety: PASS
- [x] Dimension 7 Inventory Provenance: PASS
**Approval:** approved 2026-10-11