From cc9c10226e81d0019b516a4f091b0a4df9e3ebd5 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 10 Oct 2026 00:19:48 -0400 Subject: [PATCH] Use shared form controls and enforce UI patterns (Refs #110) --- docs/development/ui-patterns.md | 13 +- scripts/check-ui-styles.mjs | 117 +++++++++++++--- src/lib/ui/components/ActivityLog.svelte | 100 ++++++-------- src/lib/ui/components/AddHostDrawer.svelte | 30 ++--- src/lib/ui/components/BetaPlanBuilder.svelte | 126 ++++++++---------- src/lib/ui/components/BetaTestProgress.svelte | 34 +++-- src/lib/ui/components/BlueprintBuilder.svelte | 67 ++++------ src/lib/ui/components/CapacitySamples.svelte | 31 ++--- src/lib/ui/components/CommandPalette.svelte | 60 +++------ src/lib/ui/components/ConfigEditor.svelte | 28 ++-- src/lib/ui/components/ConfigInspector.svelte | 34 ++--- src/lib/ui/components/ConfigLibrary.svelte | 93 +++++++------ src/lib/ui/components/ConfigTextEditor.svelte | 68 +++++----- .../ui/components/DeployServerDialog.svelte | 26 ++-- .../ui/components/FleetActionsDialog.svelte | 23 ++-- src/lib/ui/components/LifecycleDialog.svelte | 26 ++-- src/lib/ui/components/LogPanel.svelte | 20 +-- src/lib/ui/components/MatchHistory.svelte | 86 ++++++------ .../ui/components/NativeHostSelection.svelte | 45 +++---- src/lib/ui/components/OperationHistory.svelte | 36 ++--- .../ui/components/PluginGithubImport.svelte | 59 +++----- .../ui/components/ProvisioningActivity.svelte | 45 ++----- src/lib/ui/components/RetentionTools.svelte | 29 ++-- .../ui/components/ScriptUploadDrawer.svelte | 40 +++--- src/lib/ui/components/SettingsEditor.svelte | 113 ++++++++-------- .../components/capacity/RebalancePanel.svelte | 72 ++++------ src/lib/ui/components/primitives/Field.svelte | 42 +++++- src/lib/ui/components/primitives/Input.svelte | 77 +++++------ .../ui/components/primitives/Select.svelte | 56 ++++---- src/lib/ui/field-context.ts | 2 +- src/lib/ui/library.css | 19 +-- src/lib/ui/styles/components.css | 10 +- src/routes/+page.svelte | 53 +++----- src/routes/account/+page.svelte | 92 ++++++------- src/routes/autoscaling/+page.svelte | 68 ++++------ src/routes/betas/+page.svelte | 40 ++---- src/routes/betas/new/+page.svelte | 70 +++++----- src/routes/campaigns/+page.svelte | 51 +++---- src/routes/game-files/+page.svelte | 16 +-- src/routes/hosts/+page.svelte | 60 ++++----- src/routes/hosts/[id]/+page.svelte | 63 +++------ src/routes/leaderboard/+page.svelte | 63 +++++---- .../leaderboard/players/[guid]/+page.svelte | 44 +++--- src/routes/login/+page.svelte | 33 ++--- src/routes/maintenance/+page.svelte | 37 +++-- src/routes/plugins/+page.svelte | 52 ++++---- src/routes/provisioning/+page.svelte | 58 ++++---- src/routes/scripts/+page.svelte | 44 +++--- src/routes/server-keys/+page.svelte | 71 +++++----- src/routes/settings/users/+page.svelte | 75 ++++++----- src/routes/setup/+page.svelte | 63 ++++----- tests/fixtures/FormControls.svelte | 68 ++++++++++ tests/unit/ui-form-controls.test.ts | 61 +++++++++ tests/unit/ui-style-check.test.js | 71 ++++++++++ 54 files changed, 1431 insertions(+), 1449 deletions(-) create mode 100644 tests/fixtures/FormControls.svelte create mode 100644 tests/unit/ui-form-controls.test.ts create mode 100644 tests/unit/ui-style-check.test.js diff --git a/docs/development/ui-patterns.md b/docs/development/ui-patterns.md index b0e0b4d..917a4e2 100644 --- a/docs/development/ui-patterns.md +++ b/docs/development/ui-patterns.md @@ -8,7 +8,18 @@ Primitives live in its `primitives` directory. - Use `SectionHeader` for page titles and primary actions. The page owns spacing between sections. - Use `Field` with `Input`, `Select` or `Textarea` for labels, help and errors. - Give controls distinct `ariaLabel` values when several share one field. + Give controls distinct `ariaLabel` values when several share one field. Use + `hiddenLabel` on `Field` for compact filters with a visually hidden label. + `Input` and `Select` forward native validation, accessibility and event props; + use `bind:element` when a workflow needs to focus the native control. `Select` + accepts either `options` or child `option` / `optgroup` elements and preserves + the bound value's type. Keep layout on the field or its parent; controls own + their appearance. +- `npm run check:styles` rejects raw selects and text-like inputs outside their + primitives, including search, password, numeric and date/time fields. Native + checkboxes, radios, file pickers, hidden inputs, range/color pickers and input + buttons are allowed. The check parses Svelte markup, so comments and examples + in scripts are ignored. - Pair `Tabs` with a labeled panel. Use `ListFooter` for long lists. - Use `StatusBadge` for workflow states and `ServerIdentity` for server names. Keep raw IDs available for copying and troubleshooting. diff --git a/scripts/check-ui-styles.mjs b/scripts/check-ui-styles.mjs index b197887..f79c34c 100644 --- a/scripts/check-ui-styles.mjs +++ b/scripts/check-ui-styles.mjs @@ -1,32 +1,107 @@ import { readdir, readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; +import { parse } from 'svelte/compiler'; -const root = fileURLToPath(new URL('../src/', import.meta.url)); -const failures = []; -async function visit(directory) { - 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') { - const source = await readFile(file, 'utf8'); - 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 nativeInputTypes = new Set([ + 'checkbox', + 'radio', + 'file', + 'hidden', + 'range', + 'color', + 'button', + 'submit', + 'reset', + 'image' +]); + +/** + * @param {string} source + * @param {string} filename + */ +export function checkFormControls(source, filename) { + if ( + !filename.endsWith('.svelte') || + /(?:^|\/)primitives\/(?:Input|Select)\.svelte$/.test(filename) + ) + return []; + /** @type {string[]} */ + const failures = []; + /** @param {unknown} node */ + function visit(node) { + if (!node || typeof node !== 'object') return; + if ('type' in node && node.type === 'RegularElement') { + const element = /** @type {import('svelte/compiler').AST.RegularElement} */ (node); + const type = element.attributes.find( + (attribute) => attribute.type === 'Attribute' && attribute.name === 'type' + ); + const value = + type?.type === 'Attribute' && type.value !== true + ? Array.isArray(type.value) + ? type.value[0] + : type.value + : undefined; + const inputType = + value?.type === 'Text' + ? value.data + : value?.expression.type === 'Literal' + ? value.expression.value + : undefined; + if ( + element.name === 'select' || + (element.name === 'input' && + (typeof inputType !== 'string' || !nativeInputTypes.has(inputType))) + ) { + const line = source.slice(0, element.start).split('\n').length; failures.push( - `${path.relative(process.cwd(), file)}:${line}: use a shared type token (12 px minimum): ${match[0]}` + `${filename}:${line}: use Field with ${element.name === 'select' ? 'Select' : 'Input'} instead of raw <${element.name}>` ); } } + for (const value of Object.values(node)) { + if (Array.isArray(value)) value.forEach(visit); + else if (value && typeof value === 'object') visit(value); + } } + visit(parse(source, { modern: true }).fragment); + return failures; } -await visit(root); -const tokens = await readFile(path.join(root, 'lib/ui/styles/tokens.css'), 'utf8'); -for (const match of tokens.matchAll( - /--text-(?:xs|sm|base|md|lg|xl|2xl|3xl|input):\s*([\d.]+)px/g -)) { - if (Number(match[1]) < 12) failures.push(`Type tokens must be at least 12 px: ${match[0]}`); + +async function main() { + const root = fileURLToPath(new URL('../src/', import.meta.url)); + /** @type {string[]} */ + const failures = []; + /** @param {string} directory */ + async function visit(directory) { + 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') { + const source = await readFile(file, 'utf8'); + const filename = path.relative(process.cwd(), file); + 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; + failures.push( + `${filename}:${line}: use a shared type token (12 px minimum): ${match[0]}` + ); + } + } + } + } + await visit(root); + const tokens = await readFile(path.join(root, 'lib/ui/styles/tokens.css'), 'utf8'); + for (const match of tokens.matchAll( + /--text-(?:xs|sm|base|md|lg|xl|2xl|3xl|input):\s*([\d.]+)px/g + )) { + if (Number(match[1]) < 12) failures.push(`Type tokens must be at least 12 px: ${match[0]}`); + } + 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.'); } -if (failures.length) { - console.error(failures.join('\n')); - process.exitCode = 1; -} else console.log('UI styles: shared font sizes and 12 px minimum verified.'); + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) + await main(); diff --git a/src/lib/ui/components/ActivityLog.svelte b/src/lib/ui/components/ActivityLog.svelte index ffd3022..c0cf66e 100644 --- a/src/lib/ui/components/ActivityLog.svelte +++ b/src/lib/ui/components/ActivityLog.svelte @@ -1,4 +1,7 @@ {#snippet runtimeFields()} - {#if compact}

Custom images must be prepared on a host before it can run this server.

{/if} {/snippet} {#snippet networkFields()} - - -

Public mapping does not change the local Docker port reservation.

{/snippet} @@ -317,21 +319,7 @@ font-size: var(--text-sm); margin: 0; } - label { - display: grid; - gap: 7px; - font-size: var(--text-sm); - } - input { - width: 100%; - box-sizing: border-box; - padding: 11px; - color: var(--text-primary); - background: var(--surface); - border: 1px solid var(--border-control); - border-radius: var(--radius-sm); - font: inherit; - } + p { color: var(--text-secondary); font-size: var(--text-xs); @@ -360,7 +348,8 @@ cursor: pointer; font-size: var(--text-sm); } - details label { + + details :global(.field) { margin-top: 14px; } details p { diff --git a/src/lib/ui/components/OperationHistory.svelte b/src/lib/ui/components/OperationHistory.svelte index 8836bcc..e51a162 100644 --- a/src/lib/ui/components/OperationHistory.svelte +++ b/src/lib/ui/components/OperationHistory.svelte @@ -1,4 +1,7 @@ -
+
{#if label} -