From 5e05b606a7f4c310305fcde2fef25bfdda8962f1 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Sun, 11 Oct 2026 07:45:17 +0200 Subject: [PATCH] chore: 2 big design passes to harmonize and fix stuff --- docs/design/README.md | 159 +++- docs/design/change-list.md | 26 + docs/design/components.md | 881 +----------------- docs/design/foundations.md | 250 ++--- docs/design/routes.md | 50 +- docs/design/screens/accept-invite.md | 129 +-- docs/design/screens/activity.md | 230 ++--- docs/design/screens/articles.md | 161 ++-- docs/design/screens/boot.md | 26 +- docs/design/screens/editor.md | 307 ++---- docs/design/screens/forgot-password.md | 76 +- docs/design/screens/global.md | 41 +- docs/design/screens/home.md | 93 +- docs/design/screens/image-variants.md | 369 ++------ docs/design/screens/integration-api-keys.md | 70 +- docs/design/screens/integration.md | 78 +- docs/design/screens/log-in.md | 117 +-- docs/design/screens/media-picker.md | 75 +- docs/design/screens/media.md | 224 ++--- docs/design/screens/new-article.md | 76 +- docs/design/screens/pending.md | 67 +- docs/design/screens/profile.md | 209 +---- docs/design/screens/register.md | 87 +- docs/design/screens/reset-password.md | 86 +- docs/design/screens/settings.md | 295 +++--- docs/design/screens/users.md | 260 +----- docs/design/screens/verify.md | 56 +- docs/design/source/ActivityFrame.dc.html | 87 +- docs/design/source/ApiKeysPage.dc.html | 24 +- docs/design/source/AppFrame.dc.html | 130 ++- docs/design/source/CodeSnippet.dc.html | 42 + docs/design/source/IntegrationImages.dc.html | 124 +++ docs/design/source/IntegrationPage.dc.html | 45 +- docs/design/source/MediaFrame.dc.html | 57 +- docs/design/source/RenewCMS Handoff.dc.html | 4 +- docs/design/source/RenewCMS Prototype.dc.html | 33 +- .../RenewCMS Screens Back-office.dc.html | 387 +++++++- .../RenewCMS Screens Image variants.dc.html | 347 ------- docs/design/source/StatesMatrix.dc.html | 78 ++ docs/design/source/VariantsFrame.dc.html | 45 +- docs/design/source/activity-spec.js | 16 +- docs/design/states.md | 371 +------- web/visual/check.ts | 62 +- 43 files changed, 2167 insertions(+), 4183 deletions(-) create mode 100644 docs/design/change-list.md create mode 100644 docs/design/source/CodeSnippet.dc.html create mode 100644 docs/design/source/IntegrationImages.dc.html delete mode 100644 docs/design/source/RenewCMS Screens Image variants.dc.html create mode 100644 docs/design/source/StatesMatrix.dc.html diff --git a/docs/design/README.md b/docs/design/README.md index 850c812..1d0635c 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -1,57 +1,122 @@ -# RenewCMS design +# RenewCMS design pass 2 -The visual spec of record for the back-office SPA and the transactional emails, exported from Claude Design. `features/` stays the behavioural spec; if the two disagree, stop and ask. Agents read this folder and never edit it; a design change is a new export. +Nuxt UI v4 back-office. Tokens only, no raw hex (brand mark excepted). Light and dark. 1440×900 and 390×844. Radius 0. -## Files +## Boards +- `RenewCMS Screens Back-office.dc.html`: every back-office screen, Settings → Media (variants, cache), and the group "States matrix: controls". Frames: `AppFrame` (shell), `ActivityFrame`, `MediaFrame`, `VariantsFrame`, `IntegrationPage`, `IntegrationImages`, `CodeSnippet`, `StatesMatrix`. +- `RenewCMS Screens Auth.dc.html` (7 groups, 50 states), `RenewCMS Screens API keys.dc.html` (12 states). +- `RenewCMS Prototype.dc.html`: updated to match (title-only New article, no body limit or counter, neutral count, pagination, link button, AppFrame's mobile drawer). +- Unchanged: Foundations, Components, Handoff, Directions and Emails boards. -| File | What it holds | +## Specs +routes.md · components.md · states.md · foundations.md · change-list.md · activity-spec.js · screens/*.md (22 files) + +## Status +| Section | Status | |---|---| -| `routes.md` | Every route: layout, auth requirement, page component, API calls | -| `components.md` | The component inventory with its Nuxt UI mapping, props, emits and slots, then the full component and primitive specs | -| `states.md` | Every state of every screen with its trigger, and the HTTP-status-to-copy table | -| `screens/.md` | One file per screen: route, API, and per state the exact copy, fields, alerts, dialogs and toasts | -| `foundations.md` | Colors, contrast, type, spacing, motion and icons, and where the token files are | -| `emails.md` | The three email templates and their variables | -| `source/` | The raw Claude Design export. Open the `.dc.html` boards in a browser for the drawings (they load `support.js` locally and fonts from Google). | +| A1 Activity export | Done: CSV only, synchronous; progress state removed; JSON proposed | +| A2 Pagination `page`/`size` | Done in frames, routes.md, change-list.md | +| A3 Unknown variant and unsupported parameter | Done: both 400 bodies in the Responses table | +| A4 New tokens | Done: foundations.md table | +| A5 screens/*.md | Done: 22 files, copy generated from the board source | +| B1 Editor link | Done: 11 states | +| B2 Duplicates | Done for sidebar, topbar, drawer, code block, modal geometry; see Not done | +| B3 CodeSnippet | Done | +| B4 Overlays | Done | +| B5 Prototype | Done | +| B6 Contrast and states matrix | Done for the pairs listed; matrix drawn | +| B7 Consistency sweep | Done for the roles listed | +| B8 Copy glossary | Done by search; not every line re-read | +| B9 Media and picker | Badge added; filter, sort and size not folded in | +| B10 "proposed" labels | Only article pagination and JSON export | -## Screens +## State counts (Back-office board: 341) +| Group | States | +|---|---| +| Boot | 1 | +| Home | 10 | +| Articles | 14 | +| Articles: pagination | 11 | +| New article | 5 | +| Editor | 41 | +| Media | 43 | +| Media picker | 13 | +| Integration | 8 | +| Activity | 9 | +| Activity: filter cards | 10 | +| Activity: export | 15 | +| Activity details | 6 | +| My activity | 3 | +| Users | 17 | +| Settings | 25 | +| Settings: Media cleanup | 9 | +| Purge dialog | 8 | +| Purge results | 2 | +| Media duplicates entry | 2 | +| Settings: Retention | 6 | +| Profile | 13 | +| Global | 4 | +| Integration: images | 9 | +| Settings navigation | 5 | +| Variants list | 12 | +| Add edit variant | 11 | +| Media library (variants) | 6 | +| Image detail | 8 | +| Cache panel | 10 | +| Editor picker | 5 | -| Screen | Route | States | Board | -|---|---|---|---| -| [Log in](screens/log-in.md) | `/login` | 9 | `source/RenewCMS Screens Auth.dc.html` | -| [Register](screens/register.md) | `/register` | 6 | `source/RenewCMS Screens Auth.dc.html` | -| [Pending](screens/pending.md) | `/register/pending` | 6 | `source/RenewCMS Screens Auth.dc.html` | -| [Verify](screens/verify.md) | `/register/validate?c=CODE` | 6 | `source/RenewCMS Screens Auth.dc.html` | -| [Forgot password](screens/forgot-password.md) | `/register/reset/request` | 6 | `source/RenewCMS Screens Auth.dc.html` | -| [Reset password](screens/reset-password.md) | `/register/reset/validate?c=CODE&email=EMAIL` | 7 | `source/RenewCMS Screens Auth.dc.html` | -| [Boot](screens/boot.md) | `any route, first load` | 1 | `source/RenewCMS Screens Back-office.dc.html` | -| [Home](screens/home.md) | `/home` | 6 | `source/RenewCMS Screens Back-office.dc.html` | -| [Articles](screens/articles.md) | `/article` | 14 | `source/RenewCMS Screens Back-office.dc.html` | -| [New article](screens/new-article.md) | `modal from anywhere · /article/new` | 5 | `source/RenewCMS Screens Back-office.dc.html` | -| [Editor](screens/editor.md) | `/article/:id/edit` | 22 | `source/RenewCMS Screens Back-office.dc.html` | -| [Media](screens/media.md) | `/media` | 41 | `source/RenewCMS Screens Back-office.dc.html` | -| [Image variants](screens/image-variants.md) | `/settings/media/variants` · `/settings/media/cache` (plus `/media` and the editor picker) | 51 | `source/RenewCMS Screens Image variants.dc.html` | -| [Media picker](screens/media-picker.md) | `/article/:id/edit` (dialog) | 13 | `source/RenewCMS Screens Back-office.dc.html` | -| [Integration](screens/integration.md) | `/integration` | 8 | `source/RenewCMS Screens Back-office.dc.html` | -| [API keys](screens/integration-api-keys.md) | `/integration/api-keys` | 12 | `source/RenewCMS Screens API keys.dc.html` | -| [Activity](screens/activity.md) | `/activity` | 9 (plus 6 details, 3 My activity) | `source/RenewCMS Screens Back-office.dc.html` | -| [Users](screens/users.md) | `/users` | 17 | `source/RenewCMS Screens Back-office.dc.html` | -| [Settings](screens/settings.md) | `/settings` | 25 | `source/RenewCMS Screens Back-office.dc.html` | -| [Profile](screens/profile.md) | `/profile` | 13 | `source/RenewCMS Screens Back-office.dc.html` | -| [Accept invite](screens/accept-invite.md) | `/invite/accept?c=CODE` | 9 | `source/RenewCMS Screens Auth.dc.html` | -| [Global](screens/global.md) | `any route` | 4 | `source/RenewCMS Screens Back-office.dc.html` | +Back-office total 341. Auth board 50 (Log in 9, Register 6, Pending 6, Verify 6, Accept invite 10, Forgot password 6, Reset password 7). API keys board 12. Total 403 states; every one has a desktop and a mobile frame, and the theme switch gives light and dark. The matrix has 24 control rows, 5 states on desktop and 4 on mobile (no hover). screens/*.md list exactly these 403. -## Other boards +## Fixes applied (pass 2) +1. Activity export dialog: removed the 42% progress bar, the "large export" state, the server-job copy and the "ready later" toast; added "Preparing download…" (indeterminate), "Download started", failed, nothing to export, 403; CSV only, with one proposed JSON state. +2. Export dialog and menu copy now match the implemented endpoint (current view with filters, all with optional range). +3. Articles pagination used `per_page`, sizes 10/25/50, default 10, 32 pages. Now `page`/`size`, 5/25/50/100, default 25, 13 pages; frames, route, URL examples and the change list updated. +4. The Integration responses table now shows both 400 bodies, `unknown_variant` and `unsupported_parameter`. +5. `--rc-sand` and `--rc-on-sand` (and the other `--rc-*` tokens) documented as new tokens with light and dark values and their uses. +6. Raw colours removed from frames: crop handles and mask, tooltip, transparency checker, drag-drop text, selected-row text in MediaFrame, hex in the Prototype. Only the brand mark keeps hex. +7. ActivityFrame, MediaFrame and VariantsFrame sidebars, topbars and (Variants) mobile drawer deleted; they render inside AppFrame. Activity now follows the sidebar order (Users, Activity, Integration, Settings). +8. Prototype's own mobile drawer (Home, Articles, Users, Integration only) replaced by AppFrame's drawer. +9. All code blocks on Integration (auth example, endpoint examples, error response), the base URL, the image URL builder and the one-time API key use CodeSnippet; the three older block styles are gone. CodeSnippet tabs changed from sand pills to the underline style used by tabs elsewhere. +10. Variant form: mobile full page replaced by a 92% bottom sheet with grabber and close ×; back arrow removed. +11. Dialog geometry unified (140 px top offset on desktop, flush bottom sheets with grabber on mobile, header 20 24 0, footer secondary left and primary right, close × 44 px on mobile). Changed: Media and Variants dialogs (mobile inset), Activity export (centered, header bar, 480 wide), API keys dialogs (border, 18 px title, 44 px buttons, 120 px offset). +12. Current page in the paginator: border `--ui-primary` instead of sand (was 1.3:1). +13. Page padding 24/16 everywhere (was 32 and 24); card padding 16 (was 16 and 20); section gap 16 (was 16, 20, 28); page title 20/28 (was 28/36, 24/32, 20/28); stat figure 24/32; card title 15/24; inputs 32/44 (were 40); one 13 px icon became 14; toast width 356 (was 360, 372). +14. Copy: "Removed N …" in activity lines became "Deleted N …"; export titles and footers per the glossary. +15. Media screen: variant count badge added to grid card, list row and mobile row, matching the Variants board. +16. Editor toolbar on mobile was a cut-down 9-button subset; it now has the full toolbar with sideways scroll and edge fades. +17. Editor words and characters show "0 words · 0 characters" for a new empty article (was a fallback sample count). -The Back-office board is now the one place where every back-office screen is drawn together, including Activity, Media cleanup, Retention and the Home activity section, all through `source/AppFrame.dc.html`. The separate Activity and Media cleanup boards and `SettingsFrame.dc.html` were removed from the export; their states now live in the Back-office board. +## Defaults chosen +- Slide-over rule: only forms with a live preview and record details; on mobile always a 92% bottom sheet. +- Hover, active, disabled and focus tokens for the matrix (components not specified before): primary hover `--ui-text`, active `--ui-text-toned`; destructive hover opacity 0.85 and active 0.75; disabled 40% (rows 55%). +- Article pagination stays "proposed" until the backend returns page, size and total. +- The Remove action in the link popover has no confirmation; undo restores the link. +- The link button is disabled when an image is selected. +- The tooltip text is "Insert link (Ctrl+K)" and "Insert link (⌘K)" on macOS. +- Mobile toolbar scrolls sideways; no wrap, no overflow menu. +- Selected nav items keep the sand fill plus weight 500 and `aria-current` (the fill alone is 1.3:1 in light). +- Syntax colouring dropped from code samples (one rendering). +- Export "Download started" offers only Close. +- New article keeps the title counter "n / 200" (title is limited to 200); the body has no limit and no counter. +- Page title 20/28, page padding 24/16, card padding 16 (foundations.md). +- Activity position in the sidebar follows AppFrame. +- The Prototype shows a toast "Not in this prototype" for sidebar items it has no screen for (Media, Activity, Settings). +## Checks run +- Opened `RenewCMS Screens Back-office.dc.html` in the preview: it loaded, 38 sections rendered, and I read the DOM of the Activity, Export, Media, Variants, Editor, Integration, Pagination, Picker, Editor picker and States matrix groups to confirm the frames render text and the sidebar. I did not read the console. +- Computed the contrast table from token values with a script. +- Searched frames and boards for raw hex and rgb values (only token definitions on the board roots and the brand mark remain), for exclamation marks, Remove and Delete, Update and Save, and per_page. +- Generated screens/*.md and counted states from the board source; the counts equal the DOM counts for the Back-office board. -- `source/ActivityFrame.dc.html`: the frame that draws every Activity state (the Back-office board imports it). Its copy, component and API tables are in `source/activity-spec.js`, rendered at the bottom of the Back-office board. It holds the exact values. The Home activity section and the Retention card are drawn by `AppFrame.dc.html`. -- `source/MediaFrame.dc.html`: the frame that draws the Media and Media picker states (the Back-office board imports it). Like `AppFrame.dc.html`, it holds the exact values. -- `source/IntegrationPage.dc.html` and `source/ApiKeysPage.dc.html`: the page components for the Integration and API keys screens (imported by `AppFrame.dc.html`). They hold the exact values. -- `source/RenewCMS Prototype.dc.html`: the clickable flows (first run, edit and publish, password reset, delete, session expiry mid-edit). -- `source/RenewCMS Handoff.dc.html`: the handoff page the first markdown here was generated from. It predates the Users and Profile screens and the invitation email, so it does not list them. Those are written by hand from the Back-office board, `AppFrame.dc.html`, the Prototype and `emails/invitation.html`. -- `source/RenewCMS Components Product.dc.html`: not regenerated for the new screens either. The matching component changes are in `components.md`, under "Added with the Users and Profile screens" and "Added with roles, invite actions and Settings". -- `source/RenewCMS Directions.dc.html`: the three brand directions (history only). - -- `source/VariantsFrame.dc.html`: the frame that draws every Image variants state (the Image variants board imports it, inside `AppFrame.dc.html`). Like `AppFrame.dc.html`, it holds the exact values. The board is the only place the variants screens are drawn; the Back-office board does not include them. +## Not done +- Console errors on the Auth, API keys, Prototype, Foundations and Components boards were not read by me. The delivery check covers the Back-office board; the other boards use the changed frames (AppFrame, ApiKeysPage, AuthCard, MediaFrame) and may need a look. +- Light and dark were checked for the Back-office board by reading DOM only; I did not view the screenshots of each state at 1440 and 390, so visual regressions from the shell change (slot mode) are possible. +- Media screen: the "With variants / Without" filter, the "Largest total size" sort and a total-size column exist only on the Variants board states; they are not folded into the Media toolbar. +- Media picker tiles and the Editor picker were not compared field by field. +- Duplicated implementations that look alike are not merged in code: toasts (5), badges (2), empty states (2), modal shells (4). Only their visuals were aligned. +- Hover, active and focus-visible are drawn once in the matrix; screens do not redraw them. Hover and active tokens for destructive buttons use opacity, not a token. +- Contrast was not computed for every hover and active background, only the pairs listed in foundations.md. +- The glossary pass was a search for known patterns, not a line-by-line read of every string. Titles in Title Case were not searched. +- The Components, Handoff and Foundations boards were not updated to mention the new tokens, CodeSnippet or the matrix. +- Integration still lists "max 10000" for article bodies, as the backend does today (change-list item 11). +- The sandbox cannot export; no files were bundled. diff --git a/docs/design/change-list.md b/docs/design/change-list.md new file mode 100644 index 0000000..da5adf5 --- /dev/null +++ b/docs/design/change-list.md @@ -0,0 +1,26 @@ +# Change list for implementation (status after pass 2) + +Ordered by implementation dependency: tokens and shell first, then shared components, then screens. "Frontend only" means no backend work is needed. + +| # | Screen | Change | Done in design? | Needs backend (yes/no, what) | Needs new tokens or components | Frontend only | +|---|---|---|---|---|---|---| +| 1 | All | Add the `--rc-*` tokens (sand, on-sand, four soft grounds, overlay, two shadows) to the app CSS, light and dark (foundations.md) | Yes | No | 9 new tokens | Yes | +| 2 | All | One sidebar, topbar and mobile drawer; Settings parent and child selected states; Activity sits between Users and Integration | Yes | No | NavItem, NavGroup | Yes | +| 3 | All | Overlay pattern: dialog (desktop) and bottom sheet (mobile); slide-over only for the variant form and record details; footer secondary left, primary right | Yes | No | Modal wrapper rules (components.md) | Yes | +| 4 | All | CodeSnippet is the only code block (Integration, Images, API keys, base URL, image URL) | Yes | No | CodeSnippet | Yes | +| 5 | All | Copy glossary pass (Delete vs Remove, Save vs Update, toast phrasing) | Yes, by search; not every line re-read | No | None | Yes | +| 6 | Settings | "Media" card with "Manage image variants" and "Cache usage" | Yes | No | MediaLinksCard | Yes | +| 7 | Media, picker | Variant count badge on grid, list and mobile rows | Yes | No (variant counts already come with GET /v1/media?variants=) | Badge "N variants" | Yes | +| 8 | Media | "With variants / Without" filter and "Largest total size" sort in the Media toolbar | Drawn on the Variants board only, not folded into the Media screen | No (GET /v1/media?variants=&sort=total_size exists) | None | Yes | +| 9 | Image variants | Variant form as slide-over (desktop) and tall sheet (mobile) | Yes | No | None | Yes | +| 10 | New article | Title only. Body removed from the modal; the editor opens straight away | Yes | Maybe: confirm `POST /v1/article/` accepts an empty `body` (it is "required" today); otherwise the frontend sends an empty string | NewArticleModal | Yes if empty body is accepted | +| 11 | Editor | Remove the 10,000 character limit, counter, limit and over-limit states; neutral count | Yes | Yes: remove the body length validation on `POST /v1/article/` and `PATCH /v1/article/{id}` | EditorStatusBar | No | +| 12 | Editor | Insert-link button (default, hover, active, disabled), tooltip, LinkModal, link popover and touch sheet, rendered link, toolbar scrolling at 390 px | Yes | No (editor only) | LinkModal, LinkPopover, ToolbarButton | Yes | +| 13 | Articles | Pagination with `page` and `size` (5, 25, 50, 100; default 25), URL state, loading, error, out of range | Yes | **Yes (proposed):** `GET /v1/article/` returns everything today. It needs `page`, `size` and `total` in the response | Paginator, PageSizeSelect | No | +| 14 | Activity | Failed-action and security cards as toggles, synced chip and segment | Yes | No | FilterCardToggle | Yes | +| 15 | Activity | Export menu and dialog, CSV: current view (active filters) and all activity (no filters, optional `from`/`to`). Preparing, started, failed, nothing to export, 403 | Yes | No: `GET /v1/activity/export` is implemented (CSV, synchronous, owner and admin, same filters as the list) | ExportMenu, ExportDialog | Yes | +| 16 | Activity | JSON export (format control CSV or JSON) | Yes, one state, labelled proposed | **Yes (proposed):** `format=json` on `GET /v1/activity/export` | Segmented format control | No | +| 17 | Integration | Images and variants section, URL builder, response bodies `unknown_variant` and `unsupported_parameter` | Yes | No | UrlBuilder | Yes | +| 18 | All | States matrix for controls (hover, focus-visible, active, disabled) | Yes (board "States matrix: controls") | No | None | Yes | +| 19 | All | Contrast fixes: paginator current page border, no muted text on sand | Yes | No | None | Yes | +| 20 | All | Spacing, type and icon sizes made consistent (foundations.md, "One value per role") | Yes in the frames listed there | No | None | Yes | diff --git a/docs/design/components.md b/docs/design/components.md index 73924e9..f21a3cd 100644 --- a/docs/design/components.md +++ b/docs/design/components.md @@ -1,855 +1,54 @@ # Components -## Inventory +Frames: `AppFrame.dc.html` (shell: sidebar, topbar, mobile nav drawer, modal, toasts, articles, editor, users, settings, profile), `ActivityFrame`, `MediaFrame`, `VariantsFrame` (page bodies only), `IntegrationPage`, `IntegrationImages`, `ApiKeysPage`, `CodeSnippet`, `StatesMatrix`. The page-body frames mount inside AppFrame through two props (`slotBody`, `slotOverlays`) so there is one sidebar, one topbar and one drawer. -| Component | Nuxt UI base | Props | Emits | Slots | Used on | -|---|---|---|---|---|---| -| AuthShell | UPageCard, UColorModeSelect | title, description? | none | default, notice, footer | All public routes | -| DashboardShell | UDashboardGroup, UDashboardSidebar, UNavigationMenu, UDashboardPanel, UDashboardNavbar, UBreadcrumb | none (reads route.meta and the session store) | none | default, navbar-right | All verified routes | -| UserMenu | UDropdownMenu, UButton, UAvatar (a bottom sheet below md) | displayName?, username, email | logout | none | DashboardShell | -| UsersTable | UTable | rows: UserRow[], loading, canManage: boolean, busyIds: Set | cancel-invite, renew-invite | none | Users (md and up) | -| UserCardList | custom list | same as UsersTable | same as UsersTable | none | Users (below md) | -| RoleBadge | UBadge | role: 'owner'\|'admin' | none | none | Users | -| SettingsPage | custom card | none (reads GET /v1/settings) | none | none | Settings | -| InviteStatusBadge | UBadge | status: 'accepted'\|'pending'\|'expired' | none | none | Users | -| InviteUserModal | UModal, UForm, UFormField, UInput | none | invited(invite) | none | Users | -| ProfileCard | custom card (border, padding, optional footer strip) | title, description | none | default, footer | Profile | -| PageHeader | UPageHeader | title, description? | none | actions, meta | Home, Articles, Integration | -| ArticleStatusBadge | UBadge | online: boolean, size?: 'sm'\|'md', pending?: 'publish'\|'unpublish' | none | none | Home, Articles, Editor | -| ArticleTable | UTabs, UInput, UTable, UDropdownMenu, UPagination | articles: Article[], loading, busyIds: Set | publish, unpublish, delete, copy-url | empty, toolbar-right | Articles (md and up) | -| ArticleCardList | ULink, UDropdownMenu (custom list) | same as ArticleTable | same as ArticleTable | empty | Articles (below md) | -| EmptyState | UEmpty | variant: 'no-articles'\|'no-results'\|'no-drafts'\|'no-online', query? | action | none | Home, Articles | -| ConfirmDialog | UModal via useOverlay | title, description, confirmLabel, pendingLabel, tone: 'danger'\|'neutral', action: () => Promise | close(confirmed) | none | Articles, Editor, Pending | -| NewArticleModal | UModal, UForm, UFormField, UInput, UTextarea | none | created(article) | none | Home, Articles, /article/new | -| ArticleEditor | UEditor, UEditorToolbar, UDropdownMenu (+ custom Indent extension) | modelValue: string, articleId: number, disabled? | update:modelValue, request-image | none | Editor | -| ImageUploader | UModal, UFileUpload, UProgress | articleId: number | uploaded(location), close | none | Editor | -| SaveStatus | custom (span + UButton) | state: 'clean'\|'dirty'\|'saving'\|'saved'\|'error'\|'offline', savedAt?: Date | retry | none | Editor | -| PublishControl | UButton | online: boolean, dirty: boolean, pending?: string, disabled? | publish, unpublish | none | Editor | -| ArticleDetails | UCard (desktop) / USlideover (mobile) | article: Article | copy-url | none | Editor | -| CharacterCounter | custom | value: number, max: number, min?: number, warnAt?: number | none | none | NewArticleModal, Editor, Register, Accept invite | -| PasswordStrengthHint | custom (in UFormField #help) | rules: { label, met }[], showErrors: boolean | none | none | Register, Reset, Accept invite | -| ResultPanel | custom + UButton | tone: 'success'\|'error'\|'info', icon, title, description, actions: ButtonProps[] | none | default | Pending, Verify, Reset request, Reset, Accept invite, Register (403) | -| CodeBlock | custom (shiki) + UButton | code: string, lang: string, label? | none | none | Integration | -| EndpointCard | UCollapsible, UBadge, UTable, UTabs | method: 'GET'\|'POST'\|'PATCH'\|'DELETE', path, description, access, keyAccess, body?, examples: { fetch, curl, response }, errors, sessionOnly? | none | none | Integration | -| SectionIndex | custom nav (desktop), USelectMenu "Jump to" (mobile) | sections: { id, label }[], active: string | jump | none | Integration | -| SessionOnlyGroup | UAlert-style note + list rows | routes: { method, path }[] | none | none | Integration | -| ApiKeysTable | UTable, UBadge | keys: ApiKey[], showCreatedBy: boolean | revoke | none | API keys | -| CreateApiKeyModal | UModal, UForm, UFormField, UInput | open: boolean | create | none | API keys | -| ApiKeyReveal | UModal, UAlert, UButton | key: string | done | none | API keys | -| ErrorView | custom + UButton | kind: 'not-found'\|'error' | retry | none | Not found, error boundary | -| OfflineBanner | UBanner | none (reads useOnline) | none | none | DashboardShell | -| AppBoot | USkeleton inside DashboardShell | none | none | none | First load of any verified route | - -Composables behind these components: `useSession` (it also carries the display name), `useUsers` (the list and the invite action), `useProfile` (the display name draft and save), `useApi`, `useArticles` (the list cache plus actions shared by the table, cards and editor), `useArticleDraft` (dirty state, save, and the sessionStorage mirror), `useImageUpload` (XHR with progress) and `useRetryAfter` (429 countdown). - -## Product component specs - -Extracted from `source/RenewCMS Components Product.dc.html`; open it for the drawings. - -## Components: product - -These are the RenewCMS components, built from the primitives on the first page . Each one lists the Nuxt UI parts it's made from, its props, emits and slots, every state, and keyboard and responsive behavior. Components marked custom have no Nuxt UI equivalent. - -### AuthShell - -One centered card for every public screen. The logo sits above the title, and the form or ResultPanel goes in the slot. Footer links, the language chip and the theme select sit outside the card, so the card itself stays about one task. - -Log in Welcome back. Log in to manage your articles. - -default slot - -Forgot password? System - -Built from Custom layout component AuthShell.vue : a UPageCard variant="outline" inside a centered main , with AppLogo , and UColorModeSelect size="sm" in the footer, next to the language chip (see LanguageSwitcher). Forms inside it use UForm rather than UAuthForm , because every screen needs states (such as 403, 429 and an inline login during verification) that UAuthForm doesn't expose. - -Props, slots Props title: string , description?: string . Slots default , notice (an alert above the title, for session expired or password changed), footer (links). No emits. - -A11y The title is the page's h1 , and document.title becomes "Log in · RenewCMS". On route change, focus moves to the h1 . The logo is alt="RenewCMS" and isn't a link, since there's nothing public to go back to. - -Responsive 400 px card from sm up, with its top 12 vh from the top. Below sm , the card loses its border and padding drops to 20 px, filling the screen on --ui-bg , and fields and buttons use xl . - -### DashboardShell - -The sidebar has seven destinations for owner and admin (Home, Articles, Media, Users, Activity, Integration, Settings; regular users get Home, Articles, Media, Activity), and the user menu sits at its foot. For owner and admin, Settings expands to a Media group with Variants and Cache (see "Sidebar entry (1l)" in [image-variants](screens/image-variants.md)); it is built with the `children` of UNavigationMenu, and the same group appears in the mobile drawer. The top bar carries the breadcrumbs and page title. Collapsing the sidebar leaves a 56 px icon rail. Below lg , the sidebar becomes a slideover. - -RenewCMS - -Home - -Articles - -Integration - -marta Owner - -System - -Light - -Dark - -Log out - -MA marta - -Articles How we price a bathroom renovation - -page body - -MA - -Articles - -Mobile top bar. The menu button opens the sidebar as a left slideover. - -Built from UDashboardGroup storage="local" › UDashboardSidebar collapsible :resizable="false" :default-size="16" :collapsed-size="4" with the #header (AppLogo), #default ( UNavigationMenu orientation="vertical" :collapsed ) and #footer (UserMenu) slots. Each page is a UDashboardPanel , with a UDashboardNavbar holding UDashboardSidebarCollapse and UBreadcrumb . On mobile, UDashboardSidebarToggle appears and the sidebar's built-in slideover takes over. - -UserMenu UDropdownMenu :content="{ side: 'top', align: 'start' }" on a ghost UButton block with a UAvatar and the username. The groups are: the account label; System, Light and Dark as checkbox items; then Log out. There are no profile or password items, because the API doesn't support them. Superseded: see "UserMenu (changed)" under "Added with the Users and Profile screens", which restores the System, Light and Dark items. - -Props, slots No props, because the shell reads the user from the session Pinia store. The navbar reads route.meta.title and route.meta.breadcrumb . Slots navbar-right (page actions on mobile) and default . It shows the offline banner when navigator.onLine is false. - -A11y The first Tab stop is a "Skip to content" link. The sidebar is a nav labeled "Main", and the current page gets aria-current="page" . In the collapsed rail, items show tooltips and keep their aria-label . The collapse button carries aria-expanded . The mobile slideover traps focus and closes after navigating. - -Bend The active nav item uses a sand fill with ink text instead of Nuxt UI's primary-tinted background and left indicator. The shell is set up for this in ui.config.ts . - -### PageHeader - -Articles Everything you've written, online and in draft. - -Connect your website New article - -Articles Everything you've written, online and in draft. - -New article - -Built from UPageHeader :title :description , with actions in the #links slot. The ui override is { root: 'py-0 border-0', title: 'text-xl', description: 'text-sm text-muted mt-0.5' } , which brings Nuxt UI's marketing-size header down to dashboard density. - -Props, slots title , description? , slot actions (the primary action goes last, so it sits on the right), slot meta (for example, a status badge next to the title in the editor). - -Responsive Below md , the actions wrap under the text. The primary action goes full width at 44 px, and secondary actions move into the navbar's overflow menu. - -### ArticleStatusBadge - -Online - -Draft - -Online - -Draft - -Publishing… - -Unpublishing… - -Built from UBadge . Online: color="neutral" variant="solid" icon="i-lucide-globe" . Draft: color="neutral" variant="outline" icon="i-lucide-pencil-line" . Status is shown by the fill, the icon and the word, never by color alone. - -Props online: boolean , size?: 'sm' | 'md' (sm in lists, md in the editor header), pending?: 'publish' | 'unpublish' (shows the -ing label with a spinner while the request runs). No emits, because it isn't interactive. - -### ArticleTable - -Search, filter tabs, a sortable table and row actions. Everything happens in the browser on the list it has already loaded. Below md , the table becomes a card list with the same data and actions. - -All 6 Online 4 Drafts 2 - -Search by title - -Title Status Created Updated - -Online Draft - -Built from UTabs + UInput type="search" + UTable (desktop), or a custom ArticleCardList of ULink rows (mobile), switched with useBreakpoints at md . Each row has a UDropdownMenu , and the row actions go through useArticleActions() , which shares one set of calls between the table, the list and the editor. - -Props, emits Props articles: Article[] , loading: boolean , busyIds: Set . Emits publish(id) , unpublish(id) , delete(article) , copy-url(id) . Search, tab and sort state lives in the URL query ( ?q=&status=&sort= ). Slots #empty , #toolbar-right . - -Behavior Search is case-insensitive and ignores accents, and it matches anywhere in the title. It updates on input, debounced 120 ms. Tab counts always reflect the full list. Dates within 7 days are relative ("2 hours ago"); older ones are absolute ("18 Aug 2026"), with the full timestamp in a title attribute. Clicking a row opens the editor, except clicks on the menu. - -States Loading (5 skeleton rows), empty, no results, a filter tab with nothing in it, a row busy, an action failed (toast with Retry, row reverts), and a row vanished (a 404 on any action removes the row and shows an info toast). Row actions are never optimistic: the row updates when the 200 arrives. - -A11y The table has a visually hidden caption, "Articles, sorted by last updated, newest first". Each row's menu button is named "Actions for How we price a bathroom renovation". After a search, a polite live region announces "3 articles". - -### EmptyState - -No articles yet Write your first one. It stays a draft until you publish it. New article - -No articles match "tiles" Check the spelling, or search for a shorter part of the title. Clear search - -No drafts Everything you've written is online. New article - -Nothing is online yet Publish a draft and it will appear on your website. Show drafts - -Built from UEmpty :icon :title :description :actions variant="naked" , placed in the UTable #empty slot or on the page. The icon tile is overridden to rounded-none bg-elevated . - -Props variant: 'no-articles' | 'no-results' | 'no-drafts' | 'no-online' , query?: string . Emits action . Each variant has fixed copy, and the search term is shown in quotes, cut to 40 characters. - -A11y The title is an h2 . "No results" is announced through the table's live region. Clear search returns focus to the search field. - -### ConfirmDialog - -Delete this article? "Winter checklist for timber windows" and its 3 images will be deleted. This can't be undone. - -Cancel Delete article - -Delete this article? "Winter checklist for timber windows" and its 3 images will be deleted. This can't be undone. - -Couldn't delete the article, so it's still there. Try again in a moment. - -Cancel Deleting… - -Left: default. Right: after a failed attempt (the error appears inline), with the retry running (loading). - -Built from UModal opened through useOverlay().create(ConfirmDialog) . open() returns a promise that resolves once the action succeeds or the dialog is cancelled. The action runs inside the dialog, so errors show there instead of in a toast behind the overlay. - -Props, emits title , description , confirmLabel , pendingLabel , tone: 'danger' | 'neutral' , action: () => Promise . Emits close(confirmed: boolean) . The delete description comes from the title plus images.length , and reads "and its image" for 1 and just the title for 0. - -Other uses Cancel registration: "Cancel your registration?" / "Your account will be deleted. You can register again with the same or a different email." / "Cancel registration" / "Keep my account". Leaving with unsaved changes uses the neutral tone. - -A11y role="alertdialog" . Focus starts on Cancel, and Enter doesn't confirm by default. While the action runs, the dialog can't be dismissed, both buttons are disabled, and aria-busy is set. On success, focus goes to the next row, or the page heading if there isn't one. - -### ArticleEditor, SaveStatus, PublishControl, CharacterCounter - -The editor is made of four components that work together. The header holds the read-only title, status, SaveStatus, Save and PublishControl. A sticky toolbar sits above the writing surface, which uses the content type scale. The counter sits at the foot. - -How we price a bathroom renovation Draft - -Unsaved changes - -Save - -Save and publish - -Every bathroom is different, but the way we price them isn't. We start with a visit, measure everything, and write down what stays . - -What goes into the price - -Stripping out and making good - -Plumbing moves, if any - -Details - -Created 12 Mar 2026, 09:14 Updated Today, 11:02 ID 12 Images 3 - -Public API URL /v1/article/12 Returns 404 until the article is online. - -4 318 / 10 000 - -Unsaved changes dirty, Save enabled - -Saving… saving, Save disabled - -Saved at 11:02 saved, Save disabled - -Save failed Retry error, changes kept - -Publish draft, saved - -Unpublish online - -9 240 / 10 000 body ≥ 90% - -10 214 / 10 000 214 characters over. Shorten the text or remove an image. Formatting counts too. over limit, Save disabled - -ArticleEditor UEditor v-model content-type="html" :extensions + UEditorToolbar :editor :items layout="fixed" . TipTap v3 StarterKit already includes Underline and Link. TextAlign handles alignment and Image handles images. Formatting is a UDropdownMenu with Paragraph and Headings 1–4 ( h1 is kept because the public site controls how the title appears). Indent and outdent call sinkListItem / liftListItem in lists. For paragraphs, a small custom Indent extension writes data-indent="1–4" , because TipTap has no paragraph indent. Clear formatting runs unsetAllMarks().clearNodes() . The editor surface gets class="article-content" . - -Props, emits modelValue: string (HTML), articleId: number , disabled? . Emits update:modelValue , request-image . Exposes insertImage(src, alt) and focus() . The toolbar reflects the active state (bold is on at the cursor) using aria-pressed . - -SaveStatus A custom inline span aria-live="polite" , driven by useArticleDraft() : 'clean' | 'dirty' | 'saving' | 'saved' | 'error' . Saved shows the time and switches to "Saved 5 min ago" after an hour. The error state's Retry is a UButton size="xs" variant="outline" . Ctrl/⌘ S saves. Leaving with unsaved changes triggers onBeforeRouteLeave (ConfirmDialog, neutral tone) and beforeunload . There is no autosave , so the owner controls exactly what is sent. The draft is mirrored to sessionStorage so it survives a session expiry. - -PublishControl A single UButton . Draft → primary "Publish"; draft with unsaved changes → "Save and publish" (PATCH body, then PATCH publish; if the save fails, it doesn't publish); online → outline "Unpublish". Unpublishing doesn't save pending changes, so the draft edits stay pending. Toasts: "Article published", with the description "Title" is now online. ; "Article unpublished", It's a draft again and hidden from your website. - -CharacterCounter A custom component in mono type. Props value: number , max: number , min?: number , warnAt?: number (default 0.9). The title uses [...str].length , so accents and emoji count as one, matching Go's validator, which counts runes. The body uses the HTML string length, which is what the API receives. The warning and error states add an icon and text, not just color. A visually hidden live region announces the change only when a threshold is crossed, not on every keystroke. - -Responsive Below lg , the details panel becomes a right slideover, opened with i-lucide-panel-right . Below md , the header wraps: back, title and badge on the first row, then SaveStatus and the counter. Save and Publish move to a sticky bottom bar (44 px). The toolbar scrolls horizontally with fade edges and keeps undo, redo and the image button pinned at the start. - -### ImageUploader - -Opens from the editor's image button in a small modal. Files are checked in the browser before anything is sent. A JPEG, PNG or WEBP then goes through a crop-and-resize step before it uploads; an SVG skips straight to uploading. After a successful upload the image goes in at the cursor and the modal closes. - -Drop an image here Choose file PNG, JPEG, WEBP, SVG · max 10 MB - -idle - -Drop to upload - -dragging over - -Crop and resize north-wall.jpg · 3.9 MB - -crop, default: whole image selected, width "1600 px" - -Crop and resize north-wall.jpg · 3.9 MB - -crop, ratio locked to 16:9 - -Crop and resize north-wall.jpg · 3.9 MB - -crop, crop narrower than the chosen width - -north-wall.jpg 43% - -Uploading 180 of 420 KB - -uploading - -Image added - -success, closes after 600 ms - -"plan.pdf" can't be used Use a PNG, JPEG, WEBP or SVG image, up to 10 MB. Choose another file - -rejected: type or size - -Upload failed The server didn't accept the file. Your article hasn't changed. Try again - -server error - -Built from UModal title="Add an image" › UFileUpload accept="image/png,image/jpeg,image/webp,image/svg+xml" :multiple="false" layout="list" + UProgress size="xs" . Uploads go through XMLHttpRequest , because fetch has no upload progress, as FormData with the field name file . The 201 location is inserted with insertImage(location, '') . - -Crop and resize A JPEG, PNG or WEBP opens on a 640px-wide modal (`md.crop`) between the file picker and the upload, replacing the "Add an image" title with "Crop and resize" and the filename/size as the description. The stage shows the image with the crop frame starting as the whole image: a rule-of-thirds grid (lines at 33.3 %/66.6 %), four 12×12 px corner handles, and a pixel-size label (mono, bottom-left, dark pill) that tracks the frame's current size. The frame is `tabindex="0"`, `aria-label="Crop area. Arrow keys move it, Shift and arrow keys resize it."`; dragging moves it, dragging a handle resizes it, arrow keys nudge it by 1 px and Shift+arrow keys resize it. - -Aspect ratio is a `radiogroup` of five pills — Original, Free, 16:9, 4:3, 1:1 — plus Rotate and Reset actions above it. Width is a second `radiogroup` — Original, 1600 px (default), 1200 px, 800 px. A mono line below reads "Uploads as {width} × {height} px · about {size}", where the estimated size is shown in KB under 1 MB and in MB above it. When the chosen width is larger than the crop's own width, the output stays at the crop's size (never upscaled) and an info row explains why: "The crop is {crop width} px wide, so it's uploaded at that size. Enlarging it would make it blurry." The footer becomes "Choose another file" (back to the file picker, `data-act="upload-pick"` from `idle`) and "Upload image" (starts the upload with the cropped, resized file). SVGs never see this step: they upload as soon as they're chosen. The "in progress" state afterwards reports the resized file's size, e.g. "Uploading 180 of 420 KB". - -Validation Before uploading, the uploader checks both the MIME type and the file extension, and that size ≤ 10 × 1024 × 1024 . The size error names the file size: "north-wall.tif is 14.2 MB. Images can be up to 10 MB." A server 400 shows the same message as a client-side rejection. - -Props, emits articleId: number . Emits uploaded(location) , close . A 401 hands off to the global session flow. A 404 means the article was deleted, which the editor handles. - -A11y, touch The drop zone is a labeled button, so Enter opens the file picker. Progress is exposed as role="progressbar" with aria-valuenow , and the result is announced. On touch screens the drop text is hidden and "Choose file" becomes the main action. Dropping a file directly onto the editor surface also opens the uploader with that file. - -### CodeBlock and EndpointCard - -GET /v1/article/{id} - -Returns one article. Drafts return 404 to anonymous callers, the same as an id that doesn't exist. - -fetch curl Response - -JavaScript Copied - -const res = await fetch( 'https://cms.renew.example/v1/article/12' ) - -if (res.status === 404 ) return notFound() - -const article = await res.json() - -CodeBlock This is a custom component, because Nuxt UI's prose code blocks rely on Nuxt Content and don't work in a plain Vue app. Highlighting is done ahead of time with shiki (a dual github-light / github-dark theme remapped to the semantic colors: keywords info, strings success, numbers warning, comments muted). The header shows the language label and a ghost copy UButton , which switches to "Copied" for 2 s through useClipboard . Props code , lang , label? . Long lines scroll horizontally and never wrap. - -EndpointCard UCard › a method UBadge (GET is solid ink) + the path in mono + a description, with UTabs (fetch, curl, Response) showing a CodeBlock each. Props method , path , description , examples: { fetch, curl, response } . Snippets use the current location.origin . - -A11y The copy button is named "Copy JavaScript example", and its result is announced. Code is a focusable pre ( tabindex="0" ) so keyboard users can scroll it. Syntax colors pass 4.5:1 on --ui-bg-elevated in both themes. - -### PasswordStrengthHint - -At least 8 characters not met yet - -At least 8 characters met - -At least 8 characters not met on submit - -Passwords match reset screen only - -Built from A custom ul in the password UFormField 's #help slot. Props rules: { label: string, met: boolean }[] , showErrors: boolean . There is no meter or score, because the API only requires 8 characters. - -A11y The list is the field's aria-describedby . Each item has hidden text reading "met" or "not met". A change is announced once, when the rule becomes met. - -### ResultPanel - -Check your email If an account exists for that email, a password reset link has been sent. The link expires in 1 hour. - -Back to log in - -This link has expired Reset links work once, for 1 hour. Request a new one and use it within the hour. - -Request a new link - -Too many attempts For your security, wait a moment before trying again. You can try again in 0:42 . - -Try again in 0:42 API pending - -Built from A custom component that fills AuthShell's slot: an icon tile, h1 title, description and UButton block size="lg" actions. Props tone: 'success' | 'error' | 'info' , icon , title , description , actions: ButtonProps[] . Slot default for extras such as the inline login on verify. - -A11y When the panel replaces a form, focus moves to its title and the panel is role="status" , or role="alert" for errors. The countdown updates every second visually but is announced only at the start and when it reaches zero. - -### Error pages and offline banner - -You're offline. You can keep writing, but saving and publishing will wait until you're back online. - -404 Page not found The link may be mistyped, or the page may have moved. Go to Home All articles - -Error Something went wrong This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server. Try again Go to Home - -Built from UError isn't used because it's built for Nuxt's error.vue . Instead there's a custom ErrorView (props kind: 'not-found' | 'error' ), rendered inside UDashboardPanel when the user is logged in and inside AuthShell otherwise. The offline banner is a UBanner color="neutral" at --z-banner , shown and hidden with useOnline() . Once the connection returns it switches to "You're back online" for 3 s. - -A11y The error title is the page h1 and gets focus on render. The banner is role="status" , and it can't be dismissed while offline. The offline state blocks Save and Publish and says why in their tooltip. - -## Added with the Users and Profile screens - -The Product components board was not regenerated for these. They are written from `source/RenewCMS Screens Back-office.dc.html`, `source/AppFrame.dc.html` and the Prototype. Their exact values come from AppFrame. - -### UserMenu (changed) - -The chip at the foot of the sidebar no longer logs out on click. It opens the account menu, which lists Profile, the theme choice, the language choice and Log out. - -- **Chip:** 40 px high, an initials avatar (26 px, sand `#d4c5b0` with ink text), the name, and a chevrons-up-down icon. `aria-haspopup="menu"`, `aria-expanded`, title "Account". While the menu is open, or while `/profile` is the current page, it takes `--rc-sand` with `#111111` text. -- **Name:** the display name when there is one, otherwise the username. Initials come from the first letters of the first two words of the display name, uppercased. Without a display name they come from the username. -- **Desktop menu:** a 224 px popover anchored above the chip on its left edge, with `--rc-shadow-pop`. Header: the name in semibold, the email below it in muted 12 px, a bottom border. Items are 32 px: Profile (circle-user), a separator, a "Theme" section label, System, Light and Dark (14 px text, a check icon at the right edge on the current choice), a separator, a "Language" section label, English and Français (same row as the theme items, a check icon on the current one), a separator, Log out (log-out). Section labels are 11 px, weight 500, uppercase, letter-spacing .04em, `--ui-text-muted`, padded 6px 8px 2px. The current page's item is highlighted with `--ui-bg-elevated`. The theme items are radio-style: choosing one applies at once and keeps the menu open. Nuxt UI: `type: 'checkbox'` items bound to `useColorMode().preference`. -- **Mobile:** the account row in the navigation drawer opens a bottom sheet over an `--rc-overlay` scrim, with a grab handle. Header: 36 px avatar, name in 15 px semibold, email in muted text. Items are 48 px at 15 px text; the theme and language items keep the same order, section labels (padded 8px 16px 2px) and separators. -- **Language items:** a `menuitemradio` group named by the "Language" label. Each item is labelled with the language's own name (English, Français) and carries `lang="en"` / `lang="fr"`. Choosing one applies the language at once and keeps the menu open, like the theme items. See LanguageSwitcher. -- **Keyboard:** Escape or a click outside closes it. Arrow keys move between items. -- The board now draws the theme items, so the chip, menu and sheet need no visual exception. The board highlights Profile even on Home; the app highlights it only on `/profile`. - -### DashboardShell (changed) - -The navigation gains **Users** (users icon) between Articles and Integration, in the sidebar and in the mobile drawer. Breadcrumbs for the new pages are "Users" and "Profile". It later gains **Settings** (settings icon) after Integration, with the breadcrumb "Settings". **Activity** (history icon) sits between Users and Integration; see `screens/activity.md`, "Sidebar". - -### UsersTable, UserCardList - -Superseded by "UsersTable, UserCardList (changed)" below. - -Skeleton rows appear after 150 ms of loading. - -### InviteStatusBadge - -A 22 px outline-less badge with a 12 px icon and the word, so status is never shown by color alone. - -| Status | Icon | Fill and text | -|---|---|---| -| Accepted | circle-check | `--rc-success-soft`, `--ui-success` | -| Pending | mail | `--rc-warning-soft`, `--ui-warning` | -| Expired | clock-alert | transparent, `--ui-text-muted`, border `--ui-border-accented` | - -### InviteUserModal - -A UModal with one field, following the NewArticleModal pattern: title "Invite a user", the text "We'll email them a link to choose a username and password. The link expires after 3 days.", an Email field (placeholder "name@example.com", focused on open), Cancel and "Send invite". While sending, the field is disabled, the button reads "Sending…" and the dialog can't be dismissed. A field error shows under the input, a request failure shows an alert above the buttons, and the address always stays in the field. On success it closes and emits the new invite. - -### ProfileCard - -A bordered card with 20 px padding (16 px on mobile), a semibold 14 px title, a muted description, and its content. An optional footer strip sits below a top border on `--ui-bg-muted`, with the status text on the left and the action on the right. The Display name card uses the footer; Account and Password do not. - -The Display name field uses CharacterCounter with a maximum of 60, counted in Unicode characters, in the mono style on the right of the help row. Read-only fields (username, email, password) are disabled inputs on `--ui-bg-elevated` with a lock icon at the right. - -## Themed primitives - -Extracted from `source/RenewCMS Components Primitives.dc.html`. - -## Components: themed primitives - -These are Nuxt UI components with the Ledger config applied. Every specimen reads the real --ui-* variables, so the theme switch on the right changes the whole page. The product components are on a second page . - -### Button - -There are five roles. A screen gets one primary button. Danger is used only for irreversible actions, and always inside a confirmation step. - -New article primary - -Cancel secondary - -Preview ghost - -Delete article danger - -icon-only - -Save sm 28 - -Save md 32 - -Save lg 40 - -Save xl 44, touch - -Publish default - -Publish hover - -Publish focus-visible - -Publish active - -Publish disabled - -Publishing… loading - -Delete article danger hover - -Cancel secondary hover - -Preview ghost hover - -Nuxt UI UButton . Primary color="primary" variant="solid" · secondary color="neutral" variant="outline" · ghost color="neutral" variant="ghost" · danger color="error" variant="solid" · icon-only square icon="i-lucide-ellipsis" :aria-label wrapped in UTooltip . - -Loading :loading swaps the leading icon for i-lucide-loader-circle and disables the button. The label changes to the -ing form ("Publishing…") and the button width stays fixed, so the layout doesn't jump. loading-auto is used on async @click handlers. - -Keyboard, ARIA Native + + + + +
{{ code }}
+ + + + + diff --git a/docs/design/source/IntegrationImages.dc.html b/docs/design/source/IntegrationImages.dc.html new file mode 100644 index 0000000..6fc0d8a --- /dev/null +++ b/docs/design/source/IntegrationImages.dc.html @@ -0,0 +1,124 @@ + + + + + + + + + +
+
+

Images and variants

+

Every image in the media library can be requested as a named variant. Add the ?v=<variant-name> parameter to the image URL. The original file is never changed, and a variant is generated the first time it is requested. Variants are managed in Settings, Media, Variants.

+
+ + +
+ + + +
+
+ + +
Couldn't load image variantsThe snippets below still work. The variant list and the URL builder need a connection.
Try again
+
+ + +
+

Request an image

+ +
+
+ + +
No image variants yetUntil you add a variant, the ?v= parameter is not accepted and every request returns the original image.Go to image variants
+
+ +
No image variants yetAsk an owner or admin to add one. Until then, every request returns the original image.
+
+ + +
+

Variants

{{ countText }}
+
+
+
NameDimensionsFormatExample URL
+
+ +
{{ r.n }}{{ r.dim }}{{ r.fmt }}{{ r.url }}
+
+
+
+
+Disabled variants are not served. A request for one returns 400 "Unknown variant". +
+ +
+

URL builder

+
+
+
Image{{ image }}
+
Variant{{ vname }}
+
URL +
+
The variant "hero" no longer existsThere is no fallback to the original. Requests with ?v=hero return status 400 with the body {{ fallbackBody }}. Remove the parameter to get the original image, or pick another variant.
+
+
Preview +
{{ previewLabel }}
+
+
+
+ + +
+

Responses

+
+ +
{{ r.req }}{{ r.st }}{{ r.res }}{{ r.body }}
+
+
+
+
+
+ + + diff --git a/docs/design/source/IntegrationPage.dc.html b/docs/design/source/IntegrationPage.dc.html index b753df9..047206d 100644 --- a/docs/design/source/IntegrationPage.dc.html +++ b/docs/design/source/IntegrationPage.dc.html @@ -28,26 +28,21 @@
Base URL -
https://cms.renew.example -
+

Authentication

-
PublicNo key
+
PublicNo key
No key needed. Two read-only routes, and only online articles are returned.
GET /v1/article/GET /v1/article/{id}
-
API keyFull access
+
API keyFull access
Send Authorization: Bearer rcms_…. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator. -
-
-
{{ authLang }}
-
{{ authCode }}
-
+ Keys start with rcms_ and are shown in full once, when you create them. Manage API keys
@@ -78,11 +73,7 @@
NameTypeRequiredRule
{{ f.n }}{{ f.t }}{{ f.req }}{{ f.rule }}
No request body. -
-
-
{{ ep.lang }}
-
{{ ep.code }}
-
+
Errors
{{ e.c }}{{ e.cap }}
@@ -106,6 +97,8 @@
+
+

Errors

Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain. @@ -113,8 +106,7 @@
StatusMeaningTypical cause
{{ r.c }}{{ r.m }}{{ r.w }}
-
Error response · text/plain
-
{{ errBody }}
+
@@ -142,7 +134,7 @@ class Component extends DCLogic { rootRef = React.createRef() BASE = 'https://cms.renew.example' KEY = 'rcms_Kf3x9QwL0aBc…' - IDX = [['auth', 'Authentication'], ['articles', 'Articles'], ['media', 'Media'], ['users', 'Users and invites'], ['settings', 'Settings'], ['session', 'Session only'], ['errors', 'Errors'], ['notes', 'Notes']] + IDX = [['auth', 'Authentication'], ['articles', 'Articles'], ['media', 'Media'], ['images', 'Images and variants'], ['users', 'Users and invites'], ['settings', 'Settings'], ['session', 'Session only'], ['errors', 'Errors'], ['notes', 'Notes']] componentDidMount() { const i = (this.props.s && this.props.s.int) || {} const go = () => { if (i.scroll) this.scrollTo(i.scroll, !!this.props.mobile) } @@ -189,8 +181,8 @@ class Component extends DCLogic { articles: { title: 'Articles', desc: 'Read, write and publish articles. Titles can\'t be changed after creation.', eps: [ A('art-list', 'GET', '/v1/article/', 'List articles. Anonymous callers get online articles only.', 'Public', 1, [e401], [], { pub: 1, r: j([{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }]) }), A('art-get', 'GET', '/v1/article/{id}', 'Get one article. Drafts answer 404 to anonymous callers.', 'Public', 1, [e401, e404], [], { pub: 1, r: j({ id: 12, title: 'How we price a bathroom renovation', status: 'online', body: 'A bathroom renovation is priced by room size, plumbing changes and finish level.', body_format: 'markdown' }) }), - A('art-create', 'POST', '/v1/article/', 'Create a draft article.', 'Verified user', 1, [[400, mm], e401], [S('title', 'string', 'Yes', '3 to 200 characters. Can\'t be changed later.'), S('body', 'string', 'Yes', 'Up to 10 000 characters.')], { body: { title: 'Choosing tiles for a small bathroom', body: 'Small rooms show every grout line.' }, r: j({ id: 13, status: 'draft' }) }), - A('art-update', 'PATCH', '/v1/article/{id}', 'Update the body of an article.', 'Verified user', 1, [[400, mm], e401, e404], [S('body', 'string', 'Yes', 'Up to 10 000 characters.')], { body: { body: 'Updated text.' }, r: j({ id: 12, status: 'draft' }) }), + A('art-create', 'POST', '/v1/article/', 'Create a draft article.', 'Verified user', 1, [[400, mm], e401], [S('title', 'string', 'Yes', '3 to 200 characters. Can\'t be changed later.'), S('body', 'string', 'No', 'Optional. Any length.')], { body: { title: 'Choosing tiles for a small bathroom' }, r: j({ id: 13, status: 'draft' }) }), + A('art-update', 'PATCH', '/v1/article/{id}', 'Update the body of an article.', 'Verified user', 1, [[400, mm], e401, e404], [S('body', 'string', 'Yes', 'Any length.')], { body: { body: 'Updated text.' }, r: j({ id: 12, status: 'draft' }) }), A('art-pub', 'PATCH', '/v1/article/{id}/publish', 'Publish an article.', 'Verified user', 1, [e401, e404], [], { r: j({ id: 12, status: 'online' }) }), A('art-unpub', 'PATCH', '/v1/article/{id}/unpublish', 'Unpublish an article.', 'Verified user', 1, [e401, e404], [], { r: j({ id: 12, status: 'draft' }) }), A('art-del', 'DELETE', '/v1/article/{id}', 'Delete an article and its images.', 'Verified user', 1, [e401, e404], [], { r: '204 No Content' }) @@ -242,31 +234,30 @@ class Component extends DCLogic { toggle: () => this.setState(s => ({ open: { ...s.open, [e.id]: !s.open[e.id] } })), bodyTitle: e.multi ? 'Request body · multipart/form-data, 1 file field' : e.body ? 'Request body · JSON' : 'Request body', hasFields: e.fields.length > 0, noFields: e.fields.length === 0, fields: e.fields, tabs: ['fetch', 'curl', 'Response'].map(t => { const k = t === 'Response' ? 'response' : t, on = (k === tab) || (t === tab); return { t, on, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', pick: () => this.setState(s => ({ tab: { ...s.tab, [e.id]: k } })) } }), - lang, code: this.hl(code), copyLabel: 'Copy ' + lang + ' example', cT: cp ? 'Copied' : 'Copy', cIcon: cp ? 'check' : 'copy', cFg: cp ? 'var(--ui-success)' : 'var(--ui-text-toned)', - copy: () => { try { navigator.clipboard && navigator.clipboard.writeText(code) } catch (x) { } this.flash(e.id) }, + snips: [{ k: 'fetch', label: 'fetch', code: fe }, { k: 'curl', label: 'curl', code: curl }, { k: 'response', label: 'Response', code: e.r }], errs: e.errs.map(([c, cap]) => ({ c, cap })) } }) } }) } renderVals() { + const imgState = (this.props.s && this.props.s.img) || { v: 'many' } const mob = !!this.props.mobile, st = this.state const code = { curl: 'curl "' + this.BASE + '/v1/auth/me" \\\n -H "Authorization: Bearer ' + this.KEY + '"', fetch: "const res = await fetch('" + this.BASE + "/v1/auth/me', {\n headers: { Authorization: 'Bearer " + this.KEY + "' }\n})\nconst me = await res.json()" - }[st.authTab] + } const cp = k => ({ t: st.copied === k ? 'Copied' : 'Copy', icon: st.copied === k ? 'check' : 'copy', fg: st.copied === k ? 'var(--ui-success)' : 'var(--ui-text-toned)' }) const bc = cp('base'); if (st.copied !== 'base') bc.fg = 'var(--ui-text-highlighted)' const index = this.IDX.map(([k, t]) => { const on = st.active === k; return { t, on, cur: on ? 'true' : undefined, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', fw: on ? 500 : 400, bg: on ? 'var(--ui-bg-elevated)' : 'transparent', op: on ? 1 : 0, go: () => { this.setState({ jump: false, active: k }); this.scrollTo(k, mob) } } }) const sg = (t, rows) => ({ t, rows: rows.map(([m, p, d]) => ({ m, p, d })) }) const D = (a) => 'rowDisplay' return { - rootRef: this.rootRef, onScroll: this.onScroll, mob, desk: !mob, jumpOpen: st.jump, toggleJump: () => this.setState(s => ({ jump: !s.jump })), + rootRef: this.rootRef, onScroll: this.onScroll, imgState, mob, desk: !mob, jumpOpen: st.jump, toggleJump: () => this.setState(s => ({ jump: !s.jump })), activeLabel: (this.IDX.find(x => x[0] === st.active) || this.IDX[0])[1], index, - pad: mob ? '20px 16px 96px' : '32px', secGap: mob ? '28px' : '32px', h1: mob ? '20px/28px' : '28px/36px', detX: mob ? '12px' : '16px', linkH: mob ? '44px' : '28px', tabH: mob ? '44px' : '36px', + pad: mob ? '16px 16px 96px' : '24px', secGap: mob ? '28px' : '32px', h1: '20px/28px', detX: mob ? '12px' : '16px', linkH: mob ? '44px' : '28px', tabH: mob ? '44px' : '36px', authCols: mob ? 'minmax(0,1fr)' : 'minmax(0,5fr) minmax(0,7fr)', baseCopy: bc, copyBase: () => { try { navigator.clipboard && navigator.clipboard.writeText(this.BASE) } catch (x) { } this.flash('base') }, - authTabs: ['curl', 'fetch'].map(t => { const on = st.authTab === t; return { t, on, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', pick: () => this.setState({ authTab: t }) } }), - authLang: st.authTab === 'curl' ? 'Shell' : 'JavaScript', authCode: this.hl(code), authCopy: cp('auth'), copyAuth: () => { try { navigator.clipboard && navigator.clipboard.writeText(code) } catch (x) { } this.flash('auth') }, + baseSnips: [{ k: 'base', label: 'Base URL', code: this.BASE }], authSnips: [{ k: 'curl', label: 'curl', code: code.curl }, { k: 'fetch', label: 'fetch', code: code.fetch }], errSnips: [{ k: 'err', label: 'Error response · text/plain', code: 'HTTP/1.1 403 Forbidden\nContent-Type: text/plain\n\nThis action needs a real session.' }], groups: this.eps(), rowDisplay: mob ? 'flex' : 'grid', rowCols: mob ? 'none' : '300px minmax(0,1fr) auto 16px', rowAlign: mob ? 'stretch' : 'center', rowGap: mob ? '6px' : '16px', rowPad: mob ? '12px 12px' : '0 16px', descWrap: mob ? 'normal' : 'nowrap', fCols: mob ? 'minmax(0,1fr) auto auto' : '120px 72px 72px minmax(0,1fr)', ruleCol: mob ? '1 / -1' : 'auto', @@ -274,7 +265,7 @@ class Component extends DCLogic { sessCols: mob ? 'none' : '280px minmax(0,1fr) auto', sessGap: mob ? '2px' : '16px', sessPad: mob ? '8px 14px' : '0 14px', chipSelf: mob ? 'flex-start' : 'center', errCols: mob ? '56px minmax(0,1fr)' : '64px 200px minmax(0,1fr)', errCause: mob ? '2' : 'auto', errRows: [ - { c: 400, m: 'Bad request', w: '"The request cannot be processed due to a mismatch in the format of the body." or "The expiry must be a date in the future."' }, + { c: 400, m: 'Bad request', w: '"The request cannot be processed due to a mismatch in the format of the body." or "The expiry must be a date in the future." Image requests answer with a JSON body whose error is "unknown_variant" or "unsupported_parameter".' }, { c: 401, m: 'Unauthorized', w: '"You must be logged in to access this resource." Sent for an invalid, unknown, expired or revoked key, even on public routes, and for a key whose user was deleted or unverified.' }, { c: 403, m: 'Forbidden', w: '"This action needs a real session." (a session-only route called with a key), "Only the owner can do this." or "You must verify your account to access this resource."' }, { c: 404, m: 'Not found', w: '"Article not found", "Image not found", "Invite not found" or "API key not found". Drafts answer 404 to anonymous callers.' }, diff --git a/docs/design/source/MediaFrame.dc.html b/docs/design/source/MediaFrame.dc.html index 94817bf..078f5a3 100644 --- a/docs/design/source/MediaFrame.dc.html +++ b/docs/design/source/MediaFrame.dc.html @@ -7,7 +7,7 @@ -
{{ view }}
+
diff --git a/docs/design/source/RenewCMS Handoff.dc.html b/docs/design/source/RenewCMS Handoff.dc.html index df1d863..955dc4c 100644 --- a/docs/design/source/RenewCMS Handoff.dc.html +++ b/docs/design/source/RenewCMS Handoff.dc.html @@ -143,8 +143,8 @@ class Component extends DCLogic { M('Reset password', '/register/reset/validate', [['Default', 'c and email present'], ['Too short', 'Under 8 characters'], ['Mismatch', 'Confirm field differs'], ['Submitting', 'In flight'], ['Success', '200: /login?reason=password-changed'], ['Invalid or expired', '403'], ['Missing parameters', 'No c or no email']]), M('Home', '/home', [['Loading', 'List in flight (after 150 ms)'], ['Default', 'List loaded'], ['Empty', 'Zero articles'], ['Error', '500 or network']]), M('Articles', '/article', [['Loading', 'List in flight'], ['Default', 'Loaded'], ['Empty', 'Zero articles'], ['No results', 'Search matches nothing'], ['Empty tab', 'No drafts, or nothing online'], ['Row busy', 'Publish or unpublish in flight'], ['Action failed', '500 or network: toast with Retry'], ['Row vanished', '404 on any row action'], ['Delete confirm, in progress, failed, done', 'ConfirmDialog, then DELETE: 204 or 500']]), - M('New article', 'modal · /article/new', [['Default', 'Opened'], ['Validation', 'Title under 3 or body empty'], ['Submitting', 'POST in flight'], ['Title rejected', 'Server 400'], ['Error', '500 or network'], ['Created', '201: editor, with a toast']]), - M('Editor', '/article/:id/edit', [['Loading', 'GET in flight'], ['Not found', '404'], ['Saved', 'Clean'], ['Unsaved', 'Any edit'], ['Saving', 'PATCH in flight'], ['Save failed', '500 or network'], ['Near the limit', 'At least 9 000 HTML characters'], ['Too long', 'Over 10 000: Save and Publish disabled'], ['Preview', 'Switch on'], ['Uploading, rejected, inserted', 'ImageUploader'], ['Publishing, unpublishing', 'PATCH publish or unpublish'], ['Leave guard', 'Navigating away while dirty'], ['Offline', 'navigator.onLine is false'], ['Deleted', '204: /article with toast'], ['Session expired', '401: draft kept in sessionStorage, then /login']]), + M('New article', 'modal · /article/new', [['Default', 'Opened'], ['Validation', 'Title under 3 characters'], ['Submitting', 'POST in flight'], ['Title rejected', 'Server 400'], ['Error', '500 or network'], ['Created', '201: editor, with a toast']]), + M('Editor', '/article/:id/edit', [['Loading', 'GET in flight'], ['Not found', '404'], ['Saved', 'Clean'], ['Unsaved', 'Any edit'], ['Saving', 'PATCH in flight'], ['Save failed', '500 or network'], ['Very long article', '120,000 characters: no limit, neutral count'], ['Very long: saving, saved, failed', 'PATCH with large body'], ['Link: create, edit, invalid URL, empty text, long URL', 'Ctrl/⌘ K link modal'], ['Preview', 'Switch on'], ['Uploading, rejected, inserted', 'ImageUploader'], ['Publishing, unpublishing', 'PATCH publish or unpublish'], ['Leave guard', 'Navigating away while dirty'], ['Offline', 'navigator.onLine is false'], ['Deleted', '204: /article with toast'], ['Session expired', '401: draft kept in sessionStorage, then /login']]), M('Integration', '/integration', [['Default', 'Page load'], ['Copied', 'Copy clicked (2 s)']]) ] const errors = [ diff --git a/docs/design/source/RenewCMS Prototype.dc.html b/docs/design/source/RenewCMS Prototype.dc.html index 5058503..3bde51b 100644 --- a/docs/design/source/RenewCMS Prototype.dc.html +++ b/docs/design/source/RenewCMS Prototype.dc.html @@ -10,7 +10,7 @@ - +
@@ -30,7 +30,7 @@
{{ s.t }} -{{ hint }} +{{ hint }}
@@ -49,14 +49,6 @@
{{ emailSubject }}From RenewCMS · to marta@renew.example
- -
-
-
RenewCMS
-
{{ n.t }}
-
-
-
@@ -194,7 +186,7 @@ class Component extends DCLogic { if (s.userMenu) this.setState({ userMenu: false }) if (a === 'pf-reset') return this.setState({ pfReset: 'sent' }) if (a === 'mnav-close') return this.setState({ mnav: false }) - if (a.startsWith('nav-')) { const to = a.slice(4); if (to === 'users') this.mark('users'); return this.go(to) } + if (a.startsWith('nav-')) { const to = a.slice(4); if (!['home', 'articles', 'users', 'integration', 'profile'].includes(to)) return this.toast('info', 'info', 'Not in this prototype', 'This screen is on the Back-office board.'); if (to === 'users') this.mark('users'); return this.go(to) } if (a === 'logout') { this.clearTimers(); this.setState({ route: 'login', auth: {}, mnav: false, menu: null, modal: null }); return this.toast('info', 'info', 'You\'re logged out', 'Log in again to keep working.') } if (s.modal) return this.modalAct(a) const r = s.route @@ -264,6 +256,7 @@ class Component extends DCLogic { if (a === 'preview') return setEd({ preview: !e.preview }) if (a === 'back' || a === 'm-lead') return this.go('articles') if (a === 'overflow' || a === 'm-action') return this.setState({ modal: { type: 'delete', id: s.cur, fromEditor: true } }) + if (a === 'tb-link') return this.setState({ modal: { type: 'link' } }) if (a === 'tb-image-plus') return this.setState({ modal: { type: 'upload', state: 'idle' } }) if (a === 'save' && (e.save === 'dirty' || e.save === 'error')) { setEd({ save: 'saving' }) @@ -291,8 +284,8 @@ class Component extends DCLogic { this.setState({ modal: { ...m, loading: true } }) this.later(() => { const id = Math.max(0, ...this.state.articles.map(x => x.id)) + 1 - const art = { id, t: 'Choosing tiles for a small bathroom', on: false, c: '25 Sep 2026', u: 'Just now', imgs: 0, body: 'Small rooms show every grout line, so we start with the floor and work up.' } - this.setState(st => ({ articles: [art, ...st.articles], modal: null, route: 'editor', cur: id, ed: { status: 'draft', save: 'saved', image: false, preview: false, body: art.body } })) + const art = { id, t: 'Choosing tiles for a small bathroom', on: false, c: '25 Sep 2026', u: 'Just now', imgs: 0, body: '\u00a0' } + this.setState(st => ({ articles: [art, ...st.articles], modal: null, route: 'editor', cur: id, ed: { status: 'draft', save: 'saved', image: false, preview: false, body: art.body, len: 0 } })) this.mark('firstArticle') this.toast('info', 'info', 'Draft created', 'It stays private until you publish it.') }, 900) @@ -314,6 +307,11 @@ class Component extends DCLogic { } return } + if (m.type === 'link') { + if (a === 'modal-cancel' || a === 'modal-close') return close() + if (a === 'modal-confirm') this.setState(st => ({ modal: null, ed: { ...st.ed, save: 'dirty', lk: { on: true, btn: 'active' } } })) + return + } if (m.type === 'delete') { if (a === 'modal-cancel' || a === 'modal-close') return close() if (a === 'modal-confirm') { @@ -372,7 +370,7 @@ class Component extends DCLogic { const counts = [arts.length, on, arts.length - on] const T = (name, crumb) => ({ nav: name, crumbs: crumb }) if (s.route === 'home') return { kind: 'home', ...T('home', ['Home']), mTitle: 'Home', mAction: 'plus', home: arts.length ? { sub: 'Here\'s where things stand on your site.', stats: [{ n: String(counts[0]), l: 'articles' }, { n: String(counts[1]), l: 'online' }, { n: String(counts[2]), l: 'drafts' }], recent: arts.slice(0, 5).map(a => ({ ...a, draft: !a.on })) } : { sub: 'Welcome to RenewCMS.', empty: true }, modal: this.modalS() } - if (s.route === 'articles') return { kind: 'articles', ...T('articles', ['Articles']), mTitle: 'Articles', list: { counts, rows: arts.map(a => ({ ...a, menu: s.menu === a.id, busy: s.busy === a.id ? (a.on ? 'Unpublishing…' : 'Publishing…') : null })), empty: arts.length ? null : { icon: 'file-text', title: 'No articles yet', text: 'Write your first one. It stays a draft until you publish it.', action: 'New article', primary: true } }, modal: this.modalS() } + if (s.route === 'articles') return { kind: 'articles', ...T('articles', ['Articles']), mTitle: 'Articles', list: { counts, pg: { per: 25, total: arts.length, pages: 1, page: 1 }, rows: arts.map(a => ({ ...a, menu: s.menu === a.id, busy: s.busy === a.id ? (a.on ? 'Unpublishing…' : 'Publishing…') : null })), empty: arts.length ? null : { icon: 'file-text', title: 'No articles yet', text: 'Write your first one. It stays a draft until you publish it.', action: 'New article', primary: true } }, modal: this.modalS() } if (s.route === 'users') return { kind: 'users', ...T('users', ['Users']), mTitle: 'Users', users: { rows: s.users, only: s.users.length === 1 }, modal: this.modalS() } if (s.route === 'profile') return { kind: 'profile', ...T('profile', ['Profile']), mTitle: 'Profile', pf: { reset: s.pfReset } } if (s.route === 'integration') return { kind: 'integration', ...T('integration', ['Integration']), mTitle: 'Integration', copied: s.copied } @@ -385,7 +383,8 @@ class Component extends DCLogic { modalS() { const m = this.state.modal if (!m) return null - if (m.type === 'new') return { title: 'New article', desc: 'It\'s saved as a draft. Titles can\'t be changed later, so check it now.', close: true, cancel: 'Cancel', confirm: m.loading ? 'Creating…' : 'Create draft', loading: m.loading, wide: true, fields: [{ label: 'Title', value: 'Choosing tiles for a small bathroom', count: '35 / 200', help: '3 to 200 characters.' }, { label: 'First paragraph', value: 'Small rooms show every grout line, so we start with the floor and work up.', area: true, help: 'You can format it and add images in the editor.' }] } + if (m.type === 'new') return { title: 'New article', desc: 'It\'s saved as a draft. Titles can\'t be changed later, so check it now.', close: true, cancel: 'Cancel', confirm: m.loading ? 'Creating…' : 'Create draft', loading: m.loading, wide: true, fields: [{ label: 'Title', value: 'Choosing tiles for a small bathroom', count: '35 / 200', help: '3 to 200 characters.' }] } + if (m.type === 'link') return { title: 'Insert link', desc: 'Links open in the same tab unless you turn on a new tab.', close: true, cancel: 'Cancel', confirm: 'Insert link', fields: [{ label: 'Link text', value: 'timber windows' }, { label: 'URL', value: '/articles/winter-checklist' }] } if (m.type === 'invite') return { title: 'Invite a user', desc: 'We\'ll email them a link to choose a username and password. The link expires after 3 days.', close: true, cancel: 'Cancel', confirm: m.loading ? 'Sending…' : 'Send invite', loading: m.loading, fields: [m.email ? { label: 'Email', value: m.email, err: m.err } : { label: 'Email', ph: 'name@example.com', focus: true }] } if (m.type === 'delete') { const a = this.art(m.id) || { t: '', imgs: 0 } @@ -425,14 +424,14 @@ class Component extends DCLogic { const modes = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']].map(([k, label]) => { const on = s.mode === k; return { label, on, bg: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-bg)' : 'var(--ui-text-toned)', pick: () => this.setState({ mode: k }) } }) const [dw, dh] = mobile ? [390, 844] : [1440, 900] const navKey = s.route === 'editor' ? 'articles' : s.route - const mnavItems = [['nav-home', 'house', 'Home', 'home'], ['nav-articles', 'file-text', 'Articles', 'articles'], ['nav-users', 'users', 'Users', 'users'], ['nav-integration', 'plug', 'Integration', 'integration'], ['logout', 'log-out', 'Log out', '']].map(([act, icon, t, k]) => ({ act, icon, t, bg: k && k === navKey ? 'var(--rc-sand)' : 'transparent', fg: k && k === navKey ? '#111111' : 'var(--ui-text-toned)' })) + const mnavItems = [['nav-home', 'house', 'Home', 'home'], ['nav-articles', 'file-text', 'Articles', 'articles'], ['nav-users', 'users', 'Users', 'users'], ['nav-integration', 'plug', 'Integration', 'integration'], ['logout', 'log-out', 'Log out', '']].map(([act, icon, t, k]) => ({ act, icon, t, bg: k && k === navKey ? 'var(--rc-sand)' : 'transparent', fg: k && k === navKey ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)' })) const toasts = s.toasts.map(t => ({ ...t, fg: 'var(--ui-' + t.tone + ')' })) return { rootRef: this.rootRef, stageRef: this.stageRef, devices: devs, modes, flows, hint, tools, hs: s.hs, hsClass: s.hs ? 'hs' : '', hsJust: s.hs ? 'flex-end' : 'flex-start', hsBg: s.hs ? 'var(--ui-primary)' : 'var(--ui-bg-accented)', toggleHs: () => this.setState({ hs: !s.hs }), mobile, isAuth, isApp, isEmail, lang: s.lang || 'en', langOpen: !!s.langOpen, - authS: isAuth ? this.authS() : {}, appS: isApp ? { ...this.appS(), userMenu: s.userMenu, mode: s.mode, lang: s.lang || 'en' } : {}, + authS: isAuth ? this.authS() : {}, appS: isApp ? { ...this.appS(), drawer: s.mnav && mobile, userMenu: s.userMenu, mode: s.mode, lang: s.lang || 'en' } : {}, authBg: mobile ? 'var(--ui-bg)' : 'var(--ui-bg-muted)', authTop: mobile ? '40px' : '108px', emailHtml: isEmail ? this.emailHtml() : '', emailSubject: s.email === 'reset' ? 'Reset your RenewCMS password' : 'Verify your RenewCMS account', devW: dw + 'px', devH: dh + 'px', zoom: s.zoom, boxW: Math.round(dw * s.zoom) + 'px', boxH: Math.round(dh * s.zoom) + 'px', diff --git a/docs/design/source/RenewCMS Screens Back-office.dc.html b/docs/design/source/RenewCMS Screens Back-office.dc.html index 41c6d01..d611f05 100644 --- a/docs/design/source/RenewCMS Screens Back-office.dc.html +++ b/docs/design/source/RenewCMS Screens Back-office.dc.html @@ -18,7 +18,7 @@

Screens: back-office and global

-

Every state of the verified-only routes, plus the global states, at 1440 px (shown at 50%) and 390 px (shown at 75%). Home, Settings and Media show every card in one page, so each card matches its neighbours. Activity, Media cleanup and the activity cards live here too. The theme switch shows all of them in dark mode. Session expiry and unverified redirects end on screens from the auth board.

+

Every state of the verified-only routes, plus the global states, at 1440 px (shown at 50%) and 390 px (shown at 75%). Home, Settings and Media show every card in one page, so each card matches its neighbours. Activity, Media cleanup and the activity cards live here too. Settings is one area: the Settings page, then Media with Variants and Cache (formerly the Image variants board, now merged below). The theme switch shows all of them in dark mode. Session expiry and unverified redirects end on screens from the auth board.

@@ -39,19 +39,32 @@
{{ st.name }}{{ st.trigger }}
-
-
+
+
{{ g.tableTitle }}
StatusWhereCopy
{{ r.a }}{{ r.b }}{{ r.c }}
+ +
{{ t.title }}
{{ c }}
{{ cell.t }}
+
+

States matrix: controls

Hover, focus-visible, active and disabled for every interactive element, once. Focus-visible is a 2 px --ui-primary outline with a 2 px offset (inset for tabs, segmented controls and rows). Disabled is 40% opacity (rows 55%). The mobile column set has no hover and uses 44 px targets. The theme switch above applies to both.

Activity: copy per state

{{ r.a }}{{ r.b }}

Activity: component inventory (Nuxt UI v4)

{{ r.a }}{{ r.b }}

Activity: API calls

{{ r.a }}{{ r.b }}
+ +
+

{{ sp.title }}

+

{{ sp.note }}

+ +
{{ c }}
{{ cell.t }}
+
+
+
diff --git a/docs/design/source/RenewCMS Screens Image variants.dc.html b/docs/design/source/RenewCMS Screens Image variants.dc.html deleted file mode 100644 index dfa386f..0000000 --- a/docs/design/source/RenewCMS Screens Image variants.dc.html +++ /dev/null @@ -1,347 +0,0 @@ - - - - - - - - - - - - - - - -
- -
-
-

Feature: Image variants

-

Named variants of an original image, requested as /media/photo.png?v=thumb. The original is never changed. Six screens in the AppFrame back-office frame, plus the request contract, activity log copy, states matrix, API error copy and component inventory. 1440 px at 50%, 390 px at 75%. The theme switch shows every state in dark mode. Related: Back-office screens.

-
-
- - - -
-
- - -
-
-

{{ g.title }}

-
{{ g.route }}{{ g.api }}
-

{{ g.note }}

-
-
- -
-
{{ st.id }} · {{ st.name }}{{ st.trigger }}
-
-
-
-
-
-
-
- -
{{ t.title }}
{{ c }}
{{ cell.t }}
-
-
-
- - -
-

{{ sp.title }}

-

{{ sp.note }}

- -
{{ c }}
{{ cell.t }}
-
-
-
- -
-
- - - diff --git a/docs/design/source/StatesMatrix.dc.html b/docs/design/source/StatesMatrix.dc.html new file mode 100644 index 0000000..ddef660 --- /dev/null +++ b/docs/design/source/StatesMatrix.dc.html @@ -0,0 +1,78 @@ + + + + + + + + + +
+ +
+
{{ r.name }}{{ r.tokens }}
+
+ +
+{{ c.state }} +{{ c.text }} +
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/docs/design/source/VariantsFrame.dc.html b/docs/design/source/VariantsFrame.dc.html index 5e9e921..9b03aa0 100644 --- a/docs/design/source/VariantsFrame.dc.html +++ b/docs/design/source/VariantsFrame.dc.html @@ -7,7 +7,7 @@ -
{{ view }}
+
diff --git a/docs/design/source/activity-spec.js b/docs/design/source/activity-spec.js index 66d4010..091f9c2 100644 --- a/docs/design/source/activity-spec.js +++ b/docs/design/source/activity-spec.js @@ -1,7 +1,14 @@ export const copy = [ ['Page title', 'Activity. Regular users: My activity.'], ['Subtitle', 'Who did what, and when, across your site. Regular users: Everything you\'ve done on this site.'], - ['Header action', 'Export CSV (owner and admin only). Preparing: Preparing file… Success toast: Export started. Your file will download shortly. Error toast: Couldn\'t export activity. Try again.'], + ['Header action', 'Export menu button (owner and admin only). Items: Export current view; Export all activity…'], + ['Export current view', 'CSV · 214 events matching your filters and sort, across all pages. Disabled: Nothing to export. No events match these filters.'], + ['Export all activity', 'CSV · every event, ignoring filters. Owners and admins only. Disabled: Only owners and admins can export all activity.'], + ['Export dialog', 'Title Export all activity. Fields: Format (CSV only; CSV and JSON as a segmented control when JSON ships, proposed), Date range (default All time). Buttons: Cancel, Export CSV. Synchronous: one streamed download, no job.'], + ['Export preparing', 'Preparing download… (indeterminate bar, no percentage). The file starts downloading as soon as it is ready. Keep this window open. Buttons: Cancel, Preparing… (disabled).'], + ['Export result', 'Download started · activity-all-2026-10-06.csv is downloading. Check your browser\'s downloads if nothing appears. Button: Close. Failed: Couldn\'t prepare the download. Nothing was downloaded. Try again in a moment. Buttons: Cancel, Retry. Nothing to export: There is no activity in this date range. Choose a wider range. 403: You can\'t export all activity. Only owners and admins can export all activity. Ask an owner if you need a copy.'], + ['Export toasts', 'Download started · activity-2026-10-06.csv is downloading. / activity-all-2026-10-06.csv is downloading.'], + ['Card toggles', 'Failed actions and Security events cards are buttons with aria-pressed. Hint lines: Click to filter the list · Filtering. Click to clear · Click to clear this filter. Chips: Outcome: Failed · Type: Security events.'], ['Summary labels', 'Total actions · Active people · Failed actions · Security events'], ['Summary sub-lines', '12% vs previous 7 days · 7 people, 2 API keys · 0.5% of all actions · 5 failed logins'], ['Summary zero', 'Failed: Nothing failed. Security: Nothing to flag. No data at all: No data yet.'], @@ -28,8 +35,10 @@ export const copy = [ ['Retention status', 'Last cleanup 6 Oct 2026, 03:00 · 142 events removed · Next cleanup 7 Oct 2026, 03:00 · Never / Not scheduled for Forever. Save: Saved · Error: Couldn\'t save the retention period. Your previous setting is still in place.'] ] export const comps = [ - ['Page shell, sidebar entry', 'Existing dashboard shell. "Activity" (icon history) between Users and Settings, all verified users.'], - ['Page header', 'Plain heading + UButton (outline, icon download) for Export CSV.'], + ['Page shell, sidebar entry', 'Existing dashboard shell. "Activity" (icon history) between Users and Integration (same order as the sidebar in AppFrame), all verified users.'], + ['Page header', 'Plain heading + ExportMenu (UDropdownMenu on desktop, bottom sheet on mobile) with a UButton (outline, icon download, trailing chevron).'], + ['FilterCardToggle', 'Summary card as role=button, aria-pressed. Active: --rc-sand with --rc-on-sand.'], + ['ExportDialog', 'UModal, sm:max-w-md, bottom sheet on mobile. Read-only format row (segmented control when JSON ships), USelect for date range, indeterminate UProgress while the request is in flight.'], ['Summary strip', 'Four UCard, each with a UButton-style click target (as="button"). Sparkline: seven div bars in the card (no chart library). Semantic emphasis via error and warning soft tokens.'], ['Search', 'UInput, icon search, clearable.'], ['Person and Category', 'USelectMenu. Person lists users, API keys and "System" in groups.'], @@ -47,6 +56,7 @@ export const comps = [ ['Retention', 'UCard in Settings with USelectMenu and read-only text lines.'] ] export const api = [ + ['GET /v1/activity/export', 'Implemented. Owner and admin only, otherwise 403. Returns CSV only, synchronous, one streamed download, no job and no progress. Accepts the list filters: q, actor, category, outcome, security, range, from, to. JSON (format=json) is proposed.'], ['GET /v1/activity', 'Query: q, actor (user:ID, key:ID, system), category (articles, media, users, api_keys, auth, system), outcome (all, success, failed), range (today, 7d, 30d, custom), from, to (ISO dates), page, size (25, 50, 100). Sorted newest first. Returns items (id, created_at, actor {type, id, name}, category, sentence parts, target {type, id, name, exists}, outcome, failure_reason, security, source, ip, user_agent, changes [{field, before, after, kind}]), total, page, size. Users who are not owner or admin are scoped to their own events by the server; requesting another actor returns 403. Body edits return a single change with kind "body" and a character delta, never the text. The same call powers the Home card (size=5) and the slide-over (items carry the detail fields).'], ['GET /v1/activity/summary', 'Query: the same range, from, to. Owner and admin only (403 otherwise). Returns total, previous_total, active_people {users, api_keys}, failed, security, and a 7-day series for each. Clicking a card applies outcome=failed or category in (auth, users role changes, api_keys) to the table, or clears filters for Total.'], ['GET /v1/activity/export', 'Query: the same filters, no page or size. Owner and admin only. Streams text/csv with columns when, actor, action, target, outcome, source, ip. No changes or secrets.'], diff --git a/docs/design/states.md b/docs/design/states.md index edf4a35..a9a3a7d 100644 --- a/docs/design/states.md +++ b/docs/design/states.md @@ -1,342 +1,49 @@ -# State matrix and error copy +# States -Every state is drawn at desktop and mobile on the screen boards, with final copy. Each screen's file in `screens/` has the full copy per state. +Every state in the three boards is drawn in light and dark (theme switch) at 1440×900 and 390×844. Counts are in README.md. -| Screen | Route | State | Trigger | Spec | -|---|---|---|---|---| -| App boot | `any verified route` | Checking session | First navigation, while GET /v1/auth/me is pending | [boot](screens/boot.md) | -| App boot | `any verified route` | Verified | 200 with is_verified: the route renders | [boot](screens/boot.md) | -| App boot | `any verified route` | Unverified | 200 with is_verified false: goes to /register/pending | [boot](screens/boot.md) | -| App boot | `any verified route` | No session | 401: goes to /login?redirect= | [boot](screens/boot.md) | -| Log in | `/login` | Default | Registration closed | [log-in](screens/log-in.md) | -| Log in | `/login` | Fresh install | registration.open is true | [log-in](screens/log-in.md) | -| Log in | `/login` | Validation errors | Client rules fail on submit | [log-in](screens/log-in.md) | -| Log in | `/login` | Submitting | Request in flight | [log-in](screens/log-in.md) | -| Log in | `/login` | Wrong credentials | 403 | [log-in](screens/log-in.md) | -| Log in | `/login` | Too many attempts | 429 with Retry-After | [log-in](screens/log-in.md) | -| Log in | `/login` | Session expired | ?reason=expired from a 401 | [log-in](screens/log-in.md) | -| Log in | `/login` | Password changed | ?reason=password-changed | [log-in](screens/log-in.md) | -| Log in | `/login` | Server error | 500 or network | [log-in](screens/log-in.md) | -| Register | `/register` | Default | Open instance | [register](screens/register.md) | -| Register | `/register` | Validation errors | Client rules fail | [register](screens/register.md) | -| Register | `/register` | Submitting | In flight | [register](screens/register.md) | -| Register | `/register` | Registration closed | 403 | [register](screens/register.md) | -| Register | `/register` | Server error | 500 or network | [register](screens/register.md) | -| Pending | `/register/pending` | Default | After registering (email known) | [pending](screens/pending.md) | -| Pending | `/register/pending` | Email unknown | After logging in unverified | [pending](screens/pending.md) | -| Pending | `/register/pending` | Confirm cancel | Cancel registration clicked | [pending](screens/pending.md) | -| Pending | `/register/pending` | Cancelling | DELETE in flight | [pending](screens/pending.md) | -| Pending | `/register/pending` | Cancelled | 200: /register with toast | [pending](screens/pending.md) | -| Pending | `/register/pending` | Already verified | 403: /home with toast | [pending](screens/pending.md) | -| Verify | `/register/validate?c=` | Verifying | On load | [verify](screens/verify.md) | -| Verify | `/register/validate?c=` | Success | 200: /home with toast | [verify](screens/verify.md) | -| Verify | `/register/validate?c=` | Invalid or expired | 403 | [verify](screens/verify.md) | -| Verify | `/register/validate?c=` | No session | 401: inline log in, then retry | [verify](screens/verify.md) | -| Verify | `/register/validate?c=` | Missing code | No c parameter | [verify](screens/verify.md) | -| Forgot password | `/register/reset/request` | Default | Page load | [forgot-password](screens/forgot-password.md) | -| Forgot password | `/register/reset/request` | Validation | Invalid email | [forgot-password](screens/forgot-password.md) | -| Forgot password | `/register/reset/request` | Submitting | In flight | [forgot-password](screens/forgot-password.md) | -| Forgot password | `/register/reset/request` | Sent | Any 200 (neutral copy) | [forgot-password](screens/forgot-password.md) | -| Forgot password | `/register/reset/request` | Too many attempts | 429 | [forgot-password](screens/forgot-password.md) | -| Forgot password | `/register/reset/request` | Server error | 500 or network | [forgot-password](screens/forgot-password.md) | -| Reset password | `/register/reset/validate` | Default | c and email present | [reset-password](screens/reset-password.md) | -| Reset password | `/register/reset/validate` | Too short | Under 8 characters | [reset-password](screens/reset-password.md) | -| Reset password | `/register/reset/validate` | Mismatch | Confirm field differs | [reset-password](screens/reset-password.md) | -| Reset password | `/register/reset/validate` | Submitting | In flight | [reset-password](screens/reset-password.md) | -| Reset password | `/register/reset/validate` | Success | 200: /login?reason=password-changed | [reset-password](screens/reset-password.md) | -| Reset password | `/register/reset/validate` | Invalid or expired | 403 | [reset-password](screens/reset-password.md) | -| Reset password | `/register/reset/validate` | Missing parameters | No c or no email | [reset-password](screens/reset-password.md) | -| Home | `/home` | Loading | List in flight (after 150 ms) | [home](screens/home.md) | -| Home | `/home` | Default | List loaded | [home](screens/home.md) | -| Home | `/home` | Empty | Zero articles | [home](screens/home.md) | -| Home | `/home` | Error | 500 or network | [home](screens/home.md) | -| Articles | `/article` | Loading | List in flight | [articles](screens/articles.md) | -| Articles | `/article` | Default | Loaded | [articles](screens/articles.md) | -| Articles | `/article` | Empty | Zero articles | [articles](screens/articles.md) | -| Articles | `/article` | No results | Search matches nothing | [articles](screens/articles.md) | -| Articles | `/article` | Empty tab | No drafts, or nothing online | [articles](screens/articles.md) | -| Articles | `/article` | Row busy | Publish or unpublish in flight | [articles](screens/articles.md) | -| Articles | `/article` | Action failed | 500 or network: toast with Retry | [articles](screens/articles.md) | -| Articles | `/article` | Row vanished | 404 on any row action | [articles](screens/articles.md) | -| Articles | `/article` | Delete confirm, in progress, failed, done | ConfirmDialog, then DELETE: 204 or 500 | [articles](screens/articles.md) | -| New article | `modal · /article/new` | Default | Opened | [new-article](screens/new-article.md) | -| New article | `modal · /article/new` | Validation | Title under 3 or body empty | [new-article](screens/new-article.md) | -| New article | `modal · /article/new` | Submitting | POST in flight | [new-article](screens/new-article.md) | -| New article | `modal · /article/new` | Title rejected | Server 400 | [new-article](screens/new-article.md) | -| New article | `modal · /article/new` | Error | 500 or network | [new-article](screens/new-article.md) | -| New article | `modal · /article/new` | Created | 201: editor, with a toast | [new-article](screens/new-article.md) | -| Editor | `/article/:id/edit` | Loading | GET in flight | [editor](screens/editor.md) | -| Editor | `/article/:id/edit` | Not found | 404 | [editor](screens/editor.md) | -| Editor | `/article/:id/edit` | Saved | Clean | [editor](screens/editor.md) | -| Editor | `/article/:id/edit` | Unsaved | Any edit | [editor](screens/editor.md) | -| Editor | `/article/:id/edit` | Saving | PATCH in flight | [editor](screens/editor.md) | -| Editor | `/article/:id/edit` | Save failed | 500 or network | [editor](screens/editor.md) | -| Editor | `/article/:id/edit` | Near the limit | At least 9 000 HTML characters | [editor](screens/editor.md) | -| Editor | `/article/:id/edit` | Too long | Over 10 000: Save and Publish disabled | [editor](screens/editor.md) | -| Editor | `/article/:id/edit` | Preview | Switch on | [editor](screens/editor.md) | -| Editor | `/article/:id/edit` | Choose, crop and resize, uploading, rejected, inserted | ImageUploader | [editor](screens/editor.md) | -| Editor | `/article/:id/edit` | Publishing, unpublishing | PATCH publish or unpublish | [editor](screens/editor.md) | -| Editor | `/article/:id/edit` | Leave guard | Navigating away while dirty | [editor](screens/editor.md) | -| Editor | `/article/:id/edit` | Offline | navigator.onLine is false | [editor](screens/editor.md) | -| Editor | `/article/:id/edit` | Deleted | 204: /article with toast | [editor](screens/editor.md) | -| Editor | `/article/:id/edit` | Session expired | 401: draft kept in sessionStorage, then /login | [editor](screens/editor.md) | -| Media | `/media` | Grid, many images | Default view | [media](screens/media.md) | -| Media | `/media` | Grid, few images | Three images | [media](screens/media.md) | -| Media | `/media` | List view | The list icon in the toggle | [media](screens/media.md) | -| Media | `/media` | Loading | The first load, a retry, or a change of search or filter | [media](screens/media.md) | -| Media | `/media` | Empty library, first run | Zero images | [media](screens/media.md) | -| Media | `/media` | No results | Search or filter matched nothing | [media](screens/media.md) | -| Media | `/media` | Hover, focus and keyboard | Hover (second item) darkens the border, adds the pop shadow and reveals the checkbox | [media](screens/media.md) | -| Media | `/media` | Selection and bulk bar | Checkbox on any item, or Space | [media](screens/media.md) | -| Media | `/media` | Bulk delete: confirm | Delete in the bulk bar | [media](screens/media.md) | -| Media | `/media` | Bulk delete: in progress | Requests in flight | [media](screens/media.md) | -| Media | `/media` | Bulk delete: partly failed | Some requests failed | [media](screens/media.md) | -| Media | `/media` | Bulk delete: done | 204 for all | [media](screens/media.md) | -| Media | `/media` | Detail panel: in use | Click or Enter on an item | [media](screens/media.md) | -| Media | `/media` | Detail panel: unused | Used in nothing | [media](screens/media.md) | -| Media | `/media` | Detail panel: duplicate | The image has identical copies | [media](screens/media.md) | -| Media | `/media` | Copy URL | Copy URL | [media](screens/media.md) | -| Media | `/media` | Rename: dialog | Rename in the panel | [media](screens/media.md) | -| Media | `/media` | Rename: saving | PATCH in flight | [media](screens/media.md) | -| Media | `/media` | Rename: validation | Empty or only symbols, on submit or blur | [media](screens/media.md) | -| Media | `/media` | Rename: failed | 500 or network failure | [media](screens/media.md) | -| Media | `/media` | Rename: done | 200: the panel title and list update in place | [media](screens/media.md) | -| Media | `/media` | Delete: unused image | Delete in the panel | [media](screens/media.md) | -| Media | `/media` | Delete: image in use | Stronger confirm | [media](screens/media.md) | -| Media | `/media` | Delete: in progress | DELETE in flight | [media](screens/media.md) | -| Media | `/media` | Delete: failed | 500 or network failure | [media](screens/media.md) | -| Media | `/media` | Delete: done | 204: the panel closes, the item leaves the grid and focus moves to the next item | [media](screens/media.md) | -| Media | `/media` | Image already gone (404) | Any action on an image that was deleted elsewhere | [media](screens/media.md) | -| Media | `/media` | Upload: drag over the page | Files dragged onto the page | [media](screens/media.md) | -| Media | `/media` | Upload: several files, mixed | Upload button or drop, several files | [media](screens/media.md) | -| Media | `/media` | Upload: summary | All rows settled | [media](screens/media.md) | -| Media | `/media` | Upload: rejected files (413, 400) | Type and size are checked in the browser first, so nothing is sent | [media](screens/media.md) | -| Media | `/media` | Upload: too many requests (429) | Rate limited | [media](screens/media.md) | -| Media | `/media` | Duplicates: groups | The Duplicates tab | [media](screens/media.md) | -| Media | `/media` | Duplicates: empty | No groups | [media](screens/media.md) | -| Media | `/media` | Merge: confirm | "Merge into this one" on floorboards.jpg | [media](screens/media.md) | -| Media | `/media` | Merge: in progress | Request in flight | [media](screens/media.md) | -| Media | `/media` | Merge: failed | 500 or network failure | [media](screens/media.md) | -| Media | `/media` | Merge: conflict (409) | A copy was deleted, or a new copy appeared, since the page loaded | [media](screens/media.md) | -| Media | `/media` | Merge: done | 200: the group leaves the list | [media](screens/media.md) | -| Media | `/media` | List failed to load | A 500 or network failure on the list request | [media](screens/media.md) | -| Media | `/media` | Offline | navigator.onLine is false | [media](screens/media.md) | -| Media picker | `/article/:id/edit` | Upload tab | The image button in the toolbar | [media-picker](screens/media-picker.md) | -| Media picker | `/article/:id/edit` | Upload tab: already in your library | A chosen file has the same content as an existing image | [media-picker](screens/media-picker.md) | -| Media picker | `/article/:id/edit` | Library: loading | The Library tab was opened, or a search changed | [media-picker](screens/media-picker.md) | -| Media picker | `/article/:id/edit` | Library: nothing selected | Newest first | [media-picker](screens/media-picker.md) | -| Media picker | `/article/:id/edit` | Library: selected | One image selected (single selection) | [media-picker](screens/media-picker.md) | -| Media picker | `/article/:id/edit` | Library: alt text entered | Alt text is optional | [media-picker](screens/media-picker.md) | -| Media picker | `/article/:id/edit` | Library: loading more | "Load more" pressed | [media-picker](screens/media-picker.md) | -| Media picker | `/article/:id/edit` | Library: empty | Zero images | [media-picker](screens/media-picker.md) | -| Media picker | `/article/:id/edit` | Library: no results | Search matched nothing | [media-picker](screens/media-picker.md) | -| Media picker | `/article/:id/edit` | Library: failed to load | A 500 or network failure | [media-picker](screens/media-picker.md) | -| Media picker | `/article/:id/edit` | Inserted | Insert image from either tab | [media-picker](screens/media-picker.md) | -| Media picker | `/article/:id/edit` | Remove image: confirm | Remove image on an image in the article | [media-picker](screens/media-picker.md) | -| Media picker | `/article/:id/edit` | Removed | Confirmed | [media-picker](screens/media-picker.md) | -| Integration | `/integration` | Default | Page load, every endpoint card collapsed | [integration](screens/integration.md) | -| Integration | `/integration` | Group scrolled, one card expanded | A card opened, one example visible | [integration](screens/integration.md) | -| Integration | `/integration` | Copied: base URL | Copy clicked (2 s) | [integration](screens/integration.md) | -| Integration | `/integration` | Copied: CodeBlock | Copy clicked in a code block (2 s) | [integration](screens/integration.md) | -| Integration | `/integration` | Authentication | API key card, link and notes | [integration](screens/integration.md) | -| Integration | `/integration` | Session only | Routes API keys cannot call | [integration](screens/integration.md) | -| Integration | `/integration` | Errors | Status table and plain-text error shape | [integration](screens/integration.md) | -| Integration | `/integration` | Navigation | Section index active item; mobile Jump to open | [integration](screens/integration.md) | -| API keys | `/integration/api-keys` | Owner: list | Owner sees every key, with Created by | [integration-api-keys](screens/integration-api-keys.md) | -| API keys | `/integration/api-keys` | Admin: own keys only | Admin sees only their own keys | [integration-api-keys](screens/integration-api-keys.md) | -| API keys | `/integration/api-keys` | Loading | First load or retry | [integration-api-keys](screens/integration-api-keys.md) | -| API keys | `/integration/api-keys` | Empty | No keys yet | [integration-api-keys](screens/integration-api-keys.md) | -| API keys | `/integration/api-keys` | Error | 500 or network | [integration-api-keys](screens/integration-api-keys.md) | -| API keys | `/integration/api-keys` | Create key | Modal opened | [integration-api-keys](screens/integration-api-keys.md) | -| API keys | `/integration/api-keys` | Create key: validation | Name empty or expiry not in the future | [integration-api-keys](screens/integration-api-keys.md) | -| API keys | `/integration/api-keys` | Create key: name too long | More than 60 characters | [integration-api-keys](screens/integration-api-keys.md) | -| API keys | `/integration/api-keys` | One-time reveal | Key created, shown once | [integration-api-keys](screens/integration-api-keys.md) | -| API keys | `/integration/api-keys` | One-time reveal: copied | Copy clicked (2 s) | [integration-api-keys](screens/integration-api-keys.md) | -| API keys | `/integration/api-keys` | Revoke: confirm | Revoke pressed on a key | [integration-api-keys](screens/integration-api-keys.md) | -| API keys | `/integration/api-keys` | Revoked | Confirmed, key stays listed as revoked | [integration-api-keys](screens/integration-api-keys.md) | -| Users | `/users` | Default | List loaded, newest first | [users](screens/users.md) | -| Users | `/users` | Loading | First load or retry | [users](screens/users.md) | -| Users | `/users` | Only you | Nobody invited yet | [users](screens/users.md) | -| Users | `/users` | Error | 500 or network | [users](screens/users.md) | -| Users | `/users` | Invite: default, validation, sending | Invite modal opened, invalid email, POST in flight | [users](screens/users.md) | -| Users | `/users` | Invite: already a user | 409 | [users](screens/users.md) | -| Users | `/users` | Invite: failed | 500, network or mail failure | [users](screens/users.md) | -| Users | `/users` | Invite sent | 201: dialog closes, Pending row on top, toast | [users](screens/users.md) | -| Users | `/users` | Cancel invite: confirm, sending, failed | "Cancel invite" on a Pending row; DELETE in flight; 500 or network | [users](screens/users.md) | -| Users | `/users` | Cancel invite: done | 204: row removed, toast | [users](screens/users.md) | -| Users | `/users` | Renew invite: sending | "Renew invite" on an Expired row; POST in flight | [users](screens/users.md) | -| Users | `/users` | Renew invite: done | 200: row Pending on top, toast | [users](screens/users.md) | -| Users | `/users` | Renew invite: failed | 500 or network: toast, row stays Expired | [users](screens/users.md) | -| Settings | `/settings` | Up to date | Opened with no newer version | [settings](screens/settings.md) | -| Settings | `/settings` | Checking | Check for updates pressed, or the first open after 24 hours | [settings](screens/settings.md) | -| Settings | `/settings` | Check failed | Offline, or the update server didn't answer (500, network) | [settings](screens/settings.md) | -| Settings | `/settings` | Update available | A newer version exists | [settings](screens/settings.md) | -| Settings | `/settings` | Update available, notes open | "What's new in 2.3.0" expanded | [settings](screens/settings.md) | -| Settings | `/settings` | Downloading | Update pressed (202) | [settings](screens/settings.md) | -| Settings | `/settings` | Verifying | The download finished | [settings](screens/settings.md) | -| Settings | `/settings` | Ready to install | The file passed its checks | [settings](screens/settings.md) | -| Settings | `/settings` | Restart: confirm | Restart pressed | [settings](screens/settings.md) | -| Settings | `/settings` | Restart: request failed | 500 or network failure before the restart began | [settings](screens/settings.md) | -| Settings | `/settings` | Restarting to install | Full-page overlay, so it survives the server going away | [settings](screens/settings.md) | -| Settings | `/settings` | Restarting, taking longer | No answer after 60 seconds | [settings](screens/settings.md) | -| Settings | `/settings` | Update done | The page reloaded on the new version | [settings](screens/settings.md) | -| Settings | `/settings` | Update failed, rolled back | The site came back on v2.2.0-beta | [settings](screens/settings.md) | -| Settings | `/settings` | Download failed | A 500, or the connection dropped | [settings](screens/settings.md) | -| Settings | `/settings` | Verification failed | The downloaded file isn't trusted | [settings](screens/settings.md) | -| Settings | `/settings` | Too many requests (429) | Retry-After counted down in the button | [settings](screens/settings.md) | -| Settings | `/settings` | Not prepared | The server can't update from the browser | [settings](screens/settings.md) | -| Settings | `/settings` | Not prepared: command copied | Copy pressed | [settings](screens/settings.md) | -| Settings | `/settings` | Plain restart: confirm | Restart with no update waiting | [settings](screens/settings.md) | -| Settings | `/settings` | Plain restart: restarting | Same overlay as the update restart with shorter copy | [settings](screens/settings.md) | -| Settings | `/settings` | Plain restart: done | The page reloaded | [settings](screens/settings.md) | -| Settings | `/settings` | Admin view | Admins see the version and the note, with no buttons | [settings](screens/settings.md) | -| Settings | `/settings` | Role changed (403) | An owner was changed to admin while the page was open and pressed a button | [settings](screens/settings.md) | -| Settings | `/settings` | Update already running (409) | Another owner started it | [settings](screens/settings.md) | -| Home | `/home` | Update available (owner) | Owner only: quiet banner and Settings pill | [home](screens/home.md) | -| Home | `/home` | Update ready to install (owner) | Download checked: banner copy changes | [home](screens/home.md) | -| Profile | `/profile` | Account menu | Account chip clicked | [profile](screens/profile.md) | -| Profile | `/profile` | Loading | Opened by URL before /auth/me returns | [profile](screens/profile.md) | -| Profile | `/profile` | Default, no display name | First visit | [profile](screens/profile.md) | -| Profile | `/profile` | Editing, too long | Typing; over 60 characters | [profile](screens/profile.md) | -| Profile | `/profile` | Saving, saved, save failed | PATCH in flight, 200, 500 or network | [profile](screens/profile.md) | -| Profile | `/profile` | Reset: sending, sent, 429, failed | POST /v1/auth/password-reset | [profile](screens/profile.md) | -| Profile | `/profile` | Display names in the Users list | Accounts with a display name | [profile](screens/profile.md) | -| Accept invite | `/invite/accept?c=` | Checking the link | On load: GET invite | [accept-invite](screens/accept-invite.md) | -| Accept invite | `/invite/accept?c=` | Default, validation errors, filled, submitting | Valid, unused code; email read-only | [accept-invite](screens/accept-invite.md) | -| Accept invite | `/invite/accept?c=` | Username taken | 409 | [accept-invite](screens/accept-invite.md) | -| Accept invite | `/invite/accept?c=` | Invalid or used, expired | 404 or no c parameter, 410 | [accept-invite](screens/accept-invite.md) | -| Accept invite | `/invite/accept?c=` | Server error | 500 or network | [accept-invite](screens/accept-invite.md) | -| Accept invite | `/invite/accept?c=` | Success | 200: logged in, /home with toast | [accept-invite](screens/accept-invite.md) | +## Copy glossary +| Use | Not | +|---|---| +| Delete (removes a record: article, variant, image, invite, cache files) | Remove | +| Remove link, Remove from the article, Remove (detaches; the record stays) | Delete | +| Create, Add variant, Create draft | New (except the page and button name "New article") | +| Save (a form or the editor), Save changes (edit forms) | Update (Update is the software-update action only) | +| Cancel on the left, primary on the right | Close on a dialog that has an action | +| Close only when the dialog has no action | Done, OK | +| Explicit non-destructive label in place of Cancel when Cancel would be ambiguous: "Keep invite", "Keep key", "Restart later" | Cancel there | +| Sentence case everywhere | Title Case | +| Errors say what happened and what was kept, end with a full stop | Exclamation marks | +| Toast: title is a short result in the past tense ("Article deleted", "Download started"), body one sentence with a full stop | Titles with a full stop; future tense | +| In-flight verb ends in an ellipsis character: "Saving…", "Creating…", "Preparing download…" | Three dots | + +Glossary pass 2, changes made: activity log lines "Removed N expired invites" and "Removed N generated files" became "Deleted …" (they delete records or files); Activity export footer "Close" next to "Download again" became a single "Close"; export title "Export ready" became "Download started"; export failure title "Couldn't prepare the export" became "Couldn't prepare the download"; "Cancel export" became "Cancel". Checked by search across all frames and boards for exclamation marks (none found), Remove and Delete, Update and Save, "New", and dialog footers. Not re-read line by line; see README "Not done". ## HTTP status to copy +| Status | Copy | +|---|---| +| 400 | "That request wasn't valid. Check the fields and try again." | +| 401, 403 | Global redirect, no local copy | +| 404 | "That item no longer exists." | +| 409 | "This changed somewhere else. Reload and try again." | +| 413 | "That file is too large." | +| 429 | "Too many requests. Try again in a few minutes." | +| 500, network | "Something went wrong on our side. Nothing was changed. Try again in a moment." | -Response bodies are plain text and are never shown. `useApi` throws an `ApiError` with the status and a context key, and each screen maps them to this copy. +## Public image 400 bodies +`{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "hero" }` and `{ "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "width" }`. The original is not served as a fallback. -| Status | Where | Copy | Surface | -|---|---|---|---| -| 400 | Any form | The field error from the client rules. A server 400 that got through: "Something in the form isn't valid. Check the highlighted fields." | Field + alert | -| 401 | Any back-office call | Goes to /login?redirect=&reason=expired. "Your session expired. Log in again to pick up where you left off." | Redirect + alert | -| 401 | Verify | "Log in to finish verifying." Inline log in, then retry | Inline form | -| 403 | Log in | "That username and password don't match. Check both and try again." | Alert | -| 403 | Register | "This site already has an owner. Log in instead." | ResultPanel | -| 403 | Any back-office call | Unverified: goes to /register/pending | Redirect | -| 403 | Verify | "This verification link doesn't work." Offers Cancel registration and start over | ResultPanel | -| 403 | Cancel registration | "Your account is already verified." Goes to /home | Toast | -| 403 | Reset confirm | "This reset link has expired." Offers Request a new link | ResultPanel | -| 409 | Invite | "This address already has an account." or, for a live invite, "There's already a pending invite for this address." | Field error | -| 404 | Accept invite | "This invite link doesn't work." Offers Go to log in | ResultPanel | -| 409 | Accept invite | "That username is taken. Try another." | Field | -| 410 | Accept invite | "This invite has expired. Ask the person who invited you to send a new one." | ResultPanel | -| 404 | Editor load | "This article doesn't exist." Offers All articles | ErrorView | -| 404 | Row or editor action | "That article no longer exists." The row is removed | Toast | -| 404 | Unknown route | "Page not found" | ErrorView | -| 429 | Profile, reset link | "Too many requests. You've asked for several links recently. Wait a few minutes, then try again." Button disabled | Note | -| 429 | Log in, reset request | "Too many attempts. For your security, wait a moment before trying again." With a countdown | Alert + disabled button | -| 500 | Save | "Save failed" + Retry. Changes are kept | SaveStatus | -| 500 | Publish / unpublish | "Couldn't publish" / "Couldn't unpublish" + Retry | Toast | -| 500 | Load users | "Couldn't load users. Check your connection and try again." + Try again | Alert | -| 500 | Cancel invite | "Couldn't cancel the invite. Try again in a moment." The dialog stays open | Notice in dialog | -| 500 | Renew invite | "Couldn't renew the invite. Try again in a moment." The row stays Expired | Toast | -| 500 | Invite | "Couldn't send the invite. Try again in a moment." The address stays | Alert in dialog | -| 500 | Save display name | "Couldn't save your display name. Your text is still here. Try again in a moment." | Alert in card | -| 500 | Send reset link (profile) | "Couldn't send the link. Check your connection and try again." | Note | -| 500 | Delete | "Couldn't delete the article, so it's still there. Try again in a moment." | Inline in dialog | -| 500 | Upload | "Upload failed. The server didn't accept the file. Your article hasn't changed." | ImageUploader | -| network | Anywhere | "Couldn't reach RenewCMS. Check your connection and try again." Offline: banner | Alert / toast / banner | +## Articles pagination +Loading next page keeps row height, controls disabled, summary "Loading page 7…". Error "Couldn't load page 7. Showing page 6." with Retry. Out of range "That page doesn't exist. Showing the last page." Page size menu offers 5, 25, 50, 100. -## API the design marked pending +## Editor +Counter without limit ("1,204 words · 7,318 characters"; at 120,000 characters "19,845 words · 120,000 characters"), "Saving…", "Saved", "Couldn't save. Your text is kept. Retry". Link states and the toolbar overflow are drawn in the Editor group (11 states). -Existing: `GET /v1/auth/me`, `GET /v1/auth/registration`, `429` with `Retry-After` on `/v1/auth/login` and `/v1/auth/password-reset`, and `/static/uploadedImages/*`. +## Activity export +Only CSV is offered. "Preparing download…", "Download started", "Couldn't prepare the download", "Nothing to export", "You can't export all activity". The CSV/JSON format control is drawn once, labelled proposed. -Added by the Users and Profile screens, none of which exist yet: +## Media cleanup, Settings navigation, Activity toggles +As in screens/settings.md, screens/image-variants.md and screens/activity.md. Sync rule for filter cards: card pressed, chip present and the Outcome segment on Failed are one state; removing any removes all three. -- `GET /v1/user/`: the users and their invites, newest first. -- `POST /v1/user/invite`: sends an invitation email. 201, 409 for an existing account or a live pending invite. -- `DELETE /v1/user/invite/:id`: cancels a Pending invite. 204. Owner only. -- `POST /v1/user/invite/:id/renew`: replaces the code, resets the 3 days and created date, and resends the email. 200 with the invite. Owner only. -- `GET /v1/user/` and `GET /v1/auth/me` gain a `role` ("owner" or "admin"); the list gains `email` on accepted users. -- `POST /v1/user/invite` becomes owner only, and always creates an admin. -- `GET /v1/settings`: `{ "version": "..." }`. -- `PATCH /v1/user/me`: saves the display name. `GET /v1/auth/me` returns it. -- `GET /v1/auth/invite?c=CODE`: looks up the invite on load, returns `{ email }`, 404 or 410. `POST /v1/auth/invite/accept` takes `{ code, username, password }` and returns 200 with the session cookie, 409 for a taken username, 404 or 410. - -Still not supported, so shown disabled or omitted: - -Renaming a title (shown read-only, with an explanation in Details), resending the verification email (the pending page explains how to start over), changing the email or username, removing users, promoting or demoting anyone, and any role other than owner and admin. Changing the password while logged in works only through the emailed reset link on the Profile page. - -Added by the Updates card, none of which exists yet (owner only unless noted, see [settings](screens/settings.md)): - -- `GET /v1/system/update` (any verified user): `{ current, latest, available, notes: { version, released_at, items }, checked_at, can_apply, state, progress, error, started_at }`. `state` is `idle`, `downloading`, `verifying`, `ready`, `restarting`, `succeeded` or `failed`. `can_apply` is false when the server hasn't been prepared, or for admins. -- `POST /v1/system/update/check`: asks the release feed. 200, 429 with `Retry-After`, 500 when the feed can't be reached. -- `POST /v1/system/update/download`: 202 starts the download and verification, 400 when there is nothing to update, 403, 409 when one is already running or the server isn't prepared, 422 when the file fails verification, 429. -- `POST /v1/system/update/restart`: 202 starts the install and restart (or only a restart when no update is ready), 403, 409, 500. -- A restart must survive the server going away: the SPA polls `/healthz` and `GET /v1/system/update` until they answer. - -Added by the Media screens, none of which exist yet: - -- `GET /v1/media/?q=&filter=&sort=&limit=&cursor=&hash=`: the library, newest first by default. `hash` (SHA-256 hex) returns only the images with that content, which is how the browser checks for a duplicate before uploading. Each item carries `id`, `filename`, `url`, `size`, `content_type`, `width`, `height` (null for SVG), `uploaded_by`, `created_at`, `used_in` (count) and `duplicate` (bool). The response also carries the counts for the Unused and Duplicates tabs. -- `POST /v1/media/`: multipart `file`, no article id. 201 with the item. It always stores the file: the duplicate check happens in the browser (hash with `crypto.subtle`, then `GET /v1/media/?hash=`), and when `crypto.subtle` is unavailable the check is skipped and any duplicate shows up under Duplicates. The server keeps each image's SHA-256. 413 for over 10 MB, 415 for another type, 400 for an unreadable image, 429 with `Retry-After`. -- `PATCH /v1/media/{id}`: `{ filename }`, the extension is kept and the URL does not change. 200, 404. -- `DELETE /v1/media/{id}`: 204, 404. Deleting an image that is in use is allowed. -- `POST /v1/media/merge`: `{ keep_id, merge_ids }`. Rewrites article bodies from the merged copies' URLs to the kept image's URL, then deletes the copies. 200 with `{ articles_updated, merged }`, 404, 409 when a listed copy is gone or the group changed. -- `GET /v1/media/{id}/usage`: `[{ id, title, published }]`, read from article bodies. -- Images no longer belong to an article, and deleting an article no longer deletes images. `POST /v1/article/{id}/image` and `DELETE /v1/article/{id}/image/{imageId}` are replaced by the endpoints above. - -## Added with Media cleanup -### Media cleanup -| State | Surface | Behaviour | -|---|---|---| -| Default | Settings card | Off, "Never run", no "Every" row | -| On | Settings card | "Every" 30 days, last and next run | -| Saving / Saved | Settings card header | "Saving…" then "Saved" for 2 s, polite live region | -| Save failed | Under the field | "Couldn't save. Try again in a moment." Control reverts | -| Validation | Under the field | "Enter a whole number of days between 1 and 365." Nothing sent | -| Read-only | Settings card | Controls disabled, button hidden, info alert | -| Loading | Settings card | Skeleton after 150 ms | -| Offline | Settings and Media | Banner as on Media. Controls and buttons disabled | -| Preview loading | Dialog | Skeleton rows, confirm disabled | -| Confirm | Dialog | Summary, up to 5 groups, link to Duplicates | -| In progress | Dialog | "Purging…", loader-circle, not dismissible | -| Nothing to purge | Dialog | Info state, "Close" only | -| Error | Dialog | Notice, stays open, confirm retries | -| Partial | Dialog | Warning, summary refreshes, "Close" and "Retry" | -| Conflict (409) | Dialog | Warning, summary refreshes | -| Success | Toast, 5 s | "Duplicates purged" / "9 copies merged. 12 articles updated." "Last purge" updates in place | -| Unattended run | Settings | No toast. Updated on next visit | - -401 and 403 are global and have no local copy. - -## Added with Activity -### Activity -| State | Surface | Behaviour | -|---|---|---| -| Default | `/activity` | Strip, filters, day-grouped table, pagination | -| Loading | `/activity` | Skeleton strip and rows after 150 ms | -| Zero-value strip | `/activity` | Failed and Security show a calm 0 | -| Filtered | `/activity` | Chips and "Clear all"; a strip card applies its filter | -| Empty, no activity | `/activity` | "No activity yet"; Export disabled | -| Empty, no match | `/activity` | "No activity matches these filters" with "Clear filters" | -| Error | `/activity` | "Couldn't load activity" with "Try again"; the strip may still show | -| Forbidden (403) | `/activity` | "You can't view this activity." with "Back to home" | -| Details | Slide-over / bottom sheet | Sentence, time, actor, source, target, reason, changes, previous and next | -| My activity | `/activity` | No strip, Person filter or Export; own events only | -| Export | Header button, toasts | "Preparing file…", then "Export started. Your file will download shortly." or "Couldn't export activity. Try again." | -| Home card | `/home` | Owner or admin, regular user, loading, error, empty | -| Retention | `/settings` | Default (90 days or 5 GB), age only, size only (Forever), shortening (confirm), save error | - -401 is global. The 403 on `/activity` has local copy because the page itself is refused; the 403 on the summary or export of a non-admin is never shown, since the controls are hidden. - -## Added with Image variants - -Every state is drawn on `source/RenewCMS Screens Image variants.dc.html` and listed with its trigger and copy in [image-variants](screens/image-variants.md), which also holds the states matrix and the back-office error copy (400, 401, 403, 404, 409, 413, 422, 500). - -| Screen | Route | States | -|---|---|---| -| Variants list | `/settings/media/variants` | Loaded, loading, empty (two templates), error, forbidden, row menu, row action in progress, purge confirm, delete confirm, delete in progress, request reference | -| Add / edit variant | slide-over (desktop), full page (mobile) | Create pristine and dirty, field errors, no dimension, name taken (409), saving, save error, edit pristine and dirty, unsaved changes, PNG without quality | -| Media library | `/media` | Grid with badges, sort menu, filter With variants, filter Without, nothing to show, delete confirm with variants | -| Image detail | `/media` (panel) | Partly generated, nothing generated, generating, generation failed, no variants defined, SVG source, animated GIF source, preview dialog | -| Cache | `/settings/media/cache` | Loaded, loading, empty, purging, purge done, purge failed, error, purge all (confirm, typed), purge unused | -| Editor picker | `/article/:id/edit` | Variant select, select open, no variants defined, variant deleted, variant disabled | - -The public request contract (200, 400 `unknown_variant` or `unsupported_parameter`, 404) is not a screen; it is section 7 of the same file. +## Per-screen copy +screens/.md, section "Copy per state" (generated from the board source). diff --git a/web/visual/check.ts b/web/visual/check.ts index 604bcb3..55efb4c 100644 --- a/web/visual/check.ts +++ b/web/visual/check.ts @@ -197,7 +197,7 @@ function tableStates(markdown: string) { .filter((cell) => cell !== 'State' && !/^-+$/.test(cell)) } -const variantsBoard = 'RenewCMS Screens Image variants.dc.html' +const variantsBoard = officeBoard function numberedStates(prefix: string, names: string[]) { return Object.fromEntries( @@ -241,50 +241,50 @@ function variantsScreen(): Screen { board: variantsBoard, group: 'Variants list', states: listNames, - alias: numberedStates('1', listNames), + alias: numberedStates('V1', listNames), }, { board: variantsBoard, group: 'Add edit variant', states: formNames, - alias: numberedStates('2', formNames), + alias: numberedStates('V2', formNames), }, ] const libraryNames = [ - '3a · Grid with badges', - '3b · Sort menu', - '3c · Filter: With variants, sorted by total size', - '3d · Filter: Without variants', - '3e · Filter: nothing to show', - '3f · Delete confirm', + 'V3a · Grid with badges', + 'V3b · Sort menu', + 'V3c · Filter: With variants, sorted by total size', + 'V3d · Filter: Without variants', + 'V3e · Filter: nothing to show', + 'V3f · Delete confirm', ] const detailNames = [ - '4a · Partly generated', - '4b · Nothing generated yet', - '4c · Generating', - '4d · Generation failed', - '4e · No variants defined', - '4f · Source is an SVG', - '4h · Preview dialog', + 'V4a · Partly generated', + 'V4b · Nothing generated yet', + 'V4c · Generating', + 'V4d · Generation failed', + 'V4e · No variants defined', + 'V4f · Source is an SVG', + 'V4h · Preview dialog', ] const editorNames = [ - '6a · Variant select', - '6b · Select open', - '6c · No variants defined', - '6d · Variant deleted', - '6e · Variant disabled', + 'V6a · Variant select', + 'V6b · Select open', + 'V6c · No variants defined', + 'V6d · Variant deleted', + 'V6e · Variant disabled', ] const cacheNames = [ - '5a · Loaded', - '5b · Loading', - '5c · Empty cache', - '5d · Purging', - '5e · Purge done', - '5f · Purge failed', - '5g · Error loading', - '5h · Purge all: confirm', - '5i · Purge all: confirm, typed', - '5j · Purge unused: confirm', + 'V5a · Loaded', + 'V5b · Loading', + 'V5c · Empty cache', + 'V5d · Purging', + 'V5e · Purge done', + 'V5f · Purge failed', + 'V5g · Error loading', + 'V5h · Purge all: confirm', + 'V5i · Purge all: confirm, typed', + 'V5j · Purge unused: confirm', ] sources.push( { board: variantsBoard, group: 'Cache panel', states: cacheNames, alias: {} },