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 @@ -