@nysds/playground 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +25 -0
  2. package/README.md +364 -0
  3. package/bin/cli.mjs +223 -0
  4. package/bin/cli.test.mjs +36 -0
  5. package/decks/customizing-components.json +97 -0
  6. package/dist/assets/index-LO1tomgR.css +6 -0
  7. package/dist/assets/index-bBtGCGL_.js +5190 -0
  8. package/dist/assets/internal/typescript.js +193739 -0
  9. package/dist/assets/nys-icon.library-Bi_7DKlD-YSs5zqZy-DXISxj9N.js +609 -0
  10. package/dist/assets/nys-icon.library-CwuPZJAc-TryaOS7Z.js +600 -0
  11. package/dist/assets/playground-typescript-worker-BcTrPYfY.js +87 -0
  12. package/dist/assets/playground-typescript-worker.js +87 -0
  13. package/dist/favicon.svg +10 -0
  14. package/dist/index.html +769 -0
  15. package/dist/nysds-logo.svg +21 -0
  16. package/dist/nysds-symbol.svg +7 -0
  17. package/index.html +768 -0
  18. package/package.json +59 -0
  19. package/presets/00-welcome.json +8 -0
  20. package/presets/01-button.json +7 -0
  21. package/presets/02-alert.json +7 -0
  22. package/presets/03-badge-and-avatar.json +7 -0
  23. package/presets/04-text-input.json +7 -0
  24. package/presets/05-select-radio-checkbox.json +7 -0
  25. package/presets/06-form-validation.json +7 -0
  26. package/presets/07-card.json +7 -0
  27. package/presets/08-accordion.json +7 -0
  28. package/presets/09-tabs.json +7 -0
  29. package/presets/10-modal.json +7 -0
  30. package/presets/11-stepper.json +7 -0
  31. package/presets/12-table-and-pagination.json +7 -0
  32. package/presets/13-tooltip-and-dropdown.json +7 -0
  33. package/presets/14-navigation.json +7 -0
  34. package/presets/15-page-structure.json +7 -0
  35. package/presets/16-themes.json +7 -0
  36. package/presets/17-utility-classes.json +7 -0
  37. package/presets/README.md +118 -0
  38. package/public/favicon.svg +10 -0
  39. package/public/nysds-logo.svg +21 -0
  40. package/public/nysds-symbol.svg +7 -0
  41. package/src/app.css +1087 -0
  42. package/src/debounce.ts +86 -0
  43. package/src/deck-model.ts +299 -0
  44. package/src/deck-store.ts +144 -0
  45. package/src/decks.test.ts +288 -0
  46. package/src/editor-panes.ts +190 -0
  47. package/src/editors.ts +92 -0
  48. package/src/home.ts +225 -0
  49. package/src/icon-names.ts +117 -0
  50. package/src/icons.test.ts +58 -0
  51. package/src/keys.ts +162 -0
  52. package/src/main.ts +1456 -0
  53. package/src/playground.config.ts +74 -0
  54. package/src/playground.ts +261 -0
  55. package/src/present.ts +398 -0
  56. package/src/preset-schema.ts +228 -0
  57. package/src/route.test.ts +56 -0
  58. package/src/routing.ts +60 -0
  59. package/src/settings.ts +211 -0
  60. package/src/starters.ts +86 -0
  61. package/src/state.test.ts +544 -0
  62. package/src/state.ts +237 -0
  63. package/src/theme.ts +82 -0
  64. package/src/version-catalog.ts +42 -0
  65. package/src/versions.ts +88 -0
  66. package/src/wrapper.ts +84 -0
  67. package/tsconfig.json +25 -0
  68. package/vite.config.ts +62 -0
@@ -0,0 +1,544 @@
1
+ /**
2
+ * Sanity checks for the pure helpers. Run them with `npm test`.
3
+ *
4
+ * These modules import nothing from the DOM, so Node can load them directly
5
+ * with its built-in TypeScript type stripping.
6
+ */
7
+ import assert from 'node:assert/strict';
8
+ import test from 'node:test';
9
+
10
+ import {deckUrl, decodeState, encodeState, parseHash, presentUrl, slugify} from './state.ts';
11
+ import {hintText, isTypingContext, routeKey} from './keys.ts';
12
+ import {parseDeck, parsePreset} from './preset-schema.ts';
13
+ import {collapsedForPreset, layoutUrl, readLayoutParam} from './editors.ts';
14
+ import {createQuietDebounce} from './debounce.ts';
15
+ import {
16
+ DEFAULTS,
17
+ FONT_SIZES,
18
+ UPDATE_DELAYS,
19
+ clampRatio,
20
+ readFontParam,
21
+ readUpdateParam,
22
+ settingUrl,
23
+ } from './settings.ts';
24
+ import {isBuildShortcut} from './keys.ts';
25
+ import {chooseVersion} from './version-catalog.ts';
26
+ import {otherTheme, readThemeParam, themeUrl} from './theme.ts';
27
+ import {resolveHeadUrls, wrapUserHtml} from './wrapper.ts';
28
+
29
+ /** Collects validation messages instead of writing them to the console. */
30
+ function collector(): {messages: string[]; report: (message: string) => void} {
31
+ const messages: string[] = [];
32
+ return {messages, report: (message) => messages.push(message)};
33
+ }
34
+
35
+ const WRAPPER_OPTIONS = {
36
+ stylesHref: 'https://cdn.example.com/styles.css',
37
+ componentsSrc: 'https://cdn.example.com/components.js',
38
+ title: 'Preview',
39
+ };
40
+
41
+ test('wrapUserHtml puts the snippet in a whole document', () => {
42
+ const userHtml = '<nys-button label="Save"></nys-button>';
43
+ const wrapped = wrapUserHtml(userHtml, WRAPPER_OPTIONS);
44
+ assert.ok(wrapped.startsWith('<!doctype html>'));
45
+ assert.ok(wrapped.includes(userHtml));
46
+ assert.ok(wrapped.includes(WRAPPER_OPTIONS.stylesHref));
47
+ assert.ok(wrapped.includes(WRAPPER_OPTIONS.componentsSrc));
48
+ assert.ok(wrapped.includes('./styles.css'));
49
+ assert.ok(wrapped.includes('./script.js'));
50
+ });
51
+
52
+ test('wrapUserHtml leaves no editor pragmas behind', () => {
53
+ // The wrapper used to live inside the HTML pane behind `playground-hide`
54
+ // comments, which meant select-all copied it. It is its own hidden file now.
55
+ const wrapped = wrapUserHtml('<p>Hi</p>', WRAPPER_OPTIONS);
56
+ assert.ok(!wrapped.includes('playground-hide'));
57
+ assert.ok(!wrapped.includes('nysds-playground:user-html'));
58
+ });
59
+
60
+ test('wrapUserHtml keeps multi-line markup and comments intact', () => {
61
+ const userHtml = '<!-- a note -->\n<div>\n <p>Line</p>\n</div>';
62
+ assert.ok(wrapUserHtml(userHtml, WRAPPER_OPTIONS).includes(userHtml));
63
+ });
64
+
65
+ test('wrapUserHtml adds extra head markup when configured', () => {
66
+ const wrapped = wrapUserHtml('<p>Hi</p>', {
67
+ ...WRAPPER_OPTIONS,
68
+ extraHeadHtml: '<link rel="stylesheet" href="https://fonts.example.com/f.css" />',
69
+ });
70
+ assert.ok(wrapped.includes('https://fonts.example.com/f.css'));
71
+ });
72
+
73
+ test('resolveHeadUrls makes relative href and src values absolute', () => {
74
+ const html =
75
+ '<link rel="stylesheet" href="./fonts/nysds-fonts.css">' +
76
+ "<script src='../x.js'></script>" +
77
+ '<link href="https://cdn.example.com/a.css">';
78
+ const resolved = resolveHeadUrls(html, 'https://example.org/playground/index.html');
79
+ assert.ok(resolved.includes('href="https://example.org/playground/fonts/nysds-fonts.css"'));
80
+ assert.ok(resolved.includes("src='https://example.org/x.js'"));
81
+ assert.ok(resolved.includes('href="https://cdn.example.com/a.css"'));
82
+ });
83
+
84
+ test('encodeState and decodeState round-trip', () => {
85
+ const state = {
86
+ version: '1.21.0',
87
+ html: '<nys-alert type="info" heading="Hello"></nys-alert>',
88
+ css: 'body {\n padding: 1rem;\n}',
89
+ js: "console.log('hi');",
90
+ };
91
+ const decoded = decodeState(encodeState(state));
92
+ assert.deepEqual(decoded, state);
93
+ });
94
+
95
+ test('decodeState rejects unusable values', () => {
96
+ assert.equal(decodeState('not-valid-lz'), null);
97
+ assert.equal(decodeState(''), null);
98
+ });
99
+
100
+ test('parseHash recognises both hash forms', () => {
101
+ assert.deepEqual(parseHash('#preset=button'), {kind: 'preset', id: 'button'});
102
+ assert.deepEqual(parseHash(''), {kind: 'default'});
103
+ assert.deepEqual(parseHash('#code=garbage!!'), {kind: 'default'});
104
+
105
+ const state = {version: 'latest', html: '<p>x</p>', css: '', js: ''};
106
+ const parsed = parseHash(`#code=${encodeState(state)}`);
107
+ assert.equal(parsed.kind, 'code');
108
+ assert.deepEqual(parsed.kind === 'code' ? parsed.state : null, state);
109
+ });
110
+
111
+ test('presentUrl toggles the query parameter and keeps the hash', () => {
112
+ const base = 'https://example.com/app/#preset=button';
113
+ const on = presentUrl(true, base);
114
+ assert.ok(on.includes('present=1'));
115
+ assert.ok(on.endsWith('#preset=button'));
116
+ assert.equal(presentUrl(false, on), base);
117
+ });
118
+
119
+ test('slugify produces filename-safe ids', () => {
120
+ assert.equal(slugify('Button & Links!'), 'button-links');
121
+ assert.equal(slugify(' '), 'playground');
122
+ });
123
+
124
+ test('wrapUserHtml injects the deck base CSS into the head', () => {
125
+ const wrapped = wrapUserHtml('<p>Hi</p>', {
126
+ ...WRAPPER_OPTIONS,
127
+ baseCss: 'body { padding: 2rem }',
128
+ });
129
+ assert.ok(wrapped.includes('<style>body { padding: 2rem }</style>'));
130
+ assert.ok(wrapped.includes('<p>Hi</p>'));
131
+ });
132
+
133
+ test('deckUrl keeps the library deck out of the query', () => {
134
+ const base = 'https://example.com/app/?present=1#preset=old';
135
+ assert.equal(
136
+ deckUrl('library', 'welcome', 'library', base),
137
+ 'https://example.com/app/?present=1#preset=welcome',
138
+ );
139
+ assert.equal(
140
+ deckUrl('styling-levels', '01-header-drop-in', 'library', base),
141
+ 'https://example.com/app/?present=1&deck=styling-levels#preset=01-header-drop-in',
142
+ );
143
+ });
144
+
145
+ /* Preset and deck validation ------------------------------------------- */
146
+
147
+ test('parsePreset fills in defaults and keeps the new optional fields', () => {
148
+ const {messages, report} = collector();
149
+ const preset = parsePreset(
150
+ '../presets/01-button.json',
151
+ {title: 'Button', html: '<nys-button></nys-button>', group: 'Basics', notes: 'Say this'},
152
+ 'button',
153
+ report,
154
+ );
155
+ assert.deepEqual(preset, {
156
+ id: 'button',
157
+ title: 'Button',
158
+ description: '',
159
+ group: 'Basics',
160
+ notes: 'Say this',
161
+ html: '<nys-button></nys-button>',
162
+ css: '',
163
+ js: '',
164
+ version: 'latest',
165
+ editors: null,
166
+ });
167
+ assert.deepEqual(messages, []);
168
+ });
169
+
170
+ test('parsePreset names the bad file and field', () => {
171
+ const {messages, report} = collector();
172
+ const preset = parsePreset('../presets/02-alert.json', {title: 42, html: 'x'}, 'alert', report);
173
+ assert.equal(preset?.title, 'alert');
174
+ assert.equal(messages.length, 1);
175
+ assert.match(messages[0] ?? '', /02-alert\.json/);
176
+ assert.match(messages[0] ?? '', /"title"/);
177
+ });
178
+
179
+ test('parsePreset requires an id when the filename cannot supply one', () => {
180
+ const {messages, report} = collector();
181
+ assert.equal(parsePreset('../decks/d.json#presets[0]', {title: 'x'}, null, report), null);
182
+ assert.match(messages.join('\n'), /"id"/);
183
+ });
184
+
185
+ test('parseDeck reads the base CSS and the slides', () => {
186
+ const {messages, report} = collector();
187
+ const deck = parseDeck(
188
+ '../decks/styling-levels.json',
189
+ {
190
+ title: 'Three levels of strictness',
191
+ boilerplate: {baseCss: 'body { padding: 2rem }'},
192
+ presets: [{id: '01-a', group: 'One', title: 'A', html: '<p>a</p>'}],
193
+ },
194
+ report,
195
+ );
196
+ assert.equal(deck?.id, 'styling-levels');
197
+ assert.equal(deck?.title, 'Three levels of strictness');
198
+ assert.equal(deck?.baseCss, 'body { padding: 2rem }');
199
+ assert.equal(deck?.presets.length, 1);
200
+ assert.equal(deck?.presets[0]?.group, 'One');
201
+ assert.deepEqual(messages, []);
202
+ });
203
+
204
+ test('parseDeck warns about boilerplate.head and ignores it', () => {
205
+ const errors = collector();
206
+ const warnings = collector();
207
+ const deck = parseDeck(
208
+ '../decks/d.json',
209
+ {
210
+ title: 'D',
211
+ boilerplate: {head: '<link>', baseCss: 'p{}'},
212
+ presets: [{id: 'a', title: 'A', html: 'x'}],
213
+ },
214
+ errors.report,
215
+ warnings.report,
216
+ );
217
+ assert.equal(deck?.baseCss, 'p{}');
218
+ assert.deepEqual(errors.messages, []);
219
+ assert.equal(warnings.messages.length, 1);
220
+ assert.match(warnings.messages[0] ?? '', /boilerplate\.head/);
221
+ assert.match(warnings.messages[0] ?? '', /version menu/);
222
+ });
223
+
224
+ test('parseDeck rejects a deck with no slides', () => {
225
+ const {messages, report} = collector();
226
+ assert.equal(parseDeck('../decks/d.json', {title: 'D', presets: []}, report), null);
227
+ assert.match(messages.join('\n'), /presets/);
228
+ });
229
+
230
+ /* Version resolution ---------------------------------------------------- */
231
+
232
+ const CATALOG = {
233
+ all: ['2.0.0-next.1', '1.21.0', '1.20.1', '1.19.4'],
234
+ stable: ['1.21.0', '1.20.1', '1.19.4'],
235
+ latest: '1.21.0',
236
+ usedFallback: false,
237
+ };
238
+
239
+ test('chooseVersion resolves latest to the newest stable version', () => {
240
+ assert.equal(chooseVersion('latest', CATALOG), '1.21.0');
241
+ assert.equal(chooseVersion('', CATALOG), '1.21.0');
242
+ });
243
+
244
+ test('chooseVersion keeps a version the CDN lists', () => {
245
+ assert.equal(chooseVersion('1.19.4', CATALOG), '1.19.4');
246
+ assert.equal(chooseVersion('2.0.0-next.1', CATALOG), '2.0.0-next.1');
247
+ });
248
+
249
+ test('chooseVersion falls back to the newest stable for an unknown version', () => {
250
+ assert.equal(chooseVersion('9.9.9', CATALOG), '1.21.0');
251
+ });
252
+
253
+ test('chooseVersion trusts the requested version when the catalog is the fallback list', () => {
254
+ const offline = {...CATALOG, usedFallback: true};
255
+ assert.equal(chooseVersion('1.5.0', offline), '1.5.0');
256
+ assert.equal(chooseVersion('latest', offline), '1.21.0');
257
+ });
258
+
259
+ /* Presentation key routing ---------------------------------------------- */
260
+
261
+ const KEY = {altKey: false, ctrlKey: false, metaKey: false};
262
+
263
+ test('isTypingContext spots the editors and form controls', () => {
264
+ assert.equal(isTypingContext(['div', 'playground-code-editor', 'body']), true);
265
+ assert.equal(isTypingContext(['playground-file-editor']), true);
266
+ assert.equal(isTypingContext(['input']), true);
267
+ assert.equal(isTypingContext(['select', 'nys-select']), true);
268
+ assert.equal(isTypingContext(['textarea']), true);
269
+ assert.equal(isTypingContext(['nys-button', 'footer', 'body']), false);
270
+ });
271
+
272
+ test('routeKey steps the deck when the presenter is not typing', () => {
273
+ assert.equal(routeKey({...KEY, key: 'ArrowRight'}, false), 'next');
274
+ assert.equal(routeKey({...KEY, key: 'PageDown'}, false), 'next');
275
+ assert.equal(routeKey({...KEY, key: ' '}, false), 'next');
276
+ assert.equal(routeKey({...KEY, key: 'ArrowLeft'}, false), 'prev');
277
+ assert.equal(routeKey({...KEY, key: 'Home'}, false), 'first');
278
+ assert.equal(routeKey({...KEY, key: 'End'}, false), 'last');
279
+ assert.equal(routeKey({...KEY, key: 'c'}, false), 'toggle-code');
280
+ assert.equal(routeKey({...KEY, key: 'n'}, false), 'toggle-notes');
281
+ assert.equal(routeKey({...KEY, key: 'Escape'}, false), 'exit');
282
+ });
283
+
284
+ test('routeKey leaves every plain key to the editor while typing', () => {
285
+ for (const key of ['ArrowRight', 'ArrowLeft', ' ', 'Home', 'End', 'c', 'n', 'Escape']) {
286
+ assert.equal(routeKey({...KEY, key}, true), null, key);
287
+ }
288
+ });
289
+
290
+ test('routeKey steps on Alt plus an arrow even while typing', () => {
291
+ assert.equal(routeKey({...KEY, altKey: true, key: 'ArrowRight'}, true), 'next');
292
+ assert.equal(routeKey({...KEY, altKey: true, key: 'ArrowDown'}, true), 'next');
293
+ assert.equal(routeKey({...KEY, altKey: true, key: 'ArrowLeft'}, true), 'prev');
294
+ assert.equal(routeKey({...KEY, altKey: true, key: 'ArrowUp'}, true), 'prev');
295
+ assert.equal(routeKey({...KEY, altKey: true, key: 'c'}, true), null);
296
+ });
297
+
298
+ test('routeKey ignores browser and system shortcuts', () => {
299
+ assert.equal(routeKey({...KEY, ctrlKey: true, key: 'ArrowRight'}, false), null);
300
+ assert.equal(routeKey({...KEY, metaKey: true, key: 'ArrowRight'}, false), null);
301
+ });
302
+
303
+ test('routeKey toggles the editor theme with t when not typing', () => {
304
+ assert.equal(routeKey({...KEY, key: 't'}, false), 'toggle-theme');
305
+ assert.equal(routeKey({...KEY, key: 'T'}, false), 'toggle-theme');
306
+ assert.equal(routeKey({...KEY, key: 't'}, true), null);
307
+ });
308
+
309
+ test('readThemeParam accepts only light and dark', () => {
310
+ assert.equal(readThemeParam('?theme=dark'), 'dark');
311
+ assert.equal(readThemeParam('?present=1&theme=light'), 'light');
312
+ assert.equal(readThemeParam('?theme=blue'), null);
313
+ assert.equal(readThemeParam(''), null);
314
+ });
315
+
316
+ test('themeUrl sets the theme and keeps the rest of the URL', () => {
317
+ const url = themeUrl('dark', 'http://localhost/?deck=a&present=1#preset=x');
318
+ assert.equal(url, 'http://localhost/?deck=a&present=1&theme=dark#preset=x');
319
+ assert.equal(otherTheme('dark'), 'light');
320
+ });
321
+
322
+ /* Editor layout and columns --------------------------------------------- */
323
+
324
+ test('parsePreset reads the editors field and drops bad entries', () => {
325
+ const {messages, report} = collector();
326
+ const preset = parsePreset(
327
+ '../presets/01-button.json',
328
+ {title: 'Button', html: 'x', editors: ['html', 'css', 'html', 'scss', 7]},
329
+ 'button',
330
+ report,
331
+ );
332
+ assert.deepEqual(preset?.editors, ['html', 'css']);
333
+ assert.equal(messages.length, 2);
334
+ assert.match(messages.join('\n'), /"scss"/);
335
+ assert.match(messages.join('\n'), /01-button\.json/);
336
+ });
337
+
338
+ test('parsePreset treats a missing editors field as "leave the columns alone"', () => {
339
+ const preset = parsePreset('../presets/a.json', {title: 'A', html: 'x'}, 'a', () => {});
340
+ assert.equal(preset?.editors, null);
341
+ });
342
+
343
+ test('parsePreset accepts an empty editors array', () => {
344
+ const {messages, report} = collector();
345
+ const preset = parsePreset('../presets/a.json', {title: 'A', html: 'x', editors: []}, 'a', report);
346
+ assert.deepEqual(preset?.editors, []);
347
+ assert.deepEqual(messages, []);
348
+ });
349
+
350
+ test('parsePreset reports an editors field that is not an array', () => {
351
+ const {messages, report} = collector();
352
+ const preset = parsePreset(
353
+ '../presets/a.json',
354
+ {title: 'A', html: 'x', editors: 'html'},
355
+ 'a',
356
+ report,
357
+ );
358
+ assert.equal(preset?.editors, null);
359
+ assert.match(messages.join('\n'), /"editors"/);
360
+ });
361
+
362
+ test('collapsedForPreset collapses whatever the slide leaves out', () => {
363
+ assert.deepEqual([...(collapsedForPreset(['html', 'css']) ?? [])], ['js']);
364
+ assert.deepEqual([...(collapsedForPreset([]) ?? [])], ['html', 'css', 'js']);
365
+ assert.deepEqual([...(collapsedForPreset(['html', 'css', 'js']) ?? [])], []);
366
+ assert.equal(collapsedForPreset(null), null);
367
+ });
368
+
369
+ test('readLayoutParam and layoutUrl handle the editors query parameter', () => {
370
+ assert.equal(readLayoutParam('?editors=columns'), 'columns');
371
+ assert.equal(readLayoutParam('?editors=tabs'), 'tabs');
372
+ assert.equal(readLayoutParam('?editors=grid'), null);
373
+ assert.equal(readLayoutParam(''), null);
374
+ assert.equal(
375
+ layoutUrl('columns', 'https://example.com/?deck=d&theme=dark#preset=p'),
376
+ 'https://example.com/?deck=d&theme=dark&editors=columns#preset=p',
377
+ );
378
+ });
379
+
380
+ test('routeKey toggles the layout with e from anywhere but the editor', () => {
381
+ assert.equal(routeKey({...KEY, key: 'e'}, false), 'toggle-layout');
382
+ assert.equal(routeKey({...KEY, key: 'E'}, false), 'toggle-layout');
383
+ assert.equal(routeKey({...KEY, key: 'e'}, true), null);
384
+ });
385
+
386
+ test('routeKey maps 1, 2, and 3 to columns only in the columns layout', () => {
387
+ assert.equal(routeKey({...KEY, key: '1'}, false, true), 'toggle-pane:html');
388
+ assert.equal(routeKey({...KEY, key: '2'}, false, true), 'toggle-pane:css');
389
+ assert.equal(routeKey({...KEY, key: '3'}, false, true), 'toggle-pane:js');
390
+ assert.equal(routeKey({...KEY, key: '4'}, false, true), null);
391
+ assert.equal(routeKey({...KEY, key: '1'}, false, false), null);
392
+ assert.equal(routeKey({...KEY, key: '1'}, true, true), null);
393
+ });
394
+
395
+ test('hintText names the column shortcuts only in the columns layout', () => {
396
+ const tabs = hintText(true, false);
397
+ assert.ok(tabs.includes('e layout'));
398
+ assert.ok(!tabs.includes('1 2 3 panes'));
399
+ const columns = hintText(false, true);
400
+ assert.ok(columns.includes('1 2 3 panes'));
401
+ assert.ok(!columns.includes('n notes'));
402
+ });
403
+
404
+ /* Settings --------------------------------------------------------------- */
405
+
406
+ test('readFontParam and readUpdateParam accept only known values', () => {
407
+ assert.equal(readFontParam('?font=small'), 'small');
408
+ assert.equal(readFontParam('?font=medium'), 'medium');
409
+ assert.equal(readFontParam('?font=large'), 'large');
410
+ assert.equal(readFontParam('?font=huge'), null);
411
+ assert.equal(readFontParam(''), null);
412
+
413
+ assert.equal(readUpdateParam('?update=typing'), 'typing');
414
+ assert.equal(readUpdateParam('?update=pause'), 'pause');
415
+ assert.equal(readUpdateParam('?update=manual'), 'manual');
416
+ assert.equal(readUpdateParam('?update=never'), null);
417
+ assert.equal(readUpdateParam('?theme=dark'), null);
418
+ });
419
+
420
+ test('the editor layout defaults to the side-by-side columns', () => {
421
+ assert.equal(DEFAULTS.layout, 'columns');
422
+ // A stored choice still wins over the default.
423
+ assert.equal(readLayoutParam('?editors=tabs'), 'tabs');
424
+ });
425
+
426
+ test('the editor theme defaults to dark', () => {
427
+ assert.equal(DEFAULTS.theme, 'dark');
428
+ assert.equal(readThemeParam('?theme=light'), 'light');
429
+ });
430
+
431
+ test('the font sizes and update delays are the documented ones', () => {
432
+ assert.deepEqual(FONT_SIZES, {small: '13px', medium: '15px', large: '18px'});
433
+ assert.deepEqual(UPDATE_DELAYS, {typing: 800, pause: 2000, manual: null});
434
+ });
435
+
436
+ test('clampRatio keeps a pane inside its range', () => {
437
+ assert.equal(clampRatio(50, 15, 85), 50);
438
+ assert.equal(clampRatio(2, 15, 85), 15);
439
+ assert.equal(clampRatio(99, 15, 85), 85);
440
+ assert.equal(clampRatio(Number.NaN, 15, 85), 15);
441
+ });
442
+
443
+ test('settingUrl sets one parameter and keeps the rest of the URL', () => {
444
+ assert.equal(
445
+ settingUrl('font', 'large', 'https://example.com/?deck=d&theme=dark#preset=p'),
446
+ 'https://example.com/?deck=d&theme=dark&font=large#preset=p',
447
+ );
448
+ });
449
+
450
+ test('isBuildShortcut matches Cmd/Ctrl plus Enter or S only', () => {
451
+ assert.equal(isBuildShortcut({key: 'Enter', altKey: false, ctrlKey: true, metaKey: false}), true);
452
+ assert.equal(isBuildShortcut({key: 'Enter', altKey: false, ctrlKey: false, metaKey: true}), true);
453
+ assert.equal(isBuildShortcut({key: 's', altKey: false, ctrlKey: false, metaKey: true}), true);
454
+ assert.equal(isBuildShortcut({key: 'S', altKey: false, ctrlKey: true, metaKey: false}), true);
455
+ assert.equal(isBuildShortcut({key: 'Enter', altKey: false, ctrlKey: false, metaKey: false}), false);
456
+ assert.equal(isBuildShortcut({key: 'a', altKey: false, ctrlKey: true, metaKey: false}), false);
457
+ });
458
+
459
+ /* Preview rebuild debounce ----------------------------------------------- */
460
+
461
+ /** A clock whose time only moves when a test says so. */
462
+ function fakeClock() {
463
+ let now = 0;
464
+ let nextHandle = 1;
465
+ const timers = new Map<number, {at: number; handler: () => void}>();
466
+ return {
467
+ clock: {
468
+ setTimeout(handler: () => void, delay: number): number {
469
+ const handle = nextHandle++;
470
+ timers.set(handle, {at: now + delay, handler});
471
+ return handle;
472
+ },
473
+ clearTimeout(handle: number): void {
474
+ timers.delete(handle);
475
+ },
476
+ },
477
+ advance(ms: number): void {
478
+ now += ms;
479
+ for (const [handle, timer] of [...timers]) {
480
+ if (timer.at <= now) {
481
+ timers.delete(handle);
482
+ timer.handler();
483
+ }
484
+ }
485
+ },
486
+ };
487
+ }
488
+
489
+ test('the debounce waits for a pause instead of firing on every edit', () => {
490
+ const {clock, advance} = fakeClock();
491
+ let builds = 0;
492
+ const build = createQuietDebounce(() => builds++, 800, clock);
493
+
494
+ build.schedule();
495
+ advance(400);
496
+ build.schedule();
497
+ advance(400);
498
+ assert.equal(builds, 0, 'a steady stream of edits must not rebuild');
499
+ assert.equal(build.pending, true);
500
+
501
+ advance(800);
502
+ assert.equal(builds, 1, 'the pause rebuilds once');
503
+ assert.equal(build.pending, false);
504
+ });
505
+
506
+ test('the debounce never fires on its own when the delay is null', () => {
507
+ const {clock, advance} = fakeClock();
508
+ let builds = 0;
509
+ const build = createQuietDebounce(() => builds++, null, clock);
510
+
511
+ build.schedule();
512
+ advance(10000);
513
+ assert.equal(builds, 0);
514
+ assert.equal(build.pending, true);
515
+
516
+ build.flush();
517
+ assert.equal(builds, 1);
518
+ assert.equal(build.pending, false);
519
+ });
520
+
521
+ test('changing the delay reschedules a waiting build', () => {
522
+ const {clock, advance} = fakeClock();
523
+ let builds = 0;
524
+ const build = createQuietDebounce(() => builds++, 800, clock);
525
+
526
+ build.schedule();
527
+ advance(500);
528
+ build.setDelay(2000);
529
+ advance(1000);
530
+ assert.equal(builds, 0, 'the longer pause has not elapsed yet');
531
+ advance(1100);
532
+ assert.equal(builds, 1);
533
+ });
534
+
535
+ test('cancel drops a waiting build without running it', () => {
536
+ const {clock, advance} = fakeClock();
537
+ let builds = 0;
538
+ const build = createQuietDebounce(() => builds++, 800, clock);
539
+ build.schedule();
540
+ build.cancel();
541
+ advance(5000);
542
+ assert.equal(builds, 0);
543
+ assert.equal(build.pending, false);
544
+ });