diff --git a/docs/design/README.md b/docs/design/README.md index 18d172f..ad4f59c 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -33,18 +33,20 @@ The visual spec of record for the back-office SPA and the transactional emails, | [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 Activity.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`, `source/RenewCMS Screens Media cleanup.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` | ## Other boards -- `source/ActivityFrame.dc.html`: the frame that draws every Activity state, the Home card and the Settings retention card (the Activity board imports it). Its copy, component and API tables are in `source/activity-spec.js`. It holds the exact values. Home and Settings are drawn around dashed placeholders. +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. + + +- `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/SettingsFrame.dc.html`: the Media cleanup card and its purge dialog (the Media cleanup board imports it, with `MediaFrame.dc.html` for the Duplicates toolbar button). They hold 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`. diff --git a/docs/design/components.md b/docs/design/components.md index da4c9eb..1ab47bc 100644 --- a/docs/design/components.md +++ b/docs/design/components.md @@ -70,7 +70,7 @@ Responsive 400 px card from sm up, with its top 12 vh from the top. Below sm , t ### DashboardShell -The sidebar has five destinations (Home, Articles, Users, Integration, Settings), and the user menu sits at its foot. 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. +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. 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 @@ -400,7 +400,7 @@ The chip at the foot of the sidebar no longer logs out on click. It opens the ac ### 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". +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 diff --git a/docs/design/screens/activity.md b/docs/design/screens/activity.md index e1e496c..914af25 100644 --- a/docs/design/screens/activity.md +++ b/docs/design/screens/activity.md @@ -2,7 +2,7 @@ - **Route:** `/activity` - **API:** GET /v1/activity · GET /v1/activity/summary · GET /v1/activity/export (see "API contract" below) -- **Visual reference:** `source/RenewCMS Screens Activity.dc.html`, group "Activity, owner and admin" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle). The frame is `source/ActivityFrame.dc.html`; its copy, component mapping and API tables are `source/activity-spec.js`. +- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Activity" (the same board also has the groups "Activity details" and "My activity"), drawn by `source/AppFrame.dc.html` with the page from `source/ActivityFrame.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle). The frame is `source/ActivityFrame.dc.html`; its copy, component mapping and API tables are `source/activity-spec.js`. The audit log of the back-office: who did what, and when, across articles, media, users and invites, API keys, authentication and system tasks. It is for non-technical owners, so every row reads as a sentence, not a log line. Events are append-only: nothing in the app edits or deletes them. Only the retention period removes them (Settings, "Retention"). @@ -12,8 +12,8 @@ Other groups of the same board: |---|---|---| | Details slide-over | The slide-over and its bottom-sheet variant, six events | this file, "Details slide-over" | | My activity | The same screen for a user who is not owner or admin | this file, "My activity" | -| Home card | The Recent activity card on Home | `screens/home.md`, "Recent activity card" | -| Settings retention | The Retention card on Settings | `screens/settings.md`, "Activity retention" | +| Home section | The Recent activity section on Home | `screens/home.md`, "Recent activity" | +| Settings retention | The Retention card, last on Settings | `screens/settings.md`, "Activity retention" | ## Roles diff --git a/docs/design/screens/home.md b/docs/design/screens/home.md index 76724b4..d2be81d 100644 --- a/docs/design/screens/home.md +++ b/docs/design/screens/home.md @@ -55,12 +55,12 @@ The greeting reads "Good morning, {name}", where the name is the display name wh - Banner: success tone, `circle-check` icon, title "Version 2.3.0 is ready to install", text "Restart from Settings to finish. The site is unavailable for about 10 seconds.", button "Go to Settings" -## Recent activity card +## Recent activity -**API:** GET /v1/activity?size=5. Visual reference: `source/RenewCMS Screens Activity.dc.html`, group "Home card". The board draws the rest of Home as a dashed "Existing Home cards" placeholder, so this card is not pixel-checked against it. +**API:** GET /v1/activity?size=5. Visual reference: `source/RenewCMS Screens Back-office.dc.html`, group "Home" (states Default, Recent activity: loading, error and empty, Regular user), drawn by `source/AppFrame.dc.html`. This is the layout of record. The older Home card on the Activity board is superseded. -A card with the last five events. Header "Recent activity" (regular users: "Your recent activity") and a "View all" link with `arrow-right` to `/activity`. Each row: a 24 px avatar, the sentence clamped to two lines with the target in semibold, then "Marta Alves · 2 min ago" (own events: only the time). A failed event gets a "Failed" badge; a successful security event a small `shield`. +A section with the last five events, built like "Recently updated" above it: a 16 px semibold heading on the left ("Recent activity") and an underlined "View all" link on the right, then a bordered list. Header "Recent activity" (regular users: "Your recent activity") and a "View all" link with `arrow-right` to `/activity`. Each row: a 24 px avatar, the sentence clamped to two lines with the target in semibold, then "Marta Alves · 2 min ago" (own events: only the time). A failed event gets a "Failed" badge; a successful security event a small `shield`. -- Placement as drawn: a 420 px column to the right of the existing cards on desktop; stacked on mobile, drawn above the existing cards. Confirm against the real Home layout before coding. +- Placement: full width, below the stats and the "Recently updated" list, on desktop and mobile. There is no side column. "View all" links to `/activity`. - States: Owner or admin, Regular user, Loading (five skeleton rows after 150 ms), Error ("Couldn't load recent activity" with "Try again"), Empty ("No activity yet", "Actions on your site will appear here." / own: "Your actions will appear here."). -- The card never blocks the page: an error or empty card leaves the stats and the article list as they are. +- The section never blocks the page: an error or empty card leaves the stats and the article list as they are. diff --git a/docs/design/screens/settings.md b/docs/design/screens/settings.md index 042eb4a..169ec1d 100644 --- a/docs/design/screens/settings.md +++ b/docs/design/screens/settings.md @@ -1,7 +1,7 @@ # Settings - **Route:** `/settings` -- **API:** GET /v1/media/purge-settings · PATCH /v1/media/purge-settings · GET /v1/media/purge-preview · POST /v1/media/purge (see "Media cleanup (second card)" at the end) · GET /v1/settings (version) · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart +- **API:** GET /v1/media/purge-settings · PATCH /v1/media/purge-settings · GET /v1/media/purge-preview · POST /v1/media/purge (see "Media cleanup (third card)" at the end) · GET /v1/settings (version) · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart - **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Settings", drawn by `source/AppFrame.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle). Opened from the sidebar (Settings, settings icon, after Integration) and from the mobile drawer. Every verified user sees it. It holds two sections: Version (everyone) and Updates (the owner has the buttons, admins read the status). @@ -12,7 +12,7 @@ Out of scope: choosing versions, downgrades, automatic updates, update history. ## The page -Title "Settings", subtitle "Workspace information.", 720 px at most, 20 px between sections. Page padding 32 px on desktop and `20px 16px 88px` on mobile. +One column of cards, top to bottom: Version, Updates, Media cleanup, Retention (owner only). Title "Settings" (20 px/28 px semibold), subtitle "Workspace information.", 720 px at most, 20 px between sections. Every card heading (Version, Updates, Media cleanup, Retention) is 15 px/24 px semibold in `--ui-text-highlighted`; a card heading is never larger than the page title and never smaller than a body line. The Back-office board draws this page scrolled to the card each state is about. Page padding 32 px on desktop and `20px 16px 88px` on mobile. **Version card** (unchanged): label "Version" and the running build's version in IBM Plex Mono 14 px, shown exactly as the server reports it (for example `v2.2.0-beta`). @@ -28,7 +28,7 @@ Title "Settings", subtitle "Workspace information.", 720 px at most, 20 px betwe | Server command | Only in the "Not prepared" state: "For whoever manages the server", a one-line `code` block, a Copy button (reads "Copied" with a check for 2 s, announced), and the note "They run this once on the server. After that, Update and Restart appear here." The command shown is `renewcms enable-browser-updates` and is subject to the real one. | | Buttons | 32 px high (44 px on mobile, stacked full width), 14 px side padding. One primary at most. Disabled buttons use 40 % opacity. | | Hint | 12 px muted line under the buttons. | -| Link | Underlined, with an `arrow-up-right`: "How to update" or "Read the release notes". | +| Link | Underlined, with an `external-link`: "How to update" or "Read the release notes". | Buttons used: **Check for updates** (`refresh-cw`, secondary), **Update** (`download`, primary), **Restart** (`power`, primary and inside the status block when an update is ready, otherwise secondary in the buttons row), **Try again** (`rotate-cw`, primary), and the disabled "Checking…" and "Updating…" (`loader-circle`). Copy such as "Version 2.3.0" names the newer release. Decision for the spec: show the release tag without its leading `v` ("Version 2.3.0-beta" for tag `v2.3.0-beta`), while the Version card keeps the running build exactly as the server reports it. @@ -126,15 +126,15 @@ For the owner only: an **Update** pill on the Settings sidebar item, and a dot o | Role changed (403) | An owner was changed to admin while the page was open and pressed a button. The card reloads in the admin view. | | Update already running (409) | Another owner started it. The card switches to that run's progress. | -## Media cleanup (second card) +## Media cleanup (third card) -Route: `/settings`. Visual reference: `RenewCMS Screens Media cleanup.dc.html` (frames: `SettingsFrame.dc.html`, `MediaFrame.dc.html`). +Route: `/settings`. Visual reference: `source/RenewCMS Screens Back-office.dc.html`, groups "Settings: Media cleanup", "Purge dialog", "Purge results" and "Media duplicates entry" (frames: `source/AppFrame.dc.html`, `source/MediaFrame.dc.html`). API: `GET/PATCH /v1/media/purge-settings` (owner, admin), `GET /v1/media/purge-preview`, `POST /v1/media/purge`. 401 and 403 are global: no local copy. Rule: a group is purged only if at least one copy is used in an article. All copies merge into the one used in the most articles (ties: oldest, then lowest id). Groups with no used copy are left alone. Automatic purge is off by default. ### Card -Second card below Version, 20 px gap, same card styling. Page max width 720 px. +Third card, below Updates, 20 px gap, same card styling as the others. The board scrolls the page to this card for its states. - Title: "Media cleanup". Description: "Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one." - "Automatic purge" / helper "Runs in the background. Off by default." / USwitch. - When on: "Every" / helper "Between 1 and 365 days." / number input, suffix "days", default 30. @@ -180,9 +180,9 @@ Duplicates tab: secondary "Purge duplicates" button (merge) next to the sort men ## Activity retention -**API:** GET /v1/settings · PATCH /v1/settings (owner only). Visual reference: `source/RenewCMS Screens Activity.dc.html`, group "Settings retention". The board draws the other cards as a dashed "Existing Settings cards" placeholder and gives the page a different subtitle ("Site-wide options. Only owners can change them."), which is not a decision to change the existing one. +**API:** GET /v1/settings · PATCH /v1/settings (owner only). Visual reference: `source/RenewCMS Screens Back-office.dc.html`, group "Settings: Retention", drawn by `source/AppFrame.dc.html`. -A third card below Media cleanup, owner only (hidden for admins), same card styling, 640 px at most as drawn. +The last card on the page, below Media cleanup, owner only (hidden for admins and for the read-only Media cleanup view), same card styling and 720 px column as the others. - Header: "Retention" / "How long activity is kept." "Saved" (check, success) at the right after a save, fading after 2 s. - "Keep activity for" `USelectMenu`: 30 days, 90 days, 365 days, Forever. Default 90 days. Helper: "Older events are removed automatically." For Forever: "Nothing is removed automatically. The log grows over time." diff --git a/docs/design/source/AppFrame.dc.html b/docs/design/source/AppFrame.dc.html index b0fd382..ab234e1 100644 --- a/docs/design/source/AppFrame.dc.html +++ b/docs/design/source/AppFrame.dc.html @@ -27,7 +27,7 @@
{{ s.mTitle }}
-
You're offline. You can keep writing, but saving and publishing will wait until you're back online.
+
{{ offText }}
@@ -66,6 +66,14 @@
+ +
+
{{ act.title }}View all
+
Couldn't load recent activityCheck your connection and try again.
Try again
+
No activity yet{{ act.emptyText }}
+
{{ r.ini }}
{{ r.s }}{{ r.em }}{{ r.meta }}
Failed
+
+
Connect your websiteYour site reads published articles from RenewCMS. Share this guide with whoever builds it.Open the guide
@@ -211,7 +219,7 @@ -
+
SettingsWorkspace information.
Version{{ s.upd.v }}
@@ -223,6 +231,8 @@ {{ s.upd.hint }} {{ s.upd.link }}
+
Media cleanupMerge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.
Saving…Saved
Only owners and admins can change this.
Automatic purgeRuns in the background. Off by default.
EveryBetween 1 and 365 days.
{{ cl.daysVal }}days
{{ cl.errTxt }}
{{ cl.lastLine }}{{ cl.nextLine }}
Purge nowMerge every group of duplicates that has an image in use, right away.
Purge duplicates
+
RetentionHow long activity is kept. Only owners can change it.
Saving…Saved
Couldn't save the retention periodYour previous setting is still in place.
Try again
Keep activity for{{ rt.help }}
{{ rt.val }}
Events older than 30 days will be removedThis happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone.
Apply 30 daysCancel
Activity logs are append-onlyEvents can't be edited or deleted from the app. Only the retention period removes them.
{{ rt.last }}{{ rt.next }}
@@ -323,6 +333,7 @@
+{{ purgeDlg }}
@@ -338,7 +349,7 @@ class Component extends DCLogic { const s = this.props.s || {} const m = !!this.props.mobile const k = s.kind || 'home' - const navItems = [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['users', 'users', 'Users'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']] + const navItems = s.role === 'user' ? [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['activity', 'history', 'Activity']] : [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['users', 'users', 'Users'], ['activity', 'history', 'Activity'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']] const nav = navItems.map(([key, icon, t]) => ({ key, icon, t, bg: s.nav === key ? 'var(--rc-sand)' : 'transparent', badge: key === 'settings' && !!s.navBadge, fg: s.nav === key ? '#111111' : 'var(--ui-text-toned)', fw: s.nav === key ? 500 : 400 })) const crumbs = (s.crumbs || []).map((t, i, a) => ({ t, sep: i > 0, fg: i === a.length - 1 ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)', fw: i === a.length - 1 ? 500 : 400 })) const L = s.list || {} @@ -406,6 +417,71 @@ class Component extends DCLogic { const rsMap = { sent: { bg: 'var(--rc-success-soft)', fg: 'var(--ui-success)', icon: 'mail-check', title: 'Reset link sent', text: 'Check marta@renew.example. The link works once, within 1 hour.' }, limited: { bg: 'var(--rc-warning-soft)', fg: 'var(--ui-warning)', icon: 'clock-alert', title: 'Too many requests', text: 'You\'ve asked for several links recently. Wait a few minutes, then try again.' }, error: { bg: 'var(--rc-error-soft)', fg: 'var(--ui-error)', icon: 'circle-x', title: 'Couldn\'t send the link', text: 'Check your connection and try again.' } } const rsNote = rsMap[rs] || null const rsBtn = { t: rs === 'sending' ? 'Sending…' : rs === 'sent' ? 'Send again' : 'Send reset link', icon: rs === 'sending' ? 'loader-circle' : 'mail', op: rs === 'limited' ? '0.4' : '1' } + const E5 = [['Marta Alves', 'Published the article ', 'Spring release', '2 min ago', 1], ['Marta Alves', 'Edited the article ', 'Why we reuse original floorboards', '29 min ago', 1], ['Not signed in', 'Failed login attempt for ', 'alex@example.com', '1 h ago', 0], ['Jonas Weber', 'Revoked API key ', 'Mobile app', '2 h ago', 1], ['Scheduler', 'Ran the nightly backup', '', '7 h ago', 1]] + const E4 = [['Marta Alves', 'Published the article ', 'Spring release', '2 min ago', 1], ['Marta Alves', 'Edited the article ', 'Why we reuse original floorboards', '29 min ago', 1], ['Marta Alves', 'Deleted the image ', 'old-banner.png', 'Yesterday', 1], ['Marta Alves', 'Uploaded the image ', 'floorboards-copy.jpg', '3 Oct 2026', 1]] + const ho = s.home || {}, own = s.role === 'user' + const actRows = ho.act === 'loading' ? Array.from({ length: 5 }, () => ({ skel: true })) : ho.act === 'ok' ? (own ? E4 : E5).map(([a, t, em, rel, ok]) => ({ s: t, em, meta: own ? rel : a + ' · ' + rel, fail: !ok, ini: a === 'Scheduler' ? 'SY' : a === 'Not signed in' ? '?' : a.split(' ').map(w => w[0]).join('') })) : null + const act = { show: !!ho.act && k === 'home' && !s.boot, title: own ? 'Your recent activity' : 'Recent activity', rows: actRows, error: ho.act === 'error', empty: ho.act === 'empty', emptyText: own ? 'Your actions will appear here.' : 'Actions on your site will appear here.' } + const C = s.cleanup || {}, cOn = !!C.on, cDis = !!(C.ro || C.offline || C.loading), cErr = !!(C.invalid || C.saveErr) + const cl = { loading: !!C.loading, ready: !C.loading, ro: !!C.ro, notRo: !C.ro, on: cOn, saving: !!C.saving, saved: !!C.saved && !C.saving, swW: m ? '44px' : '36px', swH: m ? '44px' : '24px', swOp: cDis ? '0.4' : '1', swRing: C.focusSw ? '2px solid var(--ui-primary)' : '0', swBd: cOn ? 'var(--ui-primary)' : 'var(--ui-border-accented)', swBg: cOn ? 'var(--ui-primary)' : 'var(--ui-bg-accented)', knobL: cOn ? '17px' : '1px', knobBd: cOn ? 'transparent' : 'var(--ui-border-accented)', daysW: m ? '100%' : '140px', daysAlign: m ? 'stretch' : 'flex-end', daysBd: cErr ? 'var(--ui-error)' : C.focusDays ? 'var(--ui-primary)' : 'var(--ui-border-accented)', daysSh: C.focusDays && !cErr ? '0 0 0 1px var(--ui-primary)' : 'none', daysOp: cDis ? '0.4' : '1', daysVal: C.days ?? '30', hasErr: cErr, errTxt: C.invalid ? 'Enter a whole number of days between 1 and 365.' : 'Couldn\'t save. Try again in a moment.', errMax: m ? 'none' : '300px', errTA: m ? 'left' : 'right', lastLine: C.last ? 'Last purge: ' + C.last.d + ' · ' + C.last.c + ' copies merged · ' + C.last.a + ' articles updated' : 'Never run', nextLine: 'Next purge: ' + (C.next || '14 Jul 2026'), purgeOp: C.offline || C.loading ? '0.4' : '1', btnShrink: m ? '1' : '0' } + const R = s.ret || {}, rd = R.days || '90', fv = rd === '0' + const rt = { show: !s.noRet, saving: !!R.saving, saved: !!R.saved, err: !!R.err, shorten: !!R.shorten, val: { '30': '30 days', '90': '90 days', '365': '365 days', '0': 'Forever' }[R.shorten ? '30' : rd], help: fv ? 'Nothing is removed automatically. The log grows over time.' : 'Older events are removed automatically.', last: fv ? 'Last cleanup: Never' : 'Last cleanup: 6 Oct 2026, 03:00 · 142 events removed', next: fv ? 'Next cleanup: Not scheduled' : 'Next cleanup: 7 Oct 2026, 03:00', selW: m ? '100%' : '160px' } + const purgeDlg = (() => { + if (!s.purge) return null + const h = React.createElement, bh = m ? 44 : 32 + const P = t => Object.fromEntries(t.split(';').filter(x => x.trim()).map(x => { const i = x.indexOf(':'); const k = x.slice(0, i).trim(); return [k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase()), x.slice(i + 1).trim()] })) + const D = (t, ...c) => h('div', { style: P(t) }, ...c) + const S = (t, ...c) => h('span', { style: P(t) }, ...c) + const I = (n, z = 16, t = '') => h('span', { 'aria-hidden': 'true', style: { display: 'inline-block', flexShrink: 0, width: z, height: z, backgroundColor: 'currentColor', WebkitMask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', mask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', ...(t ? P(t) : {}) } }) + const B = (label, o = {}) => { + const k = o.k || 'outline' + const bg = k === 'primary' ? 'var(--ui-primary)' : k === 'danger' ? 'var(--ui-error)' : 'var(--ui-bg)' + const fg = k === 'primary' || k === 'danger' ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)' + const bd = k === 'primary' || k === 'danger' ? bg : 'var(--ui-border-accented)' + return S('height:' + (o.h || bh) + 'px;padding:0 ' + (o.px || 12) + 'px;display:flex;align-items:center;justify-content:center;gap:6px;background:' + bg + ';color:' + fg + ';border:1px solid ' + bd + ';font-weight:500;font-size:13px;white-space:nowrap;opacity:' + (o.dis ? 0.4 : 1) + ';' + (o.full ? 'flex:1;' : ''), o.icon && I(o.icon, 16), label) + } + const CHECK = 'conic-gradient(var(--ui-bg-accented) 25%, var(--ui-bg) 0 50%, var(--ui-bg-accented) 0 75%, var(--ui-bg) 0) 0 0/16px 16px' + const TONES = ['var(--rc-sand)', 'var(--ui-bg-accented)', 'color-mix(in srgb, var(--ui-border-accented) 35%, var(--ui-bg))', 'var(--ui-bg-elevated)'] + const mark = h('svg', { width: 20, height: 20, viewBox: '0 0 16 16', shapeRendering: 'crispEdges' }, h('rect', { width: 16, height: 16, fill: '#111' }), h('path', { fill: '#fff', fillRule: 'evenodd', d: 'M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z' })) + const Th = (im, t = '') => D('position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);background:' + (im.alpha ? CHECK : 'repeating-linear-gradient(135deg, rgb(128 128 128 / 0.12) 0 1px, transparent 1px 12px), ' + TONES[im.tone % 4]) + ';' + t, im.svg ? mark : I('image', 16, 'opacity:0.55')) + const IMX = { 12: { tone: 0 }, 7: { tone: 2 }, 2: { tone: 1 }, 4: { tone: 3, alpha: true, svg: true }, 11: { tone: 1 } } + const Alert = (tone, icon, text, title) => D('display:flex;gap:10px;padding:12px 14px;background:var(--rc-' + tone + '-soft);color:var(--ui-' + tone + ')', S('display:flex;padding-top:2px', I(icon)), D('flex:1;display:flex;flex-direction:column;gap:2px', title && S('font-weight:600', title), S('color:var(--ui-text-toned)', text))) + const hl = 'color:var(--ui-text-highlighted)' + const mono = "font-family:'IBM Plex Mono',monospace" + + const SkelBar = (w, hh = 13) => S('display:block;width:' + w + ';height:' + hh + 'px;background:var(--ui-bg-accented)') + // ---------- dialog + const G = [{ im: 12, n: 'floorboards.jpg', u: 4, c: 2 }, { im: 7, n: 'IMG_4471.jpg', u: 2, c: 1 }, { im: 2, n: 'kitchen-after.webp', u: 3, c: 3 }, { im: 4, n: 'logo-renew.svg', u: 5, c: 3 }, { im: 11, n: 'lime-plaster.jpg', u: 2, c: 1 }] + const pl = (n, w) => n + ' ' + w + (n === 1 ? '' : 's') + const PV = { groups: 4, copies: 9, arts: 12, list: G.slice(0, 4), more: 0, ...(s.purge && s.purge.pv) } + const summary = pv => pl(pv.groups, 'group') + ' · ' + pv.copies + (pv.copies === 1 ? ' copy' : ' copies') + ' will be deleted · ' + pl(pv.arts, 'article') + ' will be updated' + const GList = pv => D('border:1px solid var(--ui-border)', ...pv.list.map((g, i) => D('display:flex;align-items:center;gap:12px;padding:8px 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), Th(IMX[g.im], 'width:40px;height:30px;flex-shrink:0;border:1px solid var(--ui-border)'), D('flex:1;min-width:0;display:flex;flex-direction:column', S('font-weight:500;' + hl + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap', g.n), S('font-size:12px;color:var(--ui-text-muted)', 'Kept · used in ' + pl(g.u, 'article'))), S('font-size:12px;color:var(--ui-text-toned);white-space:nowrap;text-align:right', pl(g.c, 'copy').replace('copys', 'copies') + ' merged'))), pv.more > 0 && D('padding:8px 12px;border-top:1px solid var(--ui-border-muted);color:var(--ui-text-muted)', 'and ' + pv.more + ' more groups')) + const Link = () => S('display:inline-flex;align-items:center;gap:4px;align-self:flex-start;font-weight:500;' + hl + ';text-decoration:underline;text-underline-offset:3px;min-height:' + (m ? 44 : 20) + 'px', 'Review in Duplicates', I('arrow-right', 14)) + const dg = s.purge + const Shell = (o, body) => D('position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:' + (m ? 'flex-end' : 'flex-start') + ';justify-content:center;padding:' + (m ? 16 : 140) + 'px 16px 16px', + D('width:' + (m ? '100%' : '448px') + ';max-height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column', + o.center ? D('padding:28px 24px 0;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center', S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;background:var(--rc-info-soft);color:var(--ui-info)', I(o.icon, 20)), S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + hl, o.title), S('color:var(--ui-text-muted);text-wrap:pretty;max-width:340px', o.desc)) + : D('padding:20px 24px 0;display:flex;flex-direction:column;gap:4px', S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + hl, o.title), o.desc && S('color:var(--ui-text-muted);text-wrap:pretty', o.desc)), + body && D('padding:20px 24px 0;display:flex;flex-direction:column;gap:16px;min-height:0;overflow:hidden', ...body), + D('padding:20px 24px;display:flex;flex-direction:' + (m ? 'column-reverse' : 'row') + ';justify-content:flex-end;gap:8px;flex-shrink:0', o.cancel && B(o.cancel, { dis: o.loading, full: false }), o.confirm && S('height:' + bh + 'px;padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:var(--ui-error);color:var(--ui-bg);font-weight:500;opacity:' + (o.cdis ? 0.4 : 1), o.loading ? I('loader-circle') : o.cicon && I(o.cicon), o.confirm)))) + const DESC = 'Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can\'t be undone.' + const DSkel = () => [SkelBar('80%', 16), D('border:1px solid var(--ui-border)', ...[0, 1, 2].map(i => D('display:flex;align-items:center;gap:12px;padding:8px 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), SkelBar('40px', 30), D('flex:1;display:flex;flex-direction:column;gap:6px', SkelBar('45%'), SkelBar('60%', 11)))))] + const Dialog = () => { + const k = dg.k, pv = PV + const gp = 'Purge ' + pl(pv.groups, 'group') + const note = (tone, icon, t) => Alert(tone, icon, t) + if (k === 'loading') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: 'Purge duplicates', cdis: true }, DSkel()) + if (k === 'nothing') return Shell({ center: true, icon: 'copy', title: 'Nothing to purge', desc: 'No duplicate is used in an article, so there is nothing to merge.', cancel: 'Close' }, null) + const body = [S('font-weight:500;' + hl, summary(pv)), GList(pv), Link()] + if (k === 'confirm') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: gp }, body) + if (k === 'progress') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: 'Purging…', loading: true }, body) + if (k === 'error') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: gp }, [note('error', 'circle-x', 'Couldn\'t purge the duplicates. Nothing was changed. Try again in a moment.'), ...body]) + if (k === 'partial') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Close', confirm: 'Retry', cicon: 'rotate-cw' }, [note('warning', 'triangle-alert', 'Some groups couldn\'t be merged. 3 of 4 groups were purged. Try again to finish.'), ...body]) + if (k === 'conflict') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: gp }, [note('warning', 'triangle-alert', 'The library changed, so nothing was purged. Check the new summary and try again.'), ...body]) + } + + return Dialog() + })() const toasts = (s.toasts || []).map(t => ({ ...t, fg: 'var(--ui-' + t.tone + ')' })) const endpoints = [ { path: '/v1/article/', desc: 'Lists every online article.', l1: 'var(--ui-primary)', code: "const res = await fetch('https://cms.renew.example/v1/article/')\nconst articles = await res.json()" }, @@ -417,7 +493,7 @@ class Component extends DCLogic { const edReady = k === 'editor' && !e.loading && !e.notfound const edTitle = e.title || 'How we price a bathroom renovation' return { - cr, s, nav, crumbs, who, ini, pf, dn, dnSave, rsNote, rsBtn, + act, cl, rt, purgeDlg, rowDir: m ? 'column' : 'row', rowAlign: m ? 'stretch' : 'flex-start', rowJust: m ? 'flex-start' : 'space-between', rowGap: m ? '12px' : '16px', setScroll: -(s.scrollY || 0) + 'px', offText: s.offText || 'You\'re offline. You can keep writing, but saving and publishing will wait until you\'re back online.', cr, s, nav, crumbs, who, ini, pf, dn, dnSave, rsNote, rsBtn, langSeg: [['en','English'],['fr','Français']].map(([k,tt])=>{const on=(s.lang||'en')===k;return {act:'lang-'+k,code:k,t:tt,on,op:on?1:0}}), themeSeg: [['system','monitor','System'],['light','sun','Light'],['dark','moon','Dark']].map(([k,icon,tt])=>{const on=(s.mode||'light')===k;return {act:'theme-'+k,icon,t:tt,on,op:on?1:0}}), menuOpen: !!s.userMenu, deskMenu: !m && !!s.userMenu, mobMenu: m && !!s.userMenu, pfItemBg: 'var(--ui-bg-elevated)', chipBg: s.userMenu || s.nav === 'profile' ? 'var(--rc-sand)' : 'transparent', chipFg: s.userMenu || s.nav === 'profile' ? '#111111' : 'var(--ui-text-highlighted)', isSettings: k === 'settings', isProfile: k === 'profile', pfLoading: k === 'profile' && !!pf.loading, pfReady: k === 'profile' && !pf.loading, diff --git a/docs/design/source/RenewCMS Screens Activity.dc.html b/docs/design/source/RenewCMS Screens Activity.dc.html deleted file mode 100644 index 2c5f643..0000000 --- a/docs/design/source/RenewCMS Screens Activity.dc.html +++ /dev/null @@ -1,150 +0,0 @@ - - - - - - - - - - - - - - -
-
-
-

Screen: Activity log

-

New /activity screen, a Recent activity card for Home, and a Retention card for Settings. 1440 px at 50%, 390 px at 75%. Owner and admin see everything; regular users get "My activity". The theme switch shows every state in dark mode. Copy, component mapping and API calls are at the bottom.

-
-
- - - -
-
- -
-
-

{{ g.title }}

-
{{ g.route }}{{ g.api }}
-
-
- -
-
{{ st.name }}{{ st.trigger }}
-
-
-
-
-
-
-
-
-
- -
-

Copy per state

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

Component inventory (Nuxt UI v4)

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

API calls

-
-
{{ r.a }}{{ r.b }}
-
-
-
-
- - - diff --git a/docs/design/source/RenewCMS Screens Back-office.dc.html b/docs/design/source/RenewCMS Screens Back-office.dc.html index 954aa4e..11eb2bc 100644 --- a/docs/design/source/RenewCMS Screens Back-office.dc.html +++ b/docs/design/source/RenewCMS Screens Back-office.dc.html @@ -13,12 +13,12 @@ -
+

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%). 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. The theme switch shows all of them in dark mode. Session expiry and unverified redirects end on screens from the auth board.

@@ -39,8 +39,8 @@
{{ st.name }}{{ st.trigger }}
-
-
+
+
@@ -49,14 +49,17 @@ +

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 }}
- diff --git a/docs/design/source/RenewCMS Screens Media cleanup.dc.html b/docs/design/source/RenewCMS Screens Media cleanup.dc.html deleted file mode 100644 index 31da260..0000000 --- a/docs/design/source/RenewCMS Screens Media cleanup.dc.html +++ /dev/null @@ -1,139 +0,0 @@ - - - - - - - - - - - - - - - -
- -
-
-

Screen: Media cleanup

-

A Media cleanup card on Settings, a purge dialog, and an entry point on the Media Duplicates tab (also in the Back-office board). 1440 px at 50%, 390 px at 75%. The theme switch shows every state in dark mode.

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

{{ g.title }}

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

{{ g.note }}

-
-
- -
-
{{ st.name }}{{ st.trigger }}
-
-
-
-
-
-
-
-
{{ g.tableTitle }}
StatusWhereCopy
{{ r.a }}{{ r.b }}{{ r.c }}
-
-
- -
-
- - - diff --git a/docs/design/source/SettingsFrame.dc.html b/docs/design/source/SettingsFrame.dc.html deleted file mode 100644 index 9c2e150..0000000 --- a/docs/design/source/SettingsFrame.dc.html +++ /dev/null @@ -1,121 +0,0 @@ - - - - - - - - - -
{{ view }}
-
- - - diff --git a/features/activity_home_card_and_retention.feature b/features/activity_home_card_and_retention.feature index c674213..c484c52 100644 --- a/features/activity_home_card_and_retention.feature +++ b/features/activity_home_card_and_retention.feature @@ -7,7 +7,7 @@ Feature: Recent activity card on Home and Retention card on Settings Background: Given the activity API (features/activity-log-backend.feature, activity-log-producers.feature, activity_screen.feature) and the settings retention fields already exist And this slice adds one field to the settings API, activity_next_cleanup_at, and wires the SPA to the existing endpoints; there is no other Go change - And the Home card and the Settings "Retention" card are the two items activity_screen.feature left out of scope; the spec of record is docs/design/screens/home.md ("Recent activity card") and docs/design/screens/settings.md ("Activity retention"), groups "Home card" and "Settings retention" of RenewCMS Screens Activity.dc.html + And the Home card and the Settings "Retention" card are the two items activity_screen.feature left out of scope; the spec of record is docs/design/screens/home.md ("Recent activity") and docs/design/screens/settings.md ("Activity retention"), groups "Home" and "Settings: Retention" of RenewCMS Screens Back-office.dc.html And both boards draw the existing cards as dashed placeholders, so neither card is pixel-compared and Vitest is the SPA coverage And "owner" and "admin" are the full Home view; any other or absent session role is the regular-user view And every new English string has a French entry in messages.ts diff --git a/features/media_cleanup_spa.feature b/features/media_cleanup_spa.feature index aaf2cb5..6336fca 100644 --- a/features/media_cleanup_spa.feature +++ b/features/media_cleanup_spa.feature @@ -18,10 +18,10 @@ Feature: Media cleanup in the SPA: Settings card, purge dialog and Media Duplica summary and try again." (stale fingerprint) or "A purge is already running." (busy) And "owner or admin" is session.role in ('owner', 'admin'); only those two roles exist, so any other role is a mocked value in tests - And /settings is the verified route of settings_page.feature; the Media cleanup card is its second + And /settings is the verified route of settings_page.feature; the Media cleanup card is its third card, below Version and Updates, 20 px under the previous card, in a page at most 720 px wide And the card is --ui-bg with a 1 px --ui-border, 20 px padding (16 px on mobile), 16 px gap, as - drawn in docs/design/source/SettingsFrame.dc.html + drawn in docs/design/source/AppFrame.dc.html And the components are Nuxt UI v4 only (UCard, USwitch, UInputNumber, UFormField, UAlert, UButton, UModal, USkeleton, useToast) and design tokens only And PurgeDuplicatesDialog is one component shared by the card and the Media Duplicates tab: props diff --git a/features/media_cleanup_spa.qa.md b/features/media_cleanup_spa.qa.md index 468e8e3..abbc179 100644 --- a/features/media_cleanup_spa.qa.md +++ b/features/media_cleanup_spa.qa.md @@ -61,7 +61,7 @@ Amendments to existing specs, in the same change: `SettingsView.spec.ts` and the ## Visual (`task visual:check -- settings` and `task visual:check -- media`, desktop and mobile, light and dark) -References: `docs/design/screens/settings.md` ("Media cleanup (second card)"), `docs/design/screens/media.md`, `docs/design/source/RenewCMS Screens Media cleanup.dc.html` and the frames `SettingsFrame.dc.html`, `MediaFrame.dc.html` (exact values). Add every state below to `web/visual/states.ts`, with the mocked `/v1` calls the state needs. The gate is hard: every capture within 10 %. Nothing is skipped: no state depends on the clock, because dates come from fixture timestamps (midday UTC, so the day is stable). +References: `docs/design/screens/settings.md` ("Media cleanup (third card)"), `docs/design/screens/media.md`, `docs/design/source/RenewCMS Screens Back-office.dc.html` and the frames `AppFrame.dc.html`, `MediaFrame.dc.html` (exact values). Add every state below to `web/visual/states.ts`, with the mocked `/v1` calls the state needs. The gate is hard: every capture within 10 %. Nothing is skipped: no state depends on the clock, because dates come from fixture timestamps (midday UTC, so the day is stable). Board sample data to reuse: - Last purge fixture: `last_purge_at` 14 Jun 2026, merged 3, articles 5; "on" has 30 days and next 14 Jul 2026 (`next_purge_at` "2026-07-14T09:00:00Z"). diff --git a/web/src/components/ActivityFeedCard.vue b/web/src/components/ActivityFeedCard.vue index 11a2410..ee6de22 100644 --- a/web/src/components/ActivityFeedCard.vue +++ b/web/src/components/ActivityFeedCard.vue @@ -31,47 +31,36 @@ function metaLine(item: ActivityItem, own: boolean) { diff --git a/web/src/i18n/messages.ts b/web/src/i18n/messages.ts index f01e4d1..8f706f2 100644 --- a/web/src/i18n/messages.ts +++ b/web/src/i18n/messages.ts @@ -1193,7 +1193,8 @@ export const messages = { 'Actions on your site will appear here.': 'Les actions sur votre site apparaîtront ici.', 'Your actions will appear here.': 'Vos actions apparaîtront ici.', Retention: 'Conservation', - 'How long activity is kept.': 'Durée de conservation de l’activité.', + 'How long activity is kept. Only owners can change it.': + 'Durée de conservation de l’activité. Seul le propriétaire peut la modifier.', 'Keep activity for': 'Conserver l’activité pendant', '90 days': '90 jours', '365 days': '365 jours', diff --git a/web/src/views/HomeView.vue b/web/src/views/HomeView.vue index 3137dc1..879fa2b 100644 --- a/web/src/views/HomeView.vue +++ b/web/src/views/HomeView.vue @@ -206,167 +206,164 @@ onMounted(loadArticles) @dismiss="dismissBanner" /> -
- -
- - - - - - - - - - -
+
+ + + + + + + + + + + + +
+
+ +
+
+

+ {{ t('Connect your website') }} +

+

+ {{ + t( + 'Your site reads published articles from RenewCMS. Share this guide with whoever builds it.', + ) + }} +

+ + {{ t('Open the guide') }} +
diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index 68b04ce..bf76046 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -430,7 +430,7 @@ onBeforeUnmount(() => {
- {{ t('Version') }} + {{ t('Version') }} { expect(router.currentRoute.value.path).toBe('/home') }) - it('clamps a long sentence to two lines', async () => { + it('truncates a long sentence on one line', async () => { const items = [makeItem({ id: 1, target: target('article', '8', 'T'.repeat(150)) })] await mountHome('owner', { [ACTIVITY_ROUTE]: () => page(items), [ARTICLES_ROUTE]: () => response(200, articles), }) - expect(rows()[0]!.find('.line-clamp-2').exists()).toBe(true) + expect(rows()[0]!.find('[data-testid="recent-activity-sentence"]').classes()).toContain( + 'truncate', + ) expect(rows()[0]!.find('[data-testid="recent-activity-meta"]').classes()).toContain( 'truncate', ) @@ -322,22 +324,19 @@ describe('Home card and article list are independent', () => { }) }) -describe('Home layout is two columns from lg and stacked below', () => { - it('uses the lg two-column grid with the 420 px column and no max-width cap', async () => { +describe('Home layout puts Recent activity full width below Recently updated', () => { + it('renders the section after the recent list inside the same single column', async () => { await mountHome('owner', { [ACTIVITY_ROUTE]: () => page(recentItems(1)), [ARTICLES_ROUTE]: () => response(200, articles), }) - const grid = card().element.parentElement! - expect(grid.className).toContain('lg:grid-cols-[minmax(0,1fr)_--spacing(105)]') - expect(grid.className).toContain('lg:items-start') - expect(grid.className).toContain('gap-4') - const capped = [grid, grid.parentElement!].some((element) => - /(^|\s)max-w-/.test(element.className), - ) - expect(capped).toBe(false) - expect(card().classes()).toContain('lg:order-2') + const column = card().element.parentElement! + expect(column.className).not.toContain('grid-cols') + const list = column.querySelector('[data-testid="recent-list"]')! + expect( + list.compareDocumentPosition(card().element) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() }) }) diff --git a/web/visual/check.ts b/web/visual/check.ts index 47cda7e..8a266bc 100644 --- a/web/visual/check.ts +++ b/web/visual/check.ts @@ -26,14 +26,13 @@ interface Screen { sources: Source[] } -const cleanupBoard = 'RenewCMS Screens Media cleanup.dc.html' -const activityBoard = 'RenewCMS Screens Activity.dc.html' +const officeBoard = 'RenewCMS Screens Back-office.dc.html' const extraSources: Record = { activity: [ { - board: activityBoard, - group: 'Details slide-over', + board: officeBoard, + group: 'Activity details', states: [ 'Edit with a diff', 'Settings diff, long text', @@ -45,7 +44,7 @@ const extraSources: Record = { alias: {}, }, { - board: activityBoard, + board: officeBoard, group: 'My activity', states: ['Default', 'Empty', 'Details'], alias: {}, @@ -53,8 +52,8 @@ const extraSources: Record = { ], settings: [ { - board: cleanupBoard, - group: 'Settings', + board: officeBoard, + group: 'Settings: Media cleanup', states: [ 'Default: off, never run', 'On: 30 days, last run', @@ -69,8 +68,8 @@ const extraSources: Record = { alias: {}, }, { - board: cleanupBoard, - group: 'Dialog', + board: officeBoard, + group: 'Purge dialog', states: [ 'Loading preview', 'Confirm', @@ -84,23 +83,18 @@ const extraSources: Record = { alias: {}, }, { - board: cleanupBoard, - group: 'Results', + board: officeBoard, + group: 'Purge results', states: ['Success', 'Success, automatic purge on'], alias: {}, }, ], media: [ { - board: cleanupBoard, - group: 'Media', - states: [ - 'Duplicates tab with the button', - 'Dialog from Media', - 'Member: button hidden', - 'Offline: button disabled', - ], - alias: { 'Offline: button disabled': 'Offline' }, + board: officeBoard, + group: 'Media duplicates entry', + states: ['Duplicates tab with the button', 'Duplicates tab: offline'], + alias: {}, }, ], } diff --git a/web/visual/states.ts b/web/visual/states.ts index 7c1badb..db5c69d 100644 --- a/web/visual/states.ts +++ b/web/visual/states.ts @@ -165,11 +165,6 @@ const verifiedSession: ApiMock = { } const updateRunningVersion = 'v2.2.0-beta' -const newCardAbsentReason = 'new card absent from board frame (dashed placeholder)' -const homeActivityMock: ApiMock = { - status: 200, - body: { items: [], total: 0, page: 1, size: 5 }, -} const workspaceSettingsBody = { version: updateRunningVersion, activity_retention_days: 90, @@ -304,6 +299,7 @@ function updateApi( return { 'GET /v1/auth/me': role === 'owner' ? verifiedSession : adminSession, 'GET /v1/settings/': { status: 200, body: workspaceSettingsBody }, + [purgeSettingsPath]: settingsBody(purgeSettingsBody()), 'GET /v1/system/update': { status: 200, body: status }, 'POST /v1/system/update/check': check, } @@ -2392,7 +2388,10 @@ function cleanupApi( return { 'GET /v1/auth/me': sessionWithRole(role), 'GET /v1/settings/': { status: 200, body: workspaceSettingsBody }, - 'GET /v1/system/update': { status: 200, hang: true }, + 'GET /v1/system/update': { + status: 200, + body: updateStatusBody({ checked_at: '2026-09-10T09:41:00Z', can_apply: true }), + }, [purgeSettingsPath]: settings, ...overrides, } @@ -2402,12 +2401,47 @@ function settingsBody(body: unknown): ApiMock { return { status: 200, body } } +async function scrollSettingsToCleanup(page: Page) { + const top = (page.viewportSize()?.width ?? 1440) < 768 ? 480 : 300 + await page.evaluate((top) => { + const card = document.querySelector('[data-testid="settings-card"]') + if (card?.parentElement && !card.parentElement.querySelector('[data-scroll-room]')) { + const room = document.createElement('div') + room.setAttribute('data-scroll-room', '') + room.style.cssText = 'flex-shrink:0;height:1200px' + card.parentElement.append(room) + } + let node: HTMLElement | null = card + while (node) { + if ( + node.scrollHeight > node.clientHeight && + /auto|scroll/.test(getComputedStyle(node).overflowY) + ) { + node.scrollTop = top + return + } + node = node.parentElement + } + window.scrollTo(0, top) + }, top) +} + function cleanupState( api: Record, steps?: (page: Page) => Promise, exception?: DiffException, ): VisualState { - return { path: '/settings', api, steps, exception } + return { + path: '/settings', + now: boardNow, + api, + exception, + steps: async (page) => { + await scrollSettingsToCleanup(page) + await steps?.(page) + await scrollSettingsToCleanup(page) + }, + } } async function openCleanupDialog(page: Page) { @@ -2500,11 +2534,7 @@ const cleanupStates: Record = { await page.getByText('Enter a whole number of days between 1 and 365.').waitFor() }), 'Read-only (non-admin)': cleanupState(cleanupApi(settingsBody(purgeOnBody), {}, 'member')), - 'Loading skeleton': cleanupState(cleanupApi({ status: 200, hang: true }), undefined, { - maxPercent: 17.5, - reason: newCardAbsentReason, - approvedBy: 'fallen', - }), + 'Loading skeleton': cleanupState(cleanupApi({ status: 200, hang: true })), Offline: cleanupState(cleanupApi(settingsBody(purgeOnBody)), goOffline), 'Loading preview': cleanupState( cleanupDialogApi({ [purgePreviewPath]: { status: 200, hang: true } }), @@ -2579,19 +2609,7 @@ const mediaPurgeStates: Record = { undefined, mediaDuplicatesPath, ), - 'Dialog from Media': mediaState( - mediaDuplicatesApi(undefined, { - [purgePreviewPath]: { status: 200, body: purgePreviewBody() }, - }), - openMediaPurgeDialog, - mediaDuplicatesPath, - ), - 'Member: button hidden': mediaState( - mediaDuplicatesApi(undefined, { 'GET /v1/auth/me': sessionWithRole('member') }), - undefined, - mediaDuplicatesPath, - ), - 'Offline: button disabled': mediaState(mediaDuplicatesApi(), goOffline, mediaDuplicatesPath), + 'Duplicates tab: offline': mediaState(mediaDuplicatesApi(), goOffline, mediaDuplicatesPath), } const activityOwnerSession: ApiMock = { @@ -2869,6 +2887,25 @@ const activityBoardEvents = [ ), ] +const homeActivityTimes = [ + '2026-09-10T08:58:00Z', + '2026-09-10T08:31:00Z', + '2026-09-10T07:55:00Z', + '2026-09-10T06:55:00Z', + '2026-09-10T02:00:00Z', +] +const homeActivityMock: ApiMock = { + status: 200, + body: { + items: activityBoardEvents + .slice(0, 5) + .map((event, index) => ({ ...event, created_at: homeActivityTimes[index] })), + total: 5, + page: 1, + size: 5, + }, +} + function activityPage(items: unknown[], total: number) { return { items, total, page: 1, size: 25 } } @@ -3121,11 +3158,6 @@ export const visualStates: Record> = { 'Up to date': { path: '/settings', skip: 'clock-sensitive' }, Checking: { path: '/settings', - exception: { - maxPercent: 11.3, - reason: newCardAbsentReason, - approvedBy: 'fallen', - }, api: updateApi('owner', updateNeverChecked(), updateCheckHangs), }, 'Check failed': { path: '/settings', skip: 'clock-sensitive' }, @@ -3134,20 +3166,10 @@ export const visualStates: Record> = { Downloading: { path: '/settings', api: updateApi('owner', updateDownloading()), - exception: { - maxPercent: 10.6, - reason: newCardAbsentReason, - approvedBy: 'fallen', - }, }, Verifying: { path: '/settings', api: updateApi('owner', updateJob('verifying')), - exception: { - maxPercent: 10.8, - reason: newCardAbsentReason, - approvedBy: 'fallen', - }, }, 'Ready to install': { path: '/settings', api: updateApi('owner', updateJob('ready')) }, 'Restart: confirm': { @@ -3188,29 +3210,14 @@ export const visualStates: Record> = { }, 'Download failed': { path: '/settings', - exception: { - maxPercent: 10.9, - reason: newCardAbsentReason, - approvedBy: 'fallen', - }, api: updateApi('owner', updateJob('failed', { error: 'download' })), }, 'Verification failed': { path: '/settings', - exception: { - maxPercent: 10.5, - reason: newCardAbsentReason, - approvedBy: 'fallen', - }, api: updateApi('owner', updateJob('failed', { error: 'verification' })), }, 'Too many requests (429)': { path: '/settings', - exception: { - maxPercent: 11.3, - reason: newCardAbsentReason, - approvedBy: 'fallen', - }, api: updateApi('owner', updateNeverChecked(), { status: 429, headers: { 'Retry-After': '42' }, @@ -3218,22 +3225,14 @@ export const visualStates: Record> = { }, 'Not prepared': { path: '/settings', - exception: { - maxPercent: 11.9, - reason: newCardAbsentReason, - approvedBy: 'fallen', - }, - api: updateApi('owner', updateAvailable()), + now: boardNow, + api: updateApi('owner', { ...updateAvailable(), checked_at: '2026-09-10T09:41:00Z' }), }, 'Not prepared: command copied': { path: '/settings', - exception: { - maxPercent: 11.9, - reason: newCardAbsentReason, - approvedBy: 'fallen', - }, origin: integrationOrigin, - api: updateApi('owner', updateAvailable()), + now: boardNow, + api: updateApi('owner', { ...updateAvailable(), checked_at: '2026-09-10T09:41:00Z' }), steps: async (page) => { await page.getByRole('button', { name: 'Copy', exact: true }).click() await page.mouse.move(0, 0) @@ -3556,11 +3555,6 @@ export const visualStates: Record> = { home: { Default: { path: '/home', - exception: { - maxPercent: 13.5, - reason: newCardAbsentReason, - approvedBy: 'fallen', - }, now: boardNow, api: { 'GET /v1/auth/me': verifiedSession, @@ -3571,26 +3565,16 @@ export const visualStates: Record> = { }, Loading: { path: '/home', - exception: { - maxPercent: 18.6, - reason: newCardAbsentReason, - approvedBy: 'fallen', - }, now: boardNow, api: { 'GET /v1/auth/me': verifiedSession, - 'GET /v1/activity': homeActivityMock, + 'GET /v1/activity': { ...homeActivityMock, hang: true }, 'GET /v1/system/update': { status: 200, body: updateStatusBody() }, 'GET /v1/article/': { status: 200, body: sixArticlesFixture, hang: true }, }, }, 'Empty, first run': { path: '/home', - exception: { - maxPercent: 11.4, - reason: newCardAbsentReason, - approvedBy: 'fallen', - }, now: boardNow, api: { 'GET /v1/auth/me': verifiedSession, @@ -3601,11 +3585,6 @@ export const visualStates: Record> = { }, Error: { path: '/home', - exception: { - maxPercent: 25.3, - reason: newCardAbsentReason, - approvedBy: 'fallen', - }, now: boardNow, api: { 'GET /v1/auth/me': verifiedSession, @@ -3616,11 +3595,7 @@ export const visualStates: Record> = { }, 'Update available (owner)': { path: '/home', - exception: { - maxPercent: 11.9, - reason: newCardAbsentReason, - approvedBy: 'fallen', - }, + now: boardNow, api: { 'GET /v1/auth/me': verifiedSession, 'GET /v1/activity': homeActivityMock, @@ -3630,11 +3605,7 @@ export const visualStates: Record> = { }, 'Update ready to install (owner)': { path: '/home', - exception: { - maxPercent: 11.9, - reason: newCardAbsentReason, - approvedBy: 'fallen', - }, + now: boardNow, api: { 'GET /v1/auth/me': verifiedSession, 'GET /v1/activity': homeActivityMock, @@ -4176,27 +4147,3 @@ export const visualStates: Record> = { media: { ...mediaStates, ...mediaPurgeStates }, 'media-picker': pickerStates, } - -const hideCleanupCard = '[data-testid="media-cleanup-card"] { display: none !important }' - -function withoutCleanupCard(state: VisualState): VisualState { - if (state.skip) { - return state - } - return { - ...state, - api: { ...state.api, [purgeSettingsPath]: { status: 200, hang: true } }, - steps: async (page) => { - await page.addStyleTag({ content: hideCleanupCard }) - await state.steps?.(page) - await page.addStyleTag({ content: hideCleanupCard }) - await page.mouse.move(0, 0) - }, - } -} - -for (const [name, state] of Object.entries(visualStates.settings!)) { - if (!(name in cleanupStates)) { - visualStates.settings![name] = withoutCleanupCard(state) - } -}