From d2b29587000afff66019e0753c86ed7d82d2dee6 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 10 Oct 2026 19:44:55 -0400 Subject: [PATCH] Standardize responsive breakpoints (Refs #130) --- docs/development/ui-patterns.md | 23 +++++++ scripts/check-ui-styles.mjs | 109 ++++++++++++++++++++++++++++-- src/lib/ui/styles/tokens.css | 8 +++ src/routes/+layout.svelte | 11 +-- src/routes/+page.svelte | 4 +- tests/unit/ui-style-check.test.js | 73 +++++++++++++++++++- 6 files changed, 214 insertions(+), 14 deletions(-) diff --git a/docs/development/ui-patterns.md b/docs/development/ui-patterns.md index 917a4e2..2c62c49 100644 --- a/docs/development/ui-patterns.md +++ b/docs/development/ui-patterns.md @@ -50,6 +50,29 @@ and action targets. Avoid nested buttons or placing a button inside a link. Allow long server identities to wrap and tab/filter rows to scroll on narrow screens. +### Responsive widths + +Use the shared breakpoint scale in `src/lib/ui/styles/tokens.css`: + +| Token | Width | Use | +| ----------------- | ------- | ------------------------------------------ | +| `--breakpoint-xs` | 360 px | Extra-small screen adjustments | +| `--breakpoint-sm` | 640 px | Compact controls, cards and page summaries | +| `--breakpoint-md` | 960 px | Sidebar switches to the navigation drawer | +| `--breakpoint-lg` | 1100 px | Dense, multi-column layouts reflow | + +CSS custom properties cannot be used inside media conditions. Write the literal +value, for example `@media (max-width: 640px)`, and choose the shared width that +fits the layout. Related page and component changes should use the same width. +For JavaScript behavior, use the identical query and invert `matches` when you +need its complement; avoid adding a pixel to the boundary. + +`npm run check:styles` reads this scale and rejects other widths in Svelte and +CSS `@media` queries. The file-and-width allowlist in `scripts/check-ui-styles.mjs` +records existing layouts awaiting migration. Remove entries as those layouts +move to the shared scale; do not add exceptions for new components. The app +shell, Servers page and server cards use only shared widths. + ## Navigation Use the shared navigation metadata for Fleet, Releases, Activity, Players, and diff --git a/scripts/check-ui-styles.mjs b/scripts/check-ui-styles.mjs index f79c34c..15d8a8c 100644 --- a/scripts/check-ui-styles.mjs +++ b/scripts/check-ui-styles.mjs @@ -3,6 +3,97 @@ import { fileURLToPath } from 'node:url'; import path from 'node:path'; import { parse } from 'svelte/compiler'; +const sourceRoot = fileURLToPath(new URL('../src/', import.meta.url)); +const tokens = await readFile(path.join(sourceRoot, 'lib/ui/styles/tokens.css'), 'utf8'); +const sharedBreakpointWidths = new Set( + [...tokens.matchAll(/--breakpoint-[\w-]+:\s*([\d.]+px)\s*;/g)].map((match) => match[1]) +); + +// Existing layouts awaiting migration (Refs #130). Remove widths as each file +// moves to the shared scale; a listed file cannot introduce other legacy widths. +/** @type {Record} */ +const legacyBreakpointWidths = { + 'src/lib/ui/components/ArtifactDetail.svelte': [720, 1000], + 'src/lib/ui/components/BetaPlanBuilder.svelte': [480, 850], + 'src/lib/ui/components/BetaTestProgress.svelte': [720], + 'src/lib/ui/components/BlueprintBuilder.svelte': [620], + 'src/lib/ui/components/ConfigEditor.svelte': [600], + 'src/lib/ui/components/ConfigInspector.svelte': [600], + 'src/lib/ui/components/ConfigLibrary.svelte': [600], + 'src/lib/ui/components/ConfigTextEditor.svelte': [600], + 'src/lib/ui/components/DeployCollectionDialog.svelte': [440, 600], + 'src/lib/ui/components/DeployServerDialog.svelte': [440], + 'src/lib/ui/components/ProvisioningActivity.svelte': [600], + 'src/lib/ui/components/ProvisioningProgress.svelte': [600], + 'src/lib/ui/components/SettingsEditor.svelte': [720], + 'src/lib/ui/components/capacity/RebalancePanel.svelte': [600], + 'src/lib/ui/components/capacity/RebalanceRunView.svelte': [600], + 'src/lib/ui/components/metrics/MetricsExplore.svelte': [540], + 'src/lib/ui/components/metrics/MetricsSummary.svelte': [760], + 'src/lib/ui/components/primitives/Button.svelte': [720], + 'src/lib/ui/components/primitives/Card.svelte': [480], + 'src/lib/ui/components/primitives/Dialog.svelte': [600], + 'src/lib/ui/styles/components.css': [600], + 'src/routes/account/+page.svelte': [540], + 'src/routes/autoscaling/+page.svelte': [500], + 'src/routes/betas/+page.svelte': [720], + 'src/routes/betas/[id]/+page.svelte': [720], + 'src/routes/betas/new/+page.svelte': [700], + 'src/routes/campaigns/+page.svelte': [720], + 'src/routes/campaigns/[id]/+page.svelte': [720], + 'src/routes/capacity/+page.svelte': [600], + 'src/routes/collections/launches/[id]/+page.svelte': [600], + 'src/routes/hosts/operations/+page.svelte': [600], + 'src/routes/leaderboard/+page.svelte': [800, 1000], + 'src/routes/login/+page.svelte': [540], + 'src/routes/matches/[id]/+page.svelte': [480, 800], + 'src/routes/metrics/+page.svelte': [540], + 'src/routes/plugins/+page.svelte': [720], + 'src/routes/provisioning/+page.svelte': [720], + 'src/routes/provisioning/[id]/+page.svelte': [600, 1000], + 'src/routes/setup/+page.svelte': [540] +}; + +/** + * @param {string} source + * @param {string} filename + */ +export function checkMediaWidths(source, filename) { + // Parse styles so comments, script strings and CSS declaration values cannot + // be mistaken for media queries. Wrap standalone CSS without adding a line. + const cssSource = filename.endsWith('.css') ? `` : source; + const stylesheet = parse(cssSource, { modern: true }).css; + const legacyWidths = legacyBreakpointWidths[filename.replaceAll('\\', '/')] ?? []; + const allowed = new Set([...sharedBreakpointWidths, ...legacyWidths.map((n) => `${n}px`)]); + /** @type {string[]} */ + const failures = []; + /** @param {import('svelte/compiler').AST.CSS.StyleSheet | import('svelte/compiler').AST.CSS.Block | null} block */ + function visit(block) { + for (const node of block?.children ?? []) { + if (node.type === 'Atrule' && node.name.toLowerCase() === 'media') { + const query = node.prelude.replace(/\/\*[\s\S]*?\*\//g, ' '); + // Check both sides independently to cover chained range syntax. + const widths = [ + ...query.matchAll( + /(?]=?|=)\s*([^\s)]+)/gi + ), + ...query.matchAll(/([\d.]+(?:[a-z%]+)?)\s*(?:[<>]=?|=)\s*width\b/gi) + ]; + for (const match of widths) { + if (allowed.has(match[1].toLowerCase())) continue; + const line = cssSource.slice(0, node.start).split('\n').length; + failures.push( + `${filename}:${line}: use a shared media width (${[...sharedBreakpointWidths].join(', ')}): ${match[1]}` + ); + } + } + if ('block' in node) visit(node.block); + } + } + visit(stylesheet); + return failures; +} + const nativeInputTypes = new Set([ 'checkbox', 'radio', @@ -69,7 +160,6 @@ export function checkFormControls(source, filename) { } async function main() { - const root = fileURLToPath(new URL('../src/', import.meta.url)); /** @type {string[]} */ const failures = []; /** @param {string} directory */ @@ -77,9 +167,14 @@ async function main() { for (const entry of await readdir(directory, { withFileTypes: true })) { const file = path.join(directory, entry.name); if (entry.isDirectory()) await visit(file); - else if (/\.(svelte|css)$/.test(entry.name) && entry.name !== 'tokens.css') { + else if (/\.(svelte|css)$/.test(entry.name)) { const source = await readFile(file, 'utf8'); - const filename = path.relative(process.cwd(), file); + const filename = path + .relative(path.dirname(sourceRoot), file) + .split(path.sep) + .join('/'); + failures.push(...checkMediaWidths(source, filename)); + if (file === path.join(sourceRoot, 'lib/ui/styles/tokens.css')) continue; failures.push(...checkFormControls(source, filename)); for (const match of source.matchAll(/font-size\s*:\s*[\d.]+px\b/g)) { const line = source.slice(0, match.index).split('\n').length; @@ -90,8 +185,7 @@ async function main() { } } } - await visit(root); - const tokens = await readFile(path.join(root, 'lib/ui/styles/tokens.css'), 'utf8'); + await visit(sourceRoot); for (const match of tokens.matchAll( /--text-(?:xs|sm|base|md|lg|xl|2xl|3xl|input):\s*([\d.]+)px/g )) { @@ -100,7 +194,10 @@ async function main() { if (failures.length) { console.error(failures.join('\n')); process.exitCode = 1; - } else console.log('UI styles: shared form controls, font sizes and 12 px minimum verified.'); + } else + console.log( + 'UI styles: shared form controls, media widths, font sizes and 12 px minimum verified.' + ); } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) diff --git a/src/lib/ui/styles/tokens.css b/src/lib/ui/styles/tokens.css index 88bb7fe..5fc01c8 100644 --- a/src/lib/ui/styles/tokens.css +++ b/src/lib/ui/styles/tokens.css @@ -127,6 +127,14 @@ --space-16: 64px; --space-24: 96px; + /* Responsive widths — use these literal pixel values in @media queries; + CSS custom properties cannot be used in media conditions. The style + checker reads this scale. See docs/development/ui-patterns.md. */ + --breakpoint-xs: 360px; /* Extra-small screen adjustments. */ + --breakpoint-sm: 640px; /* Compact controls, cards and page summaries. */ + --breakpoint-md: 960px; /* Sidebar switches to the navigation drawer. */ + --breakpoint-lg: 1100px; /* Dense, multi-column layouts reflow. */ + /* Radii — three steps + pill. Softer than the old 4/8/12. */ --radius-sm: 6px; --radius-md: 10px; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index af94cb5..e208f45 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -115,11 +115,12 @@ onMount(() => { const stopTimeZone = startTimeZonePreference(); - const desktop = window.matchMedia('(min-width: 961px)'); + // Keep this identical to the sidebar's CSS query, including fractional widths. + const mobileNavigation = window.matchMedia('(max-width: 960px)'); const closeDrawer = () => { - if (desktop.matches) drawerOpen = false; + if (!mobileNavigation.matches) drawerOpen = false; }; - desktop.addEventListener('change', closeDrawer); + mobileNavigation.addEventListener('change', closeDrawer); const shortcuts = Object.fromEntries( visibleNavigation(data.user?.role ?? 'viewer') @@ -181,7 +182,7 @@ window.addEventListener('keydown', onKey); return () => { stopTimeZone(); - desktop.removeEventListener('change', closeDrawer); + mobileNavigation.removeEventListener('change', closeDrawer); window.removeEventListener('keydown', onKey); if (armTimer) clearTimeout(armTimer); }; @@ -705,7 +706,7 @@ margin-left: 0; } } - @media (max-width: 720px) { + @media (max-width: 640px) { .app-header { padding-inline: var(--space-2); gap: var(--space-1); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index ce9388e..3dd864f 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1019,7 +1019,7 @@ overflow: hidden; clip-path: inset(50%); } - @media (max-width: 1000px) { + @media (max-width: 1100px) { .attention-row { grid-template-columns: minmax(0, 1fr) auto; } @@ -1027,7 +1027,7 @@ grid-column: 1; } } - @media (max-width: 600px) { + @media (max-width: 640px) { .page { gap: var(--space-3); } diff --git a/tests/unit/ui-style-check.test.js b/tests/unit/ui-style-check.test.js index 4297c36..9c2b933 100644 --- a/tests/unit/ui-style-check.test.js +++ b/tests/unit/ui-style-check.test.js @@ -1,8 +1,79 @@ import { describe, expect, it } from 'vitest'; -import { checkFormControls } from '../../scripts/check-ui-styles.mjs'; +import { checkFormControls, checkMediaWidths } from '../../scripts/check-ui-styles.mjs'; const filename = 'src/routes/example/+page.svelte'; +describe('shared responsive width guard', () => { + /** @param {string} query */ + const styles = (query) => ``; + + it.each([360, 640, 960, 1100])('allows the shared %i px breakpoint', (width) => { + for (const feature of ['width', 'min-width', 'max-width']) { + expect(checkMediaWidths(styles(`(${feature}: ${width}px)`), filename)).toEqual([]); + } + }); + + it.each([440, 480, 500, 540, 600, 620, 700, 720, 760, 800, 850, 1000])( + 'rejects the legacy %i px breakpoint in a new component', + (width) => { + expect(checkMediaWidths(styles(`(max-width: ${width}px)`), filename)).toEqual([ + `${filename}:2: use a shared media width (360px, 640px, 960px, 1100px): ${width}px` + ]); + } + ); + + it('checks every width in compound, comma-separated and range queries', () => { + expect( + checkMediaWidths( + styles('screen and (min-width: 600px) and (max-width: 960px), (width <= 720px)'), + filename + ) + ).toHaveLength(2); + expect(checkMediaWidths(styles('(360px < width <= 1100px)'), filename)).toEqual([]); + expect(checkMediaWidths(styles('(600px= width)'), filename)).toHaveLength(1); + expect(checkMediaWidths(styles('(max-width: 40rem)'), filename)).toHaveLength(1); + }); + + it('limits migration exceptions to the recorded file and widths', () => { + const legacyFile = 'src/routes/leaderboard/+page.svelte'; + expect(checkMediaWidths(styles('(max-width: 800px)'), legacyFile)).toEqual([]); + expect(checkMediaWidths(styles('(max-width: 1000px)'), legacyFile)).toEqual([]); + expect(checkMediaWidths(styles('(max-width: 640px)'), legacyFile)).toEqual([]); + expect(checkMediaWidths(styles('(max-width: 600px)'), legacyFile)).toHaveLength(1); + expect(checkMediaWidths(styles('(max-width: 800px)'), filename)).toHaveLength(1); + }); + + it('checks nested rules and standalone CSS with accurate line numbers', () => { + const cssFile = 'src/lib/ui/example.css'; + expect( + checkMediaWidths( + '@supports (display: grid) {\n@media (max-width: 700px) { a { display: grid; } }\n}', + cssFile + ) + ).toEqual([`${cssFile}:2: use a shared media width (360px, 640px, 960px, 1100px): 700px`]); + }); + + it('ignores comments, script strings, declaration values and non-width media features', () => { + expect( + checkMediaWidths( + ` + + `, + filename + ) + ).toEqual([]); + expect(checkMediaWidths('

No styles

', filename)).toEqual([]); + }); +}); + describe('shared form control guard', () => { it.each([ '',