fix: small design harmonization and overhaul

This commit is contained in:
Florian Sylvain committed 2026-10-11 17:17:22 +02:00
1 parent d6566743a7
commit a2d224240c
9 files changed
+608 -25

No files matched your search

@@ -0,0 +1,122 @@
Feature: Shell design tokens and selected nav contrast
As the maintainer of RenewCMS
I want the new --rc-* tokens in the SPA and every selected nav item drawn in sand with on-sand text
So that the sidebar and drawer stay readable in both themes and match docs/design
Background:
Given web/src/assets/main.css declares the --rc-* tokens of docs/design/foundations.md
"New tokens", light values in :root and dark values in .dark
And DashboardShell renders the nav from web/src/components/DashboardShell.vue, with "Settings"
as the parent row (/settings) and a "Media" group holding "Variants"
(/settings/media/variants) and "Cache" (/settings/media/cache)
And the session role is "owner", so the Settings children are present
And a selected row is a link whose route is current, carrying aria-current="page"
Scenario Outline: The new tokens exist with the values given in foundations.md
When web/src/assets/main.css is loaded in the <theme> theme
Then <token> resolves to <value>
Examples:
| token | theme | value |
| --rc-sand | light | #e8dfd1 |
| --rc-sand | dark | #d4c5b0 |
| --rc-on-sand | light | #111111 |
| --rc-on-sand | dark | #111111 |
| --rc-success-soft | light | #eaefea |
| --rc-success-soft | dark | #1f241f |
| --rc-info-soft | light | #ebeef0 |
| --rc-info-soft | dark | #202428 |
| --rc-warning-soft | light | #f0ede6 |
| --rc-warning-soft | dark | #2a251e |
| --rc-error-soft | light | #f3eae9 |
| --rc-error-soft | dark | #2c201f |
| --rc-overlay | light | rgb(10 10 10 / 0.4) |
| --rc-overlay | dark | rgb(0 0 0 / 0.6) |
| --rc-shadow-pop | light | 0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06) |
| --rc-shadow-pop | dark | 0 4px 16px rgb(0 0 0 / 0.5) |
| --rc-shadow-modal | light | 0 16px 48px rgb(17 17 17 / 0.18) |
| --rc-shadow-modal | dark | 0 16px 48px rgb(0 0 0 / 0.6) |
Scenario: No --ui-* token and no --rc-canvas are introduced
When web/src/assets/main.css is compared with docs/design/foundations.md
Then no --ui-* token value is changed
And --rc-canvas is not declared
Scenario Outline: N1 Parent selected: Settings is filled in sand with on-sand text and icon
Given the current route is /settings
And the theme is <theme>
When DashboardShell renders the desktop sidebar
Then the "Settings" row has aria-current="page"
And its background is --rc-sand and its label and icon colour are --rc-on-sand
And the "Media" group is visible with "Variants" and "Cache" unselected
And neither child row uses --rc-sand
Examples:
| theme |
| light |
| dark |
Scenario Outline: N2 and N3 Child selected: the child is filled and the parent is unfilled and bold
Given the current route is <path>
And the theme is <theme>
When DashboardShell renders the desktop sidebar
Then the "<child>" row has aria-current="page", background --rc-sand and label and icon colour --rc-on-sand
And the "Settings" row is not filled, has no aria-current, uses --ui-text-highlighted at weight 500 and shows the chevron pointing down
And the other child row is not filled
Examples:
| path | child | theme |
| /settings/media/variants | Variants | light |
| /settings/media/variants | Variants | dark |
| /settings/media/cache | Cache | light |
| /settings/media/cache | Cache | dark |
Scenario Outline: Hover, focus and active on a selected row keep sand and on-sand
Given the "<row>" row is selected on <path>
And the theme is <theme>
When the row is <interaction>
Then its background stays --rc-sand and its label and icon colour stay --rc-on-sand
And no white or --ui-text-muted colour is applied to its label or icon
Examples:
| row | path | interaction | theme |
| Settings | /settings | hovered | dark |
| Settings | /settings | focus-visible | dark |
| Settings | /settings | pressed | dark |
| Settings | /settings | hovered | light |
| Variants | /settings/media/variants | hovered | dark |
| Variants | /settings/media/variants | pressed | light |
| Cache | /settings/media/cache | focus-visible | dark |
Scenario: Focus on a selected row adds a 2 px --ui-primary outline
Given the "Settings" row is selected on /settings
When the row receives keyboard focus
Then it has a 2 px --ui-primary focus outline
And its background is still --rc-sand
Scenario Outline: N4 and N5 The mobile drawer uses the same selected treatment
Given the viewport is 390 x 844 and the theme is <theme>
And the current route is <path>
When the Menu button is activated
Then the drawer rows are 44 px tall
And the "<selected>" row has aria-current="page", background --rc-sand and label and icon colour --rc-on-sand
And <parent_state>
Examples:
| path | selected | parent_state | theme |
| /settings | Settings | the Media group lists Variants and Cache, unselected | light |
| /settings | Settings | the Media group lists Variants and Cache, unselected | dark |
| /settings/media/variants | Variants | the "Settings" row is unfilled and bold | light |
| /settings/media/variants | Variants | the "Settings" row is unfilled and bold | dark |
Scenario Outline: Other selected nav items use the same pairing
Given the current route is <path> and the theme is dark
When DashboardShell renders
Then the "<row>" row has background --rc-sand and label and icon colour --rc-on-sand
Examples:
| path | row |
| /home | Home |
| /users | Users |
| /activity | Activity |
@@ -0,0 +1,37 @@
# QA: Shell design tokens and selected nav contrast (SPA only)
No /v1 behaviour changes. There is no httptest part.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mount DashboardShell with a test router and a resolved session (role "owner"). Mock the /v1 calls the shell issues (/v1/auth/me, and the update-check call if the shell makes one) so none go unmocked. Do not freeze the system date.
1. Token declarations: read web/src/assets/main.css as text; assert each token/theme/value row of the Examples table is declared in :root (light) or .dark (dark), and that --rc-canvas is absent.
2. Parent selected (N1): route /settings; the Settings link has aria-current="page" and the sand and on-sand classes (bg-(--rc-sand), text-(--rc-on-sand), or the equivalent in navUi); the Variants and Cache links have neither.
3. Child selected (N2, N3): routes /settings/media/variants and /settings/media/cache; the child link has the sand classes and aria-current; the Settings link has no sand class, no aria-current, the text-highlighted and font-medium classes, and the chevron open state (rotate-90).
4. Selected-row interaction classes: for the selected link, assert the class list includes the sand and on-sand variants for hover:, focus-visible: and active:, and includes no text-muted, text-white or text-inverted for the active state. Assert the leading icon inherits the on-sand colour.
5. Dark and light: repeat 2 and 3 with the `dark` class on the document root; assert the same class contract, since the tokens carry the theme.
6. Mobile drawer (N4, N5): open the drawer; assert 44 px row height classes and the same selected contract for /settings and /settings/media/variants.
7. Other routes: /home, /users, /activity selected links have the sand contract.
## Visual (`task visual:check`, gate: every capture within 10 %)
Each state's mocks reuse the board's own sample data from the "Settings navigation" frame in `RenewCMS Screens Back-office.dc.html`.
- `task visual:check -- settings` (the Settings screen states in docs/design/screens/settings.md and states.md)
- `task visual:check -- image-variants` for the nav states N1 to N5 (docs/design/screens/image-variants.md, "Settings navigation"; the screen key in web/visual/states.ts is `image-variants`).
Each capture is desktop 1440x900 and mobile 390x844, in light and dark. N4 and N5 are the mobile drawer states. None of these states are clock-sensitive.
| State | Route / trigger | Must show |
|---|---|---|
| N1 | /settings | Settings filled in sand, Media group visible, children unselected |
| N2 | /settings/media/variants | Settings unfilled and bold, Variants filled |
| N3 | /settings/media/cache | Settings unfilled and bold, Cache filled |
| N4 | Menu button on /settings | Drawer, 44 px rows, Settings filled |
| N5 | Menu button on /settings/media/variants | Drawer, Variants filled |
## Manual checks (screenshots cannot cover)
- Keyboard: Tab through the sidebar. The focus ring is 2 px --ui-primary on a selected row, and the sand fill stays.
- Hover and pressed on a selected row in dark mode: the label and icon never turn white or muted.
+11 -5
View File
@@ -170,11 +170,15 @@
--ui-info: var(--ui-color-info-700);
--ui-warning: var(--ui-color-warning-700);
--ui-error: var(--ui-color-error-700);
--rc-success-soft: #eaefeb;
--rc-sand: #e8dfd1;
--rc-on-sand: #111111;
--rc-success-soft: #eaefea;
--rc-info-soft: #ebeef0;
--rc-warning-soft: #f0ede6;
--rc-error-soft: #f3eae9;
--rc-overlay: rgb(10 10 10 / 0.4);
--rc-shadow-pop: var(--shadow-popover);
--rc-shadow-modal: var(--shadow-modal);
}
.dark {
@@ -199,10 +203,12 @@
--ui-info: var(--ui-color-info-300);
--ui-warning: var(--ui-color-warning-300);
--ui-error: var(--ui-color-error-300);
--rc-success-soft: #202420;
--rc-info-soft: #202325;
--rc-warning-soft: #26221c;
--rc-error-soft: #28201e;
--rc-sand: #d4c5b0;
--rc-on-sand: #111111;
--rc-success-soft: #1f241f;
--rc-info-soft: #202428;
--rc-warning-soft: #2a251e;
--rc-error-soft: #2c201f;
--rc-overlay: rgb(0 0 0 / 0.6);
--shadow-popover: 0 4px 16px rgb(0 0 0 / 0.5);
--shadow-modal: 0 16px 48px rgb(0 0 0 / 0.6);
+7 -3
View File
@@ -161,6 +161,9 @@ const sidebarCollapsed = useSidebarCollapsed()
const sidebarUi = computed(() => ({
root: sidebarCollapsed.value ? 'min-w-14 px-1 py-3' : 'min-w-14 p-3',
content: 'w-75 gap-0.5 divide-y-0 p-3 shadow-modal',
overlay: 'bg-(--rc-overlay)',
toggle: 'hidden',
header: 'mb-3 h-10 gap-2.5 px-1.5',
body: 'gap-0.5 p-0',
footer: 'p-0',
@@ -170,12 +173,13 @@ const navUi = computed(() => ({
list: 'flex flex-col gap-0.5',
link: sidebarCollapsed.value
? 'h-8 justify-center gap-0 px-0 text-sm'
: 'h-8 gap-2.5 px-2.5 py-0 text-sm [li[data-state=open]>&]:font-medium [li[data-state=open]>&]:text-highlighted',
: 'h-8 gap-2.5 px-2.5 py-0 text-sm max-lg:h-11 [li[data-state=open]>&:not([aria-current=page])]:font-medium [li[data-state=open]>&:not([aria-current=page])]:text-highlighted',
linkLeadingIcon: 'size-4',
linkTrailingIcon: 'size-3.5 group-data-[state=open]:rotate-90',
label: 'px-2.5 pt-1.5 pb-0.5 pl-3 text-[11px] leading-4 font-semibold tracking-[.04em] text-muted uppercase',
childList: 'ms-[19px] gap-0.5 border-s border-default ps-0',
childLink: 'h-8 gap-2.5 py-0 pr-2.5 pl-3 text-sm',
childList: 'ms-[19px] mt-0.5 flex flex-col gap-0.5 border-s border-default ps-0',
childItem: 'ms-0 ps-0',
childLink: 'h-8 max-lg:h-11 gap-2.5 py-0 pr-2.5 pl-3 text-sm',
childLinkIcon: 'size-3.5',
}))
const loggingOut = ref(false)
+1 -1
View File
@@ -147,7 +147,7 @@ const items = computed(() => [
:aria-expanded="chipExpanded"
:class="chipClass"
:trailing-icon="collapsed ? undefined : 'i-lucide-chevrons-up-down'"
:ui="{ trailingIcon: 'ms-auto size-3.5 text-current' }"
:ui="{ trailingIcon: 'ms-auto size-3.5 text-current max-lg:hidden' }"
:aria-label="triggerAriaLabel"
>
<template #leading>
@@ -6,6 +6,8 @@ import router from '@/router'
import { useApi } from '@/composables/useApi'
import { useColorMode } from '@/composables/useColorMode'
import DashboardShell from '../DashboardShell.vue'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport'
vi.mock('@/composables/useApi', async (importOriginal) => {
@@ -429,3 +431,342 @@ describe('DashboardShell Settings expands to the Media subpages', () => {
expect(nav.text()).not.toContain('Cache')
})
})
describe('DashboardShell selected nav rows use sand with on-sand text', () => {
const SAND = 'bg-(--rc-sand)'
const ON_SAND = 'text-(--rc-on-sand)'
const STATES = ['', 'hover:', 'active:', 'focus-visible:']
const selectedClasses = STATES.flatMap((state) => [`${state}${SAND}`, `${state}${ON_SAND}`])
const SCOPED_BOLD = '[li[data-state=open]>&:not([aria-current=page])]:font-medium'
const UNSCOPED_BOLD = '[li[data-state=open]>&]:font-medium'
it.each([
'/settings',
'/settings/media/variants',
'/settings/media/cache',
'/home',
'/users',
'/activity',
])('fills the selected row on %s with sand and on-sand in every state', async (path) => {
const wrapper = await mountShell(path, { role: 'owner' })
const nav = wrapper.find('nav[aria-label="Main"]')
const selected = nav.find(`a[href="${path}"]`)
expect(selected.attributes('aria-current')).toBe('page')
expect(selected.classes()).toEqual(expect.arrayContaining(selectedClasses))
expect(selected.classes()).toContain('focus-visible:outline-(--ui-primary)')
expect(selected.find('[class*="--rc-on-sand"]').exists()).toBe(true)
const unselected = nav
.findAll('a')
.filter((link) => link.attributes('aria-current') === undefined)
expect(unselected.length).toBeGreaterThan(0)
for (const link of unselected) {
for (const cls of selectedClasses) {
expect(link.classes()).not.toContain(cls)
}
}
})
it('keeps the unfilled Settings parent bold when a child is selected', async () => {
const wrapper = await mountShell('/settings/media/variants', { role: 'owner' })
const parent = wrapper.find('nav[aria-label="Main"] a[href="/settings"]')
expect(parent.attributes('aria-current')).toBeUndefined()
expect(parent.classes()).not.toContain(SAND)
expect(parent.classes()).toContain(SCOPED_BOLD)
expect(parent.classes()).not.toContain(UNSCOPED_BOLD)
})
it('scopes the open-parent bold rule away from the selected row', async () => {
const wrapper = await mountShell('/settings', { role: 'owner' })
const selected = wrapper.find('nav[aria-label="Main"] a[href="/settings"]')
expect(selected.classes()).toContain(SCOPED_BOLD)
expect(selected.classes()).not.toContain(UNSCOPED_BOLD)
expect(selected.classes()).not.toContain('[li[data-state=open]>&]:text-highlighted')
})
it('draws drawer rows 44 px tall with the same selected treatment', async () => {
await mountShell('/settings', { role: 'owner', width: MOBILE_WIDTH })
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
toggle.click()
await flushPromises()
const settings = document.body.querySelector(
'[role="dialog"] nav[aria-label="Main"] a[href="/settings"]',
) as HTMLElement
expect(settings.classList.contains('max-lg:h-11')).toBe(true)
for (const cls of selectedClasses) {
expect(settings.classList.contains(cls)).toBe(true)
}
})
})
const mainCss = readFileSync(join(import.meta.dirname, '../../assets/main.css'), 'utf8')
function declarationsOf(selector: string) {
const start = mainCss.indexOf(`\n${selector} {`)
const end = mainCss.indexOf('\n}', start)
const block = mainCss.slice(start, end)
return new Map(
[...block.matchAll(/^\t(--[a-z0-9-]+): ([^;]+);$/gm)].map((match) => [
match[1]!,
match[2]!,
]),
)
}
function resolveToken(declarations: Map<string, string>, token: string) {
const value = declarations.get(token) ?? declarationsOf(':root').get(token)!
const alias = /^var\((--[a-z0-9-]+)\)$/.exec(value)
if (!alias) {
return value
}
return declarations.get(alias[1]!) ?? resolveFromTheme(alias[1]!)
}
function resolveFromTheme(token: string) {
return new RegExp(`^\t${token}: ([^;]+);$`, 'm').exec(mainCss)![1]!
}
const tokenRows: [string, 'light' | 'dark', string][] = [
['--rc-sand', 'light', '#e8dfd1'],
['--rc-sand', 'dark', '#d4c5b0'],
['--rc-on-sand', 'light', '#111111'],
['--rc-on-sand', 'dark', '#111111'],
['--rc-success-soft', 'light', '#eaefea'],
['--rc-success-soft', 'dark', '#1f241f'],
['--rc-info-soft', 'light', '#ebeef0'],
['--rc-info-soft', 'dark', '#202428'],
['--rc-warning-soft', 'light', '#f0ede6'],
['--rc-warning-soft', 'dark', '#2a251e'],
['--rc-error-soft', 'light', '#f3eae9'],
['--rc-error-soft', 'dark', '#2c201f'],
['--rc-overlay', 'light', 'rgb(10 10 10 / 0.4)'],
['--rc-overlay', 'dark', 'rgb(0 0 0 / 0.6)'],
['--rc-shadow-pop', 'light', '0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06)'],
['--rc-shadow-pop', 'dark', '0 4px 16px rgb(0 0 0 / 0.5)'],
['--rc-shadow-modal', 'light', '0 16px 48px rgb(17 17 17 / 0.18)'],
['--rc-shadow-modal', 'dark', '0 16px 48px rgb(0 0 0 / 0.6)'],
]
const lightUiTokens = {
'--ui-bg': 'var(--color-white)',
'--ui-bg-muted': 'var(--color-chalk-50)',
'--ui-bg-elevated': 'var(--color-chalk-100)',
'--ui-bg-accented': 'var(--color-chalk-200)',
'--ui-bg-inverted': 'var(--color-chalk-900)',
'--ui-text-dimmed': 'var(--color-chalk-400)',
'--ui-text-muted': 'var(--color-chalk-500)',
'--ui-text-toned': 'var(--color-chalk-600)',
'--ui-text': 'var(--color-chalk-800)',
'--ui-text-highlighted': 'var(--color-chalk-900)',
'--ui-text-inverted': 'var(--color-white)',
'--ui-border': 'var(--color-chalk-200)',
'--ui-border-muted': 'var(--color-chalk-100)',
'--ui-border-accented': 'var(--color-chalk-450)',
'--ui-border-inverted': 'var(--color-chalk-900)',
'--ui-primary': 'var(--ui-color-primary-900)',
'--ui-secondary': 'var(--ui-color-secondary-700)',
'--ui-success': 'var(--ui-color-success-700)',
'--ui-info': 'var(--ui-color-info-700)',
'--ui-warning': 'var(--ui-color-warning-700)',
'--ui-error': 'var(--ui-color-error-700)',
}
const darkUiTokens = {
'--ui-bg': 'var(--color-chalk-900)',
'--ui-bg-muted': 'var(--color-chalk-950)',
'--ui-bg-elevated': 'var(--color-chalk-850)',
'--ui-bg-accented': 'var(--color-chalk-800)',
'--ui-bg-inverted': 'var(--color-chalk-50)',
'--ui-text-dimmed': 'var(--color-chalk-500)',
'--ui-text-muted': 'var(--color-chalk-400)',
'--ui-text-toned': 'var(--color-chalk-300)',
'--ui-text': 'var(--color-chalk-200)',
'--ui-text-highlighted': 'var(--color-chalk-50)',
'--ui-text-inverted': 'var(--color-chalk-900)',
'--ui-border': 'var(--color-chalk-700)',
'--ui-border-muted': 'var(--color-chalk-800)',
'--ui-border-accented': 'var(--color-chalk-500)',
'--ui-border-inverted': 'var(--color-chalk-50)',
'--ui-primary': 'var(--ui-color-primary-50)',
'--ui-secondary': 'var(--ui-color-secondary-300)',
'--ui-success': 'var(--ui-color-success-300)',
'--ui-info': 'var(--ui-color-info-300)',
'--ui-warning': 'var(--ui-color-warning-300)',
'--ui-error': 'var(--ui-color-error-300)',
}
function uiTokensOf(declarations: Map<string, string>) {
return Object.fromEntries(
[...declarations].filter(
([name]) => name.startsWith('--ui-') && !/radius|header|container/.test(name),
),
)
}
describe('main.css declares the shell design tokens', () => {
it.each(tokenRows)(
'%s resolves to the foundations value in the %s theme',
(token, theme, value) => {
const declarations = declarationsOf(theme === 'light' ? ':root' : '.dark')
expect(resolveToken(declarations, token)).toBe(value)
},
)
it('declares no --rc-canvas', () => {
expect(mainCss).not.toContain('--rc-canvas')
})
it('leaves every --ui-* token value unchanged', () => {
expect(uiTokensOf(declarationsOf(':root'))).toEqual(lightUiTokens)
expect(uiTokensOf(declarationsOf('.dark'))).toEqual(darkUiTokens)
})
})
const SELECTED_BASE = ['bg-(--rc-sand)', 'text-(--rc-on-sand)']
const SELECTED_STATES = ['hover', 'active', 'focus-visible'].flatMap((state) => [
`${state}:bg-(--rc-sand)`,
`${state}:text-(--rc-on-sand)`,
])
const FORBIDDEN_TEXT = /(^|:)(text-muted|text-white|text-inverted)$/
function navLink(root: ParentNode, href: string) {
return root.querySelector(`nav[aria-label="Main"] a[href="${href}"]`) as HTMLElement
}
function classesOf(element: Element) {
return Array.from(element.classList)
}
function expectSelected(link: HTMLElement) {
const classes = classesOf(link)
for (const cls of [...SELECTED_BASE, ...SELECTED_STATES]) {
expect(classes).toContain(cls)
}
expect(link.getAttribute('aria-current')).toBe('page')
const forbidden = classes.filter((cls) => FORBIDDEN_TEXT.test(cls))
expect(forbidden).toEqual([])
const iconClasses = classesOf(link.querySelector('svg, [data-slot="linkLeadingIcon"]')!)
expect(iconClasses.filter((cls) => FORBIDDEN_TEXT.test(cls))).toEqual([])
}
function expectUnfilled(link: HTMLElement) {
expect(link.getAttribute('aria-current')).toBeNull()
expect(classesOf(link).filter((cls) => cls.includes('--rc-sand'))).toEqual([])
}
function expectBoldParent(link: HTMLElement) {
expectUnfilled(link)
const classes = classesOf(link)
expect(classes).toContain('[li[data-state=open]>&:not([aria-current=page])]:text-highlighted')
expect(classes).toContain('[li[data-state=open]>&:not([aria-current=page])]:font-medium')
expect(link.closest('li')!.getAttribute('data-state')).toBe('open')
const chevron = link.querySelector('[data-slot="linkTrailingIcon"]')!
expect(classesOf(chevron)).toContain('group-data-[state=open]:rotate-90')
}
describe('DashboardShell selected nav rows keep the sand contract in every state', () => {
it('gives the selected Settings row a 2 px --ui-primary focus outline and keeps the sand fill', async () => {
const wrapper = await mountShell('/settings', { role: 'owner' })
const classes = classesOf(navLink(wrapper.element, '/settings'))
expect(classes).toContain('focus-visible:outline-2')
expect(classes).toContain('focus-visible:outline-(--ui-primary)')
expect(classes).toContain('focus-visible:bg-(--rc-sand)')
expect(classes).toContain('bg-(--rc-sand)')
})
it.each([
['/settings', '/settings'],
['/settings/media/variants', '/settings/media/variants'],
['/settings/media/cache', '/settings/media/cache'],
['/home', '/home'],
['/users', '/users'],
['/activity', '/activity'],
])(
'paints the selected row on %s with sand and never white or muted text',
async (path, href) => {
const wrapper = await mountShell(path, { role: 'owner' })
expectSelected(navLink(wrapper.element, href))
},
)
it('keeps Variants and Cache unselected under the filled Settings row on /settings', async () => {
const wrapper = await mountShell('/settings', { role: 'owner' })
expectUnfilled(navLink(wrapper.element, '/settings/media/variants'))
expectUnfilled(navLink(wrapper.element, '/settings/media/cache'))
})
it.each([
['/settings/media/variants', '/settings/media/cache'],
['/settings/media/cache', '/settings/media/variants'],
])('shows Settings unfilled, bold and open when %s is selected', async (path, other) => {
const wrapper = await mountShell(path, { role: 'owner' })
expectBoldParent(navLink(wrapper.element, '/settings'))
expectUnfilled(navLink(wrapper.element, other))
})
it.each([
['/settings', 'N1'],
['/settings/media/variants', 'N2'],
])('applies the same contract in the dark theme on %s (%s)', async (path) => {
document.documentElement.classList.add('dark')
try {
const wrapper = await mountShell(path, { role: 'owner' })
if (path === '/settings') {
expectSelected(navLink(wrapper.element, '/settings'))
expectUnfilled(navLink(wrapper.element, '/settings/media/variants'))
expectUnfilled(navLink(wrapper.element, '/settings/media/cache'))
} else {
expectSelected(navLink(wrapper.element, path))
expectBoldParent(navLink(wrapper.element, '/settings'))
}
} finally {
document.documentElement.classList.remove('dark')
}
})
})
describe('DashboardShell mobile drawer rows', () => {
async function openDrawer(path: string) {
await mountShell(path, { role: 'owner', width: MOBILE_WIDTH })
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
toggle.click()
await flushPromises()
return document.body.querySelector('[role="dialog"]') as HTMLElement
}
it('draws every drawer row 44 px tall', async () => {
const drawer = await openDrawer('/settings')
const links = Array.from(drawer.querySelectorAll('nav[aria-label="Main"] a'))
expect(links).toHaveLength(9)
for (const link of links) {
expect(classesOf(link)).toContain('max-lg:h-11')
}
})
it('lists Variants and Cache unselected under the filled Settings row on /settings', async () => {
const drawer = await openDrawer('/settings')
expect(drawer.querySelector('nav[aria-label="Main"]')!.textContent).toContain('Media')
expectSelected(navLink(drawer, '/settings'))
expectUnfilled(navLink(drawer, '/settings/media/variants'))
expectUnfilled(navLink(drawer, '/settings/media/cache'))
})
it('fills Variants and leaves Settings unfilled and bold on /settings/media/variants', async () => {
const drawer = await openDrawer('/settings/media/variants')
expectSelected(navLink(drawer, '/settings/media/variants'))
expectBoldParent(navLink(drawer, '/settings'))
expectUnfilled(navLink(drawer, '/settings/media/cache'))
})
})
+3 -10
View File
@@ -229,14 +229,7 @@ export const renewUi = {
},
compoundVariants: [
{
color: [
'primary',
'secondary',
'success',
'info',
'warning',
'neutral',
],
color: ['primary', 'secondary', 'success', 'info', 'warning', 'neutral'],
class: { close: 'hidden' },
},
],
@@ -302,8 +295,8 @@ export const renewUi = {
orientation: 'vertical',
active: true,
class: {
link: 'bg-(--ui-color-secondary-200) text-(--ui-color-primary-900) dark:bg-(--ui-color-secondary-300) font-medium before:hidden',
linkLeadingIcon: 'text-(--ui-color-primary-900)',
link: 'bg-(--rc-sand) text-(--rc-on-sand) hover:bg-(--rc-sand) hover:text-(--rc-on-sand) active:bg-(--rc-sand) active:text-(--rc-on-sand) focus-visible:bg-(--rc-sand) focus-visible:text-(--rc-on-sand) focus-visible:outline-2 focus-visible:outline-(--ui-primary) font-medium before:hidden',
linkLeadingIcon: 'text-(--rc-on-sand) group-hover:text-(--rc-on-sand)',
},
},
{
+40 -6
View File
@@ -26,6 +26,9 @@ interface Screen {
sources: Source[]
}
const VISUAL_REFERENCE =
/Visual reference:\*{0,2}\s*`(?:source\/)?([^`]+)`,?\s*groups?\s+((?:"[^"]+"(?:,\s*)?)+)/
const officeBoard = 'RenewCMS Screens Back-office.dc.html'
const extraSources: Record<string, Source[]> = {
@@ -286,7 +289,15 @@ function variantsScreen(): Screen {
'V5i · Purge all: confirm, typed',
'V5j · Purge unused: confirm',
]
const navNames = [
'N1 · Parent selected',
'N2 · Child selected: Variants',
'N3 · Child selected: Cache',
'N4 · Mobile drawer, parent selected',
'N5 · Mobile drawer, child selected',
]
sources.push(
{ board: officeBoard, group: 'Settings navigation', states: navNames, alias: {} },
{ board: variantsBoard, group: 'Cache panel', states: cacheNames, alias: {} },
{ board: variantsBoard, group: 'Media library', states: libraryNames, alias: {} },
{ board: variantsBoard, group: 'Image detail', states: detailNames, alias: {} },
@@ -303,22 +314,33 @@ function variantsScreen(): Screen {
...libraryNames,
...detailNames,
...editorNames,
...navNames,
],
sources,
}
}
function groupSectionRows(markdown: string, group: string) {
const heading = new RegExp(`^### .*\\(group "${group}", \\d+ states?\\)$`, 'm').exec(markdown)
if (!heading) {
return []
}
const rest = markdown.slice(heading.index + heading[0].length)
const end = rest.search(/^##/m)
const section = end === -1 ? rest : rest.slice(0, end)
return [...section.matchAll(/^\| \d+ \| ([^|]+?) \|/gm)].map((match) => match[1]!.trim())
}
async function readScreen(name: string): Promise<Screen> {
if (name === 'image-variants') {
return variantsScreen()
}
const markdown = await readFile(join(designRoot, 'screens', `${name}.md`), 'utf8')
const reference =
/\*\*Visual reference:\*\* `source\/([^`]+)`, group "([^"]+)"/.exec(markdown) ??
/^Visual reference: `([^`]+)`, groups? "([^"]+)"/m.exec(markdown)
const reference = VISUAL_REFERENCE.exec(markdown)
if (!reference) {
throw new Error(`docs/design/screens/${name}.md has no visual reference line`)
}
const referenceGroups = [...reference[2]!.matchAll(/"([^"]+)"/g)].map((match) => match[1]!)
const numberedRows = [...markdown.matchAll(/^\| \d+ \| ([^|]+?) \|/gm)].map((match) =>
match[1]!.trim(),
)
@@ -329,23 +351,35 @@ async function readScreen(name: string): Promise<Screen> {
const headingStates = [...statesMarkdown.matchAll(/^### (.+)$/gm)].map((match) =>
match[1]!.trim(),
)
const states = numberedRows.length
const groupRows = groupSectionRows(markdown, referenceGroups[0]!)
const states = groupRows.length
? groupRows
: numberedRows.length
? numberedRows
: headingStates.length
? headingStates
: tableStates(statesMarkdown)
const primary: Source = {
board: reference[1]!,
group: reference[2]!,
group: referenceGroups[0]!,
states,
alias: boardAlias[name] ?? {},
}
const configured = extraSources[name] ?? []
const documentedExtras = referenceGroups
.slice(1)
.filter((group) => !configured.some((source) => source.group === group))
.map((group): Source => ({ board: primary.board, group, states: [], alias: {} }))
return {
name,
board: primary.board,
group: primary.group,
states,
sources: [primary, ...(extraSources[name] ?? [])],
sources: [
primary,
...configured.filter((source) => source.group !== primary.group),
...documentedExtras,
],
}
}
+46
View File
@@ -3786,6 +3786,51 @@ const variantsHeroDisabled = variantsBoardList.map((entry) =>
entry.name === 'hero' ? { ...entry, enabled: false } : entry,
)
const settingsNavPath = '/settings'
function settingsNavState(path: string, drawer = false): VisualState {
const base =
path === cachePath
? cacheState()
: path === variantsPath
? variantsListState()
: {
path,
api: {
...updateApi('owner', updateNeverChecked()),
'GET /v1/auth/me': variantsSession,
},
}
if (!drawer) {
return base
}
return {
...base,
steps: async (page) => {
if (isMobileViewport(page)) {
await page.locator('[data-slot="toggle"]').first().click()
await page.mouse.move(0, 0)
await page.waitForTimeout(250)
}
},
}
}
const settingsNavStates: Record<string, VisualState> = {
'N1 · Parent selected': settingsNavState(settingsNavPath),
'N2 · Child selected: Variants': settingsNavState(variantsPath),
'N3 · Child selected: Cache': settingsNavState(cachePath),
'N4 · Mobile drawer, parent selected': {
...settingsNavState(settingsNavPath, true),
exception: {
maxPercent: { desktop: 18.2, mobile: 10 },
reason: 'board inconsistency: desktop frame of a mobile-only state',
approvedBy: 'Florian Sylvain',
},
},
'N5 · Mobile drawer, child selected': settingsNavState(variantsPath, true),
}
const variantsMediaStates: Record<string, VisualState> = {
'Entry: sidebar': {
path: '/media',
@@ -5115,6 +5160,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
'image-variants': {
...variantsStates,
...cacheStates,
...settingsNavStates,
...withVariantExceptions(variantsMediaStates),
...withVariantExceptions(variantsEditorStates),
},