@ultimat3/ui 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  SolidJS design system. Semantic tokens, SCSS modules, dark + RTL by construction.
4
4
 
5
+ **[`CATALOG.md`](CATALOG.md) is the reference** — every component, every prop, every
6
+ token, generated from source by `bun run catalog` and drift-checked by
7
+ `catalog.test.ts`. Read it instead of reading `src/`.
8
+
5
9
  ## Token roles
6
10
 
7
11
  Every colour in every component is one of these, stored as **space-separated RGB
@@ -31,6 +35,85 @@ Scales: `--space-*` (4px base), `--text-*` (fluid `clamp()`), `--radius-*`,
31
35
  | No physical directions | `margin-inline`, `inset-inline-start`, `text-align: start` — RTL needs no second stylesheet |
32
36
  | No hardcoded strings | labels are props, or `t()` through `UI_KEYS` |
33
37
  | One token source | `src/tokens/*.scss` is canonical; `tokens.ts` mirrors it and `x verify` fails on drift |
38
+ | AA contrast, both themes | `contrast.test.ts` measures every pairing a component renders — text, status fills, soft tints, focus rings, borders |
39
+
40
+ ## Contrast
41
+
42
+ `As of 2026-08` every foreground/background pairing the components render clears
43
+ **WCAG AA (4.5:1)** in light *and* dark, and borders clear a 1.4:1 visible-edge floor.
44
+ It is measured, not asserted: `roleContrast('dark', 'fg-muted', 'surface-raised')`
45
+ returns the number, and the test fails the build on a regression.
46
+
47
+ ```ts
48
+ import { AA_TEXT, contrastRatio, roleContrast } from '@ultimat3/ui';
49
+
50
+ roleContrast('dark', 'accent', 'bg') >= AA_TEXT; // true
51
+ contrastRatio('31 110 178', '253 246 240'); // 4.99 — check a brand before shipping it
52
+ ```
53
+
54
+ ## Page layout
55
+
56
+ Four composites cover the frame of an app screen. Below them are `Container`,
57
+ `Stack` and `Grid`; there is no fifth way to build a page.
58
+
59
+ | Component | Renders | Use for |
60
+ |---|---|---|
61
+ | `AppShell` | skip link + `header` / `nav` / `main` / `footer` landmarks on a CSS grid | the frame every screen sits in — one per document |
62
+ | `PageHeader` | breadcrumbs, the page's one `h1`, description, actions | the top of a screen |
63
+ | `Section` | a labelled `section` with a real heading and `aria-labelledby` | second-level structure inside a page |
64
+ | `Toolbar` | `role="toolbar"` strip, start + end slots, arrow-key roving | filters and actions above a table or list |
65
+
66
+ `AppShell` holds no state: below `md` the sidebar becomes a band above the content,
67
+ and an off-canvas menu is `Drawer` — the one component that already does that.
68
+ Heading levels are props (`headingTag`, `nextHeadingLevel`), so a nested `Section`
69
+ never skips a level.
70
+
71
+ ```tsx
72
+ <AppShell header={<Toolbar label={t('nav.main')}>{nav}</Toolbar>} sidebar={<SideNav />}>
73
+ <PageHeader
74
+ title={t('orders.title')}
75
+ description={t('orders.subtitle')}
76
+ breadcrumbs={[{ label: t('nav.home'), href: '/' }, { label: t('orders.title') }]}
77
+ actions={<Button>{t('orders.new')}</Button>}
78
+ />
79
+ <Section title={t('orders.recent')} actions={<Toolbar label={t('orders.filters')}>{filters}</Toolbar>}>
80
+ <DataTable caption={t('orders.title')} columns={columns} rows={rows} rowKey={(row) => row.id} />
81
+ </Section>
82
+ </AppShell>
83
+ ```
84
+
85
+ ## Branding
86
+
87
+ `defineTheme()` is the **only** seam for restyling. No SCSS `@use ... with ()`
88
+ override, no forked package, no second entry point — one call, validated, rendered
89
+ as the custom properties that beat `theme.scss` at every specificity level it emits.
90
+
91
+ ```ts
92
+ import { brandStyleTag, defineTheme } from '@ultimat3/ui';
93
+
94
+ export const brand = defineTheme({
95
+ colors: {
96
+ light: { accent: '99 46 210', 'accent-strong': '76 32 168' },
97
+ dark: { accent: '178 148 255', 'accent-strong': '198 176 255' },
98
+ },
99
+ radius: { md: '0.125rem', lg: '0.25rem' },
100
+ font: { sans: "Inter, system-ui, sans-serif" },
101
+ });
102
+
103
+ // in <head>, AFTER global.scss
104
+ `${brandStyleTag(brand)}`;
105
+ ```
106
+
107
+ | Slot | Accepts | Refused with |
108
+ |---|---|---|
109
+ | `colors.light` / `colors.dark` | any `ColorRole`, as `R G B` channels | `X_TOKEN_UNKNOWN` for the role, `X_UI_INVALID_VALUE` for the value |
110
+ | `radius` | any `RadiusName`, as a bare CSS length | `X_TOKEN_UNKNOWN` / `X_UI_INVALID_VALUE` |
111
+ | `font` | `sans`, `mono`, as a `font-family` list | `X_TOKEN_UNKNOWN` / `X_UI_INVALID_VALUE` |
112
+
113
+ Values are validated, never escaped: the output goes into a `<style>` element, so
114
+ anything carrying `;`, `}` or `</style>` is a refusal at the app's entry point rather
115
+ than a CSS injection. Every component in the system follows the override — they only
116
+ ever read the roles, never a colour.
34
117
 
35
118
  ## Example
36
119
 
@@ -95,14 +178,15 @@ Content-Security-Policy: script-src 'self' 'sha256-…' # themeInlineScriptCsp
95
178
 
96
179
  | Code | When |
97
180
  |---|---|
98
- | `X_TOKEN_UNKNOWN` | a token role the SCSS source does not define |
181
+ | `X_TOKEN_UNKNOWN` | a token role the SCSS source does not define — including a `defineTheme()` override of a role, radius or font slot that is not in the scale |
99
182
  | `X_THEME_INVALID` | a theme other than `light` / `dark` |
100
183
  | `X_UI_RUNTIME_MISSING` | reactive context or DOM APIs used where they do not exist |
101
- | `X_UI_INVALID_VALUE` | `<Money>` given a float, `<DateTime>` given an unparseable instant, `<Image>` given mixed `w`/`x` descriptors or one dimension without the other |
184
+ | `X_UI_INVALID_VALUE` | `<Money>` given a float, `<DateTime>` given an unparseable instant, `<Image>` given mixed `w`/`x` descriptors or one dimension without the other, a heading level off 1–6, or a `defineTheme()` value that is not a token value |
102
185
 
103
186
  ## Commands
104
187
 
105
188
  ```
106
- bun test # token parity, theme resolution, cx, a11y, formatting cores
189
+ bun test # token parity, contrast, theme resolution, brand, catalog drift, a11y
107
190
  bun run typecheck
191
+ bun run catalog # regenerate CATALOG.md after changing a component's props
108
192
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/ui",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "SolidJS design system: semantic design tokens, dark/RTL-ready SCSS modules, a11y primitives",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,6 +23,7 @@
23
23
  "files": [
24
24
  "src",
25
25
  "!src/**/*.test.ts",
26
+ "CATALOG.md",
26
27
  "README.md",
27
28
  "LICENSE"
28
29
  ],
@@ -31,13 +32,14 @@
31
32
  },
32
33
  "scripts": {
33
34
  "typecheck": "tsc --noEmit -p tsconfig.json",
34
- "test": "bun test"
35
+ "test": "bun test",
36
+ "catalog": "bun run src/catalog/build-catalog.ts"
35
37
  },
36
38
  "dependencies": {
37
- "@ultimat3/core": "1.0.0",
38
- "@ultimat3/i18n": "1.0.0",
39
- "@ultimat3/money": "1.0.0",
40
- "@ultimat3/time": "1.0.0"
39
+ "@ultimat3/core": "1.2.0",
40
+ "@ultimat3/i18n": "1.2.0",
41
+ "@ultimat3/money": "1.2.0",
42
+ "@ultimat3/time": "1.2.0"
41
43
  },
42
44
  "peerDependencies": {
43
45
  "solid-js": "^2.0.0"
@@ -0,0 +1,32 @@
1
+ // The one walk over `src/components/*.tsx` that both the writer and the drift test use, so the
2
+ // committed CATALOG.md and the test's expectation can never come from two different file sets.
3
+
4
+ import type { ComponentDoc } from './parse-component';
5
+ import { parseComponents } from './parse-component';
6
+ import { renderCatalog } from './render-catalog';
7
+
8
+ const COMPONENTS_DIR = new URL('../components/', import.meta.url).pathname;
9
+ export const CATALOG_PATH = new URL('../../CATALOG.md', import.meta.url).pathname;
10
+
11
+ /** Alphabetical by file, so the page's order is the order an agent scans for a name. */
12
+ export async function collectComponents(): Promise<ComponentDoc[]> {
13
+ const files: string[] = [];
14
+ for await (const file of new Bun.Glob('*.tsx').scan({ cwd: COMPONENTS_DIR })) {
15
+ files.push(file);
16
+ }
17
+ files.sort();
18
+
19
+ const docs: ComponentDoc[] = [];
20
+ for (const file of files) {
21
+ docs.push(...parseComponents(await Bun.file(`${COMPONENTS_DIR}${file}`).text()));
22
+ }
23
+ return docs;
24
+ }
25
+
26
+ export async function buildCatalog(): Promise<string> {
27
+ return renderCatalog(await collectComponents());
28
+ }
29
+
30
+ if (import.meta.main) {
31
+ await Bun.write(CATALOG_PATH, await buildCatalog());
32
+ }
@@ -0,0 +1,142 @@
1
+ // Reads a component's contract out of its own source: the header comment, the exported
2
+ // component(s), and every prop with its type and doc line. Text in, data out — no filesystem, so
3
+ // the rule is testable, and the catalog it feeds cannot describe a component that isn't there.
4
+
5
+ export interface PropDoc {
6
+ readonly name: string;
7
+ /** Normalised: `| undefined` stripped, whitespace collapsed. */
8
+ readonly type: string;
9
+ readonly required: boolean;
10
+ readonly doc: string;
11
+ }
12
+
13
+ export interface ComponentDoc {
14
+ readonly name: string;
15
+ /** The file's header comment, which is the component's one-paragraph purpose. */
16
+ readonly summary: string;
17
+ readonly props: readonly PropDoc[];
18
+ }
19
+
20
+ // The generic slot is optional: `DataTable<Row>(props: DataTableProps<Row>)` is still a component.
21
+ const COMPONENT_PATTERN = /export function ([A-Z]\w*)(?:<[^>]*>)?\(\s*props: (\w+)/g;
22
+
23
+ /** Every exported component in one source file, in source order. */
24
+ export function parseComponents(source: string): ComponentDoc[] {
25
+ const summary = headerComment(source);
26
+ const out: ComponentDoc[] = [];
27
+ for (const match of source.matchAll(COMPONENT_PATTERN)) {
28
+ const [, name = '', propsType = ''] = match;
29
+ out.push({ name, summary, props: parseProps(source, propsType) });
30
+ }
31
+ return out;
32
+ }
33
+
34
+ /** The `// …` block at the top of the file, before the first import. */
35
+ export function headerComment(source: string): string {
36
+ const lines: string[] = [];
37
+ for (const line of source.split('\n')) {
38
+ const trimmed = line.trim();
39
+ if (trimmed.startsWith('//')) {
40
+ lines.push(trimmed.slice(2).trim());
41
+ continue;
42
+ }
43
+ if (lines.length > 0) break;
44
+ if (trimmed !== '') break;
45
+ }
46
+ return lines.join(' ').trim();
47
+ }
48
+
49
+ /** The members of `export interface <name> { … }`, generics and all. */
50
+ export function parseProps(source: string, interfaceName: string): PropDoc[] {
51
+ const body = interfaceBody(source, interfaceName);
52
+ if (body === undefined) return [];
53
+
54
+ const props: PropDoc[] = [];
55
+ let doc: string[] = [];
56
+ let buffer = '';
57
+ let depth = 0;
58
+
59
+ for (const raw of body.split('\n')) {
60
+ const line = raw.trim();
61
+ if (line === '') continue;
62
+ if (buffer === '' && isComment(line)) {
63
+ doc.push(stripComment(line));
64
+ continue;
65
+ }
66
+ buffer = buffer === '' ? line : `${buffer} ${line}`;
67
+ depth = nesting(buffer);
68
+ // A member ends at a `;` that is not inside a function type or an inline object type.
69
+ if (depth > 0 || !buffer.endsWith(';')) continue;
70
+ const prop = toProp(buffer, doc.join(' ').trim());
71
+ if (prop !== undefined) props.push(prop);
72
+ buffer = '';
73
+ doc = [];
74
+ }
75
+ return props;
76
+ }
77
+
78
+ function isComment(line: string): boolean {
79
+ return line.startsWith('//') || line.startsWith('/*') || line.startsWith('*');
80
+ }
81
+
82
+ function stripComment(line: string): string {
83
+ return line
84
+ .replace(/^\/\*\*?/, '')
85
+ .replace(/^\/\//, '')
86
+ .replace(/^\*+\/?/, '')
87
+ .replace(/\*\/$/, '')
88
+ .trim();
89
+ }
90
+
91
+ /**
92
+ * Brackets only — never `<`/`>`, because `=>` in a function-type prop would read as a closing
93
+ * angle and end the member one line early.
94
+ */
95
+ function nesting(text: string): number {
96
+ let depth = 0;
97
+ for (const char of text) {
98
+ if (char === '(' || char === '{' || char === '[') depth += 1;
99
+ if (char === ')' || char === '}' || char === ']') depth -= 1;
100
+ }
101
+ return depth;
102
+ }
103
+
104
+ function toProp(declaration: string, doc: string): PropDoc | undefined {
105
+ const match = /^(?:readonly\s+)?('[^']+'|"[^"]+"|\w+)(\?)?:\s*([\s\S]+);$/.exec(declaration);
106
+ if (match === null) return undefined;
107
+ const [, rawName = '', optional, rawType = ''] = match;
108
+ return {
109
+ name: rawName.replace(/^['"]|['"]$/g, ''),
110
+ type: normaliseType(rawType),
111
+ required: optional === undefined,
112
+ doc,
113
+ };
114
+ }
115
+
116
+ function normaliseType(type: string): string {
117
+ return (
118
+ type
119
+ .replace(/\s+/g, ' ')
120
+ .split('|')
121
+ .map((part) => part.trim())
122
+ // The empty part is a leading `|` in a wrapped union, which is style, not a member.
123
+ .filter((part) => part !== 'undefined' && part !== '')
124
+ .join(' | ')
125
+ .trim()
126
+ );
127
+ }
128
+
129
+ /** Brace-matched so a nested object type does not end the interface early. */
130
+ function interfaceBody(source: string, interfaceName: string): string | undefined {
131
+ const header = new RegExp(`export interface ${interfaceName}(?:<[^>]*>)?\\s*\\{`).exec(source);
132
+ if (header === null) return undefined;
133
+ const start = header.index + header[0].length;
134
+ let depth = 1;
135
+ for (let i = start; i < source.length; i += 1) {
136
+ const char = source[i];
137
+ if (char === '{') depth += 1;
138
+ if (char === '}') depth -= 1;
139
+ if (depth === 0) return source.slice(start, i);
140
+ }
141
+ return undefined;
142
+ }
@@ -0,0 +1,116 @@
1
+ // Renders the parsed component contracts plus the token vocabulary as one markdown page. The
2
+ // point is an agent picking a component and a token WITHOUT opening source — so every table here
3
+ // is derived, never typed by hand, and `catalog.test.ts` fails the build when the file drifts.
4
+
5
+ import { BUTTON_VARIANTS, SIZES, TONES } from '../components/variants';
6
+ import {
7
+ breakpointTokens,
8
+ COLOR_ROLES,
9
+ durationTokens,
10
+ easingTokens,
11
+ fontSizeTokens,
12
+ fontWeightTokens,
13
+ lineHeightTokens,
14
+ radiusTokens,
15
+ spaceTokens,
16
+ zTokens,
17
+ } from '../tokens/tokens';
18
+ import type { ComponentDoc } from './parse-component';
19
+
20
+ export const CATALOG_BANNER =
21
+ '<!-- GENERATED by `bun run catalog` from packages/ui/src. Do not edit by hand. -->';
22
+
23
+ /** Pipes inside a union type would end the markdown cell. */
24
+ function cell(text: string): string {
25
+ return text.replace(/\|/g, '\\|');
26
+ }
27
+
28
+ function table(headers: readonly string[], rows: readonly (readonly string[])[]): string {
29
+ const head = `| ${headers.join(' | ')} |`;
30
+ const rule = `|${headers.map(() => '---').join('|')}|`;
31
+ return [head, rule, ...rows.map((row) => `| ${row.join(' | ')} |`)].join('\n');
32
+ }
33
+
34
+ function scaleTable(title: string, tokens: Readonly<Record<string, string>>): string {
35
+ const rows = Object.entries(tokens).map(([name, value]) => [`\`${name}\``, `\`${cell(value)}\``]);
36
+ return `### ${title}\n\n${table(['Token', 'Value'], rows)}`;
37
+ }
38
+
39
+ function componentSection(doc: ComponentDoc): string {
40
+ const rows = doc.props.map((prop) => [
41
+ `\`${prop.name}\``,
42
+ `\`${cell(prop.type)}\``,
43
+ prop.required ? 'yes' : '—',
44
+ cell(prop.doc),
45
+ ]);
46
+ const props =
47
+ rows.length === 0 ? '_No props._' : table(['Prop', 'Type', 'Required', 'Notes'], rows);
48
+ return `### ${doc.name}\n\n${doc.summary}\n\n${props}`;
49
+ }
50
+
51
+ export function renderCatalog(components: readonly ComponentDoc[]): string {
52
+ const index = components.map((doc) => `\`${doc.name}\``).join(' · ');
53
+
54
+ return `${CATALOG_BANNER}
55
+
56
+ # @ultimat3/ui catalog
57
+
58
+ Every component and every token, projected from source. Import all of it from \`@ultimat3/ui\`.
59
+
60
+ ${components.length} components: ${index}
61
+
62
+ ## Vocabulary
63
+
64
+ One size scale, one tone scale, one variant scale — shared by every component that has them.
65
+
66
+ ${table(
67
+ ['Scale', 'Values'],
68
+ [
69
+ ['`Size`', SIZES.map((size) => `\`${size}\``).join(' · ')],
70
+ ['`Tone`', TONES.map((tone) => `\`${tone}\``).join(' · ')],
71
+ ['`ButtonVariant`', BUTTON_VARIANTS.map((variant) => `\`${variant}\``).join(' · ')],
72
+ [
73
+ '`SpaceStep`',
74
+ Object.keys(spaceTokens)
75
+ .map((step) => `\`${step}\``)
76
+ .join(' · '),
77
+ ],
78
+ ],
79
+ )}
80
+
81
+ ## Components
82
+
83
+ ${components.map(componentSection).join('\n\n')}
84
+
85
+ ## Tokens
86
+
87
+ Colour roles are the only colours that exist. Use them through SCSS (\`t.role('accent')\`) or
88
+ CSS (\`rgb(var(--color-accent) / 0.12)\`); \`colorRgb(theme, role)\` resolves one for canvas,
89
+ charts and email.
90
+
91
+ ### Colour roles
92
+
93
+ ${table(
94
+ ['Role', 'Custom property'],
95
+ COLOR_ROLES.map((role) => [`\`${role}\``, `\`--color-${role}\``]),
96
+ )}
97
+
98
+ ${scaleTable('Space — `--space-*`', spaceTokens)}
99
+
100
+ ${scaleTable('Radius — `--radius-*`', radiusTokens)}
101
+
102
+ ${scaleTable('Font size — `--text-*`', fontSizeTokens)}
103
+
104
+ ${scaleTable('Font weight — `--weight-*`', fontWeightTokens)}
105
+
106
+ ${scaleTable('Line height — `--leading-*`', lineHeightTokens)}
107
+
108
+ ${scaleTable('Duration — `--duration-*`', durationTokens)}
109
+
110
+ ${scaleTable('Easing — `--easing-*`', easingTokens)}
111
+
112
+ ${scaleTable('Z-index — `--z-*`', zTokens)}
113
+
114
+ ${scaleTable('Breakpoints — `@include t.respond-to(<name>)`', breakpointTokens)}
115
+ `;
116
+ }
@@ -0,0 +1,110 @@
1
+ @use '../tokens' as t;
2
+
3
+ // Grid areas, not floats or fixed positioning: the areas re-flow at one breakpoint and the
4
+ // writing mode is handled by the grid itself, so RTL needs no second rule.
5
+ .shell {
6
+ display: grid;
7
+ grid-template-areas: 'header' 'sidebar' 'main' 'footer';
8
+ grid-template-rows: auto auto 1fr auto;
9
+ min-block-size: 100dvh;
10
+ background: t.role('bg');
11
+ color: t.role('fg');
12
+
13
+ @include t.respond-to(md) {
14
+ grid-template-columns: var(--shell-sidebar, 16rem) minmax(0, 1fr);
15
+ grid-template-areas: 'header header' 'sidebar main' 'footer footer';
16
+ grid-template-rows: auto 1fr auto;
17
+ }
18
+ }
19
+
20
+ .no-sidebar {
21
+ grid-template-areas: 'header' 'main' 'footer';
22
+ grid-template-rows: auto 1fr auto;
23
+
24
+ @include t.respond-to(md) {
25
+ grid-template-columns: minmax(0, 1fr);
26
+ grid-template-areas: 'header' 'main' 'footer';
27
+ }
28
+ }
29
+
30
+ // Off-screen but focusable — the first Tab on any page reveals it.
31
+ .skip {
32
+ @include t.focus-ring;
33
+ @include t.transition(translate);
34
+ position: fixed;
35
+ z-index: t.z(skip-nav);
36
+ inset-block-start: t.space(2);
37
+ inset-inline-start: t.space(2);
38
+ padding-block: t.space(2);
39
+ padding-inline: t.space(4);
40
+ border: 1px solid t.role('line');
41
+ border-radius: t.radius(md);
42
+ background: t.role('surface-raised');
43
+ color: t.role('fg-strong');
44
+ box-shadow: t.shadow(md);
45
+ font-weight: t.weight(semibold);
46
+ text-decoration: none;
47
+ translate: 0 -400%;
48
+
49
+ &:focus-visible {
50
+ translate: 0 0;
51
+ }
52
+ }
53
+
54
+ .header {
55
+ @include t.row(t.space(4), center, space-between);
56
+ grid-area: header;
57
+ padding-block: t.space(3);
58
+ padding-inline: t.space(4);
59
+ border-block-end: 1px solid t.role('line');
60
+ background: t.role('surface');
61
+ }
62
+
63
+ .sticky {
64
+ position: sticky;
65
+ z-index: t.z(sticky);
66
+ inset-block-start: 0;
67
+ }
68
+
69
+ // Below `md` the sidebar is a band above the content; from `md` it is the inline-start column.
70
+ .sidebar {
71
+ @include t.custom-scrollbar;
72
+ grid-area: sidebar;
73
+ padding: t.space(4);
74
+ border-block-end: 1px solid t.role('line');
75
+ background: t.role('bg-soft');
76
+ overflow: auto;
77
+
78
+ // Deliberately not sticky: a sticky sidebar and a sticky header both anchored at 0 overlap,
79
+ // and the header's height is not knowable in CSS. The page scrolls as one.
80
+ @include t.respond-to(md) {
81
+ border-block-end: 0;
82
+ border-inline-end: 1px solid t.role('line');
83
+ }
84
+ }
85
+
86
+ .main {
87
+ grid-area: main;
88
+ min-inline-size: 0;
89
+ padding-block: t.space(6);
90
+ padding-inline: t.space(4);
91
+
92
+ // The skip link moves focus here; the ring would otherwise draw around the whole page.
93
+ &:focus-visible {
94
+ outline: none;
95
+ }
96
+
97
+ @include t.respond-to(md) {
98
+ padding-inline: t.space(6);
99
+ }
100
+ }
101
+
102
+ .footer {
103
+ grid-area: footer;
104
+ padding-block: t.space(5);
105
+ padding-inline: t.space(4);
106
+ border-block-start: 1px solid t.role('line');
107
+ background: t.role('surface');
108
+ color: t.role('fg-muted');
109
+ font-size: t.text(sm);
110
+ }
@@ -0,0 +1,61 @@
1
+ // The page frame every app screen sits in: skip link, banner, navigation, main, contentinfo.
2
+ // Stateless on purpose — below `md` the sidebar becomes a band above the content instead of
3
+ // growing an open/closed flag, because an off-canvas menu is already `Drawer` and axiom 1 allows
4
+ // exactly one of those.
5
+
6
+ import type { JSX } from 'solid-js';
7
+ import { useId } from '../a11y';
8
+ import { cx } from '../cx';
9
+ import { UI_KEYS } from '../i18n-keys';
10
+ import { useUi } from '../theme/context';
11
+ import styles from './AppShell.module.scss';
12
+ import { shellIds } from './app-shell-view';
13
+
14
+ export interface AppShellProps {
15
+ /** The page. Rendered inside the one `<main>`, which is the skip link's target. */
16
+ children: JSX.Element;
17
+ header?: JSX.Element | undefined;
18
+ /** Rendered inside a `<nav>` landmark at the inline start. */
19
+ sidebar?: JSX.Element | undefined;
20
+ footer?: JSX.Element | undefined;
21
+ /** Accessible name for the sidebar landmark. Defaults to the translated `ui.navigation`. */
22
+ sidebarLabel?: string | undefined;
23
+ /** Skip-link text. Defaults to the translated `ui.skip`. */
24
+ skipLabel?: string | undefined;
25
+ /** Sidebar track width at `md` and up. Any CSS length. */
26
+ sidebarWidth?: string | undefined;
27
+ /** Keeps the header pinned while the main region scrolls. */
28
+ stickyHeader?: boolean | undefined;
29
+ class?: string | undefined;
30
+ }
31
+
32
+ export function AppShell(props: AppShellProps): JSX.Element {
33
+ const ui = useUi();
34
+ const ids = shellIds(useId('shell'));
35
+
36
+ return (
37
+ <div
38
+ class={cx(styles['shell'], props.sidebar === undefined && styles['no-sidebar'], props.class)}
39
+ style={{ '--shell-sidebar': props.sidebarWidth ?? '16rem' }}
40
+ >
41
+ <a class={styles['skip']} href={ids.skipHref}>
42
+ {props.skipLabel ?? ui.t(UI_KEYS.skip)}
43
+ </a>
44
+ {props.header === undefined ? null : (
45
+ <header class={cx(styles['header'], props.stickyHeader !== false && styles['sticky'])}>
46
+ {props.header}
47
+ </header>
48
+ )}
49
+ {props.sidebar === undefined ? null : (
50
+ <nav class={styles['sidebar']} aria-label={props.sidebarLabel ?? ui.t(UI_KEYS.navigation)}>
51
+ {props.sidebar}
52
+ </nav>
53
+ )}
54
+ {/* tabindex="-1" is what makes the skip link move focus and not just the viewport. */}
55
+ <main id={ids.mainId} class={styles['main']} tabindex={-1}>
56
+ {props.children}
57
+ </main>
58
+ {props.footer === undefined ? null : <footer class={styles['footer']}>{props.footer}</footer>}
59
+ </div>
60
+ );
61
+ }
@@ -19,11 +19,7 @@
19
19
  transform: translateY(1px);
20
20
  }
21
21
 
22
- &:disabled,
23
- &[aria-disabled='true'] {
24
- opacity: 0.55;
25
- cursor: not-allowed;
26
- }
22
+ @include t.disabled;
27
23
  }
28
24
 
29
25
  .full {
@@ -61,47 +57,7 @@
61
57
  // --- variants x tones --------------------------------------------------------
62
58
  // `--tone-*` is set by the tone class, so each variant is written once.
63
59
 
64
- .tone-neutral {
65
- --tone: #{t.role('fg-strong')};
66
- --tone-strong: #{t.role('fg')};
67
- --tone-fg: #{t.role('bg')};
68
- --tone-soft: #{t.role('bg-soft')};
69
- }
70
-
71
- .tone-accent {
72
- --tone: #{t.role('accent')};
73
- --tone-strong: #{t.role('accent-strong')};
74
- --tone-fg: #{t.role('accent-fg')};
75
- --tone-soft: #{t.role('accent', 0.12)};
76
- }
77
-
78
- .tone-success {
79
- --tone: #{t.role('success')};
80
- --tone-strong: #{t.role('success')};
81
- --tone-fg: #{t.role('success-fg')};
82
- --tone-soft: #{t.role('success-soft')};
83
- }
84
-
85
- .tone-warning {
86
- --tone: #{t.role('warning')};
87
- --tone-strong: #{t.role('warning')};
88
- --tone-fg: #{t.role('warning-fg')};
89
- --tone-soft: #{t.role('warning-soft')};
90
- }
91
-
92
- .tone-danger {
93
- --tone: #{t.role('danger')};
94
- --tone-strong: #{t.role('danger')};
95
- --tone-fg: #{t.role('danger-fg')};
96
- --tone-soft: #{t.role('danger-soft')};
97
- }
98
-
99
- .tone-info {
100
- --tone: #{t.role('info')};
101
- --tone-strong: #{t.role('info')};
102
- --tone-fg: #{t.role('info-fg')};
103
- --tone-soft: #{t.role('info-soft')};
104
- }
60
+ @include t.tone-classes;
105
61
 
106
62
  .variant-primary {
107
63
  background: var(--tone);
@@ -22,10 +22,7 @@
22
22
  animation: ultimate-dialog-in t.duration(base) t.easing(out);
23
23
  }
24
24
 
25
- &::backdrop {
26
- background: t.role('scrim', 0.45);
27
- backdrop-filter: blur(2px);
28
- }
25
+ @include t.scrim-backdrop($blur: 2px);
29
26
  }
30
27
 
31
28
  .size-sm {