@threadlabs/looma 0.39.1 → 0.39.3

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.
Files changed (50) hide show
  1. package/components/ui-button/ui-button.html +24 -3
  2. package/components/ui-checkbox/ui-checkbox.html +34 -18
  3. package/components/ui-combobox/ui-combobox.html +8 -3
  4. package/components/ui-combobox/ui-combobox.js +3 -2
  5. package/components/ui-dialog/ui-dialog.html +6 -52
  6. package/components/ui-input/ui-input.html +21 -3
  7. package/components/ui-input-group/ui-input-group.html +1 -0
  8. package/components/ui-light-editor/ui-light-editor.html +26 -25
  9. package/components/ui-select/ui-select.html +10 -3
  10. package/dist/index.js +1 -1
  11. package/docs/README.md +18 -7
  12. package/docs/catalog.json +2 -26
  13. package/docs/customizer-render.mjs +37 -33
  14. package/docs/customizer.js +82 -47
  15. package/docs/site.mjs +10 -6
  16. package/docs/theme-session.mjs +7 -8
  17. package/docs/theme.mjs +10 -22
  18. package/package.json +1 -1
  19. package/styles/ui-button.css +24 -1
  20. package/styles/ui-checkbox.css +39 -19
  21. package/styles/ui-combobox.css +16 -6
  22. package/styles/ui-dialog.css +6 -107
  23. package/styles/ui-input-group.css +1 -0
  24. package/styles/ui-input.css +24 -3
  25. package/styles/ui-light-editor.css +111 -0
  26. package/styles/ui-select.css +10 -3
  27. package/theme-system.css +4 -1
  28. package/tokens.css +9 -2
  29. package/vanilla/UiButton.js +14 -12
  30. package/vanilla/UiDialog.js +2 -2
  31. package/vanilla/UiLightEditor.js +2 -2
  32. package/vue/UiButton.vue +28 -7
  33. package/vue/UiCheckbox.vue +34 -19
  34. package/vue/UiCombobox.vue +22 -6
  35. package/vue/UiDialog.d.ts +3 -3
  36. package/vue/UiDialog.vue +18 -111
  37. package/vue/UiInput.vue +30 -3
  38. package/vue/UiInputGroup.vue +1 -0
  39. package/vue/UiLightEditor.vue +156 -40
  40. package/vue/UiSelect.vue +16 -3
  41. package/vue/chunks/UiButton.js +18 -10
  42. package/vue/chunks/UiCheckbox.js +1 -1
  43. package/vue/chunks/UiCombobox.js +1 -1
  44. package/vue/chunks/UiDialog.js +22 -14
  45. package/vue/chunks/UiInput.js +1 -1
  46. package/vue/chunks/UiInputGroup.js +1 -1
  47. package/vue/chunks/UiLightEditor.js +66 -58
  48. package/vue/chunks/UiSelect.js +1 -1
  49. package/vue/components.css +337 -160
  50. package/vue/editor/index.js +46 -36
package/docs/site.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { cp, mkdir, readFile, readdir, realpath, rename, rm, symlink, unlink, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { randomUUID } from 'node:crypto';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
4
  import { createRequire } from 'node:module';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import { discoverComponents, escapeHtml, mergeCatalogs, readCatalog, readJson } from './catalog.mjs';
@@ -144,7 +144,7 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
144
144
  const customizer = path.join(generated, 'customizer.html');
145
145
  await write(customizer, customizerResource({ catalog: combined, config, base }, config.recipe));
146
146
  const customizerPage = path.join(content, 'customize.md');
147
- await write(customizerPage, `---\ntitle: Customize\nsidebar: false\noutline: false\nprevious: false\nnext: false\n---\n\n# Weave your design system\n\n<link rel="component" href="${relativeHref(customizerPage, customizer)}">\n<looma-customizer></looma-customizer>\n`);
147
+ await write(customizerPage, `---\ntitle: Customize\nsidebar: false\noutline: false\nprevious: false\nnext: false\n---\n\n# Customize your theme\n\n<link rel="component" href="${relativeHref(customizerPage, customizer)}">\n<looma-customizer></looma-customizer>\n`);
148
148
  await write(path.join(generated, 'catalog.json'), JSON.stringify(combined));
149
149
  const redirects = {};
150
150
  for (const component of combined.components) {
@@ -220,7 +220,7 @@ export async function buildDocumentation({ root = process.cwd(), config = {}, ca
220
220
  });
221
221
  }
222
222
 
223
- async function isolatePreviewScripts(site, { outDir, writeFile: writeOutput }) {
223
+ async function isolatePreviewScripts(site, { outDir }) {
224
224
  const { build } = await tool('vite', site.root);
225
225
  // Preview entries and outputs are independent. Keep compiler memory bounded
226
226
  // and retain the build lock until every in-flight bundle has settled.
@@ -236,9 +236,13 @@ async function isolatePreviewScripts(site, { outDir, writeFile: writeOutput }) {
236
236
  const input = path.join(outDir, url.slice(site.base.length));
237
237
  const bundle = await build({ configFile: false, logLevel: 'silent', build: { write: false, minify: true, lib: { entry: input, name: 'LoomaPreview', formats: ['iife'] }, rollupOptions: { output: { codeSplitting: false } } } });
238
238
  const code = (Array.isArray(bundle) ? bundle[0] : bundle).output.find(output => output.type === 'chunk').code;
239
- const name = `${exampleKey(example.id)}.js`;
240
- await writeOutput('_looma/runtime/' + name, code);
241
- html = html.replace(tag, `<script src="${site.base}_looma/runtime/${name}" defer></script>`);
239
+ // A sandbox has an opaque origin: Safari cannot authorize this runtime via 'self'.
240
+ // Hash the exact inline bytes and run after the preview DOM, preserving isolation.
241
+ const script = code.replace(/<\/script/gi, '<\\/script');
242
+ const hash = createHash('sha256').update(script).digest('base64');
243
+ if (!/<meta\b[^>]*http-equiv="Content-Security-Policy"[^>]*content="[^"]*\bscript-src\b/.test(html)) throw new Error(`Missing script policy for isolated preview ${example.id}.`);
244
+ html = html.replace(/(<meta\b[^>]*http-equiv="Content-Security-Policy"[^>]*content="[^"]*\bscript-src)\b/, `$1 'sha256-${hash}'`);
245
+ html = html.replace(tag, '').replace('</body>', () => `<script>${script}</script></body>`);
242
246
  }
243
247
  html = html.replace(/<link\b[^>]*rel="modulepreload"[^>]*>/g, '');
244
248
  await writeFile(htmlFile, html);
@@ -7,7 +7,7 @@ export const themeEvent = 'looma-documentation-theme';
7
7
  export function createThemeSession(view, { base, baseline, systemCSS, historyLimit = 100 }) {
8
8
  const key = `looma:documentation-theme:v1:${base}`;
9
9
  let original = validateRecipe(baseline);
10
- let selected, draft, comparison;
10
+ let selected, draft;
11
11
  const past = [], future = [];
12
12
  let portableCSS = '';
13
13
  let storageAvailable = true;
@@ -17,7 +17,7 @@ export function createThemeSession(view, { base, baseline, systemCSS, historyLim
17
17
  const read = value => { try { return value ? validateRecipe(JSON.parse(value)) : undefined; } catch { return undefined; } };
18
18
  try { selected = read(view.localStorage.getItem(key)); } catch { storageAvailable = false; }
19
19
  const paint = () => {
20
- const displayed = comparison ?? draft ?? selected;
20
+ const displayed = draft ?? selected;
21
21
  portableCSS = displayed ? themeCSS(displayed, systemCSS) : '';
22
22
  view.document.documentElement.toggleAttribute('data-looma-documentation-theme', Boolean(displayed));
23
23
  style.textContent = displayed ? themeCSS(displayed, systemCSS, { scope: 'documentation' }) : '';
@@ -42,22 +42,21 @@ export function createThemeSession(view, { base, baseline, systemCSS, historyLim
42
42
  preview(recipe) { draft = validateRecipe(recipe); paint(); },
43
43
  commit() { if (draft) session.apply(draft); },
44
44
  cancel() { draft = undefined; paint(); },
45
- compare(recipe) { comparison = recipe === undefined ? undefined : validateRecipe(recipe); paint(); },
46
- undo() { if (!past.length) return; future.push(structuredClone(selected)); selected = past.pop(); draft = comparison = undefined; persist(); },
47
- redo() { if (!future.length) return; past.push(structuredClone(selected)); selected = future.pop(); draft = comparison = undefined; persist(); },
45
+ undo() { if (!past.length) return; future.push(structuredClone(selected)); selected = past.pop(); draft = undefined; persist(); },
46
+ redo() { if (!future.length) return; past.push(structuredClone(selected)); selected = future.pop(); draft = undefined; persist(); },
48
47
  apply(recipe) {
49
48
  const next = validateRecipe(recipe);
50
49
  if (JSON.stringify(next) !== JSON.stringify(selected)) remember();
51
- selected = next; draft = comparison = undefined; persist();
50
+ selected = next; draft = undefined; persist();
52
51
  },
53
- reset() { if (selected !== undefined) remember(); selected = draft = comparison = undefined; persist(); return structuredClone(original); },
52
+ reset() { if (selected !== undefined) remember(); selected = draft = undefined; persist(); return structuredClone(original); },
54
53
  baseline(recipe) { original = validateRecipe(recipe); },
55
54
  };
56
55
  view.addEventListener('storage', event => {
57
56
  if (event.key !== key && event.key !== null) return;
58
57
  const next = read(event.newValue);
59
58
  if (event.newValue !== null && !next) return;
60
- selected = next; draft = comparison = undefined; past.length = future.length = 0; paint();
59
+ selected = next; draft = undefined; past.length = future.length = 0; paint();
61
60
  });
62
61
  view.addEventListener('markupress-header-notice-dismiss', () => session.reset());
63
62
  paint();
package/docs/theme.mjs CHANGED
@@ -49,9 +49,9 @@ const starting = values => ({ schemaVersion: 1, values: { ...defaultRecipe().val
49
49
  export const startingPoints = {
50
50
  clear: defaultRecipe(),
51
51
  bookish: starting({ rhythm: '1.05', action: '#2e1228', canvas: '#f7f7f5', surface: '#fffefd', text: '#0d0c0c', 'body-font': 'Arial, sans-serif', 'display-font': 'Georgia, serif', 'text-weight': '400', 'control-roominess': '2.625', 'scale-contrast': '1.18', 'outer-depth': '1', 'inner-depth': '1', glassiness: '0.8', 'surface-separation': '0.65', translucency: '0.18', 'edge-light': '1', finish: '0.85', glow: '0.3', 'light-spread': '0.3', 'light-strength': '0.9', 'response-time': '150ms', 'response-curve': 'ease' }),
52
- wiki: starting({ rhythm: '0.8', 'text-weight': '400', 'space-curve': '0.8', action: '#4f46e5', canvas: '#ffffff', surface: '#ffffff', text: '#1a1a1a', 'control-roominess': '2.125', spacing: '0.85', 'scale-contrast': '1.15', 'corner-roundness': '6px', 'outer-depth': '0.35', 'inner-depth': '0', 'response-time': '110ms' }),
52
+ wiki: starting({ rhythm: '0.95', 'text-weight': '400', 'space-curve': '0.8', action: '#4f46e5', canvas: '#ffffff', surface: '#ffffff', text: '#1a1a1a', 'control-roominess': '2.125', spacing: '0.95', 'scale-contrast': '1.15', 'corner-roundness': '6px', 'outer-depth': '0.35', 'inner-depth': '0', 'response-time': '110ms' }),
53
53
  editorial: starting({ rhythm: '1.3', 'text-size': '17px', spacing: '1.12', 'space-curve': '1.4', 'scale-contrast': '1.3', action: '#182c24', canvas: '#eee9dc', surface: '#fffdf5', text: '#182c24', 'corner-roundness': '0px', 'outer-depth': '0', 'inner-depth': '0', 'body-font': 'Georgia, serif', 'display-font': 'Georgia, serif', 'text-weight': '400', 'line-weight': '2px', 'response-time': '90ms', 'response-curve': 'linear' }),
54
- night: starting({ rhythm: '0.9', 'display-font': 'ui-monospace, monospace', 'text-weight': '600', 'control-roominess': '2.375', spacing: '0.9', 'scale-contrast': '1.25', 'space-curve': '1.2', finish: '0.6', glow: '0.45', glassiness: '0.45', 'surface-separation': '0.25', 'light-x': '0.25', 'light-spread': '0.65', action: '#a7b8ff', canvas: '#11141c', surface: '#202634', text: '#edf1fb', 'corner-roundness': '12px', 'outer-depth': '1.4', 'inner-depth': '0.45', translucency: '0.2', 'edge-light': '0.65', 'response-time': '210ms', 'response-curve': 'cubic-bezier(0.2,0.8,0.2,1)' }),
54
+ night: starting({ rhythm: '1.05', 'display-font': 'ui-monospace, monospace', 'text-weight': '500', 'control-roominess': '2.375', spacing: '1.05', 'scale-contrast': '1.25', 'space-curve': '1', finish: '0.6', glow: '0.45', glassiness: '0.45', 'surface-separation': '0.25', 'light-x': '0.25', 'light-spread': '0.65', action: '#a7b8ff', canvas: '#11141c', surface: '#202634', text: '#edf1fb', 'corner-roundness': '12px', 'outer-depth': '1.4', 'inner-depth': '0.45', translucency: '0.2', 'edge-light': '0.65', 'response-time': '210ms', 'response-curve': 'cubic-bezier(0.2,0.8,0.2,1)' }),
55
55
  garden: starting({ rhythm: '1.2', 'body-font': 'ui-rounded, system-ui, sans-serif', 'display-font': 'Georgia, serif', 'text-weight': '400', 'space-curve': '1.15', finish: '0.15', 'light-spread': '1', action: '#256342', canvas: '#f4edda', surface: '#fff8e7', text: '#173121', 'corner-roundness': '22px', 'outer-depth': '0.35', 'inner-depth': '0', 'edge-light': '0.2', 'control-roominess': '2.875', 'scale-contrast': '1.24', spacing: '1.05', 'response-time': '180ms', 'response-curve': 'cubic-bezier(0.16,1,0.3,1)' }),
56
56
  };
57
57
  export const startingPointLabels = { clear: 'Clear', bookish: 'Bookish', wiki: 'Wiki', editorial: 'Editorial', night: 'Night', garden: 'Garden' };
@@ -95,24 +95,6 @@ export function paletteFor(input) {
95
95
  return Object.entries(themePalettes).find(([, palette]) => paletteKeys.every(key => values[key].toLowerCase() === palette.values[key].toLowerCase()))?.[0] ?? 'custom';
96
96
  }
97
97
 
98
- export const themeVariations = { warmer: 'Warmer', quieter: 'Quieter', expressive: 'More expressive', compact: 'More compact' };
99
- /** Nearby choices reuse the same bounded semantics; locks protect whole decision families. */
100
- export function nearbyVariation(input, kind, locks = []) {
101
- const recipe = validateRecipe(input);
102
- if (!Object.hasOwn(themeVariations, kind)) throw new Error('Unknown theme variation.');
103
- const deltas = {
104
- warmer: { warmth: .15 }, quieter: { 'color-intensity': -.12, glow: -.1, 'outer-depth': -.15 },
105
- expressive: { finish: .15, 'edge-light': .15, glow: .1 }, compact: { 'control-roominess': -.15, spacing: -.1 },
106
- }[kind];
107
- const values = { ...recipe.values };
108
- for (const [id, delta] of Object.entries(deltas)) {
109
- const field = themeFields.find(item => item.id === id);
110
- const family = field.family === 'Colour' ? 'palette' : field.family === 'Material' ? 'material' : 'type';
111
- if (!locks.includes(family)) values[id] = String(Number(Math.max(field.min, Math.min(field.max, Number(values[id]) + delta)).toFixed(3)));
112
- }
113
- return validateRecipe({ ...recipe, values });
114
- }
115
-
116
98
  const scalar = /^[+]?(?:\d+(?:\.\d*)?|\.\d+)$/;
117
99
  const length = /^(?:0|[+]?(?:\d+(?:\.\d*)?|\.\d+)(?:px|rem|em|ex|ch|cap|ic|lh|rlh|vw|vh|vi|vb|vmin|vmax|svw|svh|lvw|lvh|dvw|dvh|cm|mm|Q|in|pt|pc))$/;
118
100
  const font = /^(?:"[^"\\;{}<>@\x00-\x1f]+"|'[^'\\;{}<>@\x00-\x1f]+'|[\p{L}_-][\p{L}\p{N}_ -]*)(?:\s*,\s*(?:"[^"\\;{}<>@\x00-\x1f]+"|'[^'\\;{}<>@\x00-\x1f]+'|[\p{L}_-][\p{L}\p{N}_ -]*))*$/u;
@@ -291,15 +273,21 @@ export function themeCSS(input, systemCSS = '', { scope = 'portable' } = {}) {
291
273
  const selector = scope === 'documentation'
292
274
  ? ':root[data-looma-documentation-theme], :root[data-looma-documentation-theme] [data-theme], :root[data-looma-documentation-theme] [data-contrast]'
293
275
  : ':root, [data-theme], [data-contrast]';
294
- const fontURL = googleFontURL(validateRecipe(input).fonts);
276
+ const recipe = validateRecipe(input);
277
+ const fontURL = googleFontURL(recipe.fonts);
295
278
  const imports = fontURL ? `@import url("${fontURL}");\n` : '';
296
279
  const light = deriveTheme(input, { mode: 'light' }).tokens;
297
280
  const dark = deriveTheme(input, { mode: 'dark' }).tokens;
298
281
  const palette = [], geometry = [];
282
+ const relativeLengths = new Map(themeFields.filter(field => field.property && field.type.includes('length') && recipe.values[field.id].endsWith('rem')).map(field => [field.property, recipe.values[field.id]]));
299
283
  for (const [name, value] of Object.entries(light)) {
300
284
  if (name === 'color-scheme') continue;
301
285
  if (/^#[\da-f]{6}$/i.test(value)) palette.push(` ${name}: light-dark(${value}, ${dark[name]});`);
302
- else geometry.push(` ${name}: ${value};`);
286
+ else {
287
+ const relative = relativeLengths.get(name);
288
+ const resolved = relative ? value.replace(relative, `calc(${parseFloat(relative)} * var(--_ui-root-font-unit, 1rem))`) : value;
289
+ geometry.push(` ${name}: ${resolved};`);
290
+ }
303
291
  }
304
292
  const geometrySelector = scope === 'documentation' ? ':root[data-looma-documentation-theme]' : ':root';
305
293
  const chevron = color => `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='${color.replace('#', '%23')}' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E")`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadlabs/looma",
3
- "version": "0.39.1",
3
+ "version": "0.39.3",
4
4
  "type": "module",
5
5
  "description": "Looma components as HTML, Vue, and plain DOM, with design tokens.",
6
6
  "license": "MIT",
@@ -130,6 +130,7 @@
130
130
 
131
131
  :scope:where([data-component~="ui-button"]) {
132
132
  --_button-square-size: var(--ui-button-min-block-size, var(--ui-control-size-md));
133
+ box-sizing: border-box !important;
133
134
  appearance: none;
134
135
  min-block-size: var(--ui-button-min-block-size, var(--ui-control-size-md));
135
136
  border-style: solid;
@@ -176,6 +177,21 @@
176
177
  padding-block: var(--ui-space-0-5) !important;
177
178
  }
178
179
 
180
+ /* Trim the text line itself: an anonymous flex item retains font-metric space in some fonts. */
181
+ .label {
182
+ display: block !important;
183
+ align-content: center !important;
184
+ min-inline-size: 0 !important;
185
+ min-block-size: 1lh !important;
186
+ }
187
+
188
+ .label:has(svg) {
189
+ display: inline-flex !important;
190
+ align-items: center !important;
191
+ gap: var(--ui-space-2) !important;
192
+ text-box-trim: none;
193
+ }
194
+
179
195
  .badge {
180
196
  display: inline-flex !important;
181
197
  align-items: center !important;
@@ -187,7 +203,7 @@
187
203
  }
188
204
 
189
205
  @supports (text-box-trim: trim-both) {
190
- :scope:where([data-component~="ui-button"]) {
206
+ .label:not(:has(svg)) {
191
207
  text-box-trim: trim-both;
192
208
  text-box-edge: cap alphabetic;
193
209
  }
@@ -515,6 +531,11 @@
515
531
  transition: color var(--ui-motion-fast) var(--ui-motion-ease), text-decoration-color var(--ui-motion-fast) var(--ui-motion-ease);
516
532
  }
517
533
 
534
+ :scope:where([data-component~="ui-button"])[data-appearance="link"] .label {
535
+ display: inline !important;
536
+ text-box-trim: none;
537
+ }
538
+
518
539
  :scope:where([data-component~="ui-button"])[data-appearance="link"]:hover:not(:disabled, [aria-disabled="true"]) {
519
540
  color: var(--ui-button-link-hover-text, var(--ui-text-primary));
520
541
  text-decoration-color: currentColor;
@@ -552,6 +573,8 @@
552
573
  /* A count is an adornment, not another line of label text. Keep the standard control height
553
574
  with enough inset for a 24px mark; large content can still grow the button naturally. */
554
575
 
576
+ /* Trim the text line itself: an anonymous flex item retains font-metric space in some fonts. */
577
+
555
578
  @supports (text-box-trim: trim-both) {
556
579
  }
557
580
 
@@ -103,7 +103,8 @@
103
103
 
104
104
  input:where(:not([data-component], [data-slotted]))::after {
105
105
  content: "";
106
- position: absolute;
106
+ position: absolute !important;
107
+ transform: none !important;
107
108
  opacity: 0;
108
109
  transition: opacity var(--ui-motion-fast) var(--ui-motion-ease);
109
110
  }
@@ -116,43 +117,44 @@
116
117
  box-shadow: var(--ui-control-inset);
117
118
  }
118
119
 
119
- /* Centre the tick's box and lift it slightly for the rotated check shape. */
120
+ /* The SVG's painted bounds are centred in its viewBox, including its round stroke.
121
+ Centre that box directly; rotated border boxes do not centre the visible check. */
120
122
  input:checked:where(:not([data-component], [data-slotted]))::after {
121
- inset: 0;
122
- margin: auto !important;
123
- inline-size: calc(var(--_ui-checkbox-size) * 0.28 / 1.125);
124
- block-size: calc(var(--_ui-checkbox-size) * 0.5 / 1.125);
125
- /* Longhands, not `border: solid var(...)`: lowering drops a shorthand that omits a
126
- component, which left the tick with no style and no colour. */
127
- border-style: solid;
128
- border-color: var(--ui-text-on-accent);
129
- border-width: 0 calc(var(--_ui-checkbox-size) * 0.125 / 1.125) calc(var(--_ui-checkbox-size) * 0.125 / 1.125) 0;
123
+ inset: 50% auto auto 50% !important;
124
+ inline-size: calc(var(--_ui-checkbox-size) * 0.7) !important;
125
+ block-size: calc(var(--_ui-checkbox-size) * 0.7) !important;
126
+ background: var(--ui-text-on-accent);
127
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 11.5 5 5 9-9'/%3E%3C/svg%3E")
128
+ center / contain no-repeat;
130
129
  opacity: 1;
131
- transform: translateY(calc(var(--_ui-checkbox-size) * -0.09 / 1.125)) rotate(45deg);
130
+ translate: -50% -50% !important;
132
131
  animation: ui-checkbox-check var(--ui-motion-base) var(--ui-motion-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) both;
133
132
  }
134
133
 
135
134
  input:indeterminate:where(:not([data-component], [data-slotted]))::after {
136
- inset: 50% calc(var(--_ui-checkbox-size) * 0.23 / 1.125) auto;
137
- block-size: calc(var(--_ui-checkbox-size) * 0.125 / 1.125);
135
+ mask: none;
136
+ animation: none;
137
+ inset: 50% calc(var(--_ui-checkbox-size) * 0.23 / 1.125) auto !important;
138
+ inline-size: auto !important;
139
+ block-size: calc(var(--_ui-checkbox-size) * 0.125 / 1.125) !important;
138
140
  border-radius: var(--ui-radius-round);
139
141
  background: var(--ui-text-on-accent);
140
142
  opacity: 1;
141
- transform: translateY(-50%);
143
+ translate: 0 -50% !important;
142
144
  }
143
145
 
144
146
  @keyframes ui-checkbox-check {
145
147
  from {
146
148
  opacity: 0;
147
- transform: translateY(calc(var(--_ui-checkbox-size) * -0.09 / 1.125)) rotate(45deg) scale(0.55);
149
+ scale: 0.55;
148
150
  }
149
151
  65% {
150
152
  opacity: 1;
151
- transform: translateY(calc(var(--_ui-checkbox-size) * -0.09 / 1.125)) rotate(45deg) scale(1.12);
153
+ scale: 1.12;
152
154
  }
153
155
  to {
154
156
  opacity: 1;
155
- transform: translateY(calc(var(--_ui-checkbox-size) * -0.09 / 1.125)) rotate(45deg) scale(1);
157
+ scale: 1;
156
158
  }
157
159
  }
158
160
 
@@ -163,6 +165,20 @@
163
165
  }
164
166
  }
165
167
 
168
+ @media (forced-colors: active) {
169
+ input:checked,
170
+ input:indeterminate {
171
+ forced-color-adjust: none;
172
+ background: Highlight;
173
+ border-color: Highlight;
174
+ }
175
+ input:checked:where(:not([data-component], [data-slotted]))::after,
176
+ input:indeterminate:where(:not([data-component], [data-slotted]))::after {
177
+ forced-color-adjust: none;
178
+ background: HighlightText;
179
+ }
180
+ }
181
+
166
182
  input:hover:enabled {
167
183
  border-color: var(--ui-accent-solid);
168
184
  }
@@ -220,11 +236,15 @@
220
236
 
221
237
  /* The label, and under it an optional line saying what the choice means. */
222
238
 
223
- /* Centre the tick's box and lift it slightly for the rotated check shape. */
239
+ /* The SVG's painted bounds are centred in its viewBox, including its round stroke.
240
+ Centre that box directly; rotated border boxes do not centre the visible check. */
224
241
 
225
242
  @media (prefers-reduced-motion: reduce) {
226
243
  }
227
244
 
245
+ @media (forced-colors: active) {
246
+ }
247
+
228
248
  /* A sized choice keeps its box and aligns instead: the label takes that size's field type size,
229
249
  and the first line centres in that control height, so the label shares a row's baseline.
230
250
  Multi-line labels and descriptions still grow downward from that first line. */
@@ -198,10 +198,12 @@
198
198
  color: inherit;
199
199
  }
200
200
  input {
201
+ --_ui-combobox-inset-block: var(--ui-control-inset-block-md, var(--ui-space-2));
201
202
  appearance: none;
202
203
  width: 100%;
203
204
  min-inline-size: 0;
204
205
  min-block-size: calc(var(--_row) - 2 * var(--ui-border-width));
206
+ max-block-size: max(calc(var(--_row) - 2 * var(--ui-border-width)), calc(1em * var(--ui-line-height-tight) + 2 * var(--_ui-combobox-inset-block)));
205
207
  box-sizing: border-box !important;
206
208
  border: 0;
207
209
  border-radius: inherit;
@@ -209,7 +211,7 @@
209
211
  color: inherit;
210
212
  font: inherit;
211
213
  line-height: var(--ui-line-height-tight);
212
- padding: var(--ui-control-inset-block-md, var(--ui-space-2)) var(--ui-space-3) !important;
214
+ padding: var(--_ui-combobox-inset-block) var(--ui-space-3) !important;
213
215
  margin: 0 !important;
214
216
  outline: none;
215
217
  }
@@ -217,14 +219,16 @@
217
219
  --_row: var(--ui-control-size-sm);
218
220
  }
219
221
  :scope:where([data-component~="ui-combobox"])[data-ui-combobox-state~="size=sm"] input {
220
- padding: var(--ui-control-inset-block-sm, var(--ui-space-1)) var(--ui-space-2) !important;
222
+ --_ui-combobox-inset-block: var(--ui-control-inset-block-sm, var(--ui-space-1));
223
+ padding: var(--_ui-combobox-inset-block) var(--ui-space-2) !important;
221
224
  font-size: var(--ui-font-size-sm);
222
225
  }
223
226
  :scope:where([data-component~="ui-combobox"])[data-ui-combobox-state~="size=lg"] {
224
227
  --_row: var(--ui-control-size-lg);
225
228
  }
226
229
  :scope:where([data-component~="ui-combobox"])[data-ui-combobox-state~="size=lg"] input {
227
- padding: var(--ui-control-inset-block-lg, var(--ui-space-3)) var(--ui-space-4) !important;
230
+ --_ui-combobox-inset-block: var(--ui-control-inset-block-lg, var(--ui-space-3));
231
+ padding: var(--_ui-combobox-inset-block) var(--ui-space-4) !important;
228
232
  font-size: var(--ui-font-size-md);
229
233
  }
230
234
  button {
@@ -359,6 +363,7 @@
359
363
  display: none !important;
360
364
  }
361
365
  :scope:where([data-component~="ui-combobox"])[data-ui-combobox-state~="multiple"] input {
366
+ max-block-size: none;
362
367
  flex: 1 0 var(--_ui-combobox-item-input-min-inline-size, 5rem);
363
368
  width: auto;
364
369
  min-block-size: 1.75rem;
@@ -617,10 +622,12 @@
617
622
  color: inherit;
618
623
  }
619
624
  input:where(:scope, :scope *) {
625
+ --_ui-combobox-inset-block: var(--ui-control-inset-block-md, var(--ui-space-2));
620
626
  appearance: none;
621
627
  width: 100%;
622
628
  min-inline-size: 0;
623
629
  min-block-size: calc(var(--_row) - 2 * var(--ui-border-width));
630
+ max-block-size: max(calc(var(--_row) - 2 * var(--ui-border-width)), calc(1em * var(--ui-line-height-tight) + 2 * var(--_ui-combobox-inset-block)));
624
631
  box-sizing: border-box !important;
625
632
  border: 0;
626
633
  border-radius: inherit;
@@ -628,7 +635,7 @@
628
635
  color: inherit;
629
636
  font: inherit;
630
637
  line-height: var(--ui-line-height-tight);
631
- padding: var(--ui-control-inset-block-md, var(--ui-space-2)) var(--ui-space-3) !important;
638
+ padding: var(--_ui-combobox-inset-block) var(--ui-space-3) !important;
632
639
  margin: 0 !important;
633
640
  outline: none;
634
641
  }
@@ -636,14 +643,16 @@
636
643
  --_row: var(--ui-control-size-sm);
637
644
  }
638
645
  :where([data-component~="ui-combobox"])[data-ui-combobox-state~="size=sm"] input:where(:scope, :not([data-component], [data-slotted]:not([data-slotted~="ui-combobox"]))) {
639
- padding: var(--ui-control-inset-block-sm, var(--ui-space-1)) var(--ui-space-2) !important;
646
+ --_ui-combobox-inset-block: var(--ui-control-inset-block-sm, var(--ui-space-1));
647
+ padding: var(--_ui-combobox-inset-block) var(--ui-space-2) !important;
640
648
  font-size: var(--ui-font-size-sm);
641
649
  }
642
650
  :where([data-component~="ui-combobox"])[data-ui-combobox-state~="size=lg"]:where(:scope, :not([data-component], [data-slotted]:not([data-slotted~="ui-combobox"]))) {
643
651
  --_row: var(--ui-control-size-lg);
644
652
  }
645
653
  :where([data-component~="ui-combobox"])[data-ui-combobox-state~="size=lg"] input:where(:scope, :not([data-component], [data-slotted]:not([data-slotted~="ui-combobox"]))) {
646
- padding: var(--ui-control-inset-block-lg, var(--ui-space-3)) var(--ui-space-4) !important;
654
+ --_ui-combobox-inset-block: var(--ui-control-inset-block-lg, var(--ui-space-3));
655
+ padding: var(--_ui-combobox-inset-block) var(--ui-space-4) !important;
647
656
  font-size: var(--ui-font-size-md);
648
657
  }
649
658
  button:where(:scope, :scope *) {
@@ -776,6 +785,7 @@
776
785
  display: none !important;
777
786
  }
778
787
  :where([data-component~="ui-combobox"])[data-ui-combobox-state~="multiple"] input:where(:scope, :not([data-component], [data-slotted]:not([data-slotted~="ui-combobox"]))) {
788
+ max-block-size: none;
779
789
  flex: 1 0 var(--_ui-combobox-item-input-min-inline-size, 5rem);
780
790
  width: auto;
781
791
  min-block-size: 1.75rem;
@@ -193,58 +193,10 @@
193
193
  }
194
194
 
195
195
  .close {
196
- display: inline-grid !important;
197
- place-items: center;
198
- flex: none;
196
+ display: flex !important;
197
+ align-items: center !important;
198
+ flex: none !important;
199
199
  margin-inline-start: auto !important;
200
- inline-size: var(--ui-control-size-sm);
201
- block-size: var(--ui-control-size-sm);
202
- padding: 0 !important;
203
- border: 0;
204
- border-radius: var(--ui-radius-round);
205
- background: transparent;
206
- color: var(--ui-text-secondary);
207
- cursor: pointer;
208
- transition: background-color var(--ui-motion-fast) var(--ui-motion-ease), color var(--ui-motion-fast) var(--ui-motion-ease);
209
- }
210
-
211
- /* A CSS-drawn X. */
212
- .close:where(:not([data-component], [data-slotted]))::before,
213
- .close:where(:not([data-component], [data-slotted]))::after {
214
- content: "";
215
- grid-area: 1 / 1;
216
- inline-size: 0.875rem;
217
- block-size: 1.5px;
218
- border-radius: 1px;
219
- background: currentColor;
220
- transform: rotate(45deg);
221
- }
222
-
223
- .close:where(:not([data-component], [data-slotted]))::after {
224
- transform: rotate(-45deg);
225
- }
226
-
227
- .close:hover {
228
- background: var(--ui-surface-muted);
229
- color: var(--ui-text-primary);
230
- }
231
-
232
- .close:focus-visible {
233
- outline: var(--ui-focus-width) solid var(--ui-focus-ring);
234
- outline-offset: var(--ui-focus-width);
235
- }
236
-
237
- /* A touch target, like Button on coarse pointers. */
238
- @media (pointer: coarse) {
239
- .close {
240
- inline-size: var(--ui-control-min-block-size);
241
- block-size: var(--ui-control-min-block-size);
242
- }
243
- }
244
-
245
- html[data-ui-input-modality="touch"] :scope:where([data-component~="ui-dialog"]) .close {
246
- inline-size: var(--ui-control-min-block-size);
247
- block-size: var(--ui-control-min-block-size);
248
200
  }
249
201
 
250
202
  .body {
@@ -320,12 +272,6 @@
320
272
  /* Alerts have no close target to add height above the title. */
321
273
 
322
274
  /* :host-qualified so page heading rules that reach in cannot outrank the dialog chrome. */
323
-
324
- /* A CSS-drawn X. */
325
-
326
- /* A touch target, like Button on coarse pointers. */
327
- @media (pointer: coarse) {
328
- }
329
275
  }
330
276
  @scope ([data-slotted~="ui-dialog"]:not([data-component])) to ([data-component], [data-slotted]) {
331
277
  /* This component's hooks style the element they are set on, and never a nested one. */
@@ -514,57 +460,10 @@
514
460
  }
515
461
 
516
462
  .close:where(:scope, :scope *) {
517
- display: inline-grid !important;
518
- place-items: center;
519
- flex: none;
463
+ display: flex !important;
464
+ align-items: center !important;
465
+ flex: none !important;
520
466
  margin-inline-start: auto !important;
521
- inline-size: var(--ui-control-size-sm);
522
- block-size: var(--ui-control-size-sm);
523
- padding: 0 !important;
524
- border: 0;
525
- border-radius: var(--ui-radius-round);
526
- background: transparent;
527
- color: var(--ui-text-secondary);
528
- cursor: pointer;
529
- transition: background-color var(--ui-motion-fast) var(--ui-motion-ease), color var(--ui-motion-fast) var(--ui-motion-ease);
530
- }
531
-
532
- /* A CSS-drawn X. */
533
- .close:where(:not([data-component], [data-slotted]:not([data-slotted~="ui-dialog"]))):where(:scope, :scope *)::before, .close:where(:not([data-component], [data-slotted]:not([data-slotted~="ui-dialog"]))):where(:scope, :scope *)::after {
534
- content: "";
535
- grid-area: 1 / 1;
536
- inline-size: 0.875rem;
537
- block-size: 1.5px;
538
- border-radius: 1px;
539
- background: currentColor;
540
- transform: rotate(45deg);
541
- }
542
-
543
- .close:where(:not([data-component], [data-slotted]:not([data-slotted~="ui-dialog"]))):where(:scope, :scope *)::after {
544
- transform: rotate(-45deg);
545
- }
546
-
547
- .close:hover:where(:scope, :scope *) {
548
- background: var(--ui-surface-muted);
549
- color: var(--ui-text-primary);
550
- }
551
-
552
- .close:focus-visible:where(:scope, :scope *) {
553
- outline: var(--ui-focus-width) solid var(--ui-focus-ring);
554
- outline-offset: var(--ui-focus-width);
555
- }
556
-
557
- /* A touch target, like Button on coarse pointers. */
558
- @media (pointer: coarse) {
559
- .close:where(:scope, :scope *) {
560
- inline-size: var(--ui-control-min-block-size);
561
- block-size: var(--ui-control-min-block-size);
562
- }
563
- }
564
-
565
- html[data-ui-input-modality="touch"] :where([data-component~="ui-dialog"]) .close:where(:scope, :not([data-component], [data-slotted]:not([data-slotted~="ui-dialog"]))) {
566
- inline-size: var(--ui-control-min-block-size);
567
- block-size: var(--ui-control-min-block-size);
568
467
  }
569
468
 
570
469
  .body:where(:scope, :scope *) {
@@ -32,6 +32,7 @@
32
32
  Hooks do not inherit, so these are defaults for the input; its own hooks still win. */
33
33
  --_ui-default-input-border-width: 0;
34
34
  --_ui-default-input-min-block-size: 0;
35
+ --_ui-default-input-frame-width: calc(2 * var(--ui-border-width));
35
36
  --_ui-default-input-border: transparent;
36
37
  --_ui-default-input-border-hover: transparent;
37
38
  --_ui-default-input-focus-border: transparent;
@@ -61,15 +61,24 @@
61
61
  syntax: "*";
62
62
  inherits: false;
63
63
  }
64
+ /* Normalize a unitless zero border to a length before the box calculation. */
65
+ @property --_ui-input-stroke {
66
+ syntax: "<length>";
67
+ inherits: false;
68
+ initial-value: 1px;
69
+ }
64
70
 
65
71
  :scope:where([data-component~="ui-input"]) {
72
+ --_ui-input-size: var(--ui-control-size-md);
73
+ --_ui-input-inset-block: var(--ui-control-inset-block-md, var(--ui-space-2));
74
+ --_ui-input-stroke: var(--ui-input-border-width, var(--_ui-default-input-border-width, var(--ui-border-width)));
66
75
  box-sizing: border-box !important;
67
76
  display: inline-block !important;
68
77
  inline-size: var(--ui-input-inline-size, 100%);
69
78
  min-inline-size: 0;
70
79
  min-block-size: var(--ui-input-min-block-size, var(--_ui-default-input-min-block-size, var(--ui-control-size-md)));
71
80
  margin: 0 !important;
72
- padding: var(--ui-control-inset-block-md, var(--ui-space-2)) var(--ui-space-3) !important;
81
+ padding: var(--_ui-input-inset-block) var(--ui-space-3) !important;
73
82
  appearance: none;
74
83
  padding-inline-start: var(--_ui-default-input-padding-inline-start, var(--ui-space-3)) !important;
75
84
  border: var(--ui-input-border-width, var(--_ui-default-input-border-width, var(--ui-border-width))) solid
@@ -87,16 +96,25 @@
87
96
  background-color var(--ui-motion-fast) var(--ui-motion-ease);
88
97
  }
89
98
 
99
+ /* Cap Safari's excess text metrics without changing native date, file, color or range widgets. */
100
+ :scope:where([data-component~="ui-input"]):is([type="text"], [type="search"], [type="tel"], [type="url"], [type="email"], [type="password"], [type="number"]) {
101
+ max-block-size: max(calc(var(--_ui-input-size) - var(--_ui-default-input-frame-width, 0px)), calc(1em * var(--ui-line-height-tight) + 2 * var(--_ui-input-inset-block) + 2 * var(--_ui-input-stroke)));
102
+ }
103
+
90
104
  /* Each size reads the hook and then an Input Group's default first, so a group, which draws the frame, can zero it. */
91
105
  :scope:where([data-component~="ui-input"])[data-ui-input-state~="size=sm"] {
106
+ --_ui-input-size: var(--ui-control-size-sm);
107
+ --_ui-input-inset-block: var(--ui-control-inset-block-sm, var(--ui-space-1));
92
108
  min-block-size: var(--ui-input-min-block-size, var(--_ui-default-input-min-block-size, var(--ui-control-size-sm)));
93
- padding: var(--ui-control-inset-block-sm, var(--ui-space-1)) var(--ui-space-2) !important;
109
+ padding: var(--_ui-input-inset-block) var(--ui-space-2) !important;
94
110
  font-size: var(--ui-font-size-sm);
95
111
  }
96
112
 
97
113
  :scope:where([data-component~="ui-input"])[data-ui-input-state~="size=lg"] {
114
+ --_ui-input-size: var(--ui-control-size-lg);
115
+ --_ui-input-inset-block: var(--ui-control-inset-block-lg, var(--ui-space-3));
98
116
  min-block-size: var(--ui-input-min-block-size, var(--_ui-default-input-min-block-size, var(--ui-control-size-lg)));
99
- padding: var(--ui-control-inset-block-lg, var(--ui-space-3)) var(--ui-space-4) !important;
117
+ padding: var(--_ui-input-inset-block) var(--ui-space-4) !important;
100
118
  font-size: var(--ui-font-size-md);
101
119
  }
102
120
 
@@ -177,6 +195,9 @@
177
195
  @scope ([data-component~="ui-input"]) to ([data-component]) {
178
196
  /* This component's hooks style the element they are set on, and never a nested one. An Input
179
197
  Group sets --_ui-default-input-* for the input inside it, below the input's own hooks. */
198
+ /* Normalize a unitless zero border to a length before the box calculation. */
199
+
200
+ /* Cap Safari's excess text metrics without changing native date, file, color or range widgets. */
180
201
 
181
202
  /* Each size reads the hook and then an Input Group's default first, so a group, which draws the frame, can zero it. */
182
203