chore: 2 big design passes to harmonize and fix stuff

This commit is contained in:
Florian Sylvain committed 2026-10-11 07:45:17 +02:00
1 parent 67b3ff03d0
commit 5e05b606a7
43 files changed
+2167 -4183

No files matched your search

@@ -18,7 +18,7 @@
<div style="display:flex;align-items:flex-end;gap:32px;max-width:1400px">
<div style="flex:1;display:flex;flex-direction:column;gap:10px">
<h1 style="margin:0;font:600 36px/44px 'IBM Plex Sans',sans-serif;letter-spacing:-0.02em;color:var(--ui-text-highlighted)">Screens: back-office and global</h1>
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">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 <a href="RenewCMS Screens Auth.dc.html">the auth board</a>.</p>
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">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 <a href="RenewCMS Screens Auth.dc.html">the auth board</a>.</p>
</div>
<div role="radiogroup" aria-label="Theme" style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)">
<sc-for list="{{ modes }}" as="m" hint-placeholder-count="3">
@@ -39,19 +39,32 @@
<div style="display:flex;flex-direction:column;gap:12px">
<div style="display:flex;flex-direction:column;gap:2px;max-width:1040px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.name }}</span><span style="font-size:13px;color:var(--ui-text-muted)">{{ st.trigger }}</span></div>
<div style="display:flex;gap:24px;align-items:flex-start">
<div style="width:720px;height:450px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1440px;height:900px;zoom:0.5"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isAct }}"><dc-import name="ActivityFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if></div></div>
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isAct }}"><dc-import name="ActivityFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if></div></div>
<div style="width:720px;height:450px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1440px;height:900px;zoom:0.5"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isAct }}"><dc-import name="ActivityFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isVar }}"><dc-import name="VariantsFrame" s="{{ st.vs }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isImg }}"><div style="width:1440px;height:900px;overflow:hidden;box-sizing:border-box;background:var(--ui-bg-muted);padding:40px 280px"><dc-import name="IntegrationImages" s="{{ st.img }}" mobile="{{ false }}" hint-size="880px,820px"></dc-import></div></sc-if></div></div>
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><sc-if value="{{ st.notMedia }}"><dc-import name="AppFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isAct }}"><dc-import name="ActivityFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isVar }}"><dc-import name="VariantsFrame" s="{{ st.vm }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isImg }}"><div style="width:390px;height:844px;overflow:hidden;box-sizing:border-box;background:var(--ui-bg-muted);padding:16px"><dc-import name="IntegrationImages" s="{{ st.img }}" mobile="{{ true }}" hint-size="358px,800px"></dc-import></div></sc-if></div></div>
</div>
</div>
</sc-for>
</div>
<sc-if value="{{ g.table }}"><div style="display:flex;flex-direction:column;gap:8px;max-width:1240px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.tableTitle }}</span><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><div style="display:grid;grid-template-columns:90px 240px minmax(0,1fr);padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Status</span><span>Where</span><span>Copy</span></div><sc-for list="{{ g.table }}" as="r" hint-placeholder-count="3"><div style="display:grid;grid-template-columns:90px 240px minmax(0,1fr);gap:0;padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-toned);padding-right:16px">{{ r.b }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.c }}</span></div></sc-for></div></div></sc-if>
<sc-for list="{{ g.tables }}" as="t" hint-placeholder-count="1">
<div style="display:flex;flex-direction:column;gap:8px;max-width:1240px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ t.title }}</span><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><div style="display:grid;grid-template-columns:{{ t.tpl }};padding:8px 12px;gap:16px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><sc-for list="{{ t.cols }}" as="c" hint-placeholder-count="3"><span>{{ c }}</span></sc-for></div><sc-for list="{{ t.rows }}" as="r" hint-placeholder-count="3"><div style="display:grid;grid-template-columns:{{ t.tpl }};gap:16px;padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><sc-for list="{{ r }}" as="cell" hint-placeholder-count="3"><span style="color:{{ cell.fg }};font-weight:{{ cell.fw }};font-family:{{ cell.ff }};font-size:{{ cell.fs }};text-wrap:pretty;min-width:0">{{ cell.t }}</span></sc-for></div></sc-for></div></div>
</sc-for>
</section>
</sc-for>
<section data-screen-label="States matrix: controls" style="display:flex;flex-direction:column;gap:20px"><div style="display:flex;flex-direction:column;gap:6px;max-width:1100px"><h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">States matrix: controls</h2><p style="margin:4px 0 0;font-size:15px;line-height:24px;color:var(--ui-text-toned);text-wrap:pretty">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.</p></div><div style="display:flex;gap:40px;align-items:flex-start;flex-wrap:wrap"><dc-import name="StatesMatrix" mobile="{{ false }}" hint-size="1240px,2400px"></dc-import><dc-import name="StatesMatrix" mobile="{{ true }}" hint-size="390px,2400px"></dc-import></div></section>
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px"><h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Activity: copy per state</h2><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><sc-for list="{{ spCopy }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for></div></section>
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px"><h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Activity: component inventory (Nuxt UI v4)</h2><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><sc-for list="{{ spComps }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for></div></section>
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px"><h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Activity: API calls</h2><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><sc-for list="{{ spApi }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for></div></section>
<sc-for list="{{ specs }}" as="sp" hint-placeholder-count="5">
<section data-screen-label="{{ sp.label }}" style="display:flex;flex-direction:column;gap:16px;max-width:1240px">
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ sp.title }}</h2>
<p style="margin:0;font-size:15px;line-height:24px;color:var(--ui-text-toned);text-wrap:pretty">{{ sp.note }}</p>
<sc-for list="{{ sp.tables }}" as="t" hint-placeholder-count="1">
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><div style="display:grid;grid-template-columns:{{ t.tpl }};padding:8px 12px;gap:16px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><sc-for list="{{ t.cols }}" as="c" hint-placeholder-count="3"><span>{{ c }}</span></sc-for></div><sc-for list="{{ t.rows }}" as="r" hint-placeholder-count="3"><div style="display:grid;grid-template-columns:{{ t.tpl }};gap:16px;padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><sc-for list="{{ r }}" as="cell" hint-placeholder-count="3"><span style="color:{{ cell.fg }};font-weight:{{ cell.fw }};font-family:{{ cell.ff }};font-size:{{ cell.fs }};text-wrap:pretty;min-width:0">{{ cell.t }}</span></sc-for></div></sc-for></div>
</sc-for>
</section>
</sc-for>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;system&quot;,&quot;light&quot;,&quot;dark&quot;],&quot;default&quot;:&quot;system&quot;,&quot;tsType&quot;:&quot;'system' | 'light' | 'dark'&quot;}}">
@@ -79,7 +92,7 @@ class Component extends DCLogic {
if (p.theme !== this.props.theme && this.props.theme) this.setState({ mode: this.props.theme })
this.apply()
}
groups() {
boGroups() {
const base = [
{ t: 'How we price a bathroom renovation', on: true, c: '12 Mar 2026', u: '2 hours ago' },
{ t: 'Choosing lime plaster for old stone walls', on: false, c: '9 Sep 2026', u: 'Yesterday' },
@@ -88,6 +101,14 @@ class Component extends DCLogic {
{ t: 'Why we reuse original floorboards', on: true, c: '14 Jun 2026', u: '18 Aug 2026' },
{ t: 'Our first passive-house retrofit', on: true, c: '3 Apr 2026', u: '2 Jul 2026' }
]
const more = [
{ t: 'Repointing a gable wall in lime mortar', on: true, c: '21 May 2026', u: '4 Jun 2026' },
{ t: 'What a heat pump survey includes', on: false, c: '30 Jul 2026', u: '1 Aug 2026' },
{ t: 'Choosing a damp-proof course', on: true, c: '7 Feb 2026', u: '19 Apr 2026' },
{ t: 'Reglazing sash windows without replacing them', on: true, c: '16 Jan 2026', u: '2 Mar 2026' }
]
const pgRows = n => [...base, ...more].slice(0, n).map(r => ({ ...r }))
const PG = (g, o = {}) => { const rn = o.rows || 10; const { rows: _r, ...rest } = o; const { list: xl, ...ro } = rest; return L({ ...ro, list: { rows: pgRows(rn), pg: { per: 25, total: 312, pages: 13, ...g }, ...(xl || {}) } }) }
const rows = (patch = {}) => base.map((r, i) => patch[i] ? { ...r, ...patch[i] } : r)
const T = (tone, icon, title, text, action) => ({ tone, icon, title, text, action })
const withM = (st, mo = {}) => ({ ...st, m: { ...st, ...mo } })
@@ -96,10 +117,11 @@ class Component extends DCLogic {
const recent = base.slice(0, 5).map(r => ({ ...r, draft: !r.on }))
const list = { kind: 'articles', nav: 'articles', crumbs: ['Articles'], mTitle: 'Articles' }
const L = (o = {}) => ({ ...list, ...o, list: { rows: rows(), ...(o.list || {}) } })
const nm = (o = {}) => ({ 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: 'Create draft', wide: true, ...o, fields: o.fields || [{ label: 'Title', ph: 'e.g. Choosing tiles for a small bathroom', count: '0 / 200', help: '3 to 200 characters.', focus: true }, { label: 'First paragraph', ph: 'Start writing. You\'ll continue in the editor.', area: true, help: 'You can format it and add images in the editor.' }] })
const nf = (title, body, extra = {}) => [{ label: 'Title', value: title, count: [...title].length + ' / 200', ...(extra.t || {}) }, { label: 'First paragraph', value: body, area: true, ...(extra.b || {}) }]
const nm = (o = {}) => ({ 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: 'Create draft', wide: true, ...o, fields: o.fields || [{ label: 'Title', ph: 'e.g. Choosing tiles for a small bathroom', count: '0 / 200', help: '3 to 200 characters.', focus: true }] })
const nf = (title, body, extra = {}) => [{ label: 'Title', value: title, count: [...title].length + ' / 200', ...(extra.t || {}) }]
const ed = { kind: 'editor', nav: 'articles', crumbs: ['Articles', 'How we price a bathroom renovation'], mTitle: 'Edit article', mLead: 'arrow-left', mAction: 'ellipsis' }
const E = (o = {}, extra = {}) => withM({ ...ed, ...extra, ed: { status: 'draft', save: 'saved', ...o } })
const lk = (o = {}) => ({ title: 'Insert link', desc: 'Links open in the same tab unless you turn on a new tab.', close: true, cancel: 'Cancel', confirm: 'Insert link', ...o, toggle: o.toggle === undefined ? undefined : { j: o.toggle ? 'flex-end' : 'flex-start', bg: o.toggle ? 'var(--ui-primary)' : 'var(--ui-border-accented)' } })
const cropM = crop => ({ title: 'Crop and resize', desc: 'north-wall.jpg · 3.9 MB', close: true, crop, cancel: 'Choose another file', confirm: 'Upload image' })
const del = (loading, err) => ({ title: 'Delete this article?', desc: '"Winter checklist for timber windows" and its 3 images will be deleted. This can\'t be undone.', cancel: 'Cancel', confirm: loading ? 'Deleting…' : 'Delete article', tone: 'danger', loading, notice: err })
const people = [
@@ -183,9 +205,22 @@ class Component extends DCLogic {
withM({ ...L({ modal: del(false, 'Couldn\'t delete the article, so it\'s still there. Try again in a moment.') }), name: 'Delete: failed', trigger: '500, a partial failure where the article is kept. The error shows inside the dialog.' }),
withM({ ...L({ list: { counts: [5, 4, 1], rows: rows().filter((r, i) => i !== 3) }, toasts: [T('success', 'circle-check', 'Article deleted', '"Winter checklist for timber windows" and its images are gone.')] }), name: 'Deleted', trigger: '204: the row is removed and focus moves to the next row. This is also where a delete from the editor ends up.' })
] },
{ label: 'New article', title: 'New article', route: 'modal from anywhere · /article/new', api: 'POST /v1/article/', note: 'Recommendation: a modal. It needs two fields, and keeping the page underneath means cancelling costs nothing. /article/new opens the same modal over the article list, so it can be linked to and the back button closes it. A successful create always makes a draft and goes to the editor.', states: [
{ label: 'Articles: pagination', title: 'Articles: pagination', route: '/articles?page=2&size=50', api: 'GET /v1/article/?page=&size=&q=&status= (proposed: needs backend page, size, total in the response)', note: 'Page and page size live in the URL as page and size. Defaults (page 1, size 25) are omitted. Allowed sizes 5, 25, 50, 100. Search, tab or sort changes reset to page 1. Rows equal the page size; the frames draw at most 10 rows. The paginator hides when everything fits on one page; the summary stays. Mobile shows Previous, "Page N of M" and Next. Keyboard: Tab reaches page size, first, previous, each page, next, last; Enter or Space activates; the current page has aria-current="page"; ArrowDown opens the page-size list, Enter selects, Esc closes.', states: [
withM({ ...PG({ page: 1 }), name: 'First page', trigger: '/articles. Previous and First are disabled. Summary "Showing 1–25 of 312".' }),
withM({ ...PG({ page: 7 }), name: 'Middle page', trigger: '/articles?page=7. Current page uses --rc-sand and --rc-on-sand; ellipses mark skipped ranges.' }),
withM({ ...PG({ page: 13 }), name: 'Last page', trigger: '/articles?page=13. Next and Last are disabled. Summary "Showing 301–312 of 312".' }),
withM({ ...PG({ page: 1, total: 6, pages: 1 }, { rows: 6 }), name: 'Single page', trigger: 'Total fits in one page. Paginator hidden; summary "Showing 1–6 of 6" and the page-size select stay.' }),
withM({ ...PG({ page: 6, loading: 7 }, { list: { loading: true } }), name: 'Loading next page', trigger: 'Next was pressed. Skeleton rows keep the table height, controls are disabled, summary reads "Loading page 7…".' }),
withM({ ...PG({ page: 6, err: 'Couldn\'t load page 7. Showing page 6.' }), name: 'Error loading a page', trigger: '500 or network failure on page change. Previous rows stay; Retry sits in the notice.' }),
withM({ ...PG({ page: 13, note: 'That page doesn\'t exist. Showing the last page.' }), name: 'Page out of range', trigger: '/articles?page=99 is replaced with ?page=13 and this notice is shown.' }),
withM({ ...PG({ page: 1, total: 14, pages: 2 }, { list: { q: 'plaster' } }), name: 'Search resets to page 1', trigger: 'Searching "plaster" from page 7 goes to /articles?q=plaster (page omitted). Same for tab, sort and size changes.' }),
withM({ ...L({ list: { q: 'tiles', empty: { icon: 'search', title: 'No articles match "tiles"', text: 'Check the spelling, or search for a shorter part of the title.', action: 'Clear search' } } }), name: 'Empty after search', trigger: 'Zero results. Paginator and summary are hidden.' }),
withM({ ...PG({ page: 2, sizeOpen: true }), name: 'Page size menu', trigger: 'The select opens upward. 5, 25, 50 or 100; the chosen size is saved as size and the page resets to 1.' }),
withM({ ...PG({ page: 7, focus: 'next' }), name: 'Focus-visible', trigger: 'Keyboard focus on Next: 2 px --ui-primary outline, 2 px offset. Same ring on every paginator control.' })
] },
{ label: 'New article', title: 'New article', route: 'modal from anywhere · /article/new', api: 'POST /v1/article/', note: 'Recommendation: a modal. Title is the only field. The body is not asked for here; it is written in the editor, which opens straight after. Keeping the page underneath means cancelling costs nothing. /article/new opens the same modal over the article list, so it can be linked to and the back button closes it. A successful create always makes a draft and goes to the editor.', states: [
withM({ ...L({ modal: nm() }), name: 'Default', trigger: '"New article" from Home, Articles or the empty state.' }),
withM({ ...L({ modal: nm({ fields: nf('Ti', '', { t: { err: 'Use at least 3 characters.', countErr: true }, b: { err: 'Add a first paragraph. It can be a single sentence.' } }) }) }), name: 'Validation', trigger: 'On submit or blur. Nothing is sent.' }),
withM({ ...L({ modal: nm({ fields: nf('Ti', '', { t: { err: 'Use at least 3 characters.', countErr: true } }) }) }), name: 'Validation', trigger: 'On submit or blur. Nothing is sent.' }),
withM({ ...L({ modal: nm({ loading: true, confirm: 'Creating…', fields: nf('Choosing tiles for a small bathroom', 'Small rooms show every grout line, so we start with the floor.') }) }), name: 'Submitting', trigger: 'Request in flight. Fields are disabled and the dialog can\'t be dismissed.' }),
withM({ ...L({ modal: nm({ notice: 'Something in the form isn\'t valid. Check the highlighted fields.', fields: nf('Choosing tiles for a small bathroom', 'Small rooms show every grout line, so we start with the floor.', { t: { err: 'Check this title and try again.' } }) }) }), name: 'Title rejected (400)', trigger: 'A server 400 that got past the browser checks. The raw error isn\'t shown.' }),
withM({ ...L({ modal: nm({ notice: 'Couldn\'t create the article. Your text is still here. Try again in a moment.', fields: nf('Choosing tiles for a small bathroom', 'Small rooms show every grout line, so we start with the floor.') }) }), name: 'Error', trigger: '500 or network failure.' })
@@ -196,9 +231,28 @@ class Component extends DCLogic {
E({ save: 'dirty' }, { name: 'Unsaved changes', trigger: 'Any edit. Publish becomes "Save and publish". Leaving asks for confirmation.' }),
E({ save: 'saving' }, { name: 'Saving', trigger: 'Save, or Ctrl/⌘ S.' }),
E({ save: 'error' }, { name: 'Save failed', trigger: '500 or network failure. The changes are kept and Retry resends them.' }),
E({ save: 'dirty', len: 9240 }, { name: 'Near the limit', trigger: '90% of 10 000 HTML characters.' }),
E({ save: 'dirty', len: 10214 }, { name: 'Too long', trigger: 'Over 10 000. Save and Publish are disabled until it\'s shorter.' }),
E({ save: 'dirty', len: 120000 }, { name: 'Very long article', trigger: 'No length limit. The count stays neutral and correct at 120,000 characters. Save and Publish stay enabled.' }),
E({ save: 'saving', len: 120000, note: 'Saving 120,000 characters. This can take a few seconds.' }, { name: 'Very long article: saving', trigger: 'Save with large content. The status shows the spinner and the page stays editable.' }),
E({ save: 'saved', len: 120000 }, { name: 'Very long article: saved', trigger: '200. Status reads Saved with the time. The count is unchanged.' }),
E({ save: 'error', len: 120000, note: 'Couldn\'t save. Your text is kept. Retry.' }, { name: 'Very long article: save failed', trigger: '500, 413 or network failure. Nothing is lost; Retry resends the same content.' }),
E({ preview: true }, { name: 'Preview', trigger: 'The Preview switch. Shows the title and body in the content scale, the way a public site might.' }),
E({ save: 'dirty' }, { name: 'Link: create from selection', trigger: 'Ctrl/⌘ K or the link button with text selected. Link text is prefilled from the selection; URL has focus.', modal: lk({ fields: [{ label: 'Link text', value: 'timber windows' }, { label: 'URL', ph: 'https://example.com, /page, mailto: or #anchor', focus: true }], toggle: 0 }) }),
E({ save: 'dirty' }, { name: 'Link: create with no selection', trigger: 'Nothing selected. Link text is empty and has focus.', modal: lk({ fields: [{ label: 'Link text', ph: 'What the reader sees', focus: true }, { label: 'URL', ph: 'https://example.com, /page, mailto: or #anchor' }], toggle: 0 }) }),
E({ save: 'dirty' }, { name: 'Link: edit existing', trigger: 'Edit in the link popover, or Ctrl/⌘ K inside a link. Prefilled; "Remove link" is offered.', modal: lk({ title: 'Edit link', confirm: 'Save link', fields: [{ label: 'Link text', value: 'timber windows' }, { label: 'URL', value: '/articles/winter-checklist', focus: true }], toggle: 1, removeLink: true }) }),
E({ save: 'dirty' }, { name: 'Link: invalid URL', trigger: 'On submit or blur. Nothing is inserted.', modal: lk({ fields: [{ label: 'Link text', value: 'timber windows' }, { label: 'URL', value: 'www.example', err: 'Enter a valid URL. Use http(s), mailto, a path or #anchor.' }], toggle: 0 }) }),
E({ save: 'dirty' }, { name: 'Link: empty text', trigger: 'On submit with empty link text.', modal: lk({ fields: [{ label: 'Link text', err: 'Enter the link text.', ph: 'What the reader sees' }, { label: 'URL', value: '#measuring' }], toggle: 0 }) }),
E({ save: 'dirty' }, { name: 'Link: long URL', trigger: 'The field truncates in the middle of the line; the full value is available by scrolling the field and in the title attribute.', modal: lk({ title: 'Edit link', confirm: 'Save link', fields: [{ label: 'Link text', value: 'planning guidance' }, { label: 'URL', value: 'https://www.example.org/planning/guidance/listed-buildings/windows-and-doors/…/2026-update.pdf', focus: true }], toggle: 1, removeLink: true }) }),
E({ save: 'dirty', lk: { on: true } }, { name: 'Link button: default', trigger: 'Toolbar button, icon link, after the list buttons. Desktop 28 px, mobile 40 px. Text colour --ui-text-toned. The toolbar scrolls sideways on mobile (see "Toolbar overflow").' }),
E({ save: 'dirty', lk: { on: true, btn: 'hover' } }, { name: 'Link button: hover and tooltip', trigger: 'Pointer or keyboard focus after 400 ms. Tooltip "Insert link (Ctrl+K)"; on macOS it reads "Insert link (⌘K)". Touch has no tooltip.' }),
E({ save: 'dirty', lk: { on: true, btn: 'focus' } }, { name: 'Link button: focus-visible', trigger: '2 px --ui-primary outline, 1 px offset inside the toolbar. Enter or Space opens the link dialog.' }),
E({ save: 'dirty', lk: { on: true, btn: 'active' } }, { name: 'Link button: active (cursor in a link)', trigger: 'The cursor or selection is inside a link: aria-pressed true, --rc-sand fill and --rc-on-sand icon. Pressing it opens the dialog in edit mode.' }),
E({ save: 'dirty', lk: { on: true, btn: 'disabled' } }, { name: 'Link button: disabled', trigger: 'An image is selected in the editor. The button is at 40% opacity, aria-disabled, no tooltip. Ctrl/⌘ K does nothing.' }),
E({ save: 'dirty', lk: { on: true, btn: 'active', text: 'focus', pop: true } }, { name: 'Link popover and sheet', trigger: 'Click a link, or Enter on a focused link. Desktop: popover under the link with the URL and Edit, Open and Remove. Mobile (touch): bottom sheet with the link text, the URL and Edit link, Open link, Remove link at 48 px. Esc or a tap outside closes it. Remove asks for no confirmation; undo restores the link.' }),
E({ save: 'dirty', lk: { on: true } }, { name: 'Rendered link: default', trigger: 'Same as the published page: --ui-text-highlighted, 1 px underline, 3 px offset.' }),
E({ save: 'dirty', lk: { on: true, text: 'hover' } }, { name: 'Rendered link: hover', trigger: 'Underline thickens to 2 px.' }),
E({ save: 'dirty', lk: { on: true, text: 'focus' } }, { name: 'Rendered link: focus', trigger: 'Tab or arrow keys into the link: 2 px --ui-primary outline, 2 px offset.' }),
E({ save: 'dirty', lk: { on: true, text: 'selected' } }, { name: 'Rendered link: selected', trigger: 'Text selected across the link: --rc-sand fill, --rc-on-sand text.' }),
E({ save: 'dirty', lk: { on: true, shift: 740, end: true } }, { name: 'Toolbar overflow (390 px)', trigger: 'The full toolbar does not fit at 390 px. It scrolls sideways in one row (no wrap); a 24 or 32 px fade marks each hidden side. This frame is scrolled to the end, so the link, remove-formatting and Image buttons are visible. Desktop is unchanged.' }),
E({ save: 'dirty' }, { name: 'Upload: choose', trigger: 'The image button in the toolbar, or a file dropped on the editor.', modal: { title: 'Add an image', close: true, upload: { state: 'idle' }, cancel: 'Cancel' } }),
E({ save: 'dirty' }, { name: 'Upload: crop and resize', trigger: 'A JPEG, PNG or WEBP passed the checks, and nothing is sent yet. The crop starts as the whole image and the width defaults to 1600 px. SVGs skip this step.', modal: cropM({ ratio: 0, size: 1 }) }),
E({ save: 'dirty' }, { name: 'Upload: crop to a ratio', trigger: '16:9 chosen. The frame keeps the ratio while it\'s dragged or resized. Arrow keys move it; Shift and arrow keys resize it.', modal: cropM({ ratio: 2, size: 1 }) }),
@@ -313,6 +367,35 @@ class Component extends DCLogic {
Act('Error with retry', 'Request failed. The summary strip still shows if its call succeeded.', { state: 'error' }),
Act('Forbidden (403)', 'API returned 403 for this role or filter.', { state: 'forbidden', role: 'user' })
] },
{ label: 'Activity: filter cards', title: 'Activity: failed-action cards as toggles', route: '/activity', api: 'GET /v1/activity?outcome=failed · ?type=security', note: 'The Failed actions and Security events cards are toggle buttons with aria-pressed. Pressing one applies its filter and shows it in the chip row; pressing the active card again removes it. Three controls always agree: the card, the chip with its × and "Clear all". Removing the filter with any of them returns the card to its default look, removes the chip and sets the Outcome segment back to All. The active card uses --rc-sand with --rc-on-sand; the hint line changes from "Filtering. Click to clear" to "Click to clear this filter" on hover.', states: [
Act('Default, no filter', 'Both cards unpressed, no chips.', { sum: 'default' }),
Act('Card hover', 'Pointer over Failed actions. Background --ui-bg-elevated, hint "Click to filter the list".', { hover: 2, sum: 'default' }),
Act('Card focus-visible', 'Keyboard focus on the card: 2 px --ui-primary outline, 2 px offset. Enter or Space toggles.', { focus: 'card2', sum: 'default' }),
Act('Failed card active', 'aria-pressed="true". Outcome chip, Outcome segment on Failed, list reduced to failed events.', {"card":2,"count":6,"total":"6","ids":[3,9],"sum":"default"}),
Act('Active card hover', 'Hint reads "Click to clear this filter" with an × icon.', { ...{"card":2,"count":6,"total":"6","ids":[3,9],"sum":"default"}, hover: 2 }),
Act('Security card active', 'Chip "Type: Security events". The Outcome segment stays on All.', { card: 3, count: 11, total: '11', ids: [3, 4, 6, 12, 15], sum: 'default' }),
Act('Chip × hover', 'Dismissing the chip also unpresses the card.', { ...{"card":2,"count":6,"total":"6","ids":[3,9],"sum":"default"}, chipHover: 0 }),
Act('Clear all hover', 'Clears the chips, the card and every other filter together.', { ...{"card":2,"count":6,"total":"6","ids":[3,9],"sum":"default"}, clearHover: 1 }),
Act('Clear all focus-visible', 'Keyboard focus on Clear all.', { ...{"card":2,"count":6,"total":"6","ids":[3,9],"sum":"default"}, focus: 'clear' }),
Act('Card plus other filters', 'Failed card with Category and Dates. Unpressing the card removes only its chip.', { ...{"card":2,"count":6,"total":"6","ids":[3,9],"sum":"default"}, f: { cat: 'Authentication', range: '30 days' }, count: 2, total: '2', ids: [3] })
] },
{ label: 'Activity: export', title: 'Activity: export menu and dialogs', route: '/activity', api: 'GET /v1/activity/export (shipped: CSV only, synchronous streamed download, owner and admin only, 403 otherwise). Same filters as the list: q, actor, category, outcome, security, range, from, to. JSON is proposed.', note: 'Export is one menu button with two items. Export current view: the same endpoint with the active filters, across all pages. Export all activity: the same endpoint with no filters and an optional date range (from, to), default all time. There is no server job, progress or later notification. On mobile the menu and dialog are bottom sheets. Dialogs: secondary left, primary right.', states: [
Act('Menu open', 'Export pressed. Both items available. Owners and admins only; other roles never see the button.', { menu: 1 }),
Act('Menu, item hover', 'Pointer over Export all activity.', { menu: 1, mHover: 2 }),
Act('Menu, filtered view', 'Failed card applied. The first item states the matching count.', { menu: 1, ...{"card":2,"count":6,"total":"6","ids":[3,9],"sum":"default"}, menuCount: '6' }),
Act('Menu, nothing to export', 'Filters match nothing. Export current view is disabled with the reason.', { menu: 1, state: 'nomatch', f: { q: 'sitemap', cat: 'Media', out: 'Failed' }, total: '0', menuCount: '0' }),
Act('Menu, no permission', 'The role was lowered while the page was open. Item disabled with the reason; the API answers 403.', { menu: 1, noAll: 1 }),
Act('Export button focus-visible', '2 px --ui-primary outline, 2 px offset. Enter or ArrowDown opens the menu; Esc closes it.', { focus: 'export' }),
Act('Dialog: configure, CSV only', 'Export all activity…. CSV is the only format. Date range defaults to All time.', { exp: { stage: 'cfg' } }),
Act('Dialog: configure, CSV or JSON (proposed)', 'Needs the JSON backend feature. Format control appears only when it ships.', { exp: { stage: 'cfg', json: 1 } }),
Act('Dialog: preparing download', 'Request in flight. Indeterminate bar, no percentage: the server streams one file and reports no progress.', { exp: { stage: 'prep' } }),
Act('Dialog: download started', 'The browser received the file. Only Close is offered.', { exp: { stage: 'ready' } }),
Act('Dialog: failed', '500 or network failure. Retry restarts the export.', { exp: { stage: 'fail' } }),
Act('Dialog: nothing to export', 'The chosen date range has no events. Only Close.', { exp: { stage: 'empty' } }),
Act('Dialog: not allowed (403)', 'Owner or admin only.', { exp: { stage: 'denied' } }),
Act('Toast: current view', 'The response started streaming and the browser saved the file.', { toast: { t: 'Download started', d: 'activity-2026-10-06.csv is downloading.' } }),
Act('Toast: export all', 'Dialog closed after the download started.', { toast: { t: 'Download started', d: 'activity-all-2026-10-06.csv is downloading.' } })
] },
{ label: 'Activity details', title: 'Activity: details slide-over and bottom sheet', route: 'Row click', api: 'Uses the item from GET /v1/activity', states: [
Act('Edit with a diff', 'Title change and "Body edited (+312 characters)". No body text.', { panel: 2 }),
Act('Settings diff, long text', 'Long values clamp to two lines with Show more.', { panel: 11 }),
@@ -457,6 +540,288 @@ class Component extends DCLogic {
] }
])
}
groups() {
const bo = this.boGroups()
const setSt = bo.flatMap(g => g.states).find(st => st.kind === 'settings')
const vg = this.varGroups()
const vs = (g, id) => g.states.find(x => x.id === id)
const listSt = vs(vg[0], '1a')
const cacheG = vg.find(g => g.route === '/settings/media/cache')
const cacheSt = cacheG.states[0]
const mk = (id, name, trigger, s, m) => ({ id, name, trigger, isVar: true, vs: s, vm: m || s })
const drawer = (s, crumbs) => ({ ...s, crumbs, mTitle: crumbs[crumbs.length - 1], drawer: 1 })
const nav = { label: 'Settings navigation', title: 'Settings navigation: parent and child selected', route: '/settings · /settings/media/variants · /settings/media/cache', api: '', note: 'One sidebar for the whole Settings area. Settings is the parent row and expands to a Media group with Variants and Cache. Parent selected: the Settings row uses --rc-sand with --rc-on-sand. Child selected: the Settings row is not filled (--ui-text-highlighted, weight 500, chevron down) and only the child row uses --rc-sand with --rc-on-sand. Use the theme switch for dark. Hover, focus-visible and active on a selected row keep --rc-sand and --rc-on-sand; see foundations.md.', states: [
{ ...setSt, notMedia: true, id: 'N1', name: 'N1 · Parent selected', trigger: '/settings. Settings row filled, Media group visible, children unselected.', m: { ...setSt.m } },
mk('N2', 'N2 · Child selected: Variants', '/settings/media/variants. Settings row unfilled and bold, Variants filled.', listSt.s, listSt.m),
mk('N3', 'N3 · Child selected: Cache', '/settings/media/cache. Settings row unfilled and bold, Cache filled.', cacheSt.s, cacheSt.m),
mk('N4', 'N4 · Mobile drawer, parent selected', 'Menu button on /settings. Drawer rows are 44 px tall.', drawer(listSt.s, ['Settings']), drawer(listSt.m, ['Settings'])),
mk('N5', 'N5 · Mobile drawer, child selected', 'Menu button on /settings/media/variants.', drawer(listSt.s, ['Settings', 'Media', 'Variants']), drawer(listSt.m, ['Settings', 'Media', 'Variants']))
] }
const adapt = g => ({ ...g, title: 'Settings · ' + g.title, states: g.states.map(st => ({ id: 'V' + st.id, name: 'V' + st.id + ' · ' + st.name, trigger: st.trigger, isVar: true, vs: st.s, vm: st.m })) })
const navG = { ...nav, states: nav.states.map(st => st.isVar ? { ...st, id: st.id, name: st.name } : st) }
const im = (id, name, trigger, img) => ({ id, name: id + ' · ' + name, trigger, isImg: true, img })
const imgG = { label: 'Integration: images', title: 'Integration: images and ?v=', route: '/integration#images', api: 'GET /v1/image-variants (existing)', note: 'New section "Images and variants" on the Integration page, after Media and before Errors. It explains the ?v=<variant-name> parameter, shows one CodeSnippet with curl, HTML and JavaScript tabs, a table of enabled variants, a URL builder and the response codes. When there are no variants, admins see a link to /settings/media/variants and other users see plain information. A removed variant is not replaced by the original: the request returns 400 with the body shown in the builder.', states: [
im('I1', 'Many variants', 'Twelve enabled variants. The table scrolls at 264 px. On mobile the table and snippet scroll horizontally.', { v: 'many' }),
im('I2', 'One variant', 'A single enabled variant. The snippets use its name.', { v: 'one' }),
im('I3', 'No variants, admin', 'Empty state with a link to /settings/media/variants.', { v: 'none', admin: true, tab: 'curl' }),
im('I4', 'No variants, not an admin', 'Plain information, no link.', { v: 'none', admin: false, tab: 'curl' }),
im('I5', 'Variant removed', 'The builder holds "hero", which was deleted. Warning with the exact 400 body.', { v: 'many', removed: true, variant: 'hero' }),
im('I6', 'Loading', 'Skeleton blocks while variants load. Snippets are hidden until the list is known.', { v: 'loading' }),
im('I7', 'Error loading', '500 or network failure. Snippets still render with the placeholder name.', { v: 'error', tab: 'curl' }),
im('I8', 'Snippet copied', 'Copy pressed on the snippet. The label reads Copied for 2 s and is announced politely.', { v: 'many', tab: 'js', copied: true }),
im('I9', 'URL builder copied', 'Copy pressed on the built URL.', { v: 'many', variant: 'card', urlCopied: true })
] }
return [...bo, imgG, navG, ...vg.map(adapt)]
}
cell(t, i, o = {}) { return { t, fg: i === 0 && !o.plain ? 'var(--ui-text-highlighted)' : o.muted ? 'var(--ui-text-muted)' : 'var(--ui-text-toned)', fw: i === 0 && !o.plain ? 500 : 400, ff: o.mono ? "'IBM Plex Mono',monospace" : "'IBM Plex Sans',sans-serif", fs: o.mono ? '12px' : '13px' } }
tbl(title, cols, tpl, rows, o = {}) { return { title, cols, tpl, rows: rows.map(r => r.map((t, i) => this.cell(t, i, { mono: o.mono && o.mono.includes(i), muted: t === '–' }))) } }
varGroups() {
const st = (id, name, trigger, s, ms = {}) => ({ id, name, trigger, s, m: { ...s, ...ms } })
const LB = { nav: 'settings', crumbs: ['Settings', 'Media', 'Variants'], mTitle: 'Variants' }
const CB = { nav: 'settings', crumbs: ['Settings', 'Media', 'Cache'], mTitle: 'Cache' }
const MB = { nav: 'media', crumbs: ['Media'], mTitle: 'Media' }
const l = (list, o = {}) => ({ kind: 'list', ...LB, list, ...o })
const cr = { mode: 'create', fit: 'cover', pos: 50 }
const hero = { ...cr, name: 'hero', w: '1200', h: '630', fmt: 'WebP', q: 82, qv: 82, gen: true, en: true, out: '1200×630', est: '96 KB' }
const ed = (e, o = {}) => ({ kind: 'edit', ...LB, ed: e, ...o })
const ET = this.tbl.bind(this)
const C3 = ['State', 'Where', 'Copy']
const T3 = '210px 260px minmax(0,1fr)'
const tb = (title, rows) => ET(title, C3, T3, rows)
return [
{ label: 'Variants list', title: '1. Settings → Media → Variants', route: '/settings/media/variants', api: 'GET /v1/image-variants · PATCH /v1/image-variants/{name} · DELETE /v1/image-variants/{name} · POST /v1/image-variants/{name}/purge', note: 'Admin-only page under Settings → Media, with two tabs: Variants and Cache. Desktop shows a table; mobile stacks each variant as a card. The status switch always pairs with an icon and the words Enabled or Disabled. The reference card "How requests are handled" sits under the table (section 7 of the brief).',
states: [
st('1a', 'Loaded', 'Six variants, one of them disabled (print).', l({})),
st('1b', 'Loading', 'Skeleton rows appear after 150 ms.', l({ state: 'loading' })),
st('1c', 'Empty', 'No variants. Two templates pre-fill the Add variant form.', l({ state: 'empty' })),
st('1d', 'Error loading', 'A 500 or network failure on the list request.', l({ state: 'error' })),
st('1e', 'Forbidden', 'A signed-in user who is not an admin opens the URL.', { kind: 'list', ...LB, nav: null, role: 'user', forbidden: true }),
st('1f', 'Row menu', 'The ellipsis button on a row.', l({ menu: 2 })),
st('1g', 'Row action in progress', 'Purge cache was confirmed on hero. The row is dimmed and its controls are disabled.', l({ busy: 2 })),
st('1h', 'Purge confirm', 'Purge cache in the row menu.', l({}, { modal: 'purgeVariant' })),
st('1i', 'Delete confirm', 'Delete in the row menu.', l({}, { modal: 'deleteVariant' })),
st('1j', 'Delete in progress', 'Delete was confirmed. The button shows a spinner.', l({}, { modal: 'deleteVariant', loadingModal: true })),
st('1l', 'Entry: sidebar', 'From any page, Settings expands in the sidebar to Media: Variants and Cache. Admin only. On mobile the menu button opens it as a drawer.', { kind: 'media', ...MB, sideOpen: true, ml: {}, drawer: true }),
st('1k', 'Request reference', 'Mobile scrolled to the reference card. On desktop it is visible below the table.', l({}), { scroll: 900 })
],
tables: [tb('Copy: Variants list', [
['Header', 'Title, description, button', 'Image variants · Named sizes and formats generated from your original images. Originals are never changed. · Add variant'],
['Table columns', 'Header row', 'Name · Dimensions · Fit · Format · Quality · Generated · Cache size · Status'],
['Dimensions', 'Cell', '400×300 · 800 × auto · auto × 2400'],
['Quality', 'Cell', '80 · — (PNG has no quality)'],
['Status', 'Switch + text', 'Enabled · Disabled (aria-label: "Enable variant hero")'],
['Row menu', 'Menu items', 'Edit · Purge cache · Delete'],
['Footer summary', 'Under the table', '6 variants · 1,284 generated files · 212 MB'],
['Footer summary (singular)', 'One variant', '1 variant · 1 generated file · 18 KB'],
['Loading', 'Skeleton', 'No text. Region announces "Loading variants".'],
['Empty', 'Title · text', 'No variants yet · A variant is a named size that an image can be requested in, like ?v=thumb. Start with one of these.'],
['Empty', 'Template cards', 'thumb · 400×300 · cover · WebP — hero · 1200×630 · cover · WebP — Use template'],
['Error', 'Alert', 'Couldn\'t load variants · Check your connection and try again. · Try again'],
['Forbidden', 'Title · text · button', 'You don\'t have access · Only admins can manage image variants. Ask an admin if you need one changed. · Back to Home'],
['Row in progress', 'Status cell', 'Purging…'],
['Purge success', 'Toast', 'Purged 244 files from “hero”'],
['Purge dialog', 'Title · text · buttons', 'Purge cache for “hero”? · This removes 244 generated files (84 MB). Files are generated again on the next request. · Cancel · Purge 244 files'],
['Delete dialog', 'Title · text · buttons', 'Delete variant “hero”? · This removes the variant and its 244 generated files (84 MB). Articles that use ?v=hero will start returning 400. This can\'t be undone. · Cancel · Delete variant'],
['Delete success', 'Toast', 'Variant “hero” deleted'],
['Request reference', 'Card', 'How requests are handled · Body of a 400 response · unsupported_parameter replaces unknown_variant when a raw parameter is sent. There is no arbitrary-size mode.']
])]
},
{ label: 'Add edit variant', title: '2. Add / edit variant', route: 'Slide-over on desktop · full page on mobile', api: 'POST /v1/image-variants · PUT /v1/image-variants/{name}', note: 'Form on the left, live preview on the right (stacked on mobile). The preview uses a sample image with a before/after divider. The divider is a slider: arrow keys move it by 1%, Shift+arrow by 10%, Home and End jump to the ends. Name is locked in edit mode. The edit warning is always visible in edit mode. Preview images are placeholders.',
states: [
st('2a', 'Create: pristine', 'Add variant just opened. Save is disabled.', ed({ ...cr, name: '', w: '', h: '', fmt: 'Original', q: 80, qv: 80, gen: false, en: true, disSave: true, focusName: true })),
st('2b', 'Create: dirty', 'Valid values. The divider has keyboard focus. Mobile is scrolled to the preview.', ed({ ...hero, dirty: true, focus: true }), { scroll: 560 }),
st('2c', 'Field errors', 'On blur and on submit. Name format, width above 8000, quality above 100.', ed({ ...cr, name: 'Hero banner', w: '9000', h: '630', fmt: 'WebP', q: 140, qv: 100, gen: false, en: true, dirty: true, err: { name: 'Use lowercase letters, numbers and dashes, 2 to 32 characters.', w: 'Enter a value from 1 to 8000.', q: 'Enter a value from 1 to 100.' } })),
st('2d', 'No dimension', 'Width and height are both empty on submit.', ed({ ...cr, name: 'banner', w: '', h: '', fmt: 'WebP', q: 80, qv: 80, gen: false, en: true, dirty: true, err: { dim: 'Set a width, a height, or both.' } })),
st('2e', 'Name already taken', 'POST returns 409.', ed({ ...hero, name: 'thumb', w: '400', h: '300', out: '400×300', est: '18 KB', dirty: true, err: { name: 'A variant named “thumb” already exists. Choose another name.' } })),
st('2f', 'Saving', 'Request in flight. Fields and buttons are disabled.', ed({ ...hero, dirty: true, saving: true })),
st('2g', 'Save error', 'A 500 or network failure. Values are kept.', ed({ ...hero, dirty: true, saveErr: true })),
st('2h', 'Edit: pristine', 'Opened from Edit. Name is locked. Save is disabled.', ed({ ...hero, mode: 'edit', files: 244, disSave: true })),
st('2i', 'Edit: dirty', 'Quality changed from 82 to 70.', ed({ ...hero, mode: 'edit', files: 244, q: 70, qv: 70, est: '71 KB', dirty: true })),
st('2j', 'Unsaved changes', 'Close, Cancel or Escape while dirty.', ed({ ...hero, mode: 'edit', files: 244, q: 70, qv: 70, est: '71 KB', dirty: true }, { modal: 'discard' })),
st('2k', 'PNG: no quality', 'Quality is hidden when the format is PNG.', ed({ ...hero, fmt: 'PNG', est: '310 KB', dirty: true }))
],
tables: [tb('Copy: Add / edit variant', [
['Titles', 'Slide-over header', 'Add variant · Edit variant: hero'],
['Name', 'Label · placeholder · help', 'Name · e.g. hero · Lowercase letters, numbers and dashes. Can\'t be changed later.'],
['Name (edit)', 'Help', 'The name can\'t be changed.'],
['Name error: format', 'Inline', 'Use lowercase letters, numbers and dashes, 2 to 32 characters.'],
['Name error: taken', 'Inline', 'A variant named “thumb” already exists. Choose another name.'],
['Width · Height', 'Label · suffix · placeholder · help', 'Width · Height · px · auto · Set a width, a height, or both. 1–8000 px. Images are never enlarged.'],
['Dimension error', 'Inline', 'Set a width, a height, or both.'],
['Dimension range error', 'Inline', 'Enter a value from 1 to 8000.'],
['Fit', 'Options · help', 'Cover: Fills the box and crops what overflows. · Contain: Whole image inside the box, padded to the box size. · Inside: Whole image inside the box, no padding.'],
['Format', 'Options · help', 'Original · JPEG · PNG · WebP · Keeps the format of each source image.'],
['Quality', 'Label · help · error', 'Quality · 1–100. Higher is sharper and larger. · Enter a value from 1 to 100.'],
['Toggles', 'Label · help', 'Generate on upload: Creates this variant when an image is uploaded. · Enabled: Disabled variants return 400 on the public site.'],
['Edit warning', 'Alert (edit only)', 'Changing dimensions, fit, format or quality purges 244 generated files; they will be regenerated on next request.'],
['Preview', 'Heading · labels · hint', 'Preview · Sample: north-wall.jpg · Original — 4032 × 3024 · 3.9 MB · hero — 1200×630 · about 96 KB · Drag the divider, or focus it and use the arrow keys. The estimate comes from a sample; output is never larger than the source.'],
['Preview (no size)', 'Over the image', 'Set a width or height to see a preview.'],
['Slider', 'aria-label · aria-valuetext', 'Compare original and hero · 50% hero'],
['URL', 'Label · button', 'URL · /media/north-wall.jpg?v=hero · Copy · Copied'],
['Footer', 'Status · buttons', 'Unsaved changes · Cancel · Add variant · Save changes · Saving…'],
['Save error', 'Alert', 'Couldn\'t save the variant · Check your connection and try again. · Try again'],
['Save success', 'Toast', 'Variant “hero” added · Variant “hero” saved. 244 files purged.'],
['Unsaved dialog', 'Title · text · buttons', 'Discard changes? · You have unsaved changes to “hero”. If you close now, they\'re lost. · Keep editing · Discard changes']
])]
},
{ label: 'Media library', title: '3. Media library', route: '/media', api: 'GET /v1/media?variants=with|without&sort=total_size', note: 'The existing grid with three additions: a "N variants" badge on cards that have cached files, an All / With variants / Without filter, and the sort option "Largest total size (original + variants)". When sorted by total size, the meta line shows the total.',
states: [
st('3a', 'Grid with badges', 'Default view. Images with no cached variants have no badge.', { kind: 'media', ...MB, ml: { ann: 'New: badge' } }),
st('3b', 'Sort menu', 'The sort select is open. The new option is last.', { kind: 'media', ...MB, ml: { menu: true, sort: 'total', ann: 'New: sort option' } }),
st('3c', 'Filter: With variants, sorted by total size', 'The meta line becomes total · original + variants.', { kind: 'media', ...MB, ml: { filter: 'With variants', sort: 'total', ids: [1, 7, 9, 12, 5, 11, 2, 3, 10] } }),
st('3d', 'Filter: Without variants', 'Images no variant has been generated for.', { kind: 'media', ...MB, ml: { filter: 'Without', ids: [4, 6, 8] } }),
st('3e', 'Filter: nothing to show', 'With variants, and the cache is empty.', { kind: 'media', ...MB, ml: { filter: 'With variants', empty: true } }),
st('3f', 'Delete confirm', 'Delete on an image that has cached variants.', { kind: 'media', ...MB, ml: {}, modal: 'deleteImage' })
],
tables: [tb('Copy: Media library additions', [
['Badge', 'Card', '4 variants · 1 variant'],
['Filter', 'Segmented control', 'All · With variants · Without'],
['Sort', 'Select, new option', 'Largest total size (original + variants) · mobile label: Largest total size'],
['Meta line (sorted by total)', 'Card', '4.2 MB total · 3.9 MB + 260 KB'],
['Empty filter', 'Title · text · button', 'No images with variants · Variants are generated when an image is requested with ?v=<name>, or from its Variants tab. · Show all images'],
['Delete dialog', 'Title · text · note · buttons', 'Delete this image? · "north-wall.jpg" is used in 2 articles. If you delete it, they will show a broken image. This can\'t be undone. · Also removes 4 generated variants (260 KB) · Cancel · Delete image'],
['Bulk delete', 'Dialog line', 'Also removes 11 generated variants (1.1 MB)']
])]
},
{ label: 'Image detail', title: '4. Image detail: Variants tab', route: '/media (detail panel)', api: 'GET /v1/media/{id}/variants · POST /v1/media/{id}/variants/{name} · DELETE /v1/media/{id}/variants/{name}', note: 'The detail panel gets a Variants tab with one row per enabled variant. On desktop the row actions are icon buttons with tooltips and accessible names; on mobile they are labeled buttons on their own line. Only actions that apply are shown: generated rows offer Copy URL, Preview and Purge; others offer Copy URL and Generate now. The tab shows output dimensions as they will be generated, capped at the source size.',
states: [
st('4a', 'Partly generated', 'Four of five generated. Saving is shown against the original.', { kind: 'detail', ...MB, dt: { rows: ['gen', 'gen', 'gen', 'gen', 'none'] } }),
st('4b', 'Nothing generated yet', 'No cached files for this image.', { kind: 'detail', ...MB, dt: {} }),
st('4c', 'Generating', 'Generate all or Generate now was pressed. Rows show a spinner.', { kind: 'detail', ...MB, dt: { rows: ['gen', 'busy', 'busy', 'none', 'none'] } }),
st('4d', 'Generation failed', 'The row shows the cause and Retry. Both causes are shown together for reference.', { kind: 'detail', ...MB, dt: { rows: ['gen', 'fail', 'fail', 'gen', 'gen'], cause: [0, 'Unsupported source format', 'Image too large'] } }),
st('4e', 'No variants defined', 'There are no enabled variants.', { kind: 'detail', ...MB, dt: { none: true } }),
st('4f', 'Source is an SVG', 'Variants aren\'t generated for vector images.', { kind: 'detail', ...MB, dt: { id: 4, nofmt: 'SVG images are served as they are.' } }),
st('4g', 'Source is an animated GIF', 'Same rule as SVG.', { kind: 'detail', ...MB, dt: { id: 4, nofmt: 'Animated GIFs are served as they are.' } }),
st('4h', 'Preview dialog', 'Preview on a generated row. Both at actual size.', { kind: 'detail', ...MB, dt: { rows: ['gen', 'gen', 'gen', 'gen', 'none'], preview: true } })
],
tables: [tb('Copy: Variants tab', [
['Tabs', 'Panel', 'Details · Variants'],
['Original', 'Header block', 'Original · kept untouched · north-wall.jpg · JPEG · 4032 × 3024 · 3.9 MB'],
['Summary', 'Above the list', '4 of 5 generated · Nothing generated yet. · Generating variants…'],
['Generate all', 'Button', 'Generate all · Generating…'],
['Row status', 'Badge', 'Generated · Not generated · Generating · Failed'],
['Row meta (generated)', 'Under the name', '400×300 · WebP · 18 KB · -99% vs original'],
['Row meta (not generated)', 'Under the name', '400×300 · WebP'],
['Row actions', 'Tooltip · aria-label', 'Copy URL · Preview · Generate now · Purge · Retry'],
['Failed: cause', 'Row line', 'Unsupported source format · Image too large'],
['No variants defined', 'Title · text · link', 'No variants defined · Define a variant in Settings to get resized copies of this image. · Settings → Media → Variants'],
['SVG / GIF', 'Alert', 'Variants aren\'t generated for this format. · SVG images are served as they are. · Animated GIFs are served as they are.'],
['Preview dialog', 'Title · panes · hint', 'Preview: thumb · thumb · 400×300 · WebP — 18 KB · saves 99% · Original · 4032×3024 · JPEG — 3.9 MB · Both images are shown at actual size. Scroll to pan. · Close'],
['Toasts', 'After actions', 'Generated 5 variants · Generated thumb · Purged 4 files (260 KB) · Couldn\'t generate hero. Image too large.']
])]
},
{ label: 'Cache panel', title: '5. Settings → Media → Cache', route: '/settings/media/cache', api: 'GET /v1/image-variants/cache · POST /v1/image-variants/cache/purge {scope: all|unused|variant|image}', note: 'Stat tiles, a per-variant bar list (bar length is the disk size relative to the largest variant) and the ten images with the most variant disk usage. Purge all needs the word "purge" typed. Purge unused removes files of enabled variants not requested in 30 days.',
states: [
st('5a', 'Loaded', 'Default view.', { kind: 'cache', ...CB }),
st('5b', 'Loading', 'Tiles and lists show skeletons.', { kind: 'cache', ...CB, cache: { state: 'loading' } }),
st('5c', 'Empty cache', 'Nothing generated. Purge buttons are disabled.', { kind: 'cache', ...CB, cache: { state: 'empty' } }),
st('5d', 'Purging', 'Progress for a purge in progress. Content is dimmed.', { kind: 'cache', ...CB, cache: { state: 'purging' } }),
st('5e', 'Purge done', 'The cache is empty and a toast reports the result.', { kind: 'cache', ...CB, cache: { state: 'empty' }, toasts: [['success', 'circle-check', 'Purged 1,284 files, freed 212 MB', '']] }),
st('5f', 'Purge failed', 'Part of the purge did not complete.', { kind: 'cache', ...CB, toasts: [['error', 'circle-x', 'Purge didn\'t finish', '640 of 1,284 files were removed. Try again.', 'Try again']] }),
st('5g', 'Error loading', 'A 500 or network failure.', { kind: 'cache', ...CB, cache: { state: 'error' } }),
st('5h', 'Purge all: confirm', 'The word is not typed yet. The button is disabled.', { kind: 'cache', ...CB, modal: 'purgeAll' }),
st('5i', 'Purge all: confirm, typed', 'The input matches "purge".', { kind: 'cache', ...CB, modal: 'purgeAllOk' }),
st('5j', 'Purge unused: confirm', 'Variants of enabled variants not requested for 30 days.', { kind: 'cache', ...CB, modal: 'purgeUnused' })
],
tables: [tb('Copy: Cache panel', [
['Header', 'Title · text · buttons', 'Cache · Generated variant files. Purged files are generated again on the next request. · Purge unused · Purge all variants'],
['Tiles', 'Label · sub text', 'Generated files · Across 6 variants — Disk used · Originals not included — Variants defined · 5 enabled, 1 disabled — Rejected requests · Last 7 days'],
['Bar list', 'Heading · row', 'By variant · hero 244 files · 84 MB · Disabled (badge on disabled variants)'],
['Top 10', 'Heading · row · button', 'Top 10 images by variant disk usage · north-wall.jpg · 6 variants · 5.8 MB · Purge'],
['Empty cache', 'Title · text', 'Nothing generated yet · Variants are generated on the first request with ?v=<name>, or from an image\'s Variants tab.'],
['Purging', 'Banner', 'Purging 1,284 files · 640 of 1,284 · 106 MB freed'],
['Purge done', 'Toast', 'Purged 1,284 files, freed 212 MB'],
['Purge failed', 'Toast', 'Purge didn\'t finish · 640 of 1,284 files were removed. Try again. · Try again'],
['Error', 'Alert', 'Couldn\'t load cache stats · Check your connection and try again. · Try again'],
['Purge all dialog', 'Title · text · field · buttons', 'Purge all variants? · This removes 1,284 generated files (212 MB) for every variant. Originals aren\'t touched. Files are generated again on request. · Type purge to confirm · Cancel · Purge all variants'],
['Purge unused dialog', 'Title · text · buttons', 'Purge unused variants? · This removes 312 files (41 MB) from enabled variants that haven\'t been requested for 30 days. Files are generated again on request. · Cancel · Purge 312 files'],
['Purge image (row)', 'Toast', 'Purged 6 files (5.8 MB) for north-wall.jpg']
])]
},
{ label: 'Editor picker', title: '6. Editor image picker', route: '/articles/{id}/edit', api: 'Inserted URL: /media/{file}?v={name}', note: 'The Library tab of the picker gets a Variant select. Options are Original plus the enabled variants, each with its dimensions and format. Once inserted, a block whose variant was deleted or disabled shows an inline warning with a way to fix it. Disabled variants do not appear in the select.',
states: [
st('6a', 'Variant select', 'An image is selected. The URL line shows what will be inserted.', { kind: 'editor', nav: 'articles', crumbs: ['Articles', 'How we price a bathroom renovation'], mTitle: 'Edit article', back: true, edr: { picker: true, cur: 3 } }),
st('6b', 'Select open', 'Original plus five enabled variants.', { kind: 'editor', nav: 'articles', crumbs: ['Articles', 'How we price a bathroom renovation'], mTitle: 'Edit article', back: true, edr: { picker: true, cur: 3, open: true } }),
st('6c', 'No variants defined', 'Only Original is offered.', { kind: 'editor', nav: 'articles', crumbs: ['Articles', 'How we price a bathroom renovation'], mTitle: 'Edit article', back: true, edr: { picker: true, none: true } }),
st('6d', 'Variant deleted', 'The block keeps its URL. Pick another variant opens the select.', { kind: 'editor', nav: 'articles', crumbs: ['Articles', 'How we price a bathroom renovation'], mTitle: 'Edit article', back: true, edr: { block: true, msg: 'Variant \'hero\' no longer exists.' } }),
st('6e', 'Variant disabled', 'Same block, different cause. Proposed copy.', { kind: 'editor', nav: 'articles', crumbs: ['Articles', 'How we price a bathroom renovation'], mTitle: 'Edit article', back: true, edr: { block: true, msg: 'Variant \'hero\' is disabled.' } })
],
tables: [tb('Copy: Editor picker', [
['Select', 'Label', 'Variant'],
['Options', 'Option label', 'Original · 4032×3024 · JPEG — Thumb · 400×300 · WebP — Card · 800 × auto · WebP — Hero · 1200×630 · WebP — Square · 600×600 · JPEG — Og · 1200×630 · JPEG'],
['URL line', 'Under the select', '/media/north-wall.jpg?v=hero'],
['No variants', 'Help', 'No variants defined. Add one in Settings → Media → Variants.'],
['Block warning: deleted', 'Inline warning', 'Variant \'hero\' no longer exists. This image returns an error on the public site. · Pick another variant'],
['Block warning: disabled', 'Inline warning', 'Variant \'hero\' is disabled. This image returns an error on the public site. · Pick another variant'],
['Block label', 'Chip on the image', 'hero · 1200×630']
])]
}
]
}
varSpecs() {
const T = (cols, tpl, rows, o = {}) => ({ cols, tpl, rows: rows.map(r => r.map((t, i) => this.cell(t, i, { mono: o.mono && o.mono.includes(i), muted: t === '–' }))) })
return [
{ label: 'Request contract', title: '7. Public request contract', note: 'Shown as the "How requests are handled" card in section 1. Only variants that exist and are enabled are served. There is no arbitrary-size mode.', tables: [T(['Request', 'Status', 'Body or result'], '320px 80px minmax(0,1fr)', [
['/media/photo.png?v=thumb', '200', 'The cached image. Generated on first request.'],
['/media/photo.png?v=unknown', '400', 'Unknown variant'],
['/media/photo.png?v=hero (disabled)', '400', 'Unknown variant'],
['/media/photo.png?width=400', '400', 'Unsupported parameter'],
['height, format or quality parameters', '400', 'Unsupported parameter'],
['/media/missing.png?v=thumb', '404', 'Not found'],
['400 body shape', '', '{ "status": 400, "error": "unknown_variant" | "unsupported_parameter", "message": "Unknown variant", "detail": "banner" }']
], { mono: [0] })] },
{ label: 'Activity log', title: '8. Activity log entries', note: 'Row copy for the existing activity log. The actor name is bold, the quoted name is the variant name, and the detail line is secondary text.', tables: [T(['Event', 'Row copy', 'Detail line'], '220px minmax(0,1fr) minmax(0,1fr)', [
['Variant created', 'Marta Alves created variant “hero”', '1200×630 · cover · WebP · quality 82'],
['Variant updated', 'Marta Alves updated variant “hero”', 'Quality 82 → 70 · purged 244 files'],
['Variant deleted', 'Marta Alves deleted variant “hero”', 'Deleted 244 generated files (84 MB)'],
['Variant enabled', 'Marta Alves enabled variant “hero”', 'Requests for ?v=hero are served'],
['Variant disabled', 'Marta Alves disabled variant “hero”', 'Requests for ?v=hero return 400'],
['Cache purged: all', 'Marta Alves purged all variants', 'Deleted 1,284 files (212 MB)'],
['Cache purged: unused', 'Marta Alves purged unused variants', 'Deleted 312 files (41 MB) not requested for 30 days'],
['Cache purged: by variant', 'Marta Alves purged the cache of variant “hero”', 'Deleted 244 files (84 MB)'],
['Cache purged: by image', 'Marta Alves purged the variants of north-wall.jpg', 'Deleted 4 files (260 KB)'],
['Generate all', 'Marta Alves generated all variants of north-wall.jpg', '5 variants generated'],
['Generate all, with a failure', 'Marta Alves generated all variants of north-wall.jpg', '4 generated · 1 failed (Image too large)'],
['Image deleted', 'Marta Alves deleted north-wall.jpg', 'Also removed 4 generated variants (260 KB)']
])] },
{ label: 'States matrix', title: 'States matrix', note: 'Frame: a numbered state above. Copy: covered by the copy tables, with no separate frame. Existing: unchanged behavior of the existing screen.', tables: [T(['Screen', 'Loaded', 'Empty', 'Loading', 'Error', 'Forbidden', 'In progress', 'Confirm', 'Validation', 'Success'], '190px repeat(9, minmax(0,1fr))', [
['1 Variants list', 'Frame 1a', 'Frame 1c', 'Frame 1b', 'Frame 1d', 'Frame 1e', 'Frame 1g, 1j', 'Frame 1h, 1i', '–', 'Copy'],
['2 Add / edit', 'Frame 2a, 2h', '–', '–', 'Frame 2g', 'Copy', 'Frame 2f', 'Frame 2j', 'Frame 2c, 2d, 2e', 'Copy'],
['3 Media library', 'Frame 3a', 'Frame 3e', 'Existing', 'Existing', '–', '–', 'Frame 3f', '–', '–'],
['4 Image detail', 'Frame 4a', 'Frame 4b, 4e, 4f, 4g', 'Existing', 'Frame 4d', '–', 'Frame 4c', '–', '–', 'Copy'],
['5 Cache', 'Frame 5a', 'Frame 5c', 'Frame 5b', 'Frame 5g, 5f', 'Copy', 'Frame 5d', 'Frame 5h, 5i, 5j', 'Frame 5h, 5i', 'Frame 5e'],
['6 Editor picker', 'Frame 6a, 6b', 'Frame 6c', 'Existing', 'Frame 6d, 6e', '–', '–', '–', '–', '–']
])] },
{ label: 'API errors', title: 'Back-office API errors', note: 'Shown as a toast, or inline where a field is named. The first line is the title, the second the text. The public media URL does not use these; see section 7.', tables: [T(['Status', 'Title', 'Text', 'Where it shows'], '70px 200px minmax(0,1fr) 260px', [
['400', 'Check the request', 'A value in the request isn\'t valid. Reload the page and try again.', 'Toast'],
['401', 'Session expired', 'Sign in again to continue.', 'Redirect to sign in, then a toast'],
['403', 'You don\'t have access', 'Only admins can manage image variants.', 'Toast, or the forbidden state on the list'],
['404', 'Not found', 'This variant or image no longer exists. Refresh the page.', 'Toast'],
['409', 'Name already taken', 'A variant named “thumb” already exists. Choose another name.', 'Inline under Name'],
['413', 'Image too large', 'The source image is too large to generate this variant.', 'Row cause in the Variants tab'],
['422', 'Check the fields', 'Width and height must be between 1 and 8000, and one of them is required.', 'Inline under each field'],
['500', 'Something went wrong', 'The server couldn\'t finish. Try again. If it keeps happening, check the server log.', 'Alert or toast with Try again']
], { mono: [0] })] },
{ label: 'Component inventory', title: 'Component inventory', note: 'Existing components are reused as they are. New components are built on Nuxt UI parts and use only existing tokens. Every icon-only button needs an aria-label and a UTooltip.', tables: [T(['Component', 'Status', 'Nuxt UI mapping', 'Notes'], '190px 80px 300px minmax(0,1fr)', [
['Variants table', 'New', 'UTable', 'Cell slots for the status switch and actions. Below 640 px it renders as a list of UCard.'],
['Variant row (settings, mobile)', 'New', 'UCard + USwitch + UDropdownMenu', 'Name, spec line, counts, status switch and menu.'],
['Variant row (image detail)', 'New', 'UBadge + UButton + UTooltip', 'Status badge, meta line, contextual actions, error line. Spinner uses UIcon.'],
['Variant form', 'New', 'UForm, UFormField, UInput, USelect, USlider, USwitch, URadioGroup', 'Fit is a URadioGroup, horizontal, 3 options. Quality hides when the format is PNG.'],
['Slide-over / full page', 'New', 'USlideover (desktop) · routed page (mobile)', 'Close with unsaved changes opens the discard UModal.'],
['Split comparison slider', 'New', 'No match. Custom ImageCompare on a native range input', 'Two stacked images, clip-path on the top one. role="slider", arrow keys 1%, Shift 10%, Home and End. Handle ≥ 44 px on touch.'],
['Stat tile', 'New', 'UCard (default slot)', 'Icon, label, value, sub text. Skeleton uses USkeleton.'],
['Bar list', 'New', 'UProgress per row', 'Row with name, stats and a bar. Value is disk size over the largest variant.'],
['Top-N list', 'New', 'UCard + list rows + UButton', 'Thumbnail, name, count, size, Purge.'],
['Type-to-confirm dialog', 'New', 'UModal + UInput', 'Confirm button disabled until the input equals "purge".'],
['Variant select', 'New', 'USelectMenu', 'Item slot shows name, dimensions, format. Only enabled variants.'],
['Variants badge', 'New', 'UBadge (outline, icon layers)', 'Shown on media cards when count > 0.'],
['Filter', 'New', 'UTabs (variant link) or UButtonGroup', 'All, With variants, Without.'],
['Request reference card', 'New', 'UCard + code block', 'Table of responses and a JSON body.'],
['Alerts, toasts, dialogs, empty states, skeletons', 'Existing', 'UAlert, useToast, UModal, UEmpty pattern, USkeleton', 'Same copy patterns as the Media and API keys screens.']
])] },
{ label: 'Vocabulary and accessibility', title: 'Vocabulary and accessibility', note: 'Use "variant", "generated" and "purge" everywhere. Never "thumbnail preset", "version" or "rendition". Delete removes a variant definition; purge removes only its generated files. Every status is an icon plus text: Generated (circle-check), Not generated (circle-dashed), Generating (loader), Failed (circle-x), Enabled (circle-check), Disabled (circle-off). The comparison slider has a visible focus ring and works from the keyboard. Dialogs trap focus and return it to the opener. Progress is announced with aria-live="polite". Touch targets on mobile are at least 44 px. The only token not already in the system is --rc-on-sand (dark ink on the sand selection color), used for the active nav item in both themes.', tables: [] }
]
}
finish(gs) { return gs.map(g => ({ ...g, states: g.states.map(st => ({ notMedia: !st.isMedia && !st.isAct, ...st, m: { ...st.m } })) })) }
renderVals() {
const modes = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']].map(([k, label]) => {
@@ -464,7 +829,7 @@ class Component extends DCLogic {
return { label, on, bg: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-bg)' : 'var(--ui-text-toned)', pick: () => this.setState({ mode: k }) }
})
const rows = a => a.map(r => ({ a: r[0], b: r[1] }))
return { rootRef: this.rootRef, modes, groups: this.groups(), spCopy: rows(this.state.spec.copy), spComps: rows(this.state.spec.comps), spApi: rows(this.state.spec.api) }
return { rootRef: this.rootRef, modes, groups: this.groups(), specs: this.varSpecs(), spCopy: rows(this.state.spec.copy), spComps: rows(this.state.spec.comps), spApi: rows(this.state.spec.api) }
}
}
</script>