From e819936b40a66477cb5963bdc9a0c20d56faa6ef Mon Sep 17 00:00:00 2001 From: Nicolas CHAUVIN Date: Sun, 6 Sep 2026 16:08:33 +0200 Subject: [PATCH 1/2] fix(ui): stop the font shorthand from undoing a monospace family --- apps/frontend/scripts/check-text-styles.mjs | 35 +++++++++++++++++++ .../frontend/src/surfaces/dev/GalleryMenu.vue | 2 +- .../src/surfaces/dev/GallerySection.vue | 2 +- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/apps/frontend/scripts/check-text-styles.mjs b/apps/frontend/scripts/check-text-styles.mjs index d58735d..3b20ecf 100644 --- a/apps/frontend/scripts/check-text-styles.mjs +++ b/apps/frontend/scripts/check-text-styles.mjs @@ -49,7 +49,27 @@ function* files(directory) { } } +/* + * The `font` shorthand resets `font-family`, so a mono declaration placed + * before it is silently undone. That is not hypothetical: the catalogue's + * merchant reference shipped without its monospace face for exactly this + * reason, and neither the type check, the lint, nor a look at the screenshot + * caught it — a pixel comparison did. + */ +function familyUndoneByShorthand(text) { + const found = [] + for (const [, selector, block] of text.matchAll(/([^{}]*)\{([^{}]*)\}/g)) { + const family = block.indexOf('font-family:') + const shorthand = block.indexOf('font: var(--style-') + if (family !== -1 && shorthand !== -1 && shorthand > family) { + found.push(selector.trim().split('\n').pop().trim()) + } + } + return found +} + const violations = [] +const undone = [] for (const path of files(SOURCE)) { if (ALLOWED.includes(path)) { @@ -68,6 +88,21 @@ for (const path of files(SOURCE)) { } } }) + + for (const selector of familyUndoneByShorthand(readFileSync(path, 'utf8'))) { + undone.push(`${relative(ROOT, path)} ${selector}`) + } +} + +if (undone.length > 0) { + console.error( + `${undone.length} rule(s) where \`font:\` undoes the \`font-family\` above it.\n` + + 'The shorthand resets the family: declare it after, not before.\n', + ) + for (const one of undone) { + console.error(` ${one}`) + } + process.exit(1) } if (violations.length > 0) { diff --git a/apps/frontend/src/surfaces/dev/GalleryMenu.vue b/apps/frontend/src/surfaces/dev/GalleryMenu.vue index e35b482..dee9358 100644 --- a/apps/frontend/src/surfaces/dev/GalleryMenu.vue +++ b/apps/frontend/src/surfaces/dev/GalleryMenu.vue @@ -73,8 +73,8 @@ import { anchorOf, FAMILIES } from '@/surfaces/dev/gallery-registry' a { color: var(--colour-accent); - font-family: var(--font-mono); font: var(--style-caption); + font-family: var(--font-mono); text-decoration: none; } diff --git a/apps/frontend/src/surfaces/dev/GallerySection.vue b/apps/frontend/src/surfaces/dev/GallerySection.vue index 0269380..5e493d7 100644 --- a/apps/frontend/src/surfaces/dev/GallerySection.vue +++ b/apps/frontend/src/surfaces/dev/GallerySection.vue @@ -48,8 +48,8 @@ const anchor = anchorOf(props.name) .name { margin: 0; - font-family: var(--font-mono); font: var(--style-body-strong); + font-family: var(--font-mono); } .contract { From fed2f0fce74c9360e91d74c7b8cbffd06c3e05c7 Mon Sep 17 00:00:00 2001 From: Nicolas CHAUVIN Date: Sun, 6 Sep 2026 16:08:39 +0200 Subject: [PATCH 2/2] chore(admin): draw the catalogue and the vat rates with components only --- apps/frontend/eslint.config.ts | 8 + apps/frontend/src/i18n/locales/en.json | 5 +- apps/frontend/src/i18n/locales/fr.json | 5 +- apps/frontend/src/shared/ui/Table.spec.ts | 24 +++ apps/frontend/src/shared/ui/Table.vue | 11 +- .../src/surfaces/admin/CatalogueView.spec.ts | 16 +- .../src/surfaces/admin/CatalogueView.vue | 145 +----------------- .../src/surfaces/admin/ProductTable.vue | 79 ++++++++++ .../src/surfaces/admin/VatRateTable.vue | 82 ++++++++++ .../src/surfaces/admin/VatRates.spec.ts | 13 +- apps/frontend/src/surfaces/admin/VatRates.vue | 67 ++------ .../0003-shared-components-and-surfaces.md | 1 + 12 files changed, 248 insertions(+), 208 deletions(-) create mode 100644 apps/frontend/src/surfaces/admin/ProductTable.vue create mode 100644 apps/frontend/src/surfaces/admin/VatRateTable.vue diff --git a/apps/frontend/eslint.config.ts b/apps/frontend/eslint.config.ts index 9f92ef9..b67b57d 100644 --- a/apps/frontend/eslint.config.ts +++ b/apps/frontend/eslint.config.ts @@ -48,6 +48,14 @@ export default defineConfigWithVueTs( element: 'dialog', message: 'Add a shared dialog to shared/ui rather than a local one — see frontend ADR 0003.', }, + // Structure, closed once the two screens that drew their own had + // somewhere to go (#85). A table is not an affordance, but hand-writing + // one is how a surface ends up deciding what a row looks like — which + // is the boundary frontend ADR 0004 draws. + ...['table', 'thead', 'tbody', 'tr', 'th', 'td'].map((element) => ({ + element, + message: 'Use Table from shared/ui with typed cells — see frontend ADR 0004.', + })), ], // A ban reading tag names alone waves through the hand-rolled equivalent: diff --git a/apps/frontend/src/i18n/locales/en.json b/apps/frontend/src/i18n/locales/en.json index df345da..c7642b9 100644 --- a/apps/frontend/src/i18n/locales/en.json +++ b/apps/frontend/src/i18n/locales/en.json @@ -115,6 +115,7 @@ "shop": "The shop", "shopAbout": "{country} · {currency} · {timezone}. Everyone who administers this shop sees the same.", "rates": { + "title": "VAT rates", "name": "Name", "rate": "Rate", "add": "Add", @@ -141,8 +142,8 @@ "name": "Name", "legalIdentity": "Legal name and registration number", "country": "Country of the shop", - "countryDecides": "Sets which VAT rates you can charge.", - "currency": "Currency", + "countryDecides": "Sets which VAT rates you can charge.", + "currency": "Currency", "currencyFinal": "Final once the first order is placed.", "timezone": "Time zone", "contentLanguage": "Product page language", diff --git a/apps/frontend/src/i18n/locales/fr.json b/apps/frontend/src/i18n/locales/fr.json index 2d51203..8a573f8 100644 --- a/apps/frontend/src/i18n/locales/fr.json +++ b/apps/frontend/src/i18n/locales/fr.json @@ -115,6 +115,7 @@ "shop": "La boutique", "shopAbout": "{country} · {currency} · {timezone}. Tous ceux qui administrent cette boutique voient la même chose.", "rates": { + "title": "Taux de TVA", "name": "Nom", "rate": "Taux", "add": "Ajouter", @@ -141,8 +142,8 @@ "name": "Nom", "legalIdentity": "Raison sociale et immatriculation", "country": "Pays de la boutique", - "countryDecides": "Détermine les taux de TVA que vous pouvez appliquer.", - "currency": "Devise", + "countryDecides": "Détermine les taux de TVA que vous pouvez appliquer.", + "currency": "Devise", "currencyFinal": "Définitive dès la première commande.", "timezone": "Fuseau horaire", "contentLanguage": "Langue des fiches produits", diff --git a/apps/frontend/src/shared/ui/Table.spec.ts b/apps/frontend/src/shared/ui/Table.spec.ts index bec9e21..0e4fd45 100644 --- a/apps/frontend/src/shared/ui/Table.spec.ts +++ b/apps/frontend/src/shared/ui/Table.spec.ts @@ -200,6 +200,30 @@ describe('Table', () => { expect(onPress).not.toHaveBeenCalled() }) + it('renders no header row when no column names anything', () => { + stubMatchMedia(false) + // The rates screen is such a table: a name, a figure, a mark, an action — + // each reads for itself, and a row of empty headers above them is chrome + // for nothing. + const rendered = table({ + columns: [{ key: 'name', header: '' }, { key: 'act', header: '' }], + rows: [{ key: 1, cells: { name: { kind: 'strong', value: 'Standard' } } }], + }) + + expect(rendered.find('thead').exists()).toBe(false) + expect(rendered.find('tbody').exists()).toBe(true) + }) + + it('renders the header row as soon as one column names something', () => { + stubMatchMedia(false) + const rendered = table({ + columns: [{ key: 'name', header: 'Taux' }, { key: 'act', header: '' }], + rows: [{ key: 1, cells: { name: { kind: 'strong', value: 'Standard' } } }], + }) + + expect(rendered.find('thead').exists()).toBe(true) + }) + it('renders a cell a row never filed, rather than failing on it', () => { stubMatchMedia(false) // A column whose key no row carries: the table shows the absence instead diff --git a/apps/frontend/src/shared/ui/Table.vue b/apps/frontend/src/shared/ui/Table.vue index 192f735..b38eded 100644 --- a/apps/frontend/src/shared/ui/Table.vue +++ b/apps/frontend/src/shared/ui/Table.vue @@ -37,6 +37,15 @@ function cellOf(row: Row, column: Column): Cell { } const hasRows = computed(() => props.rows.length > 0) + +/** + * No header row when no column names anything. + * + * The rates screen is such a table: four columns, none of them a rubric — a + * name, a figure, a mark and an action read for themselves. A row of empty + * headers above them would be chrome for nothing. + */ +const hasHeaders = computed(() => props.columns.some((column) => column.header !== ''))