From 8269d44d12c1e4ab449c4c428e11e99aa5c2a483 Mon Sep 17 00:00:00 2001 From: AdilAzhariOmsan Date: Sun, 8 Nov 2026 15:31:42 +0800 Subject: [PATCH 1/5] feat(design): add design tokens with a tested contrast floor - resources/css/tokens.css: plain CSS custom properties (--nx-*) for colour, type, spacing, radius, elevation and motion. No Tailwind or Vue syntax, so the Filament admin can import it unchanged (ADR-0020). Two layers: --nx-palette-* raw values and semantic tokens. Light only; dark mode is a later block that re-points the semantic colours. - Direction (owner decision): premium minimalist, warm neutrals, an ink accent with no hue; colour is reserved for status. - Type: Instrument Sans (Latin and Malay) and IBM Plex Sans Arabic, both self-hosted through the existing laravel-vite-plugin Bunny fonts integration. Arabic is arabic-subset only and not preloaded. :lang(ar) switches the stack and lengthens body line heights. - app.css maps the tokens into Tailwind 4 with @theme inline and clears the default colours, radii, shadows, fonts, type scale and tracking, so every style goes through a token. - Reduced motion sets every duration token to 0ms. - Contrast (NFR-A11Y-03): contrast-pairs.ts derives the required pairs from token names (text 4.5:1; control borders, focus ring and accent 3:1) and a coverage test fails on any colour token that is neither checked nor listed as decorative. WCAG maths and token parsing are first-party (no new dependencies) and unit tested. - vitest.config.ts lets tests import resources/css as ?raw text. --- resources/css/app.css | 117 +++++++++++++- resources/css/tokens.css | 164 ++++++++++++++++++++ resources/js/design/contrast-pairs.ts | 70 +++++++++ resources/js/design/contrast.test.ts | 36 +++++ resources/js/design/contrast.ts | 37 +++++ resources/js/design/tokens-contrast.test.ts | 46 ++++++ resources/js/design/tokens.test.ts | 62 ++++++++ resources/js/design/tokens.ts | 72 +++++++++ vite.config.ts | 8 + vitest.config.ts | 3 + 10 files changed, 612 insertions(+), 3 deletions(-) create mode 100644 resources/css/tokens.css create mode 100644 resources/js/design/contrast-pairs.ts create mode 100644 resources/js/design/contrast.test.ts create mode 100644 resources/js/design/contrast.ts create mode 100644 resources/js/design/tokens-contrast.test.ts create mode 100644 resources/js/design/tokens.test.ts create mode 100644 resources/js/design/tokens.ts diff --git a/resources/css/app.css b/resources/css/app.css index 54b247e..105c8ec 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,9 +1,120 @@ @import 'tailwindcss'; +@import './tokens.css'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../storage/framework/views/*.php'; -@theme { - --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', - 'Segoe UI Symbol', 'Noto Color Emoji'; +/* + * Tailwind's theme is reduced to the Nexo tokens. The default colours, radii, + * shadows, fonts and type scale are cleared, so utilities such as bg-red-500 + * do not exist and every style goes through a token. `inline` makes each + * utility reference the token variable, so the :lang(ar) and reduced-motion + * overrides in tokens.css apply at runtime. + */ +@theme inline { + --color-*: initial; + --radius-*: initial; + --shadow-*: initial; + --font-*: initial; + --text-*: initial; + --tracking-*: initial; + --font-weight-*: initial; + + --color-transparent: transparent; + --color-current: currentColor; + + --color-canvas: var(--nx-color-bg-canvas); + --color-surface: var(--nx-color-bg-surface); + --color-subtle: var(--nx-color-bg-subtle); + --color-muted: var(--nx-color-bg-muted); + + --color-fg: var(--nx-color-fg-default); + --color-fg-muted: var(--nx-color-fg-muted); + + --color-accent: var(--nx-color-accent); + --color-accent-hover: var(--nx-color-accent-hover); + --color-on-accent: var(--nx-color-on-accent); + + --color-border-subtle: var(--nx-color-border-subtle); + --color-border: var(--nx-color-border-default); + --color-border-control: var(--nx-color-border-control); + --color-focus: var(--nx-color-focus-ring); + + --color-success: var(--nx-color-success-fg); + --color-success-bg: var(--nx-color-success-bg); + --color-warning: var(--nx-color-warning-fg); + --color-warning-bg: var(--nx-color-warning-bg); + --color-danger: var(--nx-color-danger-fg); + --color-danger-bg: var(--nx-color-danger-bg); + --color-info: var(--nx-color-info-fg); + --color-info-bg: var(--nx-color-info-bg); + + --font-sans: var(--nx-font-sans); + --font-mono: var(--nx-font-mono); + + --font-weight-regular: var(--nx-font-weight-regular); + --font-weight-medium: var(--nx-font-weight-medium); + --font-weight-semibold: var(--nx-font-weight-semibold); + + --text-xs: var(--nx-text-xs); + --text-xs--line-height: var(--nx-text-xs-leading); + --text-sm: var(--nx-text-sm); + --text-sm--line-height: var(--nx-text-sm-leading); + --text-base: var(--nx-text-base); + --text-base--line-height: var(--nx-text-base-leading); + --text-lg: var(--nx-text-lg); + --text-lg--line-height: var(--nx-text-lg-leading); + --text-xl: var(--nx-text-xl); + --text-xl--line-height: var(--nx-text-xl-leading); + --text-2xl: var(--nx-text-2xl); + --text-2xl--line-height: var(--nx-text-2xl-leading); + --text-3xl: var(--nx-text-3xl); + --text-3xl--line-height: var(--nx-text-3xl-leading); + --text-4xl: var(--nx-text-4xl); + --text-4xl--line-height: var(--nx-text-4xl-leading); + --text-5xl: var(--nx-text-5xl); + --text-5xl--line-height: var(--nx-text-5xl-leading); + + --tracking-tight: var(--nx-tracking-tight); + --tracking-normal: var(--nx-tracking-normal); + --tracking-wide: var(--nx-tracking-wide); + + --spacing: var(--nx-space-unit); + --spacing-gutter: var(--nx-space-gutter); + --spacing-section: var(--nx-space-section); + --container-content: var(--nx-content-max); + + --radius-sm: var(--nx-radius-sm); + --radius-md: var(--nx-radius-md); + --radius-lg: var(--nx-radius-lg); + --radius-full: var(--nx-radius-full); + + --shadow-xs: var(--nx-shadow-xs); + --shadow-sm: var(--nx-shadow-sm); + --shadow-md: var(--nx-shadow-md); + --shadow-lg: var(--nx-shadow-lg); + + --ease-standard: var(--nx-ease-standard); + --ease-enter: var(--nx-ease-enter); + --ease-exit: var(--nx-ease-exit); + --default-transition-duration: var(--nx-duration-base); + --default-transition-timing-function: var(--nx-ease-standard); +} + +@layer base { + html { + background-color: var(--nx-color-bg-canvas); + color: var(--nx-color-fg-default); + } + + /* Re-resolve the font stack where the language changes (see :lang(ar) + in tokens.css); inherited font-family would keep the outer stack. */ + [lang] { + font-family: var(--nx-font-sans); + } + + :focus-visible { + outline: 2px solid var(--nx-color-focus-ring); + outline-offset: 2px; + } } diff --git a/resources/css/tokens.css b/resources/css/tokens.css new file mode 100644 index 0000000..06242ba --- /dev/null +++ b/resources/css/tokens.css @@ -0,0 +1,164 @@ +/* + * Nexo design tokens. + * + * Plain CSS custom properties with no Tailwind or Vue syntax, so the Filament + * admin can import this file unchanged (ADR-0020). app.css maps them into + * Tailwind's @theme. + * + * Direction: premium minimalist on a warm neutral palette. The accent is ink + * (near-black) with no hue; colour is reserved for status. + * + * Layers: + * --nx-palette-* raw values. Components never use these directly. + * everything else semantic tokens, which components and Tailwind use. + * + * Dark mode: not shipped. It will be one block that re-points the semantic + * colour tokens at other palette values; no component changes are needed. + * + * Contrast rules for every colour token live in + * resources/js/design/contrast-pairs.ts and are enforced by Vitest. + */ + +:root { + color-scheme: light; + + /* ---- Palette: warm neutrals ---- */ + --nx-palette-neutral-0: #ffffff; + --nx-palette-neutral-25: #fcfcfa; + --nx-palette-neutral-50: #f7f6f3; + --nx-palette-neutral-100: #efeee9; + --nx-palette-neutral-200: #e3e1db; + --nx-palette-neutral-300: #cfccc4; + --nx-palette-neutral-400: #a8a49b; + --nx-palette-neutral-500: #7b776e; + --nx-palette-neutral-600: #5c5952; + --nx-palette-neutral-700: #45423d; + --nx-palette-neutral-800: #2d2b28; + --nx-palette-neutral-900: #1c1b19; + --nx-palette-neutral-950: #121110; + + /* ---- Palette: status ---- */ + --nx-palette-green-50: #edf6f0; + --nx-palette-green-700: #1f6a44; + --nx-palette-amber-50: #fbf3e2; + --nx-palette-amber-800: #7a4807; + --nx-palette-red-50: #fcefee; + --nx-palette-red-700: #b0262c; + --nx-palette-blue-50: #eef3fb; + --nx-palette-blue-700: #1f58ad; + + /* ---- Colour: backgrounds ---- */ + --nx-color-bg-canvas: var(--nx-palette-neutral-25); + --nx-color-bg-surface: var(--nx-palette-neutral-0); + --nx-color-bg-subtle: var(--nx-palette-neutral-50); + --nx-color-bg-muted: var(--nx-palette-neutral-100); + + /* ---- Colour: text ---- */ + --nx-color-fg-default: var(--nx-palette-neutral-900); + --nx-color-fg-muted: var(--nx-palette-neutral-600); + + /* ---- Colour: accent (ink) ---- */ + --nx-color-accent: var(--nx-palette-neutral-900); + --nx-color-accent-hover: var(--nx-palette-neutral-700); + --nx-color-on-accent: var(--nx-palette-neutral-0); + + /* ---- Colour: borders and focus ---- */ + --nx-color-border-subtle: var(--nx-palette-neutral-100); + --nx-color-border-default: var(--nx-palette-neutral-200); + --nx-color-border-control: var(--nx-palette-neutral-500); + --nx-color-focus-ring: var(--nx-palette-neutral-900); + + /* ---- Colour: status ---- */ + --nx-color-success-fg: var(--nx-palette-green-700); + --nx-color-success-bg: var(--nx-palette-green-50); + --nx-color-warning-fg: var(--nx-palette-amber-800); + --nx-color-warning-bg: var(--nx-palette-amber-50); + --nx-color-danger-fg: var(--nx-palette-red-700); + --nx-color-danger-bg: var(--nx-palette-red-50); + --nx-color-info-fg: var(--nx-palette-blue-700); + --nx-color-info-bg: var(--nx-palette-blue-50); + + /* ---- Typography ---- + * The Latin face also covers Malay. Arabic switches the stack below; the + * Latin face stays second so Latin words and digits in Arabic text match + * the rest of the brand (digits are Latin, ADR-0018 §3). + * --font-instrument-sans and --font-ibm-plex-sans-arabic are emitted by the + * laravel-vite-plugin fonts integration with metric-matched fallbacks. */ + --nx-font-sans: var(--font-instrument-sans, 'Instrument Sans'), ui-sans-serif, system-ui, sans-serif, var(--nx-font-emoji); + --nx-font-emoji: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --nx-font-arabic: + var(--font-ibm-plex-sans-arabic, 'IBM Plex Sans Arabic'), var(--font-instrument-sans, 'Instrument Sans'), + ui-sans-serif, system-ui, sans-serif, var(--nx-font-emoji); + --nx-font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + + --nx-font-weight-regular: 400; + --nx-font-weight-medium: 500; + --nx-font-weight-semibold: 600; + + --nx-text-xs: 0.75rem; + --nx-text-xs-leading: 1rem; + --nx-text-sm: 0.875rem; + --nx-text-sm-leading: 1.25rem; + --nx-text-base: 1rem; + --nx-text-base-leading: 1.5rem; + --nx-text-lg: 1.125rem; + --nx-text-lg-leading: 1.75rem; + --nx-text-xl: 1.25rem; + --nx-text-xl-leading: 1.75rem; + --nx-text-2xl: 1.5rem; + --nx-text-2xl-leading: 2rem; + --nx-text-3xl: 1.875rem; + --nx-text-3xl-leading: 2.25rem; + --nx-text-4xl: 2.25rem; + --nx-text-4xl-leading: 2.5rem; + --nx-text-5xl: 3rem; + --nx-text-5xl-leading: 1.1; + + --nx-tracking-tight: -0.015em; + --nx-tracking-normal: 0em; + --nx-tracking-wide: 0.04em; + + /* ---- Spacing: a 4px base unit; Tailwind multiplies it (p-4 = 16px) ---- */ + --nx-space-unit: 0.25rem; + --nx-space-gutter: 1.5rem; + --nx-space-section: 4rem; + --nx-content-max: 80rem; + + /* ---- Radius ---- */ + --nx-radius-sm: 0.25rem; + --nx-radius-md: 0.5rem; + --nx-radius-lg: 0.75rem; + --nx-radius-full: 9999px; + + /* ---- Elevation: ink-tinted, soft ---- */ + --nx-shadow-xs: 0 1px 2px 0 rgb(28 27 25 / 0.05); + --nx-shadow-sm: 0 1px 3px 0 rgb(28 27 25 / 0.08), 0 1px 2px -1px rgb(28 27 25 / 0.06); + --nx-shadow-md: 0 4px 12px -2px rgb(28 27 25 / 0.08), 0 2px 4px -2px rgb(28 27 25 / 0.05); + --nx-shadow-lg: 0 16px 32px -8px rgb(28 27 25 / 0.12), 0 4px 8px -4px rgb(28 27 25 / 0.06); + + /* ---- Motion ---- */ + --nx-duration-fast: 120ms; + --nx-duration-base: 200ms; + --nx-duration-slow: 320ms; + --nx-ease-standard: cubic-bezier(0.2, 0, 0, 1); + --nx-ease-enter: cubic-bezier(0, 0, 0, 1); + --nx-ease-exit: cubic-bezier(0.3, 0, 1, 1); +} + +/* Arabic: its own face and taller body lines. Scoped by language, not just + * the page locale, so Arabic content inside an English page (a vendor or + * product name) gets it too; app.css re-applies font-family on [lang]. */ +:lang(ar) { + --nx-font-sans: var(--nx-font-arabic); + --nx-text-base-leading: 1.75rem; + --nx-text-sm-leading: 1.5rem; + --nx-tracking-tight: 0em; +} + +@media (prefers-reduced-motion: reduce) { + :root { + --nx-duration-fast: 0ms; + --nx-duration-base: 0ms; + --nx-duration-slow: 0ms; + } +} diff --git a/resources/js/design/contrast-pairs.ts b/resources/js/design/contrast-pairs.ts new file mode 100644 index 0000000..e1c3778 --- /dev/null +++ b/resources/js/design/contrast-pairs.ts @@ -0,0 +1,70 @@ +// Which colour tokens must be legible against which, and how strongly. +// Rules are derived from token names, so a new token is checked automatically +// or the coverage test fails until it is classified (NFR-A11Y-03). + +import { contrastRatio } from './contrast'; +import { resolveToken, type TokenMap } from './tokens'; + +/** WCAG 1.4.3 (text) and 1.4.11 (non-text UI such as control borders and focus rings). */ +export const TEXT_MINIMUM = 4.5; +export const NON_TEXT_MINIMUM = 3; + +export const STATUS_TONES = ['success', 'warning', 'danger', 'info'] as const; + +/** Tokens exempt from contrast rules, with the reason. */ +export const DECORATIVE_COLOR_TOKENS: Readonly> = { + '--nx-color-border-default': 'Dividers and card outlines; decorative (WCAG 1.4.11 exempts them).', + '--nx-color-border-subtle': 'Hairlines inside already-bounded surfaces; decorative.', +}; + +export interface ContrastPair { + foreground: string; + background: string; + minimum: number; +} + +export interface ContrastResult extends ContrastPair { + ratio: number; + passes: boolean; +} + +export function colorTokenNames(tokens: TokenMap): string[] { + return [...tokens.keys()].filter((name) => name.startsWith('--nx-color-')); +} + +export function contrastPairs(tokens: TokenMap): ContrastPair[] { + const names = colorTokenNames(tokens); + const backgrounds = names.filter((n) => n.startsWith('--nx-color-bg-')); + const texts = names.filter((n) => n.startsWith('--nx-color-fg-')); + const pairs: ContrastPair[] = []; + const on = (foreground: string, background: string, minimum: number) => + pairs.push({ foreground, background, minimum }); + + for (const fg of texts) { + for (const bg of backgrounds) on(fg, bg, TEXT_MINIMUM); + } + + for (const tone of STATUS_TONES) { + const fg = `--nx-color-${tone}-fg`; + + on(fg, `--nx-color-${tone}-bg`, TEXT_MINIMUM); + for (const bg of backgrounds) on(fg, bg, TEXT_MINIMUM); + } + + on('--nx-color-on-accent', '--nx-color-accent', TEXT_MINIMUM); + on('--nx-color-on-accent', '--nx-color-accent-hover', TEXT_MINIMUM); + + for (const ui of ['--nx-color-accent', '--nx-color-border-control', '--nx-color-focus-ring']) { + for (const bg of backgrounds) on(ui, bg, NON_TEXT_MINIMUM); + } + + return pairs; +} + +export function evaluateContrast(tokens: TokenMap): ContrastResult[] { + return contrastPairs(tokens).map((pair) => { + const ratio = contrastRatio(resolveToken(tokens, pair.foreground), resolveToken(tokens, pair.background)); + + return { ...pair, ratio, passes: ratio >= pair.minimum }; + }); +} diff --git a/resources/js/design/contrast.test.ts b/resources/js/design/contrast.test.ts new file mode 100644 index 0000000..6958e9a --- /dev/null +++ b/resources/js/design/contrast.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { contrastRatio, relativeLuminance } from './contrast'; + +describe('relativeLuminance', () => { + it('is 0 for black and 1 for white', () => { + expect(relativeLuminance('#000000')).toBe(0); + expect(relativeLuminance('#ffffff')).toBe(1); + }); + + it('accepts three-digit and upper-case hex', () => { + expect(relativeLuminance('#FFF')).toBe(1); + expect(relativeLuminance('#abc')).toBeCloseTo(relativeLuminance('#aabbcc'), 10); + }); + + it('rejects values that are not hex colours', () => { + expect(() => relativeLuminance('rgb(0, 0, 0)')).toThrow(/hex colour/); + expect(() => relativeLuminance('#12345')).toThrow(/hex colour/); + }); +}); + +describe('contrastRatio', () => { + it('is 21:1 for black on white, in either order', () => { + expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 10); + expect(contrastRatio('#ffffff', '#000000')).toBeCloseTo(21, 10); + }); + + it('is 1:1 for identical colours', () => { + expect(contrastRatio('#7d7b75', '#7d7b75')).toBe(1); + }); + + it('matches the WCAG 2.x reference value for #767676 on white', () => { + // #767676 is the lightest grey that passes 4.5:1 on white. + expect(contrastRatio('#767676', '#ffffff')).toBeCloseTo(4.54, 2); + expect(contrastRatio('#777777', '#ffffff')).toBeLessThan(4.5); + }); +}); diff --git a/resources/js/design/contrast.ts b/resources/js/design/contrast.ts new file mode 100644 index 0000000..ec75c3e --- /dev/null +++ b/resources/js/design/contrast.ts @@ -0,0 +1,37 @@ +// WCAG 2.x relative luminance and contrast ratio. +// https://www.w3.org/TR/WCAG22/#dfn-relative-luminance + +const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i; + +function channels(hex: string): [number, number, number] { + if (!HEX.test(hex)) { + throw new Error(`Expected a hex colour such as #1c1b19, got "${hex}"`); + } + + const digits = + hex.length === 4 + ? hex + .slice(1) + .split('') + .map((d) => d + d) + .join('') + : hex.slice(1); + + return [0, 2, 4].map((i) => parseInt(digits.slice(i, i + 2), 16) / 255) as [number, number, number]; +} + +function linearise(channel: number): number { + return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; +} + +export function relativeLuminance(hex: string): number { + const [r, g, b] = channels(hex).map(linearise) as [number, number, number]; + + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +export function contrastRatio(a: string, b: string): number { + const [lighter, darker] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x) as [number, number]; + + return (lighter + 0.05) / (darker + 0.05); +} diff --git a/resources/js/design/tokens-contrast.test.ts b/resources/js/design/tokens-contrast.test.ts new file mode 100644 index 0000000..2b90808 --- /dev/null +++ b/resources/js/design/tokens-contrast.test.ts @@ -0,0 +1,46 @@ +import tokensCss from '../../css/tokens.css?raw'; +import { describe, expect, it } from 'vitest'; +import { colorTokenNames, contrastPairs, DECORATIVE_COLOR_TOKENS, evaluateContrast } from './contrast-pairs'; +import { readRootTokens } from './tokens'; + +const tokens = readRootTokens(tokensCss); + +describe('design token contrast (NFR-A11Y-03)', () => { + it('defines the semantic colour tokens the rules rely on', () => { + expect(colorTokenNames(tokens)).toEqual( + expect.arrayContaining([ + '--nx-color-bg-canvas', + '--nx-color-bg-surface', + '--nx-color-fg-default', + '--nx-color-fg-muted', + '--nx-color-accent', + '--nx-color-accent-hover', + '--nx-color-on-accent', + '--nx-color-border-control', + '--nx-color-focus-ring', + ]), + ); + }); + + it.each(evaluateContrast(tokens).map((r) => [r.foreground, r.background, r] as const))( + '%s on %s meets its minimum', + (_fg, _bg, result) => { + expect(result.ratio, `${result.ratio.toFixed(2)}:1 < ${result.minimum}:1`).toBeGreaterThanOrEqual( + result.minimum, + ); + }, + ); + + it('classifies every colour token: checked by a pair, or explicitly decorative', () => { + const checked = new Set(contrastPairs(tokens).flatMap((p) => [p.foreground, p.background])); + const unclassified = colorTokenNames(tokens).filter( + (name) => !checked.has(name) && !(name in DECORATIVE_COLOR_TOKENS), + ); + + expect(unclassified).toEqual([]); + }); + + it('lists only decorative tokens that exist', () => { + expect(Object.keys(DECORATIVE_COLOR_TOKENS).filter((name) => !tokens.has(name))).toEqual([]); + }); +}); diff --git a/resources/js/design/tokens.test.ts b/resources/js/design/tokens.test.ts new file mode 100644 index 0000000..c155338 --- /dev/null +++ b/resources/js/design/tokens.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { readRootTokens, resolveToken } from './tokens'; + +const css = ` +/* --nx-commented-out: #000; */ +:root { + --nx-palette-ink: #1c1b19; + --nx-color-fg-default: var(--nx-palette-ink); + --nx-color-fg-alias: var(--nx-color-fg-default); + --nx-text-base: 1rem; +} + +@media (prefers-reduced-motion: reduce) { + :root { + --nx-duration-base: 0ms; + } +} + +:root:lang(ar) { + --nx-text-base: 1.0625rem; +} + +:root { + --nx-duration-base: 200ms; +} +`; + +describe('readRootTokens', () => { + it('reads custom properties from top-level :root blocks only', () => { + const tokens = readRootTokens(css); + + expect([...tokens.keys()]).toEqual([ + '--nx-palette-ink', + '--nx-color-fg-default', + '--nx-color-fg-alias', + '--nx-text-base', + '--nx-duration-base', + ]); + expect(tokens.get('--nx-text-base')).toBe('1rem'); + expect(tokens.get('--nx-duration-base')).toBe('200ms'); + }); + + it('ignores commented-out declarations', () => { + expect(readRootTokens(css).has('--nx-commented-out')).toBe(false); + }); +}); + +describe('resolveToken', () => { + it('follows var() references to the underlying value', () => { + const tokens = readRootTokens(css); + + expect(resolveToken(tokens, '--nx-color-fg-alias')).toBe('#1c1b19'); + expect(resolveToken(tokens, '--nx-text-base')).toBe('1rem'); + }); + + it('fails loudly on unknown tokens and reference cycles', () => { + const cyclic = readRootTokens(':root { --a: var(--b); --b: var(--a); }'); + + expect(() => resolveToken(readRootTokens(css), '--nx-missing')).toThrow(/Unknown token/); + expect(() => resolveToken(cyclic, '--a')).toThrow(/cycle/); + }); +}); diff --git a/resources/js/design/tokens.ts b/resources/js/design/tokens.ts new file mode 100644 index 0000000..7ac8407 --- /dev/null +++ b/resources/js/design/tokens.ts @@ -0,0 +1,72 @@ +// Reads design tokens from resources/css/tokens.css so tests and the local +// token gallery work from the stylesheet itself rather than a copy of it. + +export type TokenMap = ReadonlyMap; + +const DECLARATION = /(--[\w-]+)\s*:\s*([^;]+);/g; +const VAR_REFERENCE = /^var\(\s*(--[\w-]+)\s*\)$/; + +/** + * Collects the custom properties declared in top-level `:root { … }` blocks. + * Blocks nested in at-rules (e.g. reduced-motion) and qualified selectors + * (e.g. `:root:lang(ar)`) are overrides, not token definitions, and are skipped. + */ +export function readRootTokens(css: string): Map { + const source = css.replace(/\/\*[\s\S]*?\*\//g, ''); + const tokens = new Map(); + let depth = 0; + let selectorStart = 0; + + for (let i = 0; i < source.length; i++) { + const char = source[i]; + + if (char === '{') { + const selector = source.slice(selectorStart, i).trim(); + const end = source.indexOf('}', i); + + if (depth === 0 && selector === ':root' && end !== -1) { + for (const [, name, value] of source.slice(i + 1, end).matchAll(DECLARATION)) { + tokens.set(name as string, (value as string).trim()); + } + } + + depth++; + selectorStart = i + 1; + } else if (char === '}') { + depth--; + selectorStart = i + 1; + } else if (char === ';' && depth === 0) { + selectorStart = i + 1; + } + } + + return tokens; +} + +/** Follows `var(--token)` references until a concrete value is reached. */ +export function resolveToken(tokens: TokenMap, name: string): string { + const seen = new Set(); + let current = name; + + for (;;) { + if (seen.has(current)) { + throw new Error(`Token reference cycle: ${[...seen, current].join(' → ')}`); + } + + seen.add(current); + + const value = tokens.get(current); + + if (value === undefined) { + throw new Error(`Unknown token ${current}${current === name ? '' : ` (referenced by ${name})`}`); + } + + const reference = VAR_REFERENCE.exec(value); + + if (!reference) { + return value; + } + + current = reference[1] as string; + } +} diff --git a/vite.config.ts b/vite.config.ts index d8a8127..a79f2f8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -13,6 +13,14 @@ export default defineConfig({ bunny('Instrument Sans', { weights: [400, 500, 600], }), + // Arabic glyphs only; Latin text in Arabic pages falls back to + // Instrument Sans (see --nx-font-arabic). Not preloaded, since + // only Arabic pages use it. + bunny('IBM Plex Sans Arabic', { + weights: [400, 500, 600], + subsets: ['arabic'], + preload: false, + }), ], }), vue({ diff --git a/vitest.config.ts b/vitest.config.ts index 3b4008e..b29c413 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,5 +12,8 @@ export default defineConfig({ test: { environment: 'jsdom', include: ['resources/js/**/*.test.ts'], + // Let tests read stylesheets as text (`?raw`); by default Vitest + // stubs CSS out and a raw import is an empty string. + css: { include: [/resources\/css\/.+\.css/] }, }, }); From c674a8277d6ed3eb27628ee0d4a634f835841fc4 Mon Sep 17 00:00:00 2001 From: AdilAzhariOmsan Date: Sun, 8 Nov 2026 15:31:42 +0800 Subject: [PATCH 2/5] feat(design): add a local-only design token gallery /dev/tokens renders every token read from tokens.css itself: semantic colours and palette, the full contrast table with ratios, the type scale, en/ms/ar samples (Arabic right to left), spacing, radius, elevation and motion. routes/local.php is loaded from bootstrap/app.php only when APP_ENV=local, so the route does not exist in testing, staging, demo or production. The Pest test boots a fresh application per environment to exercise the real gate. --- bootstrap/app.php | 6 + resources/js/pages/Dev/Tokens.test.ts | 35 +++++ resources/js/pages/Dev/Tokens.vue | 197 ++++++++++++++++++++++++++ routes/local.php | 12 ++ tests/Feature/TokenGalleryTest.php | 36 +++++ 5 files changed, 286 insertions(+) create mode 100644 resources/js/pages/Dev/Tokens.test.ts create mode 100644 resources/js/pages/Dev/Tokens.vue create mode 100644 routes/local.php create mode 100644 tests/Feature/TokenGalleryTest.php diff --git a/bootstrap/app.php b/bootstrap/app.php index 8a497e8..dff4955 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -5,12 +5,18 @@ use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Route; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', commands: __DIR__.'/../routes/console.php', health: '/up', + then: function (Application $app): void { + if ($app->environment('local')) { + Route::middleware('web')->group(base_path('routes/local.php')); + } + }, ) ->withMiddleware(function (Middleware $middleware): void { $middleware->web(append: [ diff --git a/resources/js/pages/Dev/Tokens.test.ts b/resources/js/pages/Dev/Tokens.test.ts new file mode 100644 index 0000000..5658dc9 --- /dev/null +++ b/resources/js/pages/Dev/Tokens.test.ts @@ -0,0 +1,35 @@ +import tokensCss from '../../../css/tokens.css?raw'; +import { mount } from '@vue/test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import { defineComponent } from 'vue'; +import { contrastPairs } from '@/design/contrast-pairs'; +import { readRootTokens } from '@/design/tokens'; +import Tokens from './Tokens.vue'; + +vi.mock('@inertiajs/vue3', async (importOriginal) => ({ + ...(await importOriginal()), + Head: defineComponent({ name: 'Head', render: () => null }), +})); + +const tokens = readRootTokens(tokensCss); + +describe('Dev/Tokens', () => { + it('shows a swatch for every colour token', () => { + const colorCount = [...tokens.keys()].filter((name) => name.startsWith('--nx-color-')).length; + + expect(mount(Tokens).findAll('[data-test="swatch"]')).toHaveLength(colorCount); + }); + + it('lists every contrast pair, none failing', () => { + const wrapper = mount(Tokens); + + expect(wrapper.findAll('[data-test="contrast-row"]')).toHaveLength(contrastPairs(tokens).length); + expect(wrapper.text()).toContain(', 0 failing'); + }); + + it('renders the Arabic sample right to left', () => { + const arabic = mount(Tokens).get('article[lang="ar"]'); + + expect(arabic.attributes('dir')).toBe('rtl'); + }); +}); diff --git a/resources/js/pages/Dev/Tokens.vue b/resources/js/pages/Dev/Tokens.vue new file mode 100644 index 0000000..1bb5467 --- /dev/null +++ b/resources/js/pages/Dev/Tokens.vue @@ -0,0 +1,197 @@ + + + diff --git a/routes/local.php b/routes/local.php new file mode 100644 index 0000000..52e0417 --- /dev/null +++ b/routes/local.php @@ -0,0 +1,12 @@ + Inertia::render('Dev/Tokens'))->name('dev.tokens'); diff --git a/tests/Feature/TokenGalleryTest.php b/tests/Feature/TokenGalleryTest.php new file mode 100644 index 0000000..a4b2e80 --- /dev/null +++ b/tests/Feature/TokenGalleryTest.php @@ -0,0 +1,36 @@ +refreshApplication(); +} + +afterEach(function () { + $_ENV['APP_ENV'] = $_SERVER['APP_ENV'] = 'testing'; + putenv('APP_ENV=testing'); +}); + +it('is not routed outside the local environment', function (string $environment) { + bootAs($environment); + + expect(app()->environment())->toBe($environment); + + $this->get('/dev/tokens')->assertNotFound(); +})->with(['production', 'staging', 'demo', 'testing']); + +it('renders the token gallery in the local environment', function () { + bootAs('local'); + + $this->get('/dev/tokens') + ->assertOk() + ->assertInertia(fn (Assert $page) => $page->component('Dev/Tokens')); +}); From ddb587701247b3368a3ff52ce7375922b16e9b53 Mon Sep 17 00:00:00 2001 From: AdilAzhariOmsan Date: Sun, 8 Nov 2026 15:31:43 +0800 Subject: [PATCH 3/5] style(storefront): style the home placeholder with design tokens --- resources/js/pages/Storefront/Home.vue | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/resources/js/pages/Storefront/Home.vue b/resources/js/pages/Storefront/Home.vue index 6207710..4af6564 100644 --- a/resources/js/pages/Storefront/Home.vue +++ b/resources/js/pages/Storefront/Home.vue @@ -5,8 +5,9 @@ import { Head } from '@inertiajs/vue3'; From d9a8743a6b8c50c6b2ac02220221c84f971522b7 Mon Sep 17 00:00:00 2001 From: AdilAzhariOmsan Date: Sun, 8 Nov 2026 15:31:43 +0800 Subject: [PATCH 4/5] docs(engineering): document design tokens and reassess npm advisories - code-quality.md: token rules (source of truth, cleared Tailwind defaults, semantic-only use, contrast test, local gallery, Arabic). - security.md: start-of-milestone full npm audit for M2 found the same two dev-only advisories; last-reassessed dates updated. --- docs/engineering/code-quality.md | 18 ++++++++++++++++++ docs/engineering/security.md | 4 ++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/engineering/code-quality.md b/docs/engineering/code-quality.md index e7d485b..502f7c4 100644 --- a/docs/engineering/code-quality.md +++ b/docs/engineering/code-quality.md @@ -46,6 +46,24 @@ composer analyse baseline file, no `@phpstan-ignore` sprinkled to silence a real problem. Fix the cause. A genuinely unavoidable ignore carries a comment explaining why. +## Styling — design tokens + +- **Source of truth:** `resources/css/tokens.css`, plain CSS custom properties + (`--nx-*`) with no Tailwind or Vue syntax, so the Filament admin can reuse + them (ADR-0020). `resources/css/app.css` maps them into Tailwind's `@theme`. +- **Tailwind's defaults are cleared** for colour, radius, shadow, font, type + scale and tracking. Utilities such as `bg-red-500` do not exist; use the + semantic ones (`bg-surface`, `text-fg-muted`, `text-danger`, `rounded-md`). +- **Components use semantic tokens** (`--nx-color-*`), never `--nx-palette-*`. +- **Contrast is tested.** `resources/js/design/contrast-pairs.ts` derives the + required pairs from token names (text 4.5:1, controls and focus 3:1, + NFR-A11Y-03). A new colour token fails the suite until it is either covered + by a pair or listed as decorative with a reason. +- **Gallery:** `/dev/tokens` renders every token. It is registered only when + `APP_ENV=local` (`routes/local.php`) and 404s everywhere else. +- **Arabic** switches to IBM Plex Sans Arabic and taller line heights through + `:lang(ar)`, so it also applies to Arabic content inside other locales. + ## Before every pull request ```sh diff --git a/docs/engineering/security.md b/docs/engineering/security.md index 061439c..87acdc0 100644 --- a/docs/engineering/security.md +++ b/docs/engineering/security.md @@ -73,8 +73,8 @@ endpoints. | Advisory | Chain | Why accepted | Last reassessed | |---|---|---|---| - | [GHSA-3g3m-2fjr-6pw4](https://github.com/advisories/GHSA-3g3m-2fjr-6pw4) (moderate, `glob` 13.0.6) | `@vue/test-utils` → `js-beautify` → `glob` | Test-time only; never bundled or shipped. No fixed `@vue/test-utils` 2.5.x yet; `npm audit fix --force` would downgrade to 2.4.11 | 2026-11-05 | - | [GHSA-g3hv-75wg-m249](https://github.com/advisories/GHSA-g3hv-75wg-m249) (moderate, `minimatch` 10.2.6) | `@vue/test-utils` → `js-beautify` → `editorconfig` / `glob` → `minimatch` | Same as above; inputs are our own repository paths, not untrusted patterns | 2026-11-05 | + | [GHSA-3g3m-2fjr-6pw4](https://github.com/advisories/GHSA-3g3m-2fjr-6pw4) (moderate, `glob` 13.0.6) | `@vue/test-utils` → `js-beautify` → `glob` | Test-time only; never bundled or shipped. No fixed `@vue/test-utils` 2.5.x yet; `npm audit fix --force` would downgrade to 2.4.11 | 2026-11-08 | + | [GHSA-g3hv-75wg-m249](https://github.com/advisories/GHSA-g3hv-75wg-m249) (moderate, `minimatch` 10.2.6) | `@vue/test-utils` → `js-beautify` → `editorconfig` / `glob` → `minimatch` | Same as above; inputs are our own repository paths, not untrusted patterns | 2026-11-08 | - Add a dependency only when this phase needs it. Prefer a small, maintained package or a few lines of first-party code over a large transitive tree. From 1f6a8f164e247f2a37bed3909a0b5469ef3ab80f Mon Sep 17 00:00:00 2001 From: AdilAzhariOmsan Date: Sun, 8 Nov 2026 17:11:13 +0800 Subject: [PATCH 5/5] test(design): check body text on status backgrounds Review finding F1: status backgrounds were only paired with their own status foreground, so body text inside a tinted alert or badge (e.g. a danger message in fg-muted) was never checked. Every fg-* token is now paired with every {tone}-bg at 4.5:1 (8 new pairs, 50 in total), and a test asserts those pairs exist. Current values: fg-default about 15.3:1 and fg-muted about 6.2:1 on every status background. Darkening danger-bg now fails the new fg-muted pair. --- resources/js/design/contrast-pairs.ts | 2 ++ resources/js/design/tokens-contrast.test.ts | 18 +++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/resources/js/design/contrast-pairs.ts b/resources/js/design/contrast-pairs.ts index e1c3778..c40ef3c 100644 --- a/resources/js/design/contrast-pairs.ts +++ b/resources/js/design/contrast-pairs.ts @@ -49,6 +49,8 @@ export function contrastPairs(tokens: TokenMap): ContrastPair[] { on(fg, `--nx-color-${tone}-bg`, TEXT_MINIMUM); for (const bg of backgrounds) on(fg, bg, TEXT_MINIMUM); + // Body text inside a tinted alert or badge (e.g. a danger message). + for (const text of texts) on(text, `--nx-color-${tone}-bg`, TEXT_MINIMUM); } on('--nx-color-on-accent', '--nx-color-accent', TEXT_MINIMUM); diff --git a/resources/js/design/tokens-contrast.test.ts b/resources/js/design/tokens-contrast.test.ts index 2b90808..1e47de7 100644 --- a/resources/js/design/tokens-contrast.test.ts +++ b/resources/js/design/tokens-contrast.test.ts @@ -1,6 +1,12 @@ import tokensCss from '../../css/tokens.css?raw'; import { describe, expect, it } from 'vitest'; -import { colorTokenNames, contrastPairs, DECORATIVE_COLOR_TOKENS, evaluateContrast } from './contrast-pairs'; +import { + colorTokenNames, + contrastPairs, + DECORATIVE_COLOR_TOKENS, + evaluateContrast, + STATUS_TONES, +} from './contrast-pairs'; import { readRootTokens } from './tokens'; const tokens = readRootTokens(tokensCss); @@ -40,6 +46,16 @@ describe('design token contrast (NFR-A11Y-03)', () => { expect(unclassified).toEqual([]); }); + it('checks body text on every status background (alert and badge content)', () => { + const checked = new Set(contrastPairs(tokens).map((p) => `${p.foreground}|${p.background}`)); + const texts = colorTokenNames(tokens).filter((name) => name.startsWith('--nx-color-fg-')); + const missing = texts.flatMap((fg) => + STATUS_TONES.map((tone) => `${fg}|--nx-color-${tone}-bg`).filter((pair) => !checked.has(pair)), + ); + + expect(missing).toEqual([]); + }); + it('lists only decorative tokens that exist', () => { expect(Object.keys(DECORATIVE_COLOR_TOKENS).filter((name) => !tokens.has(name))).toEqual([]); });