@threadlabs/looma 0.35.1 → 0.37.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.
Files changed (60) hide show
  1. package/components/ui-avatar/ui-avatar.html +1 -1
  2. package/components/ui-avatar-group/ui-avatar-group.html +1 -1
  3. package/components/ui-button/ui-button.html +11 -4
  4. package/components/ui-color-picker/ui-color-picker.html +1 -1
  5. package/components/ui-combobox/ui-combobox.html +1 -1
  6. package/components/ui-curve-editor/ui-curve-editor.html +1 -0
  7. package/components/ui-curve-editor/ui-curve-editor.js +15 -6
  8. package/components/ui-input/ui-input.html +4 -1
  9. package/components/ui-light-editor/ui-light-editor.html +143 -19
  10. package/components/ui-light-editor/ui-light-editor.js +26 -9
  11. package/components/ui-select/ui-select.html +10 -6
  12. package/dist/index.js +1 -1
  13. package/docs/README.md +33 -7
  14. package/docs/card.js +1 -0
  15. package/docs/catalog.json +39 -5
  16. package/docs/customizer-render.mjs +27 -9
  17. package/docs/customizer.js +146 -18
  18. package/docs/fonts.mjs +45 -0
  19. package/docs/index.d.ts +2 -2
  20. package/docs/palette.mjs +67 -0
  21. package/docs/site.mjs +1 -1
  22. package/docs/theme-session.mjs +28 -9
  23. package/docs/theme.mjs +67 -26
  24. package/docs/value-editors.mjs +6 -3
  25. package/package.json +2 -2
  26. package/styles/ui-avatar-group.css +1 -1
  27. package/styles/ui-avatar.css +1 -1
  28. package/styles/ui-button.css +11 -4
  29. package/styles/ui-color-picker.css +1 -1
  30. package/styles/ui-combobox.css +2 -2
  31. package/styles/ui-input.css +4 -1
  32. package/styles/ui-light-editor.css +111 -16
  33. package/styles/ui-select.css +10 -6
  34. package/theme-system.css +17 -4
  35. package/tokens.css +28 -15
  36. package/vanilla/UiCurveEditor.d.ts +1 -0
  37. package/vanilla/UiCurveEditor.js +1 -1
  38. package/vanilla/UiLightEditor.d.ts +1 -0
  39. package/vanilla/UiLightEditor.js +2 -2
  40. package/vue/UiAvatar.vue +1 -1
  41. package/vue/UiAvatarGroup.vue +1 -1
  42. package/vue/UiButton.vue +4 -2
  43. package/vue/UiColorPicker.vue +1 -1
  44. package/vue/UiCombobox.vue +2 -2
  45. package/vue/UiCurveEditor.d.ts +6 -0
  46. package/vue/UiCurveEditor.vue +2 -0
  47. package/vue/UiInput.vue +4 -1
  48. package/vue/UiLightEditor.d.ts +8 -0
  49. package/vue/UiLightEditor.vue +22 -3
  50. package/vue/UiSelect.vue +1 -1
  51. package/vue/chunks/UiAvatar.js +1 -1
  52. package/vue/chunks/UiAvatarGroup.js +1 -1
  53. package/vue/chunks/UiButton.js +1 -1
  54. package/vue/chunks/UiColorPicker.js +1 -1
  55. package/vue/chunks/UiCombobox.js +1 -1
  56. package/vue/chunks/UiCurveEditor.js +5 -3
  57. package/vue/chunks/UiInput.js +1 -1
  58. package/vue/chunks/UiLightEditor.js +16 -6
  59. package/vue/chunks/UiSelect.js +1 -1
  60. package/vue/components.css +43 -16
package/docs/theme.mjs CHANGED
@@ -1,6 +1,9 @@
1
+ import { googleFonts, googleFontStack, googleFontURL } from './fonts.mjs';
2
+ import { mixColour as mix, paletteSeeds, companionSurface, toLch, fromLch } from './palette.mjs';
3
+
1
4
  const bounds = {
2
- 'text-size': [14, 24], 'control-size': [32, 64], 'corner-roundness': [0, 32],
3
- 'line-weight': [.5, 3], density: [.75, 1.5], 'scale-contrast': [1, 1.5],
5
+ 'text-size': [14, 24], 'control-roominess': [2, 4], 'corner-roundness': [0, 32],
6
+ 'line-weight': [.5, 3], spacing: [.75, 1.5], 'scale-contrast': [1, 1.5],
4
7
  'space-curve': [.5, 2], 'text-weight': [400, 700], 'outer-depth': [0, 2], 'inner-depth': [0, 2],
5
8
  'response-time': [0, 600],
6
9
  };
@@ -11,13 +14,16 @@ export const themeFields = [
11
14
  ['Colour', 'canvas', 'Canvas', '--ui-surface', 'colour', '#f6f6f8', 'The ground behind surfaces.'],
12
15
  ['Colour', 'surface', 'Surface', '--ui-surface-raised', 'colour', '#ffffff', 'Fields, cards and floating surfaces.'],
13
16
  ['Colour', 'text', 'Text', '--ui-text', 'colour', '#1a1a1a', 'Readable content; supporting text follows this and Surface.'],
17
+ ['Colour', 'warmth', 'Surface warmth', null, 'fraction', '0.5', '0% cool, 50% original, 100% warm paper and ink; your brand hue stays independent.'],
18
+ ['Colour', 'color-intensity', 'Color intensity', null, 'fraction', '0.5', '0% quiet, 50% original, 100% vivid, coordinated across surfaces, actions and status colors.'],
19
+ ['Colour', 'palette-relationship', 'Palette relationship', null, 'choice', 'preserve', 'Keep seed hues, or relate quiet surfaces to the brand through neighboring or complementary hues.'],
14
20
  ['Type', 'body-font', 'Body font', '--ui-font-sans', 'font', 'system-ui, sans-serif', 'The shared interface typeface.'],
15
21
  ['Type', 'display-font', 'Display font', '--ui-font-display', 'font', 'system-ui, sans-serif', 'The heading typeface.'],
16
- ['Type', 'text-size', 'Text size', '--ui-font-size', 'positive-length', '16px', 'Base type and choice-mark size. Accepts native CSS lengths, such as 1rem.'],
22
+ ['Type', 'text-size', 'Base text size', '--ui-font-size', 'positive-length', '16px', 'Sets the document root in px. Type, spacing and layout use rem relationships and scale from this base once.'],
17
23
  ['Type', 'text-weight', 'Emphasis weight', '--ui-font-medium', 'weight', '500', 'Control-label emphasis; body and headings keep their roles.'],
18
- ['Size & spacing', 'control-size', 'Control size', '--ui-control-size', 'positive-length', '40px', 'One shared height for buttons, fields and swatches.'],
19
- ['Size & spacing', 'density', 'Density', '--ui-density', 'positive', '1', 'More or less breathing room throughout the interface.'],
20
- ['Size & spacing', 'scale-contrast', 'Size ratio', '--ui-scale-ratio', 'ratio', '1.2', 'The multiplier between small, medium and large controls.'],
24
+ ['Size & spacing', 'control-roominess', 'Control roominess', '--ui-control-roominess', 'positive', '2.5', 'Shared control height as a multiple of base text size; buttons, fields and swatches stay aligned.'],
25
+ ['Size & spacing', 'spacing', 'Spacing', '--ui-spacing', 'positive', '1', 'More or less breathing room throughout the interface.'],
26
+ ['Size & spacing', 'scale-contrast', 'Size ratio', '--ui-scale-ratio', 'ratio', '1.2', 'The multiplier between small, medium and large controls. At 1 the sizes are equal; higher values separate them.'],
21
27
  ['Size & spacing', 'space-curve', 'Spacing curve', '--ui-space-curve', 'nonnegative', '1', 'Spacing growth: 1 is linear, 2 is quadratic. Independent of control sizes.'],
22
28
  ['Size & spacing', 'corner-roundness', 'Corner roundness', '--ui-radius-md', 'length', '8px', 'One corner family across controls and surfaces.'],
23
29
  ['Size & spacing', 'line-weight', 'Line weight', '--ui-border-width', 'length', '1px', 'Shared edge weight; focus stays independently visible.'],
@@ -35,20 +41,20 @@ export const themeFields = [
35
41
  ['Material', 'light-strength', 'Light strength', '--ui-light-strength', 'fraction', '0.65', 'Shared illumination; each material and component role determines its response.'],
36
42
  ['Motion', 'response-time', 'Response time', '--ui-motion-fast', 'time', '120ms', 'Shared feedback pace; floating transitions take proportionally longer.'],
37
43
  ['Motion', 'response-curve', 'Response curve', '--ui-motion-ease', 'easing', 'cubic-bezier(0.2,0,0.2,1)', 'How movement accelerates and settles.'],
38
- ].map(([family, id, label, property, type, defaultValue, description]) => Object.freeze({ family, id, label, property, type, defaultValue, description, ...(bounds[id] ? { min: bounds[id][0], max: bounds[id][1] } : type === 'fraction' ? { min: 0, max: 1 } : {}) }));
44
+ ].map(([family, id, label, property, type, defaultValue, description]) => Object.freeze({ family, id, label, property, type, defaultValue, description, ...(type === 'choice' ? { choices: { preserve: 'Preserve seed hues', neighboring: 'Neighboring hues', complementary: 'Complementary hues' } } : {}), ...(bounds[id] ? { min: bounds[id][0], max: bounds[id][1] } : type === 'fraction' ? { min: 0, max: 1 } : {}) }));
39
45
 
40
46
  export const defaultRecipe = () => ({ schemaVersion: 1, values: Object.fromEntries(themeFields.map(field => [field.id, field.defaultValue])) });
41
47
  const starting = values => ({ schemaVersion: 1, values: { ...defaultRecipe().values, ...values } });
42
48
  export const startingPoints = {
43
49
  clear: defaultRecipe(),
44
- bookish: starting({ action: '#2e1228', canvas: '#f7f7f5', surface: '#fffefd', text: '#0d0c0c', 'body-font': 'Arial, sans-serif', 'display-font': 'Georgia, serif', 'text-weight': '400', 'control-size': '42px', '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' }),
45
- wiki: starting({ action: '#4f46e5', canvas: '#ffffff', surface: '#ffffff', text: '#1a1a1a', 'control-size': '34px', density: '0.85', 'scale-contrast': '1.15', 'corner-roundness': '6px', 'outer-depth': '0.35', 'inner-depth': '0', 'response-time': '110ms' }),
50
+ bookish: starting({ 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' }),
51
+ wiki: starting({ 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' }),
46
52
  editorial: starting({ 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' }),
47
53
  night: starting({ 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)' }),
48
- garden: starting({ action: '#256342', canvas: '#eef5e7', surface: '#fcfff6', text: '#173121', 'corner-roundness': '22px', 'outer-depth': '0.35', 'inner-depth': '0', 'edge-light': '0.2', 'control-size': '46px', 'scale-contrast': '1.24', density: '1.05', 'response-time': '180ms', 'response-curve': 'cubic-bezier(0.16,1,0.3,1)' }),
54
+ garden: starting({ action: '#256342', canvas: '#eef5e7', surface: '#fcfff6', 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)' }),
49
55
  };
50
56
  export const startingPointLabels = { clear: 'Clear', bookish: 'Bookish', wiki: 'Wiki', editorial: 'Editorial', night: 'Night', garden: 'Garden' };
51
- const paletteKeys = ['action', 'canvas', 'surface', 'text'];
57
+ const paletteKeys = themeFields.filter(field => field.family === 'Colour').map(field => field.id);
52
58
  // Palette choices move only the shared color seeds; size, material and motion stay independent.
53
59
  export const themePalettes = Object.fromEntries([
54
60
  ['clear', 'Indigo & mist'], ['bookish', 'Plum & paper'], ['wiki', 'Indigo & chalk'],
@@ -88,6 +94,24 @@ export function paletteFor(input) {
88
94
  return Object.entries(themePalettes).find(([, palette]) => paletteKeys.every(key => values[key].toLowerCase() === palette.values[key].toLowerCase()))?.[0] ?? 'custom';
89
95
  }
90
96
 
97
+ export const themeVariations = { warmer: 'Warmer', quieter: 'Quieter', expressive: 'More expressive', compact: 'More compact' };
98
+ /** Nearby choices reuse the same bounded semantics; locks protect whole decision families. */
99
+ export function nearbyVariation(input, kind, locks = []) {
100
+ const recipe = validateRecipe(input);
101
+ if (!Object.hasOwn(themeVariations, kind)) throw new Error('Unknown theme variation.');
102
+ const deltas = {
103
+ warmer: { warmth: .15 }, quieter: { 'color-intensity': -.12, glow: -.1, 'outer-depth': -.15 },
104
+ expressive: { finish: .15, 'edge-light': .15, glow: .1 }, compact: { 'control-roominess': -.15, spacing: -.1 },
105
+ }[kind];
106
+ const values = { ...recipe.values };
107
+ for (const [id, delta] of Object.entries(deltas)) {
108
+ const field = themeFields.find(item => item.id === id);
109
+ const family = field.family === 'Colour' ? 'palette' : field.family === 'Material' ? 'material' : 'type';
110
+ if (!locks.includes(family)) values[id] = String(Number(Math.max(field.min, Math.min(field.max, Number(values[id]) + delta)).toFixed(3)));
111
+ }
112
+ return validateRecipe({ ...recipe, values });
113
+ }
114
+
91
115
  const scalar = /^[+]?(?:\d+(?:\.\d*)?|\.\d+)$/;
92
116
  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))$/;
93
117
  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;
@@ -122,6 +146,7 @@ export function validateRecipe(input) {
122
146
  const value = values[field.id];
123
147
  let valid = typeof value === 'string' && value.length > 0;
124
148
  if (valid) switch (field.type) {
149
+ case 'choice': valid = Object.hasOwn(field.choices, value); break;
125
150
  case 'colour': valid = /^#[\da-f]{6}$/i.test(value); break;
126
151
  case 'font': valid = font.test(value); break;
127
152
  case 'length': valid = length.test(value) && Number.isFinite(parseFloat(value)); break;
@@ -136,6 +161,7 @@ export function validateRecipe(input) {
136
161
  if (field.type === 'weight') valid &&= number >= 1 && number <= 1000;
137
162
  }
138
163
  }
164
+ if (field.id === 'text-size' && valid && !value.endsWith('px')) throw new Error('Base text size: enter a px value for the document root.');
139
165
  if (!valid) throw new Error(`${field.label}: enter a valid ${field.type.replaceAll('-', ' ')} value.`);
140
166
  if (Object.hasOwn(bounds, field.id)) {
141
167
  const number = boundedNumber(field, value), [min, max] = bounds[field.id];
@@ -143,7 +169,9 @@ export function validateRecipe(input) {
143
169
  }
144
170
  }
145
171
  if (input.presets !== undefined && (!input.presets || typeof input.presets !== 'object' || Array.isArray(input.presets) || Object.entries(input.presets).some(([family, id]) => !Object.hasOwn(refinementPresets(family) ?? {}, id)))) throw new Error('Unknown refinement preset.');
146
- return { schemaVersion: 1, values, ...(input.presets === undefined ? {} : { presets: { ...input.presets } }) };
172
+ if (input.fonts !== undefined && (!input.fonts || typeof input.fonts !== 'object' || Array.isArray(input.fonts) || Object.entries(input.fonts).some(([key, family]) => !['body-font', 'display-font'].includes(key) || !Object.hasOwn(googleFonts, family) || values[key] !== googleFontStack(family)))) throw new Error('Choose a Google font with its matching font stack.');
173
+ if (input.origin !== undefined && !Object.hasOwn(startingPoints, input.origin)) throw new Error('Unknown theme origin.');
174
+ return { schemaVersion: 1, values, ...(input.fonts === undefined ? {} : { fonts: { ...input.fonts } }), ...(input.origin === undefined ? {} : { origin: input.origin }), ...(input.presets === undefined ? {} : { presets: { ...input.presets } }) };
147
175
  }
148
176
 
149
177
  /** Match presets by their complete semantic recipe, including locally saved themes. */
@@ -153,7 +181,6 @@ export function startingPointFor(input) {
153
181
  }
154
182
 
155
183
  const rgb = hex => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16));
156
- const mix = (a, b, weight) => '#' + rgb(a).map((n, i) => Math.round(n * weight + rgb(b)[i] * (1 - weight)).toString(16).padStart(2, '0')).join('');
157
184
  const luminance = hex => rgb(hex).map(n => { const s = n / 255; return s <= .04045 ? s / 12.92 : ((s + .055) / 1.055) ** 2.4; }).reduce((sum, n, i) => sum + n * [.2126, .7152, .0722][i], 0);
158
185
  const contrast = (a, b) => (Math.max(luminance(a), luminance(b)) + .05) / (Math.min(luminance(a), luminance(b)) + .05);
159
186
  // The brightest reflection stop is itself readable. Interpolating it over a readable
@@ -177,29 +204,38 @@ function readableColour(seed, surfaces, minimum, endpoint) {
177
204
  }
178
205
 
179
206
  /** Resolve editable colour seeds into a coordinated set of readable semantic roles. */
207
+ const paletteCache = new Map();
180
208
  export function deriveTheme(input, { mode } = {}) {
181
209
  if (mode !== undefined && !['light', 'dark'].includes(mode)) throw new Error('Unknown theme color mode.');
182
210
  const { values } = validateRecipe(input);
183
- const tokens = Object.fromEntries(themeFields.map(field => [field.property, values[field.id]]));
211
+ const tokens = Object.fromEntries(themeFields.filter(field => field.property).map(field => [field.property, values[field.id]]));
184
212
  for (const field of themeFields.filter(field => field.type.includes('length') && Object.hasOwn(bounds, field.id))) {
185
213
  if (boundedNumber(field, values[field.id]) === undefined) {
186
214
  const [min, max] = bounds[field.id]; tokens[field.property] = `clamp(${min}px, ${values[field.id]}, ${max}px)`;
187
215
  }
188
216
  }
217
+ const cacheKey = JSON.stringify([mode, ...paletteKeys.map(key => values[key])]);
218
+ if (paletteCache.has(cacheKey)) {
219
+ const cached = paletteCache.get(cacheKey);
220
+ return { tokens: { ...tokens, ...cached.tokens }, adjustments: cached.adjustments.map(item => ({ ...item })) };
221
+ }
222
+ const geometry = new Set(Object.keys(tokens));
223
+ const seeds = paletteSeeds(values);
189
224
  const adjustments = [];
190
- const nativeInk = foreground(values.canvas, '#ffffff', '#000000');
225
+ const nativeInk = foreground(seeds.canvas, '#ffffff', '#000000');
191
226
  const ink = mode === 'light' ? '#000000' : mode === 'dark' ? '#ffffff' : nativeInk;
192
227
  const paper = ink === '#000000' ? '#ffffff' : '#000000';
193
228
  // Preserve the requested palette in its native mode. The companion keeps a restrained
194
229
  // tint of the same surface seeds; contrast resolution below governs all usable roles.
195
- const companionSurface = mix(values.text, paper, .8);
196
- const surface = seed => readableColour(nativeInk === ink ? seed : mix(seed, companionSurface, .02), [ink], 7, paper);
197
- tokens['--ui-surface'] = surface(values.canvas);
198
- tokens['--ui-surface-raised'] = surface(values.surface);
230
+ const surface = seed => readableColour(nativeInk === ink ? seed : companionSurface(seed, mode ?? (ink === '#000000' ? 'light' : 'dark')), [ink], 7, paper);
231
+ tokens['--ui-surface'] = surface(seeds.canvas);
232
+ tokens['--ui-surface-raised'] = surface(seeds.surface);
199
233
  tokens['--ui-surface-sunken'] = surface(mix(tokens['--ui-surface-raised'], tokens['--ui-surface'], .92));
200
234
  const surfaces = ['--ui-surface', '--ui-surface-raised', '--ui-surface-sunken'].map(name => tokens[name]);
201
- const tones = { accent: values.action, danger: '#c92d1e', success: '#007a33', warning: '#ba5b0b', info: '#0066cc' };
202
- for (const [tone, seed] of Object.entries(tones)) {
235
+ const tones = { accent: seeds.action, danger: '#c92d1e', success: '#007a33', warning: '#ba5b0b', info: '#0066cc' };
236
+ for (const [tone, original] of Object.entries(tones)) {
237
+ const [l, c, h] = toLch(original);
238
+ const seed = tone === 'accent' ? original : fromLch([l, c * (.6 + Number(values['color-intensity']) * .8), h]);
203
239
  const fill = readableColour(seed, surfaces, 5, ink);
204
240
  tokens[`--ui-${tone}`] = fill;
205
241
  tokens[`--ui-on-${tone}`] = foreground(fill, '#ffffff', '#000000');
@@ -214,7 +250,7 @@ export function deriveTheme(input, { mode } = {}) {
214
250
  for (const tone of Object.keys(tones)) {
215
251
  tokens[`--ui-${tone}-subtle-text`] = readableColour(tokens[`--ui-${tone}`], readingSurfaces, 4.5, ink);
216
252
  }
217
- const textSeed = nativeInk === ink ? values.text : mix(values.text, ink, .08);
253
+ const textSeed = nativeInk === ink ? seeds.text : mix(seeds.text, ink, .08);
218
254
  tokens['--ui-text'] = readableColour(textSeed, readingSurfaces, 4.5, ink);
219
255
  tokens['--ui-text-secondary'] = readableColour(mix(tokens['--ui-text'], tokens['--ui-surface-raised'], .72), readingSurfaces, 4.5, ink);
220
256
  tokens['--ui-text-muted'] = tokens['--ui-text-secondary'];
@@ -232,16 +268,19 @@ export function deriveTheme(input, { mode } = {}) {
232
268
  tokens['--ui-control-border'] = readableColour(mix(tokens['--ui-text'], tokens['--ui-surface-raised'], .43), readingSurfaces, 3, ink);
233
269
  tokens['--ui-control-border-hover'] = tokens['--ui-control-border'];
234
270
  tokens['--ui-border-strong'] = tokens['--ui-control-border'];
235
- tokens['--ui-disabled-text'] = tokens['--ui-text-secondary'];
271
+ tokens['--ui-disabled-text'] = mix(tokens['--ui-text'], tokens['--ui-surface-raised'], .45);
236
272
  tokens['--ui-disabled-surface'] = tokens['--ui-surface-sunken'];
237
- tokens['--ui-disabled-border'] = tokens['--ui-control-border'];
273
+ tokens['--ui-disabled-border'] = mix(tokens['--ui-text'], tokens['--ui-surface-raised'], .2);
238
274
  tokens['--ui-focus-ring'] = readableColour(tokens['--ui-accent'], readingSurfaces, 3, ink);
239
275
  tokens['--ui-focus-width'] = 'max(2px, calc(var(--ui-border-width) * 2))';
240
276
  tokens['color-scheme'] = ink === '#000000' ? 'light' : 'dark';
241
277
  for (const [id, property] of [['action', '--ui-accent'], ['canvas', '--ui-surface'], ['surface', '--ui-surface-raised'], ['text', '--ui-text']]) {
242
278
  if (tokens[property].toLowerCase() !== values[id].toLowerCase()) adjustments.push({ id, requested: values[id], resolved: tokens[property], reason: 'Adjusted to keep the shared palette readable.' });
243
279
  }
244
- return { tokens, adjustments };
280
+ const colors = Object.fromEntries(Object.entries(tokens).filter(([key]) => !geometry.has(key) || /^#[\da-f]{6}$/i.test(tokens[key])));
281
+ paletteCache.set(cacheKey, { tokens: colors, adjustments });
282
+ if (paletteCache.size > 16) paletteCache.delete(paletteCache.keys().next().value);
283
+ return { tokens, adjustments: adjustments.map(item => ({ ...item })) };
245
284
  }
246
285
 
247
286
  /** Shared public seeds; combine with theme-system.css for a standalone exported theme. */
@@ -251,6 +290,8 @@ export function themeCSS(input, systemCSS = '', { scope = 'portable' } = {}) {
251
290
  const selector = scope === 'documentation'
252
291
  ? ':root[data-looma-documentation-theme], :root[data-looma-documentation-theme] [data-theme], :root[data-looma-documentation-theme] [data-contrast]'
253
292
  : ':root, [data-theme], [data-contrast]';
293
+ const fontURL = googleFontURL(validateRecipe(input).fonts);
294
+ const imports = fontURL ? `@import url("${fontURL}");\n` : '';
254
295
  const light = deriveTheme(input, { mode: 'light' }).tokens;
255
296
  const dark = deriveTheme(input, { mode: 'dark' }).tokens;
256
297
  const palette = [], geometry = [];
@@ -269,5 +310,5 @@ export function themeCSS(input, systemCSS = '', { scope = 'portable' } = {}) {
269
310
  // Resolve colors at their point of use, so a nearer mode boundary wins independently
270
311
  // of the selector specificity needed to restore an early documentation theme.
271
312
  // Keep the first comment stable: the local writer recognizes its generated files by this marker.
272
- return `/* Looma shared semantic theme. */\n/* Always includes light and dark.\n Set data-theme="light" or "dark" on any element to fix that subtree's mode.\n Leave the root attribute unset to follow the system; a switch can set/remove it. */\n${systemCSS}\n${geometrySelector} {\n${geometry.join('\n')}\n}\n${selector} {\n${palette.join('\n')}\n}\n${chevrons}\n:root:not([data-theme]) { color-scheme: light dark; }\n[data-theme="light"] { color-scheme: light; }\n[data-theme="dark"] { color-scheme: dark; }\n@media (prefers-reduced-motion: reduce) {\n ${selector} { --ui-motion-fast: 0ms; --ui-motion-base: 0ms; --ui-motion-press: 0ms; --ui-press-scale: 1; }\n}\n@media (prefers-reduced-transparency: reduce), (forced-colors: active) {\n ${selector} { --ui-finish: 0; --ui-glow: 0; }\n}\n`;
313
+ return `/* Looma shared semantic theme. */\n/* Always includes light and dark.\n Set data-theme="light" or "dark" on any element to fix that subtree's mode.\n Leave the root attribute unset to follow the system; a switch can set/remove it. */\n${imports}${systemCSS}\n${geometrySelector} {\n${geometry.join('\n')}\n}\n${selector} {\n${palette.join('\n')}\n}\n${chevrons}\n:root:not([data-theme]) { color-scheme: light dark; }\n[data-theme="light"] { color-scheme: light; }\n[data-theme="dark"] { color-scheme: dark; }\n@media (prefers-reduced-motion: reduce) {\n ${selector} { --ui-motion-fast: 0ms; --ui-motion-base: 0ms; --ui-motion-press: 0ms; --ui-press-scale: 1; }\n}\n@media (prefers-reduced-transparency: reduce), (forced-colors: active) {\n ${selector} { --ui-finish: 0; --ui-glow: 0; }\n}\n`;
273
314
  }
@@ -4,25 +4,28 @@ const timeUnits = { ms: 1, s: 1000 };
4
4
  const rounded = value => String(Number(Number(value).toFixed(6)));
5
5
 
6
6
  export function valueEditor(field, value, { remPixels = 16 } = {}) {
7
+ if (field.id === 'text-size') return { kind: 'number', value, unit: 'px', min: field.min, max: field.max, step: 1 };
8
+ if (field.type === 'choice') return { kind: 'choice', value };
7
9
  if (field.type === 'easing') return { kind: /^step-/.test(value) ? 'text' : 'curve', value };
8
10
  if (['colour', 'font'].includes(field.type)) return { kind: 'text', value };
9
11
  if (field.type.includes('length') || field.type === 'time') {
10
12
  const unit = value.match(/[a-z]+$/i)?.[0] ?? 'px';
11
- const units = field.type === 'time' ? timeUnits : { ...lengthUnits, rem: remPixels, em: remPixels };
13
+ // Convert authoring limits against the same root as the displayed quantity.
14
+ const units = field.type === 'time' ? timeUnits : { ...lengthUnits, rem: remPixels };
12
15
  const factor = units[unit];
13
16
  return { kind: 'number', value: value === '0' ? '0px' : value, unit,
14
17
  min: factor ? field.min / factor : .001, max: factor ? field.max / factor : 1000000,
15
18
  step: (field.type === 'time' ? 10 : field.id === 'line-weight' ? .25 : 1) / (factor ?? 1),
16
19
  units: field.type === 'time' ? ['ms', 's'] : ['px', 'rem'], convertible: Boolean(factor) };
17
20
  }
18
- const scale = field.type === 'fraction' || field.id === 'density' ? 100 : 1;
21
+ const scale = field.type === 'fraction' || field.id === 'spacing' ? 100 : 1;
19
22
  return { kind: field.type === 'weight' ? 'number' : 'slider', value: field.type === 'weight' ? value : Number(value) * scale,
20
23
  min: field.min * scale, max: field.max * scale, step: field.type === 'weight' ? 50 : scale === 100 ? 1 : .01,
21
24
  unit: scale === 100 ? '%' : '', scale };
22
25
  }
23
26
 
24
27
  export function editorValue(field, value) {
25
- return field.type === 'fraction' || field.id === 'density' ? rounded(Number(value) / 100) : String(value);
28
+ return field.type === 'fraction' || field.id === 'spacing' ? rounded(Number(value) / 100) : String(value);
26
29
  }
27
30
 
28
31
  export function convertValueUnit(value, nextUnit, { remPixels = 16 } = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadlabs/looma",
3
- "version": "0.35.1",
3
+ "version": "0.37.0",
4
4
  "type": "module",
5
5
  "description": "Looma components as HTML, Vue, and plain DOM, with design tokens.",
6
6
  "license": "MIT",
@@ -127,7 +127,7 @@
127
127
  "@tiptap/pm": ">=2 <3",
128
128
  "@tiptap/vue-3": ">=2 <3",
129
129
  "vue": "^3.5.43",
130
- "markupress": "^0.8.5",
130
+ "markupress": "^0.8.7",
131
131
  "@nextwebwg/htmlkit": "1.0.0-alpha.69",
132
132
  "vite": "^8.3.4"
133
133
  },
@@ -70,7 +70,7 @@
70
70
  inline-size: 1.25rem;
71
71
  block-size: 1.25rem;
72
72
  /* Matches the xs Avatar's initials. */
73
- font-size: calc(var(--ui-font-size) * 0.5625);
73
+ font-size: calc(var(--ui-font-size-md) * 0.5625);
74
74
  line-height: 1;
75
75
  }
76
76
 
@@ -60,7 +60,7 @@
60
60
  inline-size: 1.25rem;
61
61
  block-size: 1.25rem;
62
62
  /* Below the type scale on purpose: two initials need room inside a 1.25rem circle. */
63
- font-size: calc(var(--ui-font-size) * 0.5625);
63
+ font-size: calc(var(--ui-font-size-md) * 0.5625);
64
64
  letter-spacing: 0.02em;
65
65
  }
66
66
 
@@ -145,7 +145,9 @@
145
145
  line-height: var(--ui-line-height-tight);
146
146
  padding: var(--ui-control-inset-block-md, var(--ui-space-2)) var(--ui-action-inset-md, var(--ui-space-3)) !important;
147
147
  background-image: var(--ui-surface-highlight, none);
148
- box-shadow: var(--ui-button-shadow, var(--ui-action-shadow, var(--_ui-action-shadow))), var(--_tone-glow);
148
+ box-shadow:
149
+ var(--ui-button-shadow, var(--ui-action-shadow, var(--_ui-action-shadow))),
150
+ var(--_tone-glow);
149
151
  cursor: pointer;
150
152
  transition:
151
153
  scale var(--ui-motion-press, var(--ui-motion-fast)) var(--ui-motion-ease),
@@ -278,7 +280,9 @@
278
280
  background-color: var(--_tone);
279
281
  color: var(--_on-tone);
280
282
  background-image: var(--_tone-highlight);
281
- box-shadow: var(--ui-button-solid-shadow, var(--ui-action-shadow, var(--_ui-action-shadow))), var(--_tone-glow);
283
+ box-shadow:
284
+ var(--ui-button-solid-shadow, var(--ui-action-shadow, var(--_ui-action-shadow))),
285
+ var(--_tone-glow);
282
286
  }
283
287
 
284
288
  :is(:scope:where([data-component~="ui-button"])[data-appearance="solid"], :scope:where([data-component~="ui-button"])[data-appearance="danger"]):hover:not(:disabled, [aria-disabled="true"]) {
@@ -448,8 +452,11 @@
448
452
 
449
453
  /* The size states outrank :host, so sm is named too, or a small button would stay small. */
450
454
  @media (pointer: coarse) {
451
- :scope:where([data-component~="ui-button"]), :scope:where([data-component~="ui-button"])[data-ui-button-state~="size=sm"] {
452
- min-block-size: var(--ui-control-min-block-size);
455
+ :scope:where([data-component~="ui-button"]) {
456
+ min-block-size: max(var(--ui-control-size-md), var(--ui-control-min-block-size));
457
+ }
458
+ :scope:where([data-component~="ui-button"])[data-ui-button-state~="size=sm"] {
459
+ min-block-size: max(var(--ui-control-size-sm), var(--ui-control-min-block-size));
453
460
  }
454
461
  }
455
462
 
@@ -109,7 +109,7 @@
109
109
  --_size: max(var(--ui-control-size-md), var(--ui-control-min-block-size));
110
110
  }
111
111
  :scope:where([data-component~="ui-color-picker"])[data-ui-color-picker-state~="size=sm"] {
112
- --_size: var(--ui-control-min-block-size);
112
+ --_size: max(var(--ui-control-size-sm), var(--ui-control-min-block-size));
113
113
  --_font: var(--ui-font-size-md);
114
114
  }
115
115
  }
@@ -320,7 +320,7 @@
320
320
  /* Under touch a small field is still a touch-sized target, as a small button is, and keeps
321
321
  body-size text: below 16px, iOS zooms the page into the focused field. */
322
322
  :scope:where([data-component~="ui-combobox"])[data-ui-combobox-state~="size=sm"] {
323
- --_row: var(--ui-control-min-block-size);
323
+ --_row: max(var(--ui-control-size-sm), var(--ui-control-min-block-size));
324
324
  }
325
325
  :scope:where([data-component~="ui-combobox"])[data-ui-combobox-state~="size=sm"] input {
326
326
  font-size: inherit;
@@ -732,7 +732,7 @@
732
732
  /* Under touch a small field is still a touch-sized target, as a small button is, and keeps
733
733
  body-size text: below 16px, iOS zooms the page into the focused field. */
734
734
  :where([data-component~="ui-combobox"])[data-ui-combobox-state~="size=sm"]:where(:scope, :not([data-component], [data-slotted]:not([data-slotted~="ui-combobox"]))) {
735
- --_row: var(--ui-control-min-block-size);
735
+ --_row: max(var(--ui-control-size-sm), var(--ui-control-min-block-size));
736
736
  }
737
737
  :where([data-component~="ui-combobox"])[data-ui-combobox-state~="size=sm"] input:where(:scope, :not([data-component], [data-slotted]:not([data-slotted~="ui-combobox"]))) {
738
738
  font-size: inherit;
@@ -117,7 +117,10 @@
117
117
  }
118
118
 
119
119
  :scope:where([data-component~="ui-input"])[data-ui-input-state~="size=sm"] {
120
- min-block-size: var(--ui-input-min-block-size, var(--_ui-default-input-min-block-size, var(--ui-control-min-block-size)));
120
+ min-block-size: var(
121
+ --ui-input-min-block-size,
122
+ var(--_ui-default-input-min-block-size, max(var(--ui-control-size-sm), var(--ui-control-min-block-size)))
123
+ );
121
124
  }
122
125
  }
123
126
 
@@ -7,22 +7,117 @@
7
7
  [data-component~="ui-light-editor"] { --html-next-scope-owner: "ui-light-editor" !important; }
8
8
  }
9
9
  @scope ([data-component~="ui-light-editor"]) to ([data-component], [data-slotted]) {
10
- :scope:where([data-component~="ui-light-editor"]) { display: grid !important; gap: var(--ui-space-3) !important; min-inline-size: 0; padding: 0 !important; margin: 0 !important; }
11
- .stage { box-sizing: border-box !important; display: block !important; block-size: 13rem; padding: calc(var(--ui-control-min-block-size) / 2) !important; border: var(--ui-border-width) solid var(--ui-control-border); border-radius: var(--ui-field-radius, var(--ui-radius-md)); background: var(--ui-neutral-surface, var(--ui-surface)); }
12
- .plane { position: relative; display: block !important; inline-size: 100%; block-size: 100%; }
13
- svg { inline-size: 100%; block-size: 100%; overflow: visible; }
14
- path { fill: none; stroke: var(--ui-border); stroke-width: 1; vector-effect: non-scaling-stroke; }
15
- .specimen { position: absolute; inset: 0; display: grid !important; place-items: center !important; }
16
- .source { position: absolute; left: 50%; top: 0; translate: -50% -50%; display: grid !important; place-items: center !important; inline-size: max(2rem, var(--ui-control-min-block-size)); block-size: max(2rem, var(--ui-control-min-block-size)); padding: 0 !important; margin: 0 !important; border: 0; border-radius: 50%; background: transparent; cursor: grab; touch-action: none; }
17
- .source:where(:not([data-component], [data-slotted]))::after { content: ""; inline-size: 1rem; block-size: 1rem; border: var(--ui-border-width) solid var(--ui-accent); border-radius: 50%; background: var(--ui-surface-raised); box-shadow: 0 0 0 var(--ui-space-2) color-mix(in srgb, var(--ui-accent) 12%, transparent), var(--ui-shadow-sm); }
18
- .source:active { cursor: grabbing; }
19
- .source:focus-visible { outline: var(--ui-focus-width) solid var(--ui-focus-ring); outline-offset: var(--ui-space-1); }
20
- .source:disabled { cursor: not-allowed; }
21
- .source:disabled:where(:not([data-component], [data-slotted]))::after { background: var(--ui-disabled-surface); border-color: var(--ui-disabled-border); box-shadow: none; }
22
- .coordinates { display: grid !important; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--ui-space-2) !important; }
23
- .hint { font-size: var(--ui-font-size-sm); color: var(--ui-text-secondary); }
24
- @media (forced-colors: active) { .source:where(:not([data-component], [data-slotted]))::after { background: Canvas; border-color: CanvasText; } .source:focus-visible { outline-color: Highlight; } }
10
+ :scope:where([data-component~="ui-light-editor"]) {
11
+ display: grid !important;
12
+ gap: var(--ui-space-3) !important;
13
+ min-inline-size: 0;
14
+ padding: 0 !important;
15
+ margin: 0 !important;
16
+ }
17
+ .stage {
18
+ box-sizing: border-box !important;
19
+ display: block !important;
20
+ block-size: 13rem;
21
+ padding: calc(var(--ui-control-min-block-size) / 2) !important;
22
+ border: var(--ui-border-width) solid var(--ui-control-border);
23
+ border-radius: var(--ui-field-radius, var(--ui-radius-md));
24
+ background: var(--ui-neutral-surface, var(--ui-surface));
25
+ }
26
+ .plane {
27
+ position: relative;
28
+ display: block !important;
29
+ inline-size: 100%;
30
+ block-size: 100%;
31
+ }
32
+ svg {
33
+ inline-size: 100%;
34
+ block-size: 100%;
35
+ overflow: visible;
36
+ }
37
+ path {
38
+ fill: none;
39
+ stroke: var(--ui-border);
40
+ stroke-width: 1;
41
+ vector-effect: non-scaling-stroke;
42
+ }
43
+ .specimen {
44
+ position: absolute;
45
+ inset: 0;
46
+ display: grid !important;
47
+ place-items: center !important;
48
+ }
49
+ .source {
50
+ position: absolute;
51
+ left: 50%;
52
+ top: 0;
53
+ translate: -50% -50%;
54
+ display: grid !important;
55
+ place-items: center !important;
56
+ inline-size: max(2rem, var(--ui-control-min-block-size));
57
+ block-size: max(2rem, var(--ui-control-min-block-size));
58
+ padding: 0 !important;
59
+ margin: 0 !important;
60
+ border: 0;
61
+ border-radius: 50%;
62
+ background: transparent;
63
+ cursor: grab;
64
+ touch-action: none;
65
+ }
66
+ .source-glyph {
67
+ position: relative;
68
+ z-index: 1;
69
+ display: inline-flex !important;
70
+ color: var(--ui-accent);
71
+ font-size: var(--ui-font-size-lg);
72
+ }
73
+ .source:where(:not([data-component], [data-slotted]))::after {
74
+ content: "";
75
+ position: absolute;
76
+ inline-size: 2rem;
77
+ block-size: 2rem;
78
+ border: var(--ui-border-width) solid var(--ui-accent);
79
+ border-radius: 50%;
80
+ background: var(--ui-surface-raised);
81
+ box-shadow: 0 0 0 var(--ui-space-2) color-mix(in srgb, var(--ui-accent) 12%, transparent), var(--ui-shadow-sm);
82
+ }
83
+ .source:active {
84
+ cursor: grabbing;
85
+ }
86
+ .source:focus-visible {
87
+ outline: var(--ui-focus-width) solid var(--ui-focus-ring);
88
+ outline-offset: var(--ui-space-1);
89
+ }
90
+ .source:disabled {
91
+ cursor: not-allowed;
92
+ }
93
+ .source:disabled:where(:not([data-component], [data-slotted]))::after {
94
+ background: var(--ui-disabled-surface);
95
+ border-color: var(--ui-disabled-border);
96
+ box-shadow: none;
97
+ }
98
+ .coordinates {
99
+ display: grid !important;
100
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
101
+ gap: var(--ui-space-2) !important;
102
+ }
103
+ .hint {
104
+ font-size: var(--ui-font-size-sm);
105
+ color: var(--ui-text-secondary);
106
+ }
107
+ @media (forced-colors: active) {
108
+ .source-glyph {
109
+ color: CanvasText;
110
+ }
111
+ .source:where(:not([data-component], [data-slotted]))::after {
112
+ background: Canvas;
113
+ border-color: CanvasText;
114
+ }
115
+ .source:focus-visible {
116
+ outline-color: Highlight;
117
+ }
118
+ }
25
119
  }
26
120
  @scope ([data-component~="ui-light-editor"]) to ([data-component]) {
27
- @media (forced-colors: active) { }
121
+ @media (forced-colors: active) {
122
+ }
28
123
  }
@@ -77,10 +77,12 @@
77
77
  --_ui-select-highlight: var(--ui-surface-highlight, linear-gradient(transparent, transparent));
78
78
  appearance: none;
79
79
  padding-inline-end: calc(var(--ui-space-3) + 1.5rem) !important;
80
- background-image: var(
81
- --ui-select-chevron,
82
- url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%235c5c58' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E")
83
- ), var(--_ui-select-highlight);
80
+ background-image:
81
+ var(
82
+ --ui-select-chevron,
83
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%235c5c58' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E")
84
+ ),
85
+ var(--_ui-select-highlight);
84
86
  background-repeat: no-repeat;
85
87
  background-position: right var(--ui-space-3) center, center;
86
88
  background-size: 1.125rem 1.125rem, 100% 100%;
@@ -127,7 +129,9 @@
127
129
  padding: var(--ui-control-inset-block-sm, var(--ui-space-1)) var(--ui-space-2) !important;
128
130
  padding-inline-end: calc(var(--ui-space-2) + var(--ui-icon-size-sm) + var(--ui-space-1)) !important;
129
131
  background-position: right var(--ui-space-2) center, center;
130
- background-size: var(--ui-icon-size-sm) var(--ui-icon-size-sm), 100% 100%;
132
+ background-size:
133
+ var(--ui-icon-size-sm) var(--ui-icon-size-sm),
134
+ 100% 100%;
131
135
  font-size: var(--ui-font-size-sm);
132
136
  }
133
137
 
@@ -147,7 +151,7 @@
147
151
  }
148
152
 
149
153
  :scope:where([data-component~="ui-select"])[data-ui-select-state~="size=sm"] {
150
- min-block-size: var(--ui-control-min-block-size);
154
+ min-block-size: max(var(--ui-control-size-sm), var(--ui-control-min-block-size));
151
155
  font-size: inherit;
152
156
  }
153
157
  }
package/theme-system.css CHANGED
@@ -5,14 +5,16 @@
5
5
  /* Reserve the same order when the remembered theme arrives before baseline CSS. */
6
6
  @layer tokens, base, theme, components;
7
7
 
8
- @property --ui-font-size { syntax: "<length>"; inherits: true; initial-value: 16px; }
8
+ /* Keep the root font seed unregistered: a registered rem length used by root
9
+ font-size creates a dependency cycle (CSS Properties and Values API 2.7.2). */
9
10
  @property --ui-control-size { syntax: "<length>"; inherits: true; initial-value: 40px; }
10
11
  @property --ui-radius-md { syntax: "<length>"; inherits: true; initial-value: 8px; }
11
12
  @property --ui-border-width { syntax: "<length>"; inherits: true; initial-value: 1px; }
12
13
 
13
14
  @layer theme {
14
15
  :root {
15
- --ui-density: 1;
16
+ --ui-spacing: 1;
17
+ --ui-control-roominess: 2.5;
16
18
  --ui-scale-ratio: 1.2;
17
19
  --ui-space-curve: 1;
18
20
  --ui-outer-depth: 0.6;
@@ -32,8 +34,9 @@
32
34
  :where(:root, [data-theme], [data-contrast]) {
33
35
  /* Controls use a ratio. Spacing uses an independent curve, so increasing the size
34
36
  contrast cannot unexpectedly turn linear spacing into quadratic spacing. */
37
+ --ui-control-size: calc(1rem * var(--ui-control-roominess));
35
38
  --_ui-theme-ratio: max(1, var(--ui-scale-ratio));
36
- --_ui-theme-unit: calc(var(--ui-control-size) * 0.1 * max(0, var(--ui-density)));
39
+ --_ui-theme-unit: calc(1rem * 0.25 * max(0, var(--ui-spacing)));
37
40
  --_ui-theme-curve: max(0, var(--ui-space-curve));
38
41
  --ui-control-size-sm: calc(var(--ui-control-size) / var(--_ui-theme-ratio));
39
42
  --ui-control-size-md: var(--ui-control-size);
@@ -51,7 +54,7 @@
51
54
  --ui-control-inset-block-sm: max(0px, calc((var(--ui-control-size-sm) - var(--ui-font-size-sm) * var(--ui-line-height-tight) - 2 * var(--ui-border-width)) / 2));
52
55
  --ui-control-inset-block-md: max(0px, calc((var(--ui-control-size-md) - var(--ui-font-size-md) * var(--ui-line-height-tight) - 2 * var(--ui-border-width)) / 2));
53
56
  --ui-control-inset-block-lg: max(0px, calc((var(--ui-control-size-lg) - var(--ui-font-size-md) * var(--ui-line-height-tight) - 2 * var(--ui-border-width)) / 2));
54
- --ui-font-size-xs: max(12px, calc(var(--ui-font-size) * 0.75));
57
+ --ui-font-size-xs: max(12px, calc(1rem * 0.75));
55
58
  --ui-action-inset-sm: var(--ui-space-2);
56
59
  --ui-action-inset-md: var(--ui-space-3);
57
60
  --ui-action-inset-lg: var(--ui-space-4);
@@ -116,6 +119,16 @@
116
119
  --ui-press-scale: 0.98;
117
120
  }
118
121
 
122
+ @media (pointer: coarse) {
123
+ :where(:root, [data-theme], [data-contrast]) {
124
+ --ui-control-size-sm: max(calc(var(--ui-control-size) / var(--_ui-theme-ratio)), var(--ui-control-min-block-size));
125
+ --ui-control-size-md: calc(var(--ui-control-size-sm) * var(--_ui-theme-ratio));
126
+ --ui-control-size-lg: calc(var(--ui-control-size-md) * var(--_ui-theme-ratio));
127
+ /* Small native fields use body-size text on touch to avoid focus zoom. */
128
+ --ui-control-inset-block-sm: max(0px, calc((var(--ui-control-size-sm) - var(--ui-font-size-md) * var(--ui-line-height-tight) - 2 * var(--ui-border-width)) / 2));
129
+ }
130
+ }
131
+
119
132
  @media (prefers-reduced-motion: reduce) {
120
133
  :where(:root, [data-theme], [data-contrast]) {
121
134
  --ui-motion-press: 0ms;