@d0nj/dsh-ui-custom 0.1.0 → 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.
package/cordis.patch.yml CHANGED
@@ -1,22 +1,22 @@
1
- # Contributes the customization document and its defaults.
2
- #
3
- # The browser half reads this row's namespace ("ui-custom") through the settings
4
- # client, so a choice made in Theme Studio persists here and survives a reload.
5
- # Selecting the preset below is a starting point, not a requirement: the panel
6
- # changes it through the same namespace.
7
- - insert:
8
- - id: ui-custom
9
- name: '@d0nj/dsh-ui-custom'
10
- config:
11
- basePreset: none
12
- tokens: {}
13
- typography:
14
- textFontFamily: ''
15
- codeFontFamily: ''
16
- terminalFontFamily: ''
17
- fontSize: 14
18
- codeFontSize: 11
19
- terminalFontSize: 13
20
- geometry:
21
- radius: 8
22
- density: comfortable
1
+ # Contributes the customization document and its defaults.
2
+ #
3
+ # The browser half reads this row's namespace ("ui-custom") through the settings
4
+ # client, so a choice made in Theme Studio persists here and survives a reload.
5
+ # Selecting the preset below is a starting point, not a requirement: the panel
6
+ # changes it through the same namespace.
7
+ - insert:
8
+ - id: ui-custom
9
+ name: '@d0nj/dsh-ui-custom'
10
+ config:
11
+ basePreset: none
12
+ tokens: {}
13
+ typography:
14
+ textFontFamily: ''
15
+ codeFontFamily: ''
16
+ terminalFontFamily: ''
17
+ fontSize: 14
18
+ codeFontSize: 11
19
+ terminalFontSize: 13
20
+ geometry:
21
+ radius: 8
22
+ density: comfortable
@@ -874,6 +874,13 @@ window.__ModuleLoader__.load({
874
874
  const { DENSITIES, FONT_SIZE_RANGES, RADIUS_MAX, RADIUS_MIN, TOKEN_NAMES, findPreset, tokenSpec } = __require('./spec.js');
875
875
  /** A colour in one of the forms the theme service and browsers accept. */
876
876
  const COLOR_PATTERN = /^(#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})|(?:rgb|hsl|oklch|lab|lch|color)\([^)]*\)|[a-z]+)$/iu;
877
+ /** CSS generic families, which always resolve and so never need a local match. */
878
+ const GENERIC_FAMILIES = new Set([
879
+ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui',
880
+ 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', 'emoji', 'math',
881
+ 'fangsong', '-apple-system', 'blinkmacsystemfont',
882
+ 'inherit', 'initial', 'unset',
883
+ ]);
877
884
  /**
878
885
  * Whether a value is a plausible CSS colour.
879
886
  *
@@ -942,6 +949,16 @@ window.__ModuleLoader__.load({
942
949
  function isDensity(value) {
943
950
  return DENSITIES.includes(value);
944
951
  }
952
+ /**
953
+ * @param stack - comma-separated font stack.
954
+ * @returns the concrete family names, in order, without generics.
955
+ */
956
+ function familyNames(stack) {
957
+ return stack
958
+ .split(',')
959
+ .map((part) => part.trim().replace(/^["']|["']$/gu, '').trim())
960
+ .filter((name) => name !== '' && !GENERIC_FAMILIES.has(name.toLowerCase()));
961
+ }
945
962
  /**
946
963
  * Resolve a customization document into theme tokens and typography.
947
964
  *
@@ -1004,7 +1021,7 @@ window.__ModuleLoader__.load({
1004
1021
  /** Every documented token name, re-exported for the panel's reset action. */
1005
1022
  Object.assign(exports, { TOKEN_NAMES: TOKEN_NAMES });
1006
1023
  //# sourceMappingURL=resolve.js.map
1007
- Object.assign(exports, { isColor: isColor, clamp: clamp, geometryTokens: geometryTokens, correctFontSize: correctFontSize, isDensity: isDensity, resolveCustomization: resolveCustomization });
1024
+ Object.assign(exports, { isColor: isColor, clamp: clamp, geometryTokens: geometryTokens, correctFontSize: correctFontSize, isDensity: isDensity, familyNames: familyNames, resolveCustomization: resolveCustomization });
1008
1025
 
1009
1026
  },
1010
1027
  "./client/panel.js": (module, exports, __require) => {
@@ -1024,6 +1041,7 @@ window.__ModuleLoader__.load({
1024
1041
  *
1025
1042
  * @module @d0nj/dsh-ui-custom/panel
1026
1043
  */
1044
+ const { findHostedFont, hostedFamilies, loadHostedFont } = __require('./client/hosted-fonts.js');
1027
1045
  /** Shared inline styles, computed from theme tokens. */
1028
1046
  function styles() {
1029
1047
  return {
@@ -1093,6 +1111,47 @@ window.__ModuleLoader__.load({
1093
1111
  font: 'inherit',
1094
1112
  fontSize: 12,
1095
1113
  },
1114
+ select: {
1115
+ width: 260,
1116
+ padding: '4px 6px',
1117
+ borderRadius: 'var(--d0nj-radius-sm, 4px)',
1118
+ border: '1px solid var(--dsw-alias-border-l1)',
1119
+ background: 'var(--dsw-alias-bg-base)',
1120
+ color: 'var(--dsw-alias-label-primary)',
1121
+ font: 'inherit',
1122
+ fontSize: 12,
1123
+ },
1124
+ fontStack: { display: 'flex', flexDirection: 'column', gap: 4, width: 260 },
1125
+ fontRow: { gap: 18 },
1126
+ combo: { position: 'relative', width: 260 },
1127
+ listbox: {
1128
+ position: 'absolute',
1129
+ zIndex: 10,
1130
+ top: '100%',
1131
+ left: 0,
1132
+ right: 0,
1133
+ marginTop: 4,
1134
+ maxHeight: 240,
1135
+ overflowY: 'auto',
1136
+ padding: 4,
1137
+ borderRadius: 'var(--d0nj-radius-sm, 4px)',
1138
+ border: '1px solid var(--dsw-alias-border-l2)',
1139
+ // A popup over arbitrary content cannot inherit the surface behind it, so
1140
+ // it paints the module fill; only the element's own list uses this literal.
1141
+ background: 'var(--dsw-alias-bg-module-platform, var(--dsw-alias-bg-layer-1))',
1142
+ boxShadow: 'var(--dsw-elevation-lv2, 0 4px 12px rgba(0, 0, 0, 0.2))',
1143
+ },
1144
+ option: {
1145
+ padding: '4px 6px',
1146
+ borderRadius: 'var(--d0nj-radius-sm, 4px)',
1147
+ fontSize: 12,
1148
+ color: 'var(--dsw-alias-label-primary)',
1149
+ cursor: 'pointer',
1150
+ whiteSpace: 'nowrap',
1151
+ overflow: 'hidden',
1152
+ textOverflow: 'ellipsis',
1153
+ },
1154
+ optionActive: { background: 'var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2))' },
1096
1155
  colorInput: {
1097
1156
  width: 34,
1098
1157
  height: 26,
@@ -1131,6 +1190,41 @@ window.__ModuleLoader__.load({
1131
1190
  row2: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' },
1132
1191
  };
1133
1192
  }
1193
+ /**
1194
+ * Combobox state per role.
1195
+ *
1196
+ * Kept at module scope because the panel re-renders on every settings echo, and
1197
+ * state created inside the render would reset to closed each pass.
1198
+ */
1199
+ const combos = new Map();
1200
+ /** Exported so tests do not inherit an open list between cases. */
1201
+ function resetCombos() {
1202
+ combos.clear();
1203
+ }
1204
+ /**
1205
+ * @param key - the role's accessible label.
1206
+ * @returns the state for that role, created on first use.
1207
+ */
1208
+ function comboState(key) {
1209
+ const existing = combos.get(key);
1210
+ if (existing !== undefined)
1211
+ return existing;
1212
+ const created = { open: false, query: '', active: 0 };
1213
+ combos.set(key, created);
1214
+ return created;
1215
+ }
1216
+ /**
1217
+ * Fonts offered by the picker.
1218
+ *
1219
+ * Hosted rather than local: a browser cannot enumerate installed fonts without a
1220
+ * permission-gated API most lack, and a local family looks different elsewhere.
1221
+ */
1222
+ let fontChoiceCache;
1223
+ /** @returns the families the picker offers. */
1224
+ function fontChoices() {
1225
+ fontChoiceCache ??= hostedFamilies();
1226
+ return fontChoiceCache;
1227
+ }
1134
1228
  /**
1135
1229
  * Render the Theme Studio page.
1136
1230
  *
@@ -1153,6 +1247,127 @@ window.__ModuleLoader__.load({
1153
1247
  const [localTokens, setLocalTokens] = React.useState({});
1154
1248
  const [localGeometry, setLocalGeometry] = React.useState(state.geometry);
1155
1249
  const [localTypography, setLocalTypography] = React.useState(state.typography);
1250
+ // The counter must keep changing: writing the same number makes React bail out.
1251
+ const [renderTick, setRender] = React.useState(0);
1252
+ const choices = fontChoices();
1253
+ const rerender = () => setRender(renderTick + 1);
1254
+ /**
1255
+ * @param role - font role being changed.
1256
+ * @param value - CSS stack to apply.
1257
+ */
1258
+ const applyFont = (role, value) => {
1259
+ const hosted = findHostedFont(value);
1260
+ if (hosted === undefined) {
1261
+ actions.setFont(role, value);
1262
+ return;
1263
+ }
1264
+ void loadHostedFont(hosted).then(() => actions.setFont(role, value));
1265
+ };
1266
+ /**
1267
+ * @param ariaLabel - accessible name for the input.
1268
+ * @param family - current stack.
1269
+ * @param onChange - writes the chosen stack.
1270
+ * @returns the combobox element.
1271
+ */
1272
+ const fontCombo = (ariaLabel, family, onChange) => {
1273
+ const combo = comboState(ariaLabel);
1274
+ // Not the word "Default", which could collide with a family of that name.
1275
+ const DEFAULT_CHOICE = '\u0000default';
1276
+ const isDefault = (choice) => choice === DEFAULT_CHOICE;
1277
+ const matches = () => {
1278
+ const query = combo.query.trim().toLowerCase();
1279
+ if (query === '')
1280
+ return [DEFAULT_CHOICE, ...choices.slice(0, 39)];
1281
+ const filtered = choices.filter((choice) => choice.toLowerCase().includes(query));
1282
+ const keepDefault = 'default (built-in)'.includes(query) || 'built-in'.includes(query);
1283
+ return (keepDefault ? [DEFAULT_CHOICE, ...filtered] : filtered).slice(0, 40);
1284
+ };
1285
+ const commit = (value) => {
1286
+ combo.open = false;
1287
+ combo.query = '';
1288
+ combo.active = 0;
1289
+ const next = isDefault(value) ? '' : value;
1290
+ if (next !== combo.written)
1291
+ onChange(next);
1292
+ combo.written = undefined;
1293
+ rerender();
1294
+ };
1295
+ const visible = combo.open ? matches() : [];
1296
+ return h('div', { style: s.combo }, h('input', {
1297
+ type: 'text',
1298
+ role: 'combobox',
1299
+ style: s.select,
1300
+ 'aria-label': ariaLabel,
1301
+ 'aria-expanded': combo.open,
1302
+ 'aria-autocomplete': 'list',
1303
+ placeholder: 'Default (built-in stack)',
1304
+ value: family,
1305
+ onFocus: (event) => {
1306
+ combo.open = true;
1307
+ combo.query = '';
1308
+ combo.active = 0;
1309
+ event.target.select?.();
1310
+ rerender();
1311
+ },
1312
+ onInput: (event) => {
1313
+ combo.open = true;
1314
+ combo.query = event.target.value;
1315
+ combo.active = 0;
1316
+ combo.written = event.target.value;
1317
+ onChange(event.target.value);
1318
+ rerender();
1319
+ },
1320
+ onKeyDown: (event) => {
1321
+ const list = visible;
1322
+ if (event.key === 'Escape') {
1323
+ combo.open = false;
1324
+ combo.query = '';
1325
+ rerender();
1326
+ return;
1327
+ }
1328
+ if (event.key === 'ArrowDown' && list.length > 0) {
1329
+ event.preventDefault();
1330
+ combo.active = (combo.active + 1) % list.length;
1331
+ rerender();
1332
+ return;
1333
+ }
1334
+ if (event.key === 'ArrowUp' && list.length > 0) {
1335
+ event.preventDefault();
1336
+ combo.active = (combo.active - 1 + list.length) % list.length;
1337
+ rerender();
1338
+ return;
1339
+ }
1340
+ if (event.key === 'Enter') {
1341
+ const picked = list[combo.active];
1342
+ if (combo.open && picked !== undefined)
1343
+ commit(picked);
1344
+ else if (combo.open && combo.query.trim() !== '')
1345
+ commit(combo.query.trim());
1346
+ }
1347
+ },
1348
+ onBlur: () => {
1349
+ combo.open = false;
1350
+ combo.query = '';
1351
+ rerender();
1352
+ },
1353
+ }), combo.open && visible.length > 0
1354
+ ? h('div', { style: s.listbox, role: 'listbox' }, ...visible.map((choice, index) => h('div', {
1355
+ key: choice,
1356
+ role: 'option',
1357
+ 'aria-selected': index === combo.active,
1358
+ style: {
1359
+ ...s.option,
1360
+ ...(isDefault(choice) ? {} : { fontFamily: `"${choice}"` }),
1361
+ ...(index === combo.active ? s.optionActive : {}),
1362
+ },
1363
+ // Fires before the input's blur, which would close the list first.
1364
+ onMouseDown: (event) => {
1365
+ event.preventDefault();
1366
+ commit(choice);
1367
+ },
1368
+ }, isDefault(choice) ? 'Default (built-in stack)' : choice)))
1369
+ : null);
1370
+ };
1156
1371
  /** The preset currently selected, for filling a missing mode from its colours. */
1157
1372
  const basePreset = presets.find((preset) => preset.id === (localPreset ?? state.basePreset));
1158
1373
  /**
@@ -1248,31 +1463,27 @@ window.__ModuleLoader__.load({
1248
1463
  },
1249
1464
  }, 'Reset')));
1250
1465
  });
1251
- const fontRow = (role, label, family, size) => h('div', { style: s.row }, h('div', { style: s.rowLabel }, h('span', { style: { fontSize: 12, fontWeight: 600 } }, label)), h('input', {
1252
- style: { ...s.input, width: 260 },
1253
- type: 'text',
1254
- 'aria-label': `${label} font stack`,
1255
- placeholder: 'Font family, fallback, sans-serif',
1256
- value: family,
1257
- onChange: (event) => {
1466
+ const fontRow = (role, label, family, size) => {
1467
+ const edit = (value) => {
1258
1468
  const key = role === 'text' ? 'textFontFamily' : role === 'code' ? 'codeFontFamily' : 'terminalFontFamily';
1259
- setLocalTypography({ ...(localTypography ?? state.typography), [key]: event.target.value });
1260
- actions.setFont(role, event.target.value);
1261
- },
1262
- }), h('div', { style: s.row2 }, h('input', {
1263
- style: s.input,
1264
- type: 'number',
1265
- min: 8,
1266
- max: 24,
1267
- 'aria-label': `${label} size`,
1268
- value: size,
1269
- onChange: (event) => {
1270
- const px = Number(event.target.value);
1271
- const key = role === 'text' ? 'fontSize' : role === 'code' ? 'codeFontSize' : 'terminalFontSize';
1272
- setLocalTypography({ ...(localTypography ?? state.typography), [key]: px });
1273
- actions.setFontSize(role, px);
1274
- },
1275
- }), h('span', { style: s.hint }, 'px')));
1469
+ setLocalTypography({ ...(localTypography ?? state.typography), [key]: value });
1470
+ applyFont(role, value);
1471
+ };
1472
+ return h('div', { style: { ...s.row, ...s.fontRow } }, h('div', { style: s.rowLabel }, h('span', { style: { fontSize: 12, fontWeight: 600 } }, label)), fontCombo(`${label} font family`, family, edit), h('div', { style: s.row2 }, h('input', {
1473
+ style: s.input,
1474
+ type: 'number',
1475
+ min: 8,
1476
+ max: 24,
1477
+ 'aria-label': `${label} size`,
1478
+ value: size,
1479
+ onChange: (event) => {
1480
+ const px = Number(event.target.value);
1481
+ const key = role === 'text' ? 'fontSize' : role === 'code' ? 'codeFontSize' : 'terminalFontSize';
1482
+ setLocalTypography({ ...(localTypography ?? state.typography), [key]: px });
1483
+ actions.setFontSize(role, px);
1484
+ },
1485
+ }), h('span', { style: s.hint }, 'px')));
1486
+ };
1276
1487
  return h('div', { style: s.root }, state.writable
1277
1488
  ? null
1278
1489
  : h('div', { style: s.warning }, 'This profile does not accept settings writes; changes will not persist.'), h('section', { style: s.section }, h('h3', { style: s.heading }, 'Base theme'), h('p', { style: s.hint }, 'A preset supplies a complete token set. Tune individual values below it.'), h('div', { style: s.grid }, presetCards)), h('section', { style: s.section }, h('h3', { style: s.heading }, 'Live preview'), h('div', { style: s.preview }, h('span', { style: { fontSize: 15, fontWeight: 600 } }, 'The quick brown fox'), h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, 'Secondary text sits one step down.'), h('div', { style: s.row2 }, h('span', { style: { ...s.button, background: 'var(--dsw-alias-brand-primary)', color: 'var(--dsw-alias-bg-base)' } }, 'Accent'), h('span', { style: { ...s.button, borderColor: 'var(--dsw-alias-state-error-primary)' } }, 'Error'), h('span', { style: { ...s.button, borderColor: 'var(--dsw-alias-state-success-primary)' } }, 'Success'), h('span', { style: { ...s.button, borderColor: 'var(--dsw-alias-state-warn-primary)' } }, 'Warning')))), h('section', { style: s.section }, h('h3', { style: s.heading }, 'Typography'), fontRow('text', 'Interface text', (localTypography ?? state.typography).textFontFamily, (localTypography ?? state.typography).fontSize), fontRow('code', 'Code', (localTypography ?? state.typography).codeFontFamily, (localTypography ?? state.typography).codeFontSize), fontRow('terminal', 'Terminal', (localTypography ?? state.typography).terminalFontFamily, (localTypography ?? state.typography).terminalFontSize)), h('section', { style: s.section }, h('h3', { style: s.heading }, 'Geometry'), h('div', { style: s.row }, h('div', { style: s.rowLabel }, h('span', { style: { fontSize: 12, fontWeight: 600 } }, 'Corner radius')), h('input', {
@@ -1302,7 +1513,109 @@ window.__ModuleLoader__.load({
1302
1513
  : null, h('section', { style: s.section }, h('button', { type: 'button', style: s.button, onClick: () => actions.resetAll() }, 'Reset all customization')));
1303
1514
  }
1304
1515
  //# sourceMappingURL=panel.js.map
1305
- Object.assign(exports, { ThemeStudio: ThemeStudio });
1516
+ Object.assign(exports, { resetCombos: resetCombos, ThemeStudio: ThemeStudio });
1517
+
1518
+ },
1519
+ "./client/hosted-fonts.js": (module, exports, __require) => {
1520
+ /**
1521
+ * Families fetched from a font CDN on demand.
1522
+ *
1523
+ * @module @d0nj/dsh-ui-custom/hosted-fonts
1524
+ */
1525
+ const HOSTED_FONTS = [
1526
+ { family: 'Inter', category: 'sans' },
1527
+ { family: 'IBM Plex Sans', category: 'sans' },
1528
+ { family: 'Source Sans 3', category: 'sans' },
1529
+ { family: 'Public Sans', category: 'sans' },
1530
+ { family: 'Work Sans', category: 'sans' },
1531
+ { family: 'DM Sans', category: 'sans' },
1532
+ { family: 'Manrope', category: 'sans' },
1533
+ { family: 'Figtree', category: 'sans' },
1534
+ { family: 'Nunito Sans', category: 'sans' },
1535
+ { family: 'Open Sans', category: 'sans' },
1536
+ { family: 'Roboto', category: 'sans' },
1537
+ { family: 'Lato', category: 'sans' },
1538
+ { family: 'Montserrat', category: 'sans' },
1539
+ { family: 'Poppins', category: 'sans' },
1540
+ { family: 'Lexend', category: 'sans' },
1541
+ { family: 'Outfit', category: 'sans' },
1542
+ { family: 'Sora', category: 'sans' },
1543
+ { family: 'Space Grotesk', category: 'sans' },
1544
+ { family: 'JetBrains Mono', category: 'mono' },
1545
+ { family: 'Fira Code', category: 'mono' },
1546
+ { family: 'Source Code Pro', category: 'mono' },
1547
+ { family: 'IBM Plex Mono', category: 'mono' },
1548
+ { family: 'Roboto Mono', category: 'mono' },
1549
+ { family: 'Space Mono', category: 'mono' },
1550
+ { family: 'Inconsolata', category: 'mono' },
1551
+ { family: 'Ubuntu Mono', category: 'mono' },
1552
+ { family: 'Anonymous Pro', category: 'mono' },
1553
+ { family: 'Source Serif 4', category: 'serif' },
1554
+ { family: 'IBM Plex Serif', category: 'serif' },
1555
+ { family: 'Lora', category: 'serif' },
1556
+ { family: 'Merriweather', category: 'serif' },
1557
+ { family: 'Newsreader', category: 'serif' },
1558
+ { family: 'Crimson Pro', category: 'serif' },
1559
+ { family: 'Bitter', category: 'serif' },
1560
+ { family: 'Bricolage Grotesque', category: 'display' },
1561
+ { family: 'Instrument Serif', category: 'display' },
1562
+ ];
1563
+ const CDN_BASE = 'https://fonts.googleapis.com/css2';
1564
+ /** Stylesheets outlive any one render, and a repeat request is a wasted round-trip. */
1565
+ const requested = new Set();
1566
+ /**
1567
+ * @param font - the family to request.
1568
+ * @returns an absolute stylesheet URL.
1569
+ */
1570
+ function hostedFontUrl(font) {
1571
+ const name = font.family.trim().replace(/\s+/gu, '+');
1572
+ const weights = (font.weights ?? [400]).join(';');
1573
+ return `${CDN_BASE}?family=${name}:wght@${weights}&display=swap`;
1574
+ }
1575
+ /**
1576
+ * Load one family into the page; resolves on failure too, because a font that
1577
+ * cannot be fetched must not break the panel.
1578
+ *
1579
+ * @param font - the family to load.
1580
+ * @param doc - document to load into; defaults to the global one.
1581
+ */
1582
+ async function loadHostedFont(font, doc = typeof document === 'undefined' ? undefined : document) {
1583
+ if (doc === undefined)
1584
+ return;
1585
+ const key = font.family.toLowerCase();
1586
+ if (requested.has(key))
1587
+ return;
1588
+ requested.add(key);
1589
+ const stylesheet = doc.createElement('link');
1590
+ stylesheet.rel = 'stylesheet';
1591
+ stylesheet.href = hostedFontUrl(font);
1592
+ stylesheet.dataset.d0njHostedFont = font.family;
1593
+ await new Promise((resolve) => {
1594
+ stylesheet.addEventListener('load', () => resolve(), { once: true });
1595
+ stylesheet.addEventListener('error', () => resolve(), { once: true });
1596
+ doc.head.append(stylesheet);
1597
+ });
1598
+ // Without this the first render after a pick can still measure the fallback.
1599
+ await doc.fonts?.ready.catch(() => { });
1600
+ }
1601
+ /**
1602
+ * @param fonts - catalog to read.
1603
+ * @returns family names, sorted.
1604
+ */
1605
+ function hostedFamilies(fonts = HOSTED_FONTS) {
1606
+ return fonts.map((font) => font.family).sort((left, right) => left.localeCompare(right));
1607
+ }
1608
+ /**
1609
+ * @param family - family name to find.
1610
+ * @param fonts - catalog to search.
1611
+ * @returns the entry, or `undefined` when the name is not hosted here.
1612
+ */
1613
+ function findHostedFont(family, fonts = HOSTED_FONTS) {
1614
+ const key = family.trim().replace(/^["']|["']$/gu, '').toLowerCase();
1615
+ return fonts.find((font) => font.family.toLowerCase() === key);
1616
+ }
1617
+ //# sourceMappingURL=hosted-fonts.js.map
1618
+ Object.assign(exports, { hostedFontUrl: hostedFontUrl, loadHostedFont: loadHostedFont, hostedFamilies: hostedFamilies, findHostedFont: findHostedFont, HOSTED_FONTS: HOSTED_FONTS });
1306
1619
 
1307
1620
  },
1308
1621
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Families fetched from a font CDN on demand.
3
+ *
4
+ * @module @d0nj/dsh-ui-custom/hosted-fonts
5
+ */
6
+ /** Role a family is suited to, used to group the picker. */
7
+ export type FontCategory = 'sans' | 'serif' | 'mono' | 'display';
8
+ /** One hosted family this package can load. */
9
+ export interface HostedFont {
10
+ /** CSS family name, exactly as the stylesheet declares it. */
11
+ readonly family: string;
12
+ /** Grouping in the picker. */
13
+ readonly category: FontCategory;
14
+ /** Weights requested from the CDN; 400 is enough for body text. */
15
+ readonly weights?: readonly number[];
16
+ }
17
+ export declare const HOSTED_FONTS: readonly HostedFont[];
18
+ /**
19
+ * @param font - the family to request.
20
+ * @returns an absolute stylesheet URL.
21
+ */
22
+ export declare function hostedFontUrl(font: HostedFont): string;
23
+ /**
24
+ * Load one family into the page; resolves on failure too, because a font that
25
+ * cannot be fetched must not break the panel.
26
+ *
27
+ * @param font - the family to load.
28
+ * @param doc - document to load into; defaults to the global one.
29
+ */
30
+ export declare function loadHostedFont(font: HostedFont, doc?: Document | undefined): Promise<void>;
31
+ /**
32
+ * @param fonts - catalog to read.
33
+ * @returns family names, sorted.
34
+ */
35
+ export declare function hostedFamilies(fonts?: readonly HostedFont[]): string[];
36
+ /**
37
+ * @param family - family name to find.
38
+ * @param fonts - catalog to search.
39
+ * @returns the entry, or `undefined` when the name is not hosted here.
40
+ */
41
+ export declare function findHostedFont(family: string, fonts?: readonly HostedFont[]): HostedFont | undefined;
42
+ //# sourceMappingURL=hosted-fonts.d.ts.map
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Families fetched from a font CDN on demand.
3
+ *
4
+ * @module @d0nj/dsh-ui-custom/hosted-fonts
5
+ */
6
+ export const HOSTED_FONTS = [
7
+ { family: 'Inter', category: 'sans' },
8
+ { family: 'IBM Plex Sans', category: 'sans' },
9
+ { family: 'Source Sans 3', category: 'sans' },
10
+ { family: 'Public Sans', category: 'sans' },
11
+ { family: 'Work Sans', category: 'sans' },
12
+ { family: 'DM Sans', category: 'sans' },
13
+ { family: 'Manrope', category: 'sans' },
14
+ { family: 'Figtree', category: 'sans' },
15
+ { family: 'Nunito Sans', category: 'sans' },
16
+ { family: 'Open Sans', category: 'sans' },
17
+ { family: 'Roboto', category: 'sans' },
18
+ { family: 'Lato', category: 'sans' },
19
+ { family: 'Montserrat', category: 'sans' },
20
+ { family: 'Poppins', category: 'sans' },
21
+ { family: 'Lexend', category: 'sans' },
22
+ { family: 'Outfit', category: 'sans' },
23
+ { family: 'Sora', category: 'sans' },
24
+ { family: 'Space Grotesk', category: 'sans' },
25
+ { family: 'JetBrains Mono', category: 'mono' },
26
+ { family: 'Fira Code', category: 'mono' },
27
+ { family: 'Source Code Pro', category: 'mono' },
28
+ { family: 'IBM Plex Mono', category: 'mono' },
29
+ { family: 'Roboto Mono', category: 'mono' },
30
+ { family: 'Space Mono', category: 'mono' },
31
+ { family: 'Inconsolata', category: 'mono' },
32
+ { family: 'Ubuntu Mono', category: 'mono' },
33
+ { family: 'Anonymous Pro', category: 'mono' },
34
+ { family: 'Source Serif 4', category: 'serif' },
35
+ { family: 'IBM Plex Serif', category: 'serif' },
36
+ { family: 'Lora', category: 'serif' },
37
+ { family: 'Merriweather', category: 'serif' },
38
+ { family: 'Newsreader', category: 'serif' },
39
+ { family: 'Crimson Pro', category: 'serif' },
40
+ { family: 'Bitter', category: 'serif' },
41
+ { family: 'Bricolage Grotesque', category: 'display' },
42
+ { family: 'Instrument Serif', category: 'display' },
43
+ ];
44
+ const CDN_BASE = 'https://fonts.googleapis.com/css2';
45
+ /** Stylesheets outlive any one render, and a repeat request is a wasted round-trip. */
46
+ const requested = new Set();
47
+ /**
48
+ * @param font - the family to request.
49
+ * @returns an absolute stylesheet URL.
50
+ */
51
+ export function hostedFontUrl(font) {
52
+ const name = font.family.trim().replace(/\s+/gu, '+');
53
+ const weights = (font.weights ?? [400]).join(';');
54
+ return `${CDN_BASE}?family=${name}:wght@${weights}&display=swap`;
55
+ }
56
+ /**
57
+ * Load one family into the page; resolves on failure too, because a font that
58
+ * cannot be fetched must not break the panel.
59
+ *
60
+ * @param font - the family to load.
61
+ * @param doc - document to load into; defaults to the global one.
62
+ */
63
+ export async function loadHostedFont(font, doc = typeof document === 'undefined' ? undefined : document) {
64
+ if (doc === undefined)
65
+ return;
66
+ const key = font.family.toLowerCase();
67
+ if (requested.has(key))
68
+ return;
69
+ requested.add(key);
70
+ const stylesheet = doc.createElement('link');
71
+ stylesheet.rel = 'stylesheet';
72
+ stylesheet.href = hostedFontUrl(font);
73
+ stylesheet.dataset.d0njHostedFont = font.family;
74
+ await new Promise((resolve) => {
75
+ stylesheet.addEventListener('load', () => resolve(), { once: true });
76
+ stylesheet.addEventListener('error', () => resolve(), { once: true });
77
+ doc.head.append(stylesheet);
78
+ });
79
+ // Without this the first render after a pick can still measure the fallback.
80
+ await doc.fonts?.ready.catch(() => { });
81
+ }
82
+ /**
83
+ * @param fonts - catalog to read.
84
+ * @returns family names, sorted.
85
+ */
86
+ export function hostedFamilies(fonts = HOSTED_FONTS) {
87
+ return fonts.map((font) => font.family).sort((left, right) => left.localeCompare(right));
88
+ }
89
+ /**
90
+ * @param family - family name to find.
91
+ * @param fonts - catalog to search.
92
+ * @returns the entry, or `undefined` when the name is not hosted here.
93
+ */
94
+ export function findHostedFont(family, fonts = HOSTED_FONTS) {
95
+ const key = family.trim().replace(/^["']|["']$/gu, '').toLowerCase();
96
+ return fonts.find((font) => font.family.toLowerCase() === key);
97
+ }
98
+ //# sourceMappingURL=hosted-fonts.js.map
@@ -55,6 +55,8 @@ export interface PanelActions {
55
55
  setDensity(density: string): void;
56
56
  resetAll(): void;
57
57
  }
58
+ /** Exported so tests do not inherit an open list between cases. */
59
+ export declare function resetCombos(): void;
58
60
  /**
59
61
  * Render the Theme Studio page.
60
62
  *
@@ -14,6 +14,7 @@
14
14
  *
15
15
  * @module @d0nj/dsh-ui-custom/panel
16
16
  */
17
+ import { findHostedFont, hostedFamilies, loadHostedFont } from './hosted-fonts.js';
17
18
  /** Shared inline styles, computed from theme tokens. */
18
19
  function styles() {
19
20
  return {
@@ -83,6 +84,47 @@ function styles() {
83
84
  font: 'inherit',
84
85
  fontSize: 12,
85
86
  },
87
+ select: {
88
+ width: 260,
89
+ padding: '4px 6px',
90
+ borderRadius: 'var(--d0nj-radius-sm, 4px)',
91
+ border: '1px solid var(--dsw-alias-border-l1)',
92
+ background: 'var(--dsw-alias-bg-base)',
93
+ color: 'var(--dsw-alias-label-primary)',
94
+ font: 'inherit',
95
+ fontSize: 12,
96
+ },
97
+ fontStack: { display: 'flex', flexDirection: 'column', gap: 4, width: 260 },
98
+ fontRow: { gap: 18 },
99
+ combo: { position: 'relative', width: 260 },
100
+ listbox: {
101
+ position: 'absolute',
102
+ zIndex: 10,
103
+ top: '100%',
104
+ left: 0,
105
+ right: 0,
106
+ marginTop: 4,
107
+ maxHeight: 240,
108
+ overflowY: 'auto',
109
+ padding: 4,
110
+ borderRadius: 'var(--d0nj-radius-sm, 4px)',
111
+ border: '1px solid var(--dsw-alias-border-l2)',
112
+ // A popup over arbitrary content cannot inherit the surface behind it, so
113
+ // it paints the module fill; only the element's own list uses this literal.
114
+ background: 'var(--dsw-alias-bg-module-platform, var(--dsw-alias-bg-layer-1))',
115
+ boxShadow: 'var(--dsw-elevation-lv2, 0 4px 12px rgba(0, 0, 0, 0.2))',
116
+ },
117
+ option: {
118
+ padding: '4px 6px',
119
+ borderRadius: 'var(--d0nj-radius-sm, 4px)',
120
+ fontSize: 12,
121
+ color: 'var(--dsw-alias-label-primary)',
122
+ cursor: 'pointer',
123
+ whiteSpace: 'nowrap',
124
+ overflow: 'hidden',
125
+ textOverflow: 'ellipsis',
126
+ },
127
+ optionActive: { background: 'var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2))' },
86
128
  colorInput: {
87
129
  width: 34,
88
130
  height: 26,
@@ -121,6 +163,41 @@ function styles() {
121
163
  row2: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' },
122
164
  };
123
165
  }
166
+ /**
167
+ * Combobox state per role.
168
+ *
169
+ * Kept at module scope because the panel re-renders on every settings echo, and
170
+ * state created inside the render would reset to closed each pass.
171
+ */
172
+ const combos = new Map();
173
+ /** Exported so tests do not inherit an open list between cases. */
174
+ export function resetCombos() {
175
+ combos.clear();
176
+ }
177
+ /**
178
+ * @param key - the role's accessible label.
179
+ * @returns the state for that role, created on first use.
180
+ */
181
+ function comboState(key) {
182
+ const existing = combos.get(key);
183
+ if (existing !== undefined)
184
+ return existing;
185
+ const created = { open: false, query: '', active: 0 };
186
+ combos.set(key, created);
187
+ return created;
188
+ }
189
+ /**
190
+ * Fonts offered by the picker.
191
+ *
192
+ * Hosted rather than local: a browser cannot enumerate installed fonts without a
193
+ * permission-gated API most lack, and a local family looks different elsewhere.
194
+ */
195
+ let fontChoiceCache;
196
+ /** @returns the families the picker offers. */
197
+ function fontChoices() {
198
+ fontChoiceCache ??= hostedFamilies();
199
+ return fontChoiceCache;
200
+ }
124
201
  /**
125
202
  * Render the Theme Studio page.
126
203
  *
@@ -143,6 +220,127 @@ export function ThemeStudio(React, props) {
143
220
  const [localTokens, setLocalTokens] = React.useState({});
144
221
  const [localGeometry, setLocalGeometry] = React.useState(state.geometry);
145
222
  const [localTypography, setLocalTypography] = React.useState(state.typography);
223
+ // The counter must keep changing: writing the same number makes React bail out.
224
+ const [renderTick, setRender] = React.useState(0);
225
+ const choices = fontChoices();
226
+ const rerender = () => setRender(renderTick + 1);
227
+ /**
228
+ * @param role - font role being changed.
229
+ * @param value - CSS stack to apply.
230
+ */
231
+ const applyFont = (role, value) => {
232
+ const hosted = findHostedFont(value);
233
+ if (hosted === undefined) {
234
+ actions.setFont(role, value);
235
+ return;
236
+ }
237
+ void loadHostedFont(hosted).then(() => actions.setFont(role, value));
238
+ };
239
+ /**
240
+ * @param ariaLabel - accessible name for the input.
241
+ * @param family - current stack.
242
+ * @param onChange - writes the chosen stack.
243
+ * @returns the combobox element.
244
+ */
245
+ const fontCombo = (ariaLabel, family, onChange) => {
246
+ const combo = comboState(ariaLabel);
247
+ // Not the word "Default", which could collide with a family of that name.
248
+ const DEFAULT_CHOICE = '\u0000default';
249
+ const isDefault = (choice) => choice === DEFAULT_CHOICE;
250
+ const matches = () => {
251
+ const query = combo.query.trim().toLowerCase();
252
+ if (query === '')
253
+ return [DEFAULT_CHOICE, ...choices.slice(0, 39)];
254
+ const filtered = choices.filter((choice) => choice.toLowerCase().includes(query));
255
+ const keepDefault = 'default (built-in)'.includes(query) || 'built-in'.includes(query);
256
+ return (keepDefault ? [DEFAULT_CHOICE, ...filtered] : filtered).slice(0, 40);
257
+ };
258
+ const commit = (value) => {
259
+ combo.open = false;
260
+ combo.query = '';
261
+ combo.active = 0;
262
+ const next = isDefault(value) ? '' : value;
263
+ if (next !== combo.written)
264
+ onChange(next);
265
+ combo.written = undefined;
266
+ rerender();
267
+ };
268
+ const visible = combo.open ? matches() : [];
269
+ return h('div', { style: s.combo }, h('input', {
270
+ type: 'text',
271
+ role: 'combobox',
272
+ style: s.select,
273
+ 'aria-label': ariaLabel,
274
+ 'aria-expanded': combo.open,
275
+ 'aria-autocomplete': 'list',
276
+ placeholder: 'Default (built-in stack)',
277
+ value: family,
278
+ onFocus: (event) => {
279
+ combo.open = true;
280
+ combo.query = '';
281
+ combo.active = 0;
282
+ event.target.select?.();
283
+ rerender();
284
+ },
285
+ onInput: (event) => {
286
+ combo.open = true;
287
+ combo.query = event.target.value;
288
+ combo.active = 0;
289
+ combo.written = event.target.value;
290
+ onChange(event.target.value);
291
+ rerender();
292
+ },
293
+ onKeyDown: (event) => {
294
+ const list = visible;
295
+ if (event.key === 'Escape') {
296
+ combo.open = false;
297
+ combo.query = '';
298
+ rerender();
299
+ return;
300
+ }
301
+ if (event.key === 'ArrowDown' && list.length > 0) {
302
+ event.preventDefault();
303
+ combo.active = (combo.active + 1) % list.length;
304
+ rerender();
305
+ return;
306
+ }
307
+ if (event.key === 'ArrowUp' && list.length > 0) {
308
+ event.preventDefault();
309
+ combo.active = (combo.active - 1 + list.length) % list.length;
310
+ rerender();
311
+ return;
312
+ }
313
+ if (event.key === 'Enter') {
314
+ const picked = list[combo.active];
315
+ if (combo.open && picked !== undefined)
316
+ commit(picked);
317
+ else if (combo.open && combo.query.trim() !== '')
318
+ commit(combo.query.trim());
319
+ }
320
+ },
321
+ onBlur: () => {
322
+ combo.open = false;
323
+ combo.query = '';
324
+ rerender();
325
+ },
326
+ }), combo.open && visible.length > 0
327
+ ? h('div', { style: s.listbox, role: 'listbox' }, ...visible.map((choice, index) => h('div', {
328
+ key: choice,
329
+ role: 'option',
330
+ 'aria-selected': index === combo.active,
331
+ style: {
332
+ ...s.option,
333
+ ...(isDefault(choice) ? {} : { fontFamily: `"${choice}"` }),
334
+ ...(index === combo.active ? s.optionActive : {}),
335
+ },
336
+ // Fires before the input's blur, which would close the list first.
337
+ onMouseDown: (event) => {
338
+ event.preventDefault();
339
+ commit(choice);
340
+ },
341
+ }, isDefault(choice) ? 'Default (built-in stack)' : choice)))
342
+ : null);
343
+ };
146
344
  /** The preset currently selected, for filling a missing mode from its colours. */
147
345
  const basePreset = presets.find((preset) => preset.id === (localPreset ?? state.basePreset));
148
346
  /**
@@ -238,31 +436,27 @@ export function ThemeStudio(React, props) {
238
436
  },
239
437
  }, 'Reset')));
240
438
  });
241
- const fontRow = (role, label, family, size) => h('div', { style: s.row }, h('div', { style: s.rowLabel }, h('span', { style: { fontSize: 12, fontWeight: 600 } }, label)), h('input', {
242
- style: { ...s.input, width: 260 },
243
- type: 'text',
244
- 'aria-label': `${label} font stack`,
245
- placeholder: 'Font family, fallback, sans-serif',
246
- value: family,
247
- onChange: (event) => {
439
+ const fontRow = (role, label, family, size) => {
440
+ const edit = (value) => {
248
441
  const key = role === 'text' ? 'textFontFamily' : role === 'code' ? 'codeFontFamily' : 'terminalFontFamily';
249
- setLocalTypography({ ...(localTypography ?? state.typography), [key]: event.target.value });
250
- actions.setFont(role, event.target.value);
251
- },
252
- }), h('div', { style: s.row2 }, h('input', {
253
- style: s.input,
254
- type: 'number',
255
- min: 8,
256
- max: 24,
257
- 'aria-label': `${label} size`,
258
- value: size,
259
- onChange: (event) => {
260
- const px = Number(event.target.value);
261
- const key = role === 'text' ? 'fontSize' : role === 'code' ? 'codeFontSize' : 'terminalFontSize';
262
- setLocalTypography({ ...(localTypography ?? state.typography), [key]: px });
263
- actions.setFontSize(role, px);
264
- },
265
- }), h('span', { style: s.hint }, 'px')));
442
+ setLocalTypography({ ...(localTypography ?? state.typography), [key]: value });
443
+ applyFont(role, value);
444
+ };
445
+ return h('div', { style: { ...s.row, ...s.fontRow } }, h('div', { style: s.rowLabel }, h('span', { style: { fontSize: 12, fontWeight: 600 } }, label)), fontCombo(`${label} font family`, family, edit), h('div', { style: s.row2 }, h('input', {
446
+ style: s.input,
447
+ type: 'number',
448
+ min: 8,
449
+ max: 24,
450
+ 'aria-label': `${label} size`,
451
+ value: size,
452
+ onChange: (event) => {
453
+ const px = Number(event.target.value);
454
+ const key = role === 'text' ? 'fontSize' : role === 'code' ? 'codeFontSize' : 'terminalFontSize';
455
+ setLocalTypography({ ...(localTypography ?? state.typography), [key]: px });
456
+ actions.setFontSize(role, px);
457
+ },
458
+ }), h('span', { style: s.hint }, 'px')));
459
+ };
266
460
  return h('div', { style: s.root }, state.writable
267
461
  ? null
268
462
  : h('div', { style: s.warning }, 'This profile does not accept settings writes; changes will not persist.'), h('section', { style: s.section }, h('h3', { style: s.heading }, 'Base theme'), h('p', { style: s.hint }, 'A preset supplies a complete token set. Tune individual values below it.'), h('div', { style: s.grid }, presetCards)), h('section', { style: s.section }, h('h3', { style: s.heading }, 'Live preview'), h('div', { style: s.preview }, h('span', { style: { fontSize: 15, fontWeight: 600 } }, 'The quick brown fox'), h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, 'Secondary text sits one step down.'), h('div', { style: s.row2 }, h('span', { style: { ...s.button, background: 'var(--dsw-alias-brand-primary)', color: 'var(--dsw-alias-bg-base)' } }, 'Accent'), h('span', { style: { ...s.button, borderColor: 'var(--dsw-alias-state-error-primary)' } }, 'Error'), h('span', { style: { ...s.button, borderColor: 'var(--dsw-alias-state-success-primary)' } }, 'Success'), h('span', { style: { ...s.button, borderColor: 'var(--dsw-alias-state-warn-primary)' } }, 'Warning')))), h('section', { style: s.section }, h('h3', { style: s.heading }, 'Typography'), fontRow('text', 'Interface text', (localTypography ?? state.typography).textFontFamily, (localTypography ?? state.typography).fontSize), fontRow('code', 'Code', (localTypography ?? state.typography).codeFontFamily, (localTypography ?? state.typography).codeFontSize), fontRow('terminal', 'Terminal', (localTypography ?? state.typography).terminalFontFamily, (localTypography ?? state.typography).terminalFontSize)), h('section', { style: s.section }, h('h3', { style: s.heading }, 'Geometry'), h('div', { style: s.row }, h('div', { style: s.rowLabel }, h('span', { style: { fontSize: 12, fontWeight: 600 } }, 'Corner radius')), h('input', {
package/dist/index.d.ts CHANGED
@@ -26,18 +26,11 @@ export declare const inject: readonly [];
26
26
  * naming a preset from a newer version must not stop the plugin mounting.
27
27
  */
28
28
  export declare const Config: ReturnType<typeof z.object>;
29
- /**
30
- * The resolved config value.
31
- *
32
- * Fields are mutable and optional where the schema supplies defaults, matching
33
- * what `z.object` produces; nothing in this package mutates them.
34
- */
35
29
  /**
36
30
  * The parsed shape of this plugin's config.
37
31
  *
38
- * Each field is olatile in the schema, so the Host hands pply a
39
- * Volatile reference for it rather than a plain value; {@link toDocument}
40
- * unwraps either form.
32
+ * Each field is volatile in the schema, so the Host hands `apply` a Volatile
33
+ * reference rather than a plain value; {@link toDocument} unwraps either form.
41
34
  */
42
35
  export interface PluginConfig {
43
36
  basePreset?: string;
@@ -71,10 +64,8 @@ export declare function toDocument(input: unknown): CustomizationDocument;
71
64
  /**
72
65
  * Host plugin body.
73
66
  *
74
- * The Host contributes no runtime behavior: the browser half owns the theme
75
- * override layer, and the settings service owns persistence. Returning nothing
76
- * keeps the mount a pure configuration declaration, so a profile can hold this
77
- * row without this process doing any work per session.
67
+ * The Host contributes nothing at runtime: the browser half owns the theme
68
+ * layer and fonts, and the settings service owns persistence.
78
69
  */
79
70
  export declare function apply(): void;
80
71
  export { PRESETS, TOKEN_SPECS, TOKEN_GROUPS, PRESET_IDS } from './spec.js';
package/dist/index.js CHANGED
@@ -132,10 +132,8 @@ export function toDocument(input) {
132
132
  /**
133
133
  * Host plugin body.
134
134
  *
135
- * The Host contributes no runtime behavior: the browser half owns the theme
136
- * override layer, and the settings service owns persistence. Returning nothing
137
- * keeps the mount a pure configuration declaration, so a profile can hold this
138
- * row without this process doing any work per session.
135
+ * The Host contributes nothing at runtime: the browser half owns the theme
136
+ * layer and fonts, and the settings service owns persistence.
139
137
  */
140
138
  export function apply() { }
141
139
  export { PRESETS, TOKEN_SPECS, TOKEN_GROUPS, PRESET_IDS } from './spec.js';
package/dist/resolve.d.ts CHANGED
@@ -100,6 +100,11 @@ export declare function correctFontSize(role: FontRole, px: number): number;
100
100
  * @returns true for a known density.
101
101
  */
102
102
  export declare function isDensity(value: string): value is Density;
103
+ /**
104
+ * @param stack - comma-separated font stack.
105
+ * @returns the concrete family names, in order, without generics.
106
+ */
107
+ export declare function familyNames(stack: string): string[];
103
108
  /**
104
109
  * Resolve a customization document into theme tokens and typography.
105
110
  *
package/dist/resolve.js CHANGED
@@ -17,6 +17,13 @@
17
17
  import { DENSITIES, FONT_SIZE_RANGES, RADIUS_MAX, RADIUS_MIN, TOKEN_NAMES, findPreset, tokenSpec, } from './spec.js';
18
18
  /** A colour in one of the forms the theme service and browsers accept. */
19
19
  const COLOR_PATTERN = /^(#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})|(?:rgb|hsl|oklch|lab|lch|color)\([^)]*\)|[a-z]+)$/iu;
20
+ /** CSS generic families, which always resolve and so never need a local match. */
21
+ const GENERIC_FAMILIES = new Set([
22
+ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui',
23
+ 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', 'emoji', 'math',
24
+ 'fangsong', '-apple-system', 'blinkmacsystemfont',
25
+ 'inherit', 'initial', 'unset',
26
+ ]);
20
27
  /**
21
28
  * Whether a value is a plausible CSS colour.
22
29
  *
@@ -85,6 +92,16 @@ export function correctFontSize(role, px) {
85
92
  export function isDensity(value) {
86
93
  return DENSITIES.includes(value);
87
94
  }
95
+ /**
96
+ * @param stack - comma-separated font stack.
97
+ * @returns the concrete family names, in order, without generics.
98
+ */
99
+ export function familyNames(stack) {
100
+ return stack
101
+ .split(',')
102
+ .map((part) => part.trim().replace(/^["']|["']$/gu, '').trim())
103
+ .filter((name) => name !== '' && !GENERIC_FAMILIES.has(name.toLowerCase()));
104
+ }
88
105
  /**
89
106
  * Resolve a customization document into theme tokens and typography.
90
107
  *
package/icon.svg CHANGED
@@ -1,15 +1,15 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Theme Studio">
2
- <defs>
3
- <linearGradient id="paint" x1="0" y1="0" x2="1" y2="1">
4
- <stop offset="0" stop-color="#8b5cf6"/>
5
- <stop offset="0.5" stop-color="#3f8cff"/>
6
- <stop offset="1" stop-color="#22c7a9"/>
7
- </linearGradient>
8
- </defs>
9
- <rect x="6" y="6" width="52" height="52" rx="12" fill="none" stroke="url(#paint)" stroke-width="2.6"/>
10
- <path d="M32 16a16 16 0 1 0 0 32a16 16 0 1 0 0-32" fill="none" stroke="url(#paint)" stroke-width="2.2" opacity="0.55"/>
11
- <circle cx="32" cy="32" r="6.5" fill="url(#paint)"/>
12
- <circle cx="32" cy="14" r="3.6" fill="#8b5cf6"/>
13
- <circle cx="48" cy="40" r="3.6" fill="#3f8cff"/>
14
- <circle cx="16" cy="40" r="3.6" fill="#22c7a9"/>
15
- </svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Theme Studio">
2
+ <defs>
3
+ <linearGradient id="paint" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#8b5cf6"/>
5
+ <stop offset="0.5" stop-color="#3f8cff"/>
6
+ <stop offset="1" stop-color="#22c7a9"/>
7
+ </linearGradient>
8
+ </defs>
9
+ <rect x="6" y="6" width="52" height="52" rx="12" fill="none" stroke="url(#paint)" stroke-width="2.6"/>
10
+ <path d="M32 16a16 16 0 1 0 0 32a16 16 0 1 0 0-32" fill="none" stroke="url(#paint)" stroke-width="2.2" opacity="0.55"/>
11
+ <circle cx="32" cy="32" r="6.5" fill="url(#paint)"/>
12
+ <circle cx="32" cy="14" r="3.6" fill="#8b5cf6"/>
13
+ <circle cx="48" cy="40" r="3.6" fill="#3f8cff"/>
14
+ <circle cx="16" cy="40" r="3.6" fill="#22c7a9"/>
15
+ </svg>
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
- {
2
- "meta": {
3
- "title": "Theme Studio",
4
- "description": "Customize the Web UI's colours, fonts, spacing and corner radius, with presets in the style of popular editor themes and a live editor for individual colour tokens."
5
- }
6
- }
1
+ {
2
+ "meta": {
3
+ "title": "Theme Studio",
4
+ "description": "Customize the Web UI's colours, fonts, spacing and corner radius, with presets in the style of popular editor themes and a live editor for individual colour tokens."
5
+ }
6
+ }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
- {
2
- "meta": {
3
- "title": "主题工作室",
4
- "description": "自定义 Web 界面的配色、字体、间距与圆角,内置多款流行编辑器风格预设,并可逐项微调颜色令牌。"
5
- }
6
- }
1
+ {
2
+ "meta": {
3
+ "title": "主题工作室",
4
+ "description": "自定义 Web 界面的配色、字体、间距与圆角,内置多款流行编辑器风格预设,并可逐项微调颜色令牌。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d0nj/dsh-ui-custom",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Theme, colour-token, typography and geometry customisation for the DeepSeek Harness Web UI, with presets and a live editor.",
5
5
  "license": "MIT",
6
6
  "repository": {