@prongsjs/ui 0.0.0-stage → 0.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.
@@ -0,0 +1,360 @@
1
+ import { COMPONENT_NAMES, componentFor } from '../vocabulary.js';
2
+ import { COMPONENT_DOCS, LAYOUT_PROPS } from './components.js';
3
+ import { SURFACE_TOKENS, THEME_TOKENS } from './theme.js';
4
+ /** What an intent renders, without its classes: `<form>`, `role="status"`, `type="email"`. */
5
+ export function describeBinding(binding) {
6
+ const b = binding;
7
+ const parts = [];
8
+ if (b.element)
9
+ parts.push(`<${b.element}>`);
10
+ if (b.ariaRole)
11
+ parts.push(`role="${b.ariaRole}"`);
12
+ if (b.behavior?.type)
13
+ parts.push(`type="${b.behavior.type}"`);
14
+ if (b.behavior?.role)
15
+ parts.push(`role="${b.behavior.role}"`);
16
+ const arrangement = arrangementOf(b.classes ?? '');
17
+ return [...parts.map((p) => '`' + p + '`'), arrangement && `(${arrangement})`].filter(Boolean).join(' ');
18
+ }
19
+ /**
20
+ * How a box already lays out its children, read from its classes: `column, gap-4, centred
21
+ * max-w-2xl`. Agents read this instead of the registry to know what not to repeat in className.
22
+ */
23
+ function arrangementOf(classes) {
24
+ const list = classes.split(/\s+/);
25
+ const has = (name) => list.includes(name);
26
+ const flow = has('grid') ? 'grid' : has('flex-col') ? 'column' : has('flex-row') || has('flex') ? 'row' : '';
27
+ if (!flow)
28
+ return '';
29
+ const gap = list.find((name) => /^gap-[\d.]+$/.test(name));
30
+ const width = has('mx-auto') ? list.find((name) => name.startsWith('max-w-')) : undefined;
31
+ return [flow, gap, width && `centred ${width}`].filter(Boolean).join(', ');
32
+ }
33
+ const NESTING = [
34
+ { ids: ['layout.form', 'layout.field', 'layout.actions'], text: '`layout.form` holds `layout.field`s, then one `layout.actions` with its buttons.' },
35
+ {
36
+ ids: ['layout.field', 'text.label', 'text.caption', 'feedback.error'],
37
+ text: '`<Field label hint error>` renders a `layout.field`: its `text.label`, the field, a `text.caption` hint and a `feedback.error`, with the ids that connect them. Build a `layout.field` by hand only for something Field cannot render, and connect the ids yourself.',
38
+ },
39
+ { ids: ['layout.list', 'layout.item'], text: '`layout.list` (`<ul>`) holds only `layout.item`s (`<li>`). Put anything else inside the items.' },
40
+ {
41
+ ids: ['layout.table'],
42
+ text: '`layout.table` is the `<table>`. Inside it write plain `<thead>`, `<tbody>`, `<tr>`, `<th scope="col">` and `<td>` with no classes; put `<Text>` and `<Action>` intents in the cells. When it has more columns than fit on a phone, wrap it in `<Layout intent="layout.scroller" tabIndex={0} aria-label="...">`.',
43
+ },
44
+ { ids: ['layout.shell', 'layout.aside', 'layout.page'], text: '`layout.shell` holds one `layout.aside` (the sidebar) and the `layout.page`.' },
45
+ { ids: ['layout.meta', 'text.meta'], text: '`layout.meta` holds `text.meta` facts (time, author, counts) about one item.' },
46
+ { ids: ['layout.toolbar'], text: '`layout.toolbar` holds the controls for the content below it: filters, views, search, a refresh.' },
47
+ { ids: ['layout.notice', 'action.dismiss'], text: '`layout.notice` holds one `feedback.*` message and an `action.dismiss`.' },
48
+ { ids: ['layout.disclosure', 'text.summary'], text: '`layout.disclosure` (`<details>`) starts with one `text.summary` line, then the content it opens.' },
49
+ { ids: ['layout.divider', 'layout.list'], text: '`layout.divider` is an `<hr>` between parts of the same content. Lines between list items come from `layout.list`.' },
50
+ { ids: ['popover.menu'], text: 'A `<Popover>` takes the `<Action>` that opens it as `trigger`. A `popover.menu` holds `<Action>`s, which look like menu items inside it.' },
51
+ { ids: ['dialog.confirm', 'layout.actions'], text: 'A `<Dialog>` holds a heading, its content and a `layout.actions` row. Set its `label` to the heading text.' },
52
+ ];
53
+ const EXAMPLES = [
54
+ {
55
+ title: 'A form with a label, a hint and an error',
56
+ ids: ['layout.form', 'layout.field', 'text.label', 'field.text', 'feedback.error', 'field.email', 'text.caption', 'layout.actions', 'action.cancel', 'action.save'],
57
+ text: `<Layout intent="layout.form" onSubmit={save}>
58
+ <Field intent="field.text" label="Display name" error={nameError} value={name} onChange={(e) => setName(e.target.value)} />
59
+ <Field intent="field.email" label="Email" hint="Only used for signing in." />
60
+ <Layout intent="layout.actions">
61
+ <Action intent="action.cancel" onClick={cancel}>Cancel</Action>
62
+ <Action intent="action.save" type="submit">Save</Action>
63
+ </Layout>
64
+ </Layout>`,
65
+ },
66
+ {
67
+ title: 'A list of items with facts and a row action',
68
+ ids: ['layout.list', 'layout.item', 'media.person', 'layout.group', 'heading.item', 'layout.meta', 'text.meta', 'action.remove'],
69
+ text: `<Layout intent="layout.list">
70
+ {members.map((member) => (
71
+ <Layout key={member.id} intent="layout.item" className="flex-row items-center">
72
+ <Media intent="media.person" src={member.avatarUrl} alt={member.name} />
73
+ <Layout intent="layout.group" className="flex-1">
74
+ <Text intent="heading.item">{member.name}</Text>
75
+ <Layout intent="layout.meta">
76
+ <Text intent="text.meta">{member.role}</Text>
77
+ <Text intent="text.meta">Joined {member.joined}</Text>
78
+ </Layout>
79
+ </Layout>
80
+ <Action intent="action.remove" onClick={() => remove(member.id)}>Remove</Action>
81
+ </Layout>
82
+ ))}
83
+ </Layout>`,
84
+ },
85
+ {
86
+ title: 'A toolbar with filters, views and search',
87
+ ids: ['layout.toolbar', 'choice.filter', 'nav.view', 'field.search'],
88
+ text: `<Layout intent="layout.toolbar">
89
+ {['All', 'Unread', 'Starred'].map((name) => (
90
+ <Action key={name} intent="choice.filter" pressed={filter === name} onClick={() => setFilter(name)}>{name}</Action>
91
+ ))}
92
+ <Action intent="nav.view" pressed={view === 'list'} onClick={() => setView('list')}>List</Action>
93
+ <Action intent="nav.view" pressed={view === 'grid'} onClick={() => setView('grid')}>Grid</Action>
94
+ <Field intent="field.search" aria-label="Search articles" value={query} onChange={(e) => setQuery(e.target.value)} />
95
+ </Layout>`,
96
+ },
97
+ {
98
+ title: 'A confirmation dialog',
99
+ ids: ['action.delete', 'dialog.confirm', 'layout.group', 'heading.item', 'text.body', 'layout.actions', 'action.cancel'],
100
+ text: `<Action intent="action.delete" onClick={() => setOpen(true)}>Delete project</Action>
101
+ <Dialog intent="dialog.confirm" open={open} onClose={() => setOpen(false)} label="Delete this project?">
102
+ <Layout intent="layout.group" className="gap-4">
103
+ <Text intent="heading.item" as="h2">Delete this project?</Text>
104
+ <Text intent="text.body">The project and all its data will be gone for good.</Text>
105
+ <Layout intent="layout.actions">
106
+ <Action intent="action.cancel" onClick={() => setOpen(false)}>Cancel</Action>
107
+ <Action intent="action.delete" onClick={deleteProject}>Delete</Action>
108
+ </Layout>
109
+ </Layout>
110
+ </Dialog>`,
111
+ },
112
+ {
113
+ title: 'Loading, empty and a dismissible notice',
114
+ ids: ['layout.loading', 'layout.group', 'text.body', 'action.create', 'layout.notice', 'feedback.success', 'action.dismiss'],
115
+ text: `{projects === undefined && <Layout intent="layout.loading" className="h-24" aria-busy="true" />}
116
+ {projects?.length === 0 && (
117
+ <Layout intent="layout.group">
118
+ <Text intent="text.body">No projects yet.</Text>
119
+ <Action intent="action.create" href="/projects/new">Create your first project</Action>
120
+ </Layout>
121
+ )}
122
+ {saved && (
123
+ <Layout intent="layout.notice">
124
+ <Text intent="feedback.success">Your changes were saved.</Text>
125
+ <Action intent="action.dismiss" icon={<CloseIcon />} label="Dismiss" onClick={() => setSaved(false)} />
126
+ </Layout>
127
+ )}`,
128
+ },
129
+ ];
130
+ /** The props section of PRONGS.md, from COMPONENT_DOCS. `intent` and `unsafeClassName` are covered by the rules above. */
131
+ function componentProps() {
132
+ const withLayout = COMPONENT_NAMES.filter((name) => COMPONENT_DOCS[name].layoutProps).map((name) => `\`<${name}>\``);
133
+ const out = [
134
+ 'Each component also takes its element’s usual attributes (`id`, `onClick`, `aria-*`, `data-*`...), except `className` and `style`.',
135
+ '',
136
+ `Layout props, on ${withLayout.slice(0, -1).join(', ')} and ${withLayout.at(-1)}: ${LAYOUT_PROPS.map((p) => `\`${p.name}\` (${p.type})`).join(', ')}.`,
137
+ ];
138
+ for (const name of COMPONENT_NAMES) {
139
+ const doc = COMPONENT_DOCS[name];
140
+ out.push('', `### \`<${name}>\` props`, '');
141
+ for (const prop of doc.props) {
142
+ // intent and unsafeClassName are in the rules above; ref works as it does on any element.
143
+ if (prop.name === 'intent' || prop.name === 'unsafeClassName' || prop.name === 'ref')
144
+ continue;
145
+ out.push(`- \`${prop.name}\`${prop.required ? ' (required)' : ''}: ${prop.description}`);
146
+ }
147
+ out.push(...doc.rules.map((rule) => `- Rule: ${rule}`));
148
+ }
149
+ return out;
150
+ }
151
+ /** The examples whose intents all exist, for tests that check them against a registry. */
152
+ export function specExamples(registry) {
153
+ return EXAMPLES.filter((e) => e.ids.every((id) => registry.has(id))).map((e) => ({ title: e.title, code: e.text }));
154
+ }
155
+ export function uiSpecSection(registry, context) {
156
+ const from = context.componentsImport ?? './components/prongs';
157
+ const has = (g) => g.ids.every((id) => registry.has(id));
158
+ const out = [
159
+ '## UI rules',
160
+ '',
161
+ `\`import { ${COMPONENT_NAMES.join(', ')} } from '${from}';\``,
162
+ '',
163
+ '- The intent decides the element (`heading.page` renders `<h1>`). Use `as` on `<Text>` only to fix document structure.',
164
+ `- ${COMPONENT_NAMES.filter((n) => n !== 'Layout').map((n) => `\`<${n}>\``).join(', ')} take no \`className\` or \`style\`. Spacing comes from the parent \`<Layout>\`.`,
165
+ '- `<Layout>` takes a `className` for layout and spacing only (margins, sizes, flex/grid, gaps, padding, text alignment).',
166
+ '- `unsafeClassName` bypasses the intent. It needs a `// prongs-unsafe: <reason>` comment on the line above and is counted by `check`.',
167
+ '- Do not use raw `<button>`, `<h1>`–`<h6>`, `<input>`, `<textarea>`, `<select>`, `<dialog>`, or styled `<p>`/`<span>`/`<a>`/`<div>`. A justified exception takes `// prongs-ignore: <reason>` on the line above.',
168
+ '- State is a prop, not a style: `pressed` for toggles, choices and views (sets `aria-pressed`), `current` for the page or step you are on (sets `aria-current`). Other state goes in `data-*` attributes (`data-read`, `data-compact`), which `<Text>`, `<Action>`, `<Field>`, `<Layout>` and `<Media>` pass to their element; the intent styles them with `data-[read]:` variants. Never switch classes or intents to show state.',
169
+ "- Values that come from data (a colour the user picked) go through `vars={{ '--name': value }}`, never `style`.",
170
+ '- Every element carries `data-intent` (e.g. `data-intent="action.delete"`) for analytics, tests and A/B tools to find it by purpose. Never style with it, in CSS or in classes: looks come from the registry.',
171
+ '- Inline emphasis inside a sentence is plain `<strong>` or `<em>`, not an intent. Thematic breaks are `<hr>`.',
172
+ '',
173
+ '## Component props',
174
+ '',
175
+ ...componentProps(),
176
+ ];
177
+ const nesting = NESTING.filter(has);
178
+ if (nesting.length)
179
+ out.push('', '## How intents nest', '', ...nesting.map((n) => `- ${n.text}`));
180
+ const examples = EXAMPLES.filter(has);
181
+ if (examples.length) {
182
+ out.push('', '## Examples', '');
183
+ for (const e of examples)
184
+ out.push(`### ${e.title}`, '', '```tsx', e.text, '```', '');
185
+ }
186
+ return out.join('\n');
187
+ }
188
+ /** What only editing the registry or the theme needs: PRONGS.reference.md. */
189
+ export function uiReferenceSection() {
190
+ return themeSection().join('\n');
191
+ }
192
+ /** How the registry's classes reach colours: for whoever edits the registry. */
193
+ function themeSection() {
194
+ const colors = THEME_TOKENS.filter((t) => t.utility).map((t) => '`' + t.utility + '`');
195
+ const surface = SURFACE_TOKENS.map((t) => `- \`${t.utility}\` (\`var(${t.name})\`): ${t.description}`);
196
+ return [
197
+ '## Theme',
198
+ '',
199
+ 'The registry is the only place classes live, and its colours come from the theme (`theme.css`), never from values. `prongs check` reports a colour literal or a Tailwind palette colour in the registry (`raw-value`), and a `var()` that nothing declares (`unknown-token`).',
200
+ '',
201
+ `Colours, as Tailwind colours (\`bg-primary\`, \`text-success\`): ${colors.join(', ')}.`,
202
+ '',
203
+ 'Surfaces layer themselves. A look with the class `surface` sits one level above the surface it is in; `surface-inset` is a recess, `surface-overlay` floats over the page (dialogs, menus), and `surface-page` restarts at level 0 under an element that sets its own `--background`/`--foreground`. Inside one, use the surface colours (`hover:bg-surface-hover`, `text-surface-muted`, `border-surface-border`) so hover fills, quiet text and hairlines stay visible at every level:',
204
+ '',
205
+ ...surface,
206
+ ];
207
+ }
208
+ /** How a call site writes an intent, for `prongs find`. */
209
+ export function uiUsage(id, binding) {
210
+ const component = componentFor(id);
211
+ const b = binding;
212
+ if (component === 'Field')
213
+ return `<Field intent="${id}" label="…" />`;
214
+ if (component === 'Layout' && b.element === 'hr')
215
+ return `<Layout intent="${id}" />`;
216
+ if (component === 'Dialog')
217
+ return `<Dialog intent="${id}" open={open} onClose={close} label="…">…</Dialog>`;
218
+ if (component === 'Popover')
219
+ return `<Popover intent="${id}" label="…" trigger={<Action intent="…">…</Action>}>…</Popover>`;
220
+ return `<${component} intent="${id}">…</${component}>`;
221
+ }
222
+ /** Groups the spec's index by the component that renders each intent. */
223
+ export function groupByComponent(id) {
224
+ const component = componentFor(id);
225
+ return component ? `\`<${component}>\`` : `\`${id.split('.')[0]}\``;
226
+ }
227
+ /**
228
+ * What the UI profile adds to PRONGS.md, without the code analysis (and the TypeScript
229
+ * compiler it needs). For writing the spec somewhere other than the CLI, like a docs site.
230
+ */
231
+ export { COMPONENT_DOCS, LAYOUT_PROPS } from './components.js';
232
+ export const uiSpecTooling = {
233
+ name: 'ui',
234
+ describe: describeBinding,
235
+ groupOf: groupByComponent,
236
+ usage: uiUsage,
237
+ specSection: uiSpecSection,
238
+ referenceSection: uiReferenceSection,
239
+ };
240
+ /** What each UI rule finds, for the docs and for anyone reading a check report. */
241
+ export const UI_RULE_DOCS = [
242
+ {
243
+ id: 'raw-element',
244
+ profile: 'ui',
245
+ severity: 'error',
246
+ summary: 'A `<button>`, `<h2>`, `<input>` or styled `<p>`/`<span>`/`<a>` instead of a component. Suggests the closest intents.',
247
+ bad: '<button className="rounded bg-red-600 px-4 text-white" onClick={remove}>Delete</button>',
248
+ good: '<Action intent="action.delete" onClick={remove}>Delete</Action>',
249
+ },
250
+ {
251
+ id: 'raw-surface',
252
+ profile: 'ui',
253
+ severity: 'error',
254
+ summary: 'A `<div>` that draws its own box (background, border, shadow). Suggests a Layout intent that says what the region is.',
255
+ bad: '<div className="rounded-lg border bg-white p-6">…</div>',
256
+ good: '<Layout intent="layout.panel">…</Layout>',
257
+ },
258
+ {
259
+ id: 'domain-mismatch',
260
+ profile: 'ui',
261
+ severity: 'error',
262
+ summary: 'An intent rendered by the wrong component, like an action on `<Text>`.',
263
+ bad: '<Text intent="action.save">Save</Text>',
264
+ good: '<Action intent="action.save">Save</Action>',
265
+ },
266
+ {
267
+ id: 'leaf-classname',
268
+ profile: 'ui',
269
+ severity: 'error',
270
+ summary: '`className` on `<Text>`, `<Action>`, `<Field>`, `<Media>`, `<Dialog>` or `<Popover>`, which take intents and layout props only.',
271
+ bad: '<Text intent="text.caption" className="text-red-600">…</Text>',
272
+ good: '<Text intent="feedback.error">…</Text>',
273
+ },
274
+ {
275
+ id: 'leaf-style',
276
+ profile: 'ui',
277
+ severity: 'error',
278
+ summary: '`style` on a component that takes no style. An inline style overrides the intent just like a className.',
279
+ bad: '<Action intent="action.save" style={{ color: \'red\' }}>Save</Action>',
280
+ good: '<Action intent="action.delete">Delete</Action>',
281
+ },
282
+ {
283
+ id: 'layout-visual-class',
284
+ profile: 'ui',
285
+ severity: 'error',
286
+ summary: 'Colours, type or borders in a `<Layout>` className, which is for layout and spacing only.',
287
+ bad: '<Layout intent="layout.group" className="gap-4 bg-muted">…</Layout>',
288
+ good: '<Layout intent="layout.panel" className="gap-4">…</Layout>',
289
+ },
290
+ {
291
+ id: 'repeated-tweak',
292
+ profile: 'ui',
293
+ severity: 'warning',
294
+ summary: 'The same layout class added to the same `<Layout>` intent in three or more places, across the whole project. Each is allowed, but a tweak that repeats is a missing intent: points to an intent that already has it, or a ready `add-intent` command. `check --suggest` lists these with the overrides.',
295
+ bad: '<Layout intent="layout.group" className="gap-4">…</Layout> // and in 15 other places',
296
+ good: '<Layout intent="layout.section">…</Layout>',
297
+ },
298
+ {
299
+ id: 'raw-style',
300
+ profile: 'ui',
301
+ severity: 'error',
302
+ summary: 'An inline style that sets colour, type or borders. Layout values and CSS variables are fine.',
303
+ bad: '<div style={{ color: \'#c00\', fontWeight: 600 }}>…</div>',
304
+ good: "<Action intent=\"choice.color\" vars={{ '--choice-color': color }} />",
305
+ },
306
+ {
307
+ id: 'unsafe-classname',
308
+ profile: 'ui',
309
+ severity: 'warning',
310
+ summary: 'The escape hatch. A warning with a `// prongs-unsafe: <reason>` comment on the line above, an error without one; counted by `--max-unsafe`.',
311
+ },
312
+ {
313
+ id: 'name-mismatch',
314
+ profile: 'ui',
315
+ severity: 'warning',
316
+ summary: 'An action whose handler, label or text points to a different intent, through the intents’ keywords.',
317
+ bad: '<Action intent="action.save" onClick={shareArticle}>Share</Action>',
318
+ good: '<Action intent="action.share" onClick={shareArticle}>Share</Action>',
319
+ },
320
+ {
321
+ id: 'raw-look',
322
+ profile: 'ui',
323
+ severity: 'error',
324
+ summary: 'A plain element with look classes that the other rules do not cover: a `<div>` with text colours, a `<span>` drawn as a dot, a styled `<hr>` or `<iframe>`. Inline SVG and the document root are allowed.',
325
+ bad: '<span className="h-2 w-2 rounded-full bg-[var(--accent)]" />',
326
+ good: '<Text intent="text.indicator" aria-label="Unread" />',
327
+ },
328
+ {
329
+ id: 'intent-selector',
330
+ profile: 'ui',
331
+ severity: 'error',
332
+ summary: 'CSS or a class that selects elements by `data-intent`. The attribute is for finding elements (analytics, tests, A/B tools); looks come from the registry.',
333
+ bad: '[data-intent="action.delete"] { color: red; }',
334
+ good: "'action.delete': { refs: ['control.destructive'] }",
335
+ },
336
+ {
337
+ id: 'unknown-token',
338
+ profile: 'ui',
339
+ severity: 'error',
340
+ summary: 'A registry class that reads a CSS variable nothing declares, so it resolves to nothing. Variables count as declared when a CSS file sets them or code passes them through `vars`.',
341
+ bad: "'tone.accent': { binding: { classes: 'text-[var(--brand-blue)]' } }",
342
+ good: "'tone.accent': { binding: { classes: 'text-primary' } }",
343
+ },
344
+ {
345
+ id: 'raw-value',
346
+ profile: 'ui',
347
+ severity: 'warning',
348
+ summary: 'A colour the theme cannot change: a literal (`bg-[#e5383b]`) or a Tailwind palette colour (`text-sky-700`) in a registry class, or a literal colour set as a CSS variable in `style` or `vars`. White and black are allowed.',
349
+ bad: "'tone.info': { binding: { classes: 'text-sky-700' } }",
350
+ good: "'tone.info': { binding: { classes: 'text-info' } }",
351
+ },
352
+ {
353
+ id: 'dynamic-intent',
354
+ profile: 'ui',
355
+ severity: 'info',
356
+ summary: 'An intent computed at runtime, so it could not be checked. A ternary of literals can be.',
357
+ bad: '<Action intent={intentFor(state)}>…</Action>',
358
+ good: "<Action intent={following ? 'action.toggle' : 'action.add'}>…</Action>",
359
+ },
360
+ ];
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The theme the registry's classes read: colour tokens plus surfaces that layer themselves. `prongs add` writes
3
+ * `themeCss()` to theme.css; the values are the project's to change, the names are the
4
+ * contract the registry and `prongs check` rely on.
5
+ */
6
+ export type ThemeToken = {
7
+ name: `--${string}`;
8
+ light: string;
9
+ dark: string;
10
+ /** What the token is for, shown in PRONGS.md and the docs. */
11
+ description: string;
12
+ /** The Tailwind colour it becomes (`bg-primary`), when it is a colour. */
13
+ utility?: string;
14
+ };
15
+ export declare const THEME_TOKENS: readonly ThemeToken[];
16
+ /** How surfaces step. Percentages mix `--foreground` into `--background`. */
17
+ export type SurfaceSettings = {
18
+ /** How far each level moves towards the foreground, in percent. */
19
+ step: number;
20
+ /** The level an overlay (dialog, menu) starts at. */
21
+ overlay: number;
22
+ /** How much foreground is in quiet text, in percent of the way from the surface. */
23
+ muted: number;
24
+ /** How much foreground is in a surface's hairline. */
25
+ border: number;
26
+ };
27
+ export declare const SURFACE_SETTINGS: Record<'light' | 'dark', SurfaceSettings>;
28
+ /** Levels a nested surface can reach; deeper nesting stays at the last one. */
29
+ export declare const SURFACE_DEPTH = 4;
30
+ /** The variables a surface sets for whatever is inside it. */
31
+ export declare const SURFACE_TOKENS: readonly ThemeToken[];
32
+ /** The classes that make an element a surface. */
33
+ export declare const SURFACE_CLASSES: readonly [{
34
+ readonly name: "surface";
35
+ readonly description: "A raised surface: one level above the surface it sits in.";
36
+ }, {
37
+ readonly name: "surface-inset";
38
+ readonly description: "A recess: one level back from the surface it sits in (forward on the page itself).";
39
+ }, {
40
+ readonly name: "surface-overlay";
41
+ readonly description: "Floats over the page (dialogs, menus): starts its own stack, whatever it is nested in.";
42
+ }, {
43
+ readonly name: "surface-page";
44
+ readonly description: "An element that sets its own --background or --foreground (a themed section, a preview): starts again at level 0 with its colours. It does not paint itself.";
45
+ }];
46
+ /** Every name `theme.css` declares, so `prongs check` can tell a typo from a token. */
47
+ export declare const THEME_VARIABLES: readonly string[];
48
+ /** The CSS `prongs add` writes to theme.css. */
49
+ export declare function themeCss(): string;
50
+ type Oklch = {
51
+ l: number;
52
+ c: number;
53
+ h: number;
54
+ };
55
+ /** Parses `oklch(L C H)` (L as 0–1 or a percentage) or `#rrggbb`. Alpha is not supported. */
56
+ export declare function parseColor(value: string): Oklch | undefined;
57
+ /** color-mix(in oklch, a p, b): `p` is the share of `a`, 0–1. A grey takes the other colour's hue. */
58
+ export declare function mixOklch(a: Oklch, b: Oklch, p: number): Oklch;
59
+ export declare function contrast(a: Oklch, b: Oklch): number;
60
+ export type SurfaceLevel = {
61
+ /** `page`, `1`…, or `overlay`, `overlay 1`… */
62
+ name: string;
63
+ level: number;
64
+ background: Oklch;
65
+ /** Contrast of main text, quiet text and the hairline on this level. */
66
+ text: number;
67
+ muted: number;
68
+ border: number;
69
+ /** Contrast between this level and its hover fill. */
70
+ hover: number;
71
+ };
72
+ /** Every level the CSS can produce for a background and foreground, with its contrasts. */
73
+ export declare function surfaceLevels(colors: {
74
+ background: string;
75
+ foreground: string;
76
+ }, settings: SurfaceSettings): SurfaceLevel[];
77
+ /** `surfaceLevels` for the default theme. */
78
+ export declare function defaultSurfaceLevels(mode: 'light' | 'dark'): SurfaceLevel[];
79
+ /** Contrast of a default token pair, e.g. `--primary-foreground` on `--primary`. */
80
+ export declare function tokenContrast(mode: 'light' | 'dark', fg: string, bg: string): number;
81
+ /** Contrast of a default token's text on every level of the default surfaces. */
82
+ export declare function tokenOnSurfaces(mode: 'light' | 'dark', token: string): number[];
83
+ export {};