@d0nj/dsh-ui-custom 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/cordis.patch.yml +22 -22
- package/dist/client/client.js +339 -26
- package/dist/client/hosted-fonts.d.ts +42 -0
- package/dist/client/hosted-fonts.js +98 -0
- package/dist/client/panel.d.ts +2 -0
- package/dist/client/panel.js +218 -24
- package/dist/index.d.ts +4 -13
- package/dist/index.js +2 -4
- package/dist/resolve.d.ts +5 -0
- package/dist/resolve.js +17 -0
- package/icon.svg +15 -15
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -80,4 +80,4 @@ Catalog invariants, preset completeness across every token and both modes, resol
|
|
|
80
80
|
|
|
81
81
|
## Deps
|
|
82
82
|
|
|
83
|
-
Peers: `@deepseek-ai/cordis ~4.0
|
|
83
|
+
Peers: `@deepseek-ai/cordis ~4.1.0` and `@deepseek-ai/schemastery ~3.19.0`. The browser services (`dsh-client-ui-theme`, `-ui-settings`, `-ui-slots`) are supplied by the Host's runtime module table and are not npm dependencies. React arrives from the loader.
|
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
|
package/dist/client/client.js
CHANGED
|
@@ -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) =>
|
|
1252
|
-
|
|
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]:
|
|
1260
|
-
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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
|
package/dist/client/panel.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/client/panel.js
CHANGED
|
@@ -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) =>
|
|
242
|
-
|
|
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]:
|
|
250
|
-
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
39
|
-
*
|
|
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
|
|
75
|
-
*
|
|
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
|
|
136
|
-
*
|
|
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
|
|
3
|
+
"version": "0.2.1",
|
|
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": {
|
|
@@ -54,12 +54,12 @@
|
|
|
54
54
|
}
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
|
-
"@deepseek-ai/cordis": "~4.0
|
|
58
|
-
"@deepseek-ai/schemastery": "~3.
|
|
57
|
+
"@deepseek-ai/cordis": "~4.1.0",
|
|
58
|
+
"@deepseek-ai/schemastery": "~3.19.0"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
|
-
"@deepseek-ai/cordis": "~4.0
|
|
62
|
-
"@deepseek-ai/schemastery": "~3.
|
|
61
|
+
"@deepseek-ai/cordis": "~4.1.0",
|
|
62
|
+
"@deepseek-ai/schemastery": "~3.19.0",
|
|
63
63
|
"@types/node": "^24.0.0",
|
|
64
64
|
"typescript": "^5.9.0"
|
|
65
65
|
},
|