This commit is contained in:
@@ -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
|
Allow long server identities to wrap and tab/filter rows to scroll on narrow
|
||||||
screens.
|
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
|
## Navigation
|
||||||
|
|
||||||
Use the shared navigation metadata for Fleet, Releases, Activity, Players, and
|
Use the shared navigation metadata for Fleet, Releases, Activity, Players, and
|
||||||
|
|||||||
+103
-6
@@ -3,6 +3,97 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { parse } from 'svelte/compiler';
|
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<string, number[]>} */
|
||||||
|
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') ? `<style>${source}</style>` : 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(
|
||||||
|
/(?<![\w-])(?:min-|max-)?width\s*(?::|[<>]=?|=)\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([
|
const nativeInputTypes = new Set([
|
||||||
'checkbox',
|
'checkbox',
|
||||||
'radio',
|
'radio',
|
||||||
@@ -69,7 +160,6 @@ export function checkFormControls(source, filename) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const root = fileURLToPath(new URL('../src/', import.meta.url));
|
|
||||||
/** @type {string[]} */
|
/** @type {string[]} */
|
||||||
const failures = [];
|
const failures = [];
|
||||||
/** @param {string} directory */
|
/** @param {string} directory */
|
||||||
@@ -77,9 +167,14 @@ async function main() {
|
|||||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||||
const file = path.join(directory, entry.name);
|
const file = path.join(directory, entry.name);
|
||||||
if (entry.isDirectory()) await visit(file);
|
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 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));
|
failures.push(...checkFormControls(source, filename));
|
||||||
for (const match of source.matchAll(/font-size\s*:\s*[\d.]+px\b/g)) {
|
for (const match of source.matchAll(/font-size\s*:\s*[\d.]+px\b/g)) {
|
||||||
const line = source.slice(0, match.index).split('\n').length;
|
const line = source.slice(0, match.index).split('\n').length;
|
||||||
@@ -90,8 +185,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await visit(root);
|
await visit(sourceRoot);
|
||||||
const tokens = await readFile(path.join(root, 'lib/ui/styles/tokens.css'), 'utf8');
|
|
||||||
for (const match of tokens.matchAll(
|
for (const match of tokens.matchAll(
|
||||||
/--text-(?:xs|sm|base|md|lg|xl|2xl|3xl|input):\s*([\d.]+)px/g
|
/--text-(?:xs|sm|base|md|lg|xl|2xl|3xl|input):\s*([\d.]+)px/g
|
||||||
)) {
|
)) {
|
||||||
@@ -100,7 +194,10 @@ async function main() {
|
|||||||
if (failures.length) {
|
if (failures.length) {
|
||||||
console.error(failures.join('\n'));
|
console.error(failures.join('\n'));
|
||||||
process.exitCode = 1;
|
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))
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url))
|
||||||
|
|||||||
@@ -127,6 +127,14 @@
|
|||||||
--space-16: 64px;
|
--space-16: 64px;
|
||||||
--space-24: 96px;
|
--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. */
|
/* Radii — three steps + pill. Softer than the old 4/8/12. */
|
||||||
--radius-sm: 6px;
|
--radius-sm: 6px;
|
||||||
--radius-md: 10px;
|
--radius-md: 10px;
|
||||||
|
|||||||
@@ -115,11 +115,12 @@
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const stopTimeZone = startTimeZonePreference();
|
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 = () => {
|
const closeDrawer = () => {
|
||||||
if (desktop.matches) drawerOpen = false;
|
if (!mobileNavigation.matches) drawerOpen = false;
|
||||||
};
|
};
|
||||||
desktop.addEventListener('change', closeDrawer);
|
mobileNavigation.addEventListener('change', closeDrawer);
|
||||||
|
|
||||||
const shortcuts = Object.fromEntries(
|
const shortcuts = Object.fromEntries(
|
||||||
visibleNavigation(data.user?.role ?? 'viewer')
|
visibleNavigation(data.user?.role ?? 'viewer')
|
||||||
@@ -181,7 +182,7 @@
|
|||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => {
|
return () => {
|
||||||
stopTimeZone();
|
stopTimeZone();
|
||||||
desktop.removeEventListener('change', closeDrawer);
|
mobileNavigation.removeEventListener('change', closeDrawer);
|
||||||
window.removeEventListener('keydown', onKey);
|
window.removeEventListener('keydown', onKey);
|
||||||
if (armTimer) clearTimeout(armTimer);
|
if (armTimer) clearTimeout(armTimer);
|
||||||
};
|
};
|
||||||
@@ -705,7 +706,7 @@
|
|||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 640px) {
|
||||||
.app-header {
|
.app-header {
|
||||||
padding-inline: var(--space-2);
|
padding-inline: var(--space-2);
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
|
|||||||
@@ -1019,7 +1019,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
clip-path: inset(50%);
|
clip-path: inset(50%);
|
||||||
}
|
}
|
||||||
@media (max-width: 1000px) {
|
@media (max-width: 1100px) {
|
||||||
.attention-row {
|
.attention-row {
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
}
|
}
|
||||||
@@ -1027,7 +1027,7 @@
|
|||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 640px) {
|
||||||
.page {
|
.page {
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,79 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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';
|
const filename = 'src/routes/example/+page.svelte';
|
||||||
|
|
||||||
|
describe('shared responsive width guard', () => {
|
||||||
|
/** @param {string} query */
|
||||||
|
const styles = (query) => `<style>\n@media ${query} { .example { display: none; } }\n</style>`;
|
||||||
|
|
||||||
|
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<=720px)'), filename)).toHaveLength(2);
|
||||||
|
expect(checkMediaWidths(styles('(720px >= 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(
|
||||||
|
`<script>const example = '@media (max-width: 700px) {}';</script>
|
||||||
|
<!-- @media (max-width: 500px) {} -->
|
||||||
|
<style>
|
||||||
|
/* @media (max-width: 800px) {} */
|
||||||
|
a::before { content: '@media (max-width: 700px) {}'; }
|
||||||
|
@media (max-width: /* (max-width: 720px) */ 640px) { a { display: none; } }
|
||||||
|
@media (max-height: 700px) and (prefers-reduced-motion: reduce) {
|
||||||
|
a { transition: none; }
|
||||||
|
}
|
||||||
|
</style>`,
|
||||||
|
filename
|
||||||
|
)
|
||||||
|
).toEqual([]);
|
||||||
|
expect(checkMediaWidths('<p>No styles</p>', filename)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('shared form control guard', () => {
|
describe('shared form control guard', () => {
|
||||||
it.each([
|
it.each([
|
||||||
'',
|
'',
|
||||||
|
|||||||
Reference in New Issue
Block a user