@pure-ds/storybook 0.1.3 → 0.1.5

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.
@@ -0,0 +1,1058 @@
1
+ import { LitElement, html, nothing } from "../../../src/js/lit.js";
2
+ //import { config } from "../config";
3
+ import { Generator } from "../../../src/js/pds-core/pds-generator.js";
4
+ import { presets } from "../../../src/js/pds-core/pds-config.js";
5
+ import { PDS, validateDesign } from "../../../src/js/pds.js";
6
+ import { deepMerge } from "../../../src/js/common/common.js";
7
+ import { loadTypographyFonts } from "../../../src/js/common/font-loader.js";
8
+ import { AutoComplete } from "pure-web/ac";
9
+ import { figmafyTokens } from "./figma-export.js";
10
+ const STORAGE_KEY = "pure-ds-config";
11
+
12
+
13
+
14
+ async function toast(message, options = {}) {
15
+ let toaster = document.querySelector("#global-toaster");
16
+ if (!toaster) {
17
+ toaster = document.createElement("pds-toaster");
18
+ toaster.id = "global-toaster";
19
+ document.body.appendChild(toaster);
20
+ await customElements.whenDefined("pds-toaster");
21
+ }
22
+
23
+ return toaster.toast(message, options);
24
+ }
25
+
26
+ customElements.define(
27
+ "pds-config-form",
28
+ class extends LitElement {
29
+ #tmr;
30
+ #lastDesignEmit = 0;
31
+ #scheduledDesignEmit = null;
32
+ #scheduledApply = null;
33
+ #designEmitDelay = 500; // ms throttle
34
+
35
+ static properties = {
36
+ config: { type: Object, state: true },
37
+ schema: { type: Object, state: true },
38
+ mode: { type: String },
39
+ inspectorMode: { type: Boolean, state: true },
40
+ formValues: { type: Object, state: true }, // Filtered values for the form
41
+ validationIssues: { type: Array, state: true }, // Accessibility/design issues from PDS.validateDesign
42
+ formKey: { type: Number, state: true }, // Force form re-render
43
+ showInspector: { type: Boolean, attribute: "show-inspector" }, // Show/hide Code Inspector button
44
+ showPresetSelector: { type: Boolean, attribute: "show-preset-selector" }, // Show/hide Preset selector
45
+ showThemeSelector: { type: Boolean, attribute: "show-theme-selector" }, // Show/hide Theme selector
46
+ };
47
+
48
+ createRenderRoot() {
49
+ return this; // Disable shadow DOM
50
+ }
51
+
52
+ connectedCallback() {
53
+ super.connectedCallback();
54
+ this.formKey = 0;
55
+ this._validationToastId = null;
56
+
57
+ this.mode = "simple";
58
+ this.inspectorMode = false;
59
+
60
+ // Boolean attributes: when present they're true, when absent they're false
61
+ // No need to set defaults - Lit handles boolean attributes correctly
62
+
63
+ this.config = this.loadConfig();
64
+
65
+ // Listen for inspector deactivation requests from the unified PDS bus
66
+ this._inspectorDeactivateHandler = () => {
67
+ if (this.inspectorMode) {
68
+ this.inspectorMode = false;
69
+ this.dispatchInspectorModeChange();
70
+ }
71
+ };
72
+ PDS.addEventListener(
73
+ "pds:inspector:deactivate",
74
+ this._inspectorDeactivateHandler
75
+ );
76
+
77
+ // Let PDS manage the theme: apply the resolved theme and enable system listener
78
+ try {
79
+ PDS._applyResolvedTheme(PDS.theme);
80
+ PDS._setupSystemListenerIfNeeded(PDS.theme);
81
+ } catch (ex) {
82
+ /* ignore if document not available or other errors */
83
+ }
84
+
85
+ this.updateForm();
86
+
87
+ // Apply host-level CSS variable overrides from the default design config
88
+ // so the designer UI doesn't visually change when user edits are applied
89
+ // elsewhere (these variables remain fixed for the designer element).
90
+ this.applyDefaultHostVariables();
91
+ // Defer initial validation until after the form has rendered
92
+ this._initialValidationScheduled = false;
93
+ this._loadingToastShown = false;
94
+ }
95
+
96
+ disconnectedCallback() {
97
+ super.disconnectedCallback();
98
+ if (this._inspectorDeactivateHandler) {
99
+ PDS.removeEventListener(
100
+ "pds:inspector:deactivate",
101
+ this._inspectorDeactivateHandler
102
+ );
103
+ this._inspectorDeactivateHandler = null;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Apply CSS custom properties to the designer host derived from
109
+ * the default preset. This locks spacing and typography variables
110
+ * for the configurator UI so it remains stable while editing.
111
+ */
112
+ applyDefaultHostVariables() {
113
+ try {
114
+ const baseConfig = structuredClone(presets.default);
115
+ const tmpDesigner = new Generator({ design: baseConfig });
116
+
117
+ // Spacing tokens (keys are numeric strings 1..N)
118
+ const spacing = tmpDesigner.generateSpacingTokens(
119
+ baseConfig.spatialRhythm || {}
120
+ );
121
+ Object.entries(spacing).forEach(([key, val]) => {
122
+ try {
123
+ this.style.setProperty(`--spacing-${key}`, val);
124
+ } catch (e) {
125
+ /* ignore per-property failures */
126
+ }
127
+ });
128
+
129
+ // Also expose base unit explicitly (px) so consumers can read it directly
130
+ const baseUnitValue =
131
+ (baseConfig.spatialRhythm && baseConfig.spatialRhythm.baseUnit) || 16;
132
+ this.style.setProperty("--base-unit", `${baseUnitValue}px`);
133
+
134
+ // Typography tokens
135
+ const typography = tmpDesigner.generateTypographyTokens(
136
+ baseConfig.typography || {}
137
+ );
138
+ if (typography.fontSize) {
139
+ Object.entries(typography.fontSize).forEach(([k, v]) => {
140
+ this.style.setProperty(`--font-size-${k}`, v);
141
+ });
142
+ }
143
+ // Also expose the numeric baseFontSize explicitly
144
+ const baseFontSizeValue =
145
+ (baseConfig.typography && baseConfig.typography.baseFontSize) || 16;
146
+ this.style.setProperty("--base-font-size", `${baseFontSizeValue}px`);
147
+ if (typography.fontFamily) {
148
+ Object.entries(typography.fontFamily).forEach(([k, v]) => {
149
+ this.style.setProperty(`--font-family-${k}`, v);
150
+ });
151
+ }
152
+ if (typography.lineHeight) {
153
+ Object.entries(typography.lineHeight).forEach(([k, v]) => {
154
+ this.style.setProperty(`--font-lineHeight-${k}`, v);
155
+ });
156
+ }
157
+
158
+ console.debug("pds-config-form: applied default host CSS variables");
159
+ } catch (ex) {
160
+ console.warn(
161
+ "pds-config-form: failed to apply default host variables",
162
+ ex
163
+ );
164
+ }
165
+ }
166
+
167
+ updateForm() {
168
+ this.schema = null; // Reset schema to show loading state
169
+ fetch(`/assets/data/auto-design-${this.mode}.json`)
170
+ .then((response) => response.json())
171
+ .then((data) => {
172
+ this.schema = data;
173
+ // Sync form values to current config filtered by schema
174
+ this.formValues = this.filterConfigForSchema(this.config);
175
+ // Force form re-render if needed
176
+ this.formKey = (this.formKey || 0) + 1;
177
+ })
178
+ .catch((ex) => {
179
+ console.warn("Failed to load schema:", ex);
180
+ });
181
+ }
182
+
183
+ // --- Storage helpers: preset + overrides ---
184
+ // Deep equality for primitives/arrays/objects
185
+ _isEqual(a, b) {
186
+ if (a === b) return true;
187
+ if (typeof a !== typeof b) return false;
188
+ if (a && b && typeof a === "object") {
189
+ if (Array.isArray(a)) {
190
+ if (!Array.isArray(b) || a.length !== b.length) return false;
191
+ for (let i = 0; i < a.length; i++)
192
+ if (!this._isEqual(a[i], b[i])) return false;
193
+ return true;
194
+ }
195
+ const aKeys = Object.keys(a);
196
+ const bKeys = Object.keys(b);
197
+ if (aKeys.length !== bKeys.length) {
198
+ // lengths can differ but still be equal when we only compare a -> b; we will compare per-key below
199
+ }
200
+ for (const k of new Set([...aKeys, ...bKeys])) {
201
+ if (!this._isEqual(a[k], b[k])) return false;
202
+ }
203
+ return true;
204
+ }
205
+ return false;
206
+ }
207
+
208
+ _deepDiff(base, target) {
209
+ // Return only properties in target that differ from base
210
+ if (this._isEqual(base, target)) return undefined;
211
+ if (
212
+ base &&
213
+ target &&
214
+ typeof base === "object" &&
215
+ typeof target === "object" &&
216
+ !Array.isArray(base) &&
217
+ !Array.isArray(target)
218
+ ) {
219
+ const out = {};
220
+ const keys = new Set([...Object.keys(target)]);
221
+ for (const k of keys) {
222
+ const d = this._deepDiff(base[k], target[k]);
223
+ if (d !== undefined) out[k] = d;
224
+ }
225
+ return Object.keys(out).length ? out : undefined;
226
+ }
227
+ // For arrays or primitives, return the target if not equal
228
+ return this._isEqual(base, target) ? undefined : target;
229
+ }
230
+
231
+ _resolvePresetBase(presetId) {
232
+ const id = String(presetId || "default").toLowerCase();
233
+ const preset = presets?.[id] || presets?.["default"];
234
+ return preset
235
+ ? JSON.parse(JSON.stringify(preset))
236
+ : JSON.parse(JSON.stringify(presets.default));
237
+ }
238
+
239
+ loadConfig() {
240
+ const stored = localStorage.getItem(STORAGE_KEY);
241
+ if (stored) {
242
+ try {
243
+ const parsed = JSON.parse(stored);
244
+ // New shape: { preset?: string, design?: object }
245
+ if (parsed && ("preset" in parsed || "design" in parsed)) {
246
+ const presetId = parsed.preset || "default";
247
+ const base = this._resolvePresetBase(presetId);
248
+ const full = deepMerge(base, parsed.design || {});
249
+ this._stored = { preset: presetId, design: parsed.design || {} };
250
+ this._legacy = false;
251
+ return full;
252
+ }
253
+ // Legacy: merged full config
254
+ const merged = deepMerge(config.design, parsed);
255
+ this._stored = null;
256
+ this._legacy = true;
257
+ return merged;
258
+ } catch (e) {
259
+ console.warn("Failed to parse stored config, using defaults", e);
260
+ }
261
+ }
262
+ this._stored = { preset: "default", design: {} };
263
+ this._legacy = false;
264
+ return JSON.parse(JSON.stringify(this._resolvePresetBase("default")));
265
+ }
266
+
267
+ saveConfig() {
268
+ try {
269
+ // Determine baseline: preset base if known, else default
270
+ const presetId = (this._stored && this._stored.preset) || "default";
271
+ const base = this._resolvePresetBase(presetId);
272
+ const overrides = this._deepDiff(base, this.config) || {};
273
+ const toStore = { preset: presetId, design: overrides };
274
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore));
275
+ this._stored = toStore;
276
+ this._legacy = false;
277
+ } catch (e) {
278
+ console.warn("Failed to save config: ", e);
279
+ }
280
+ }
281
+
282
+ updated(changedProps) {
283
+ if (changedProps.has("schema")) {
284
+ // When schema changes (mode switch), update form values
285
+ this.formValues = this.filterConfigForSchema(this.config);
286
+
287
+ const delay = this._initialValidationScheduled ? 50 : 150;
288
+ this._initialValidationScheduled = true;
289
+ setTimeout(() => {
290
+ try {
291
+ this.applyStyles(true);
292
+ } catch (ex) {
293
+ console.warn("Delayed applyStyles failed:", ex);
294
+ }
295
+ }, delay);
296
+ }
297
+ }
298
+
299
+ async applyStyles(useUserConfig = false) {
300
+ // Runtime baseline: if using user config, build from preset base + overrides; else default
301
+ let baseConfig = structuredClone(presets.default);
302
+ if (useUserConfig && this.config) {
303
+ const presetId = (this._stored && this._stored.preset) || "default";
304
+ const presetBase = this._resolvePresetBase(presetId);
305
+ baseConfig = deepMerge(presetBase, this.config);
306
+ }
307
+
308
+ // Load fonts from Google Fonts if needed (before validation and style generation)
309
+ if (baseConfig.typography) {
310
+ try {
311
+ await loadTypographyFonts(baseConfig.typography);
312
+ } catch (ex) {
313
+ console.warn("Failed to load some fonts from Google Fonts:", ex);
314
+ // Continue anyway - the system will fall back to default fonts
315
+ }
316
+ }
317
+
318
+ // Validate before generating/applying styles
319
+ try {
320
+ const v = validateDesign(baseConfig);
321
+ if (!v.ok) {
322
+ this.validationIssues = v.issues;
323
+ if (!this._validationToastId) {
324
+ const summary = v.issues
325
+ .slice(0, 3)
326
+ .map((i) => `• ${i.message}`)
327
+ .join("\n");
328
+ this._validationToastId = toast(
329
+ `Design has accessibility issues. Fix before applying.\n${summary}`,
330
+ { type: "error", persistent: true }
331
+ );
332
+ }
333
+ return; // Do not apply invalid styles
334
+ }
335
+ // Clear any existing persistent error toast
336
+ this.validationIssues = [];
337
+ if (this._validationToastId) {
338
+ document
339
+ .querySelector("#global-toaster")
340
+ ?.dismissToast(this._validationToastId);
341
+ this._validationToastId = null;
342
+ }
343
+ } catch (ex) {
344
+ console.warn("Validation failed unexpectedly:", ex);
345
+ }
346
+
347
+ // Pass explicit theme option (from PDS.theme) separately so
348
+ // configs remain theme-agnostic while Generator can emit the correct
349
+ // scoping (html[data-theme] vs prefers-color-scheme).
350
+ const storedTheme = PDS.theme || null;
351
+
352
+ const generatorOptions = { design: structuredClone(baseConfig) };
353
+ if (storedTheme) generatorOptions.theme = storedTheme;
354
+
355
+ this.generator = new Generator(generatorOptions);
356
+ Generator.applyStyles(this.generator);
357
+
358
+ // Let PDS ensure document html[data-theme] reflects persisted preference
359
+ try {
360
+ PDS._applyResolvedTheme(PDS.theme);
361
+ PDS._setupSystemListenerIfNeeded(PDS.theme);
362
+ } catch (ex) {
363
+ /* ignore in non-browser environments */
364
+ }
365
+
366
+ // Emit design-updated in a throttled manner with the actual designer
367
+ this.scheduleDesignUpdatedEmit({
368
+ config: baseConfig,
369
+ designer: this.generator,
370
+ });
371
+ }
372
+
373
+ /**
374
+ * Centralized throttled emitter for the `design-updated` event.
375
+ * Accepts an object with { config, designer } to include in the event detail.
376
+ */
377
+ scheduleDesignUpdatedEmit(detail) {
378
+ const now = Date.now();
379
+
380
+ const emitNow = () => {
381
+ this.#lastDesignEmit = Date.now();
382
+ // Clear any scheduled timer
383
+ if (this.#scheduledDesignEmit) {
384
+ clearTimeout(this.#scheduledDesignEmit);
385
+ this.#scheduledDesignEmit = null;
386
+ }
387
+
388
+ PDS.dispatchEvent(
389
+ new CustomEvent("pds:design:updated", {
390
+ detail,
391
+ })
392
+ );
393
+ };
394
+
395
+ if (now - this.#lastDesignEmit >= this.#designEmitDelay) {
396
+ // Enough time has passed — emit immediately
397
+ emitNow();
398
+ return;
399
+ }
400
+
401
+ // Otherwise schedule a trailing emit (only one scheduled at a time)
402
+ if (!this.#scheduledDesignEmit) {
403
+ const delay = this.#designEmitDelay - (now - this.#lastDesignEmit);
404
+ this.#scheduledDesignEmit = setTimeout(() => {
405
+ this.#scheduledDesignEmit = null;
406
+ emitNow();
407
+ }, delay);
408
+ }
409
+ }
410
+
411
+ toggleInspectorMode() {
412
+ this.inspectorMode = !this.inspectorMode;
413
+ this.dispatchInspectorModeChange();
414
+
415
+ toast(
416
+ this.inspectorMode
417
+ ? "Code Inspector active - click any element in the showcase to view its code"
418
+ : "Code Inspector deactivated",
419
+ { type: "info", duration: 3000 }
420
+ );
421
+ }
422
+
423
+ dispatchInspectorModeChange() {
424
+ // Dispatch event to notify showcase (unified)
425
+ PDS.dispatchEvent(
426
+ new CustomEvent("pds:inspector:mode:changed", {
427
+ detail: { active: this.inspectorMode },
428
+ })
429
+ );
430
+ }
431
+
432
+ // Flatten nested config to dot-notation for pds-jsonform
433
+ flattenConfig(obj, prefix = "") {
434
+ const flattened = {};
435
+ for (const [key, value] of Object.entries(obj)) {
436
+ // Use JSON Pointer format with / separator
437
+ const newKey = prefix ? `${prefix}/${key}` : `/${key}`;
438
+ if (value && typeof value === "object" && !Array.isArray(value)) {
439
+ Object.assign(flattened, this.flattenConfig(value, newKey));
440
+ } else {
441
+ flattened[newKey] = value;
442
+ }
443
+ }
444
+ return flattened;
445
+ }
446
+
447
+ // Get schema property paths in JSON Pointer format for pds-jsonform
448
+ getSchemaProperties(schema, prefix = "") {
449
+ const paths = new Set();
450
+ if (!schema || !schema.properties) return paths;
451
+
452
+ for (const [key, value] of Object.entries(schema.properties)) {
453
+ const jsonPointerPath = prefix ? `${prefix}/${key}` : `/${key}`;
454
+ paths.add(jsonPointerPath);
455
+
456
+ if (value.type === "object" && value.properties) {
457
+ const nested = this.getSchemaProperties(value, jsonPointerPath);
458
+ nested.forEach((p) => paths.add(p));
459
+ }
460
+ }
461
+ return paths;
462
+ }
463
+
464
+ // Filter config values to only include those that exist in the current schema
465
+ filterConfigForSchema(config) {
466
+ if (!config) {
467
+ return {};
468
+ }
469
+
470
+ if (!this.schema) {
471
+ // If schema isn't loaded yet, return full flattened config
472
+ return this.flattenConfig(config);
473
+ }
474
+
475
+ const validPaths = this.getSchemaProperties(this.schema);
476
+ const flattened = this.flattenConfig(config);
477
+ const filtered = {};
478
+
479
+ for (const [key, value] of Object.entries(flattened)) {
480
+ if (validPaths.has(key) && value !== null && value !== undefined) {
481
+ filtered[key] = value;
482
+ }
483
+ }
484
+
485
+ return filtered;
486
+ }
487
+
488
+ handleFormChange = (event) => {
489
+ // Get values from the pds-jsonform's serialize method or from event detail
490
+ let values;
491
+ let changedField = null;
492
+
493
+ // Capture which field changed for smart scrolling
494
+ if (event.type === "pw:value-change" && event.detail) {
495
+ changedField = event.detail.name;
496
+ }
497
+
498
+ if (event.detail && event.detail.json) {
499
+ // pw:serialize event provides { json, formData, valid, issues }
500
+ values = event.detail.json;
501
+ } else {
502
+ // pw:value-change event - get values directly from the form element
503
+ const form =
504
+ event.currentTarget?.tagName?.toUpperCase() === "PDS-JSONFORM"
505
+ ? event.currentTarget
506
+ : this.querySelector("pds-jsonform");
507
+
508
+ if (form) {
509
+ // Use getValuesFlat() to get JSON Pointer formatted keys
510
+ values = form.getValuesFlat?.() || form.values || {};
511
+ } else {
512
+ console.warn("No form element found in form change event", event);
513
+ return;
514
+ }
515
+ }
516
+
517
+ console.log("Form values received:", values);
518
+
519
+ // Convert flattened dot-notation or JSON-pointer keys to nested structure
520
+ // e.g., { "colors.primary": "#123" } => { colors: { primary: "#123" } }
521
+ // and { "/colors/primary": "#123" } => { colors: { primary: "#123" } }
522
+ const nestedValues = {};
523
+ const unescapePointer = (seg) =>
524
+ seg.replace(/~1/g, "/").replace(/~0/g, "~");
525
+
526
+ for (const [key, value] of Object.entries(values)) {
527
+ if (!key) continue;
528
+
529
+ if (key.startsWith("/")) {
530
+ // JSON Pointer style
531
+ const raw = key.replace(/^\//, "");
532
+ const parts = raw.split("/").map(unescapePointer);
533
+ let current = nestedValues;
534
+ for (let i = 0; i < parts.length - 1; i++) {
535
+ const p = parts[i];
536
+ if (!current[p] || typeof current[p] !== "object") current[p] = {};
537
+ current = current[p];
538
+ }
539
+ current[parts[parts.length - 1]] = value;
540
+ } else if (key.includes(".")) {
541
+ const parts = key.split(".");
542
+ let current = nestedValues;
543
+ for (let i = 0; i < parts.length - 1; i++) {
544
+ if (!current[parts[i]] || typeof current[parts[i]] !== "object")
545
+ current[parts[i]] = {};
546
+ current = current[parts[i]];
547
+ }
548
+ current[parts[parts.length - 1]] = value;
549
+ } else {
550
+ nestedValues[key] = value;
551
+ }
552
+ }
553
+
554
+ console.log("Nested values:", nestedValues);
555
+
556
+ // Validate candidate config before persisting/applying
557
+ const candidate = deepMerge(structuredClone(this.config), nestedValues);
558
+ const validation = PDS.validateDesign(candidate);
559
+ if (!validation.ok) {
560
+ this.validationIssues = validation.issues;
561
+ // Show persistent toaster once
562
+ if (!this._validationToastId) {
563
+ const summary = validation.issues
564
+ .slice(0, 3)
565
+ .map((i) => `• ${i.message}`)
566
+ .join("\n");
567
+ this._validationToastId = toast(
568
+ `Design has accessibility issues. Fix before saving.\n${summary}`,
569
+ { type: "error" }
570
+ );
571
+ }
572
+ return; // Do not persist or apply invalid config
573
+ }
574
+
575
+ this.validationIssues = [];
576
+ // Clear persistent toast if present
577
+ try {
578
+ if (this._validationToastId) {
579
+ document
580
+ .querySelector("#global-toaster")
581
+ ?.dismissToast(this._validationToastId);
582
+ this._validationToastId = null;
583
+ }
584
+ } catch {}
585
+ // Accept new config and persist
586
+ this.config = candidate;
587
+ console.log("Updated (persisted) config (not applied):", this.config);
588
+ this.saveConfig();
589
+
590
+ // Emit event for showcase to scroll to relevant section
591
+ if (changedField) {
592
+ console.log(
593
+ "🔔 Emitting design-field-changed event for field:",
594
+ changedField
595
+ );
596
+ PDS.dispatchEvent(
597
+ new CustomEvent("pds:design:field:changed", {
598
+ detail: {
599
+ field: changedField,
600
+ config: this.config,
601
+ },
602
+ })
603
+ );
604
+ }
605
+ // Debounce applying styles using the user-edited config so the showcase
606
+ // receives the actual runtime CSS generated from user edits. We still
607
+ // persist immediately, but only apply (and emit) the styles at most once
608
+ // per #designEmitDelay to avoid excessive recalculation.
609
+ try {
610
+ if (this.#scheduledApply) {
611
+ clearTimeout(this.#scheduledApply);
612
+ this.#scheduledApply = null;
613
+ }
614
+ this.#scheduledApply = setTimeout(() => {
615
+ this.#scheduledApply = null;
616
+ this.applyStyles(true); // apply with user config and emit
617
+ }, this.#designEmitDelay);
618
+ } catch (ex) {
619
+ console.warn("Failed to schedule applyStyles with user config:", ex);
620
+ }
621
+ };
622
+
623
+ handleReset = async () => {
624
+ const result = await PDS.ask(
625
+ "Reset to default configuration? This will clear your saved settings."
626
+ );
627
+
628
+ if (result) {
629
+ // Migrate to new storage shape: preset 'default' with no overrides
630
+ this._stored = { preset: "default", design: {} };
631
+ const base = this._resolvePresetBase("default");
632
+ this.config = JSON.parse(JSON.stringify(base));
633
+ this.formValues = this.filterConfigForSchema(this.config); // Update form values
634
+ this.formKey = (this.formKey || 0) + 1; // Increment to force form update
635
+ this.saveConfig();
636
+ this.applyStyles(true);
637
+
638
+ toast("Configuration reset to defaults", {
639
+ type: "info",
640
+ duration: 2000,
641
+ });
642
+ }
643
+ };
644
+
645
+ applyPreset = async (preset) => {
646
+ const result = await PDS.ask(
647
+ `Load "${preset.name}" preset? This will replace your current settings.`
648
+ );
649
+
650
+ if (result) {
651
+ // Build from preset only (no default baseline); store as preset + overrides {}
652
+ const presetConfig = JSON.parse(JSON.stringify(preset));
653
+ const validationResult = PDS.validateDesign(presetConfig);
654
+ if (!validationResult.ok) {
655
+ this.validationIssues = validationResult.issues;
656
+ if (!this._validationToastId) {
657
+ const summary = validationResult.issues
658
+ .slice(0, 3)
659
+ .map((i) => `• ${i.message}`)
660
+ .join("\n");
661
+ this._validationToastId = toast(
662
+ `Preset "${preset.name}" has accessibility issues — not applied.\n${summary}`,
663
+ { type: "error", persistent: true }
664
+ );
665
+ }
666
+ return;
667
+ }
668
+
669
+ this.validationIssues = [];
670
+ // Clear any persistent error toast if present
671
+ try {
672
+ if (this._validationToastId) {
673
+ document
674
+ .querySelector("#global-toaster")
675
+ ?.dismissToast(this._validationToastId);
676
+ this._validationToastId = null;
677
+ }
678
+ } catch {}
679
+ this.config = presetConfig;
680
+ this._stored = {
681
+ preset: (preset.id || preset.name || "").toLowerCase(),
682
+ design: {},
683
+ };
684
+ this.formValues = this.filterConfigForSchema(this.config);
685
+ this.saveConfig();
686
+ this.applyStyles(true);
687
+
688
+ toast(`"${preset.name}" preset loaded successfully!`, {
689
+ type: "success",
690
+ duration: 3000,
691
+ });
692
+ }
693
+ };
694
+
695
+ handleThemeChange(e) {
696
+ try {
697
+ const value = e.target.value;
698
+ // Update centralized theme via PDS (this persists + applies + sets up listeners)
699
+ PDS.theme = value;
700
+
701
+ // Apply immediately and emit styles using the user config (keep config separate)
702
+ this.applyStyles(true);
703
+
704
+ toast(`Theme set to ${value}`, { type: "info", duration: 1200 });
705
+ } catch (ex) {
706
+ console.warn("Failed to change theme:", ex);
707
+ }
708
+ }
709
+
710
+ handleDownload = (format) => {
711
+ let content, filename, mimeType;
712
+
713
+ switch (format) {
714
+ case "css":
715
+ content = this.generator.layeredCSS;
716
+ filename = "pure-ds.css";
717
+ mimeType = "text/css";
718
+ break;
719
+
720
+ case "config":
721
+ content = `// Pure Design System Configuration
722
+ // Generated: ${new Date().toISOString()}
723
+
724
+ import { PDS } from 'pure-ds';
725
+
726
+ export const pdsConfig = ${JSON.stringify(this.config, null, 2)};
727
+ `;
728
+ filename = "pds.config.js";
729
+ mimeType = "text/javascript";
730
+ break;
731
+
732
+ case "tokens":
733
+ const tokens = figmafyTokens(this.generator.generateTokens());
734
+ content = JSON.stringify(tokens, null, 2);
735
+ filename = "design-tokens.json";
736
+ mimeType = "application/json";
737
+ break;
738
+ }
739
+
740
+ const blob = new Blob([content], { type: mimeType });
741
+ const url = URL.createObjectURL(blob);
742
+ const a = document.createElement("a");
743
+ a.href = url;
744
+ a.download = filename;
745
+ a.click();
746
+ URL.revokeObjectURL(url);
747
+ };
748
+
749
+ render() {
750
+ if (!this.schema) {
751
+ if (!this._loadingToastShown) {
752
+ this._loadingToastShown = true;
753
+ setTimeout(() => {
754
+ try {
755
+ toast("Loading schema...", { duration: 1000 });
756
+ } catch {}
757
+ }, 250);
758
+ }
759
+ return nothing;
760
+ }
761
+ return html`
762
+ <div class="designer-container">
763
+ <div class="designer-toolbar">
764
+ <label data-toggle id="mode-toggle">
765
+ <input
766
+ type="checkbox"
767
+ .checked=${this.mode === "advanced"}
768
+ @change=${(e) => {
769
+ this.mode = e.target.checked ? "advanced" : "simple";
770
+ this.updateForm();
771
+ }}
772
+ /><span
773
+ >${this.mode === "advanced"
774
+ ? "Switch to Basic Mode"
775
+ : "Switch to Advanced Mode"}</span
776
+ >
777
+ </label>
778
+
779
+ ${this.showInspector
780
+ ? html`
781
+ <button
782
+ class="inspector-toggle ${this.inspectorMode
783
+ ? "active"
784
+ : ""}"
785
+ @click=${this.toggleInspectorMode}
786
+ title="${this.inspectorMode
787
+ ? "Deactivate"
788
+ : "Activate"} Code Inspector"
789
+ >
790
+ <pds-icon
791
+ icon="${this.inspectorMode ? "eye-slash" : "code"}"
792
+ size="sm"
793
+ ></pds-icon>
794
+ <span
795
+ >${this.inspectorMode
796
+ ? "Inspector Active"
797
+ : "Code Inspector"}</span
798
+ >
799
+ </button>
800
+ `
801
+ : nothing}
802
+ ${this.showThemeSelector
803
+ ? html`
804
+ <fieldset
805
+ role="radiogroup"
806
+ aria-label="Theme"
807
+ class="theme-select buttons"
808
+ >
809
+ <legend>Theme</legend>
810
+ ${(() => {
811
+ const stored = PDS.theme || null;
812
+ const selected = stored || "system";
813
+ return html`
814
+ <label>
815
+ <input
816
+ type="radio"
817
+ name="theme"
818
+ value="system"
819
+ @change=${this.handleThemeChange}
820
+ .checked=${selected === "system"}
821
+ />
822
+ <span
823
+ ><pds-icon icon="moon-stars"></pds-icon>
824
+ System</span
825
+ >
826
+ </label>
827
+ <label>
828
+ <input
829
+ type="radio"
830
+ name="theme"
831
+ value="light"
832
+ @change=${this.handleThemeChange}
833
+ .checked=${selected === "light"}
834
+ />
835
+ <span><pds-icon icon="sun"></pds-icon> Light</span>
836
+ </label>
837
+ <label>
838
+ <input
839
+ type="radio"
840
+ name="theme"
841
+ value="dark"
842
+ @change=${this.handleThemeChange}
843
+ .checked=${selected === "dark"}
844
+ />
845
+ <span><pds-icon icon="moon"></pds-icon> Dark</span>
846
+ </label>
847
+ `;
848
+ })()}
849
+ </fieldset>
850
+ `
851
+ : nothing}
852
+ ${this.showPresetSelector
853
+ ? html`
854
+ <fieldset>
855
+ <legend>Preset</legend>
856
+ <div class="input-icon">
857
+ <pds-icon icon="magnifying-glass"></pds-icon>
858
+ <input
859
+ @focus=${(e) =>
860
+ AutoComplete.connect(
861
+ e,
862
+ this.presetAutoCompleteSettings
863
+ )}
864
+ id="preset-search"
865
+ type="search"
866
+ placeholder="Start typing to search all presets..."
867
+ autocomplete="off"
868
+ />
869
+ </div>
870
+ </fieldset>
871
+ `
872
+ : nothing}
873
+ </div>
874
+
875
+ <div class="designer-form-container">
876
+ <pds-jsonform
877
+ .jsonSchema=${this.schema}
878
+ .uiSchema=${this._designerUiSchema()}
879
+ .values=${this.formValues || {}}
880
+ hide-reset
881
+ hide-submit
882
+ @pw:value-change=${this.handleFormChange}
883
+ @pw:serialize=${this.handleFormChange}
884
+ >
885
+ </pds-jsonform>
886
+ </div>
887
+
888
+ <div class="designer-actions">
889
+ <nav data-dropdown>
890
+ <button class="btn-primary" style="width: 100%;">
891
+ <pds-icon icon="download" size="sm"></pds-icon>
892
+ <span>Download</span>
893
+ <pds-icon icon="caret-down" size="sm"></pds-icon>
894
+ </button>
895
+
896
+ <menu>
897
+ <li>
898
+ <a
899
+ href="#"
900
+ @click=${(e) => {
901
+ e.preventDefault();
902
+ this.handleDownload("css");
903
+ }}
904
+ >
905
+ <pds-icon icon="file-css" size="sm"></pds-icon>
906
+ <span>CSS File</span>
907
+ </a>
908
+ </li>
909
+
910
+ <li>
911
+ <a
912
+ href="#"
913
+ @click=${(e) => {
914
+ e.preventDefault();
915
+ this.handleDownload("config");
916
+ }}
917
+ >
918
+ <pds-icon icon="file-js" size="sm"></pds-icon>
919
+ <span>Config File</span>
920
+ </a>
921
+ </li>
922
+
923
+ <li>
924
+ <a
925
+ href="#"
926
+ @click=${(e) => {
927
+ e.preventDefault();
928
+ this.handleDownload("tokens");
929
+ }}
930
+ >
931
+ <pds-icon icon="brackets-curly" size="sm"></pds-icon>
932
+ <span>Design Tokens (JSON)</span>
933
+ </a>
934
+ </li>
935
+ </menu>
936
+ </nav>
937
+ </div>
938
+ </div>
939
+ `;
940
+ }
941
+
942
+ get presetAutoCompleteSettings() {
943
+ return {
944
+ direction: "down",
945
+ //debug: true,
946
+ iconHandler: (item) => {
947
+ const preset = PDS.presets[item.id];
948
+ return /*html*/ `<span class="preset-colors">
949
+ <span style="background-color: ${preset.colors.primary}"></span>
950
+ <span style="background-color: ${preset.colors.secondary}"></span>
951
+ <span style="background-color: ${preset.colors.accent}"></span>
952
+ </span>`;
953
+ },
954
+ categories: {
955
+ Presets: {
956
+ action: (options) => {
957
+ const preset = PDS.presets[options.id];
958
+ this.applyPreset(preset);
959
+ },
960
+ getItems: (options) => {
961
+ const maxPresets = 10;
962
+ const all = Object.values(PDS.presets);
963
+ let filtered = [];
964
+
965
+ if (options.search.length > 0) {
966
+ let n = 0;
967
+ filtered = all.filter((preset) => {
968
+ n++;
969
+ return (
970
+ (n <= maxPresets &&
971
+ preset.name
972
+ .toLowerCase()
973
+ .includes(options.search.toLowerCase())) ||
974
+ (preset.description &&
975
+ preset.description
976
+ .toLowerCase()
977
+ .includes(options.search.toLowerCase()))
978
+ );
979
+ });
980
+ } else {
981
+ filtered = all
982
+ .filter((preset) => preset.tags?.includes("featured"))
983
+ .slice(0, maxPresets);
984
+ }
985
+
986
+ return filtered.map((preset) => {
987
+ return {
988
+ id: preset.id,
989
+ text: preset.name,
990
+ description: preset.description,
991
+ icon: "palette",
992
+ };
993
+ });
994
+ },
995
+ },
996
+ },
997
+ };
998
+ }
999
+
1000
+ // Provide a uiSchema to customize widgets for designer UX (datalists for fonts, ranges for numeric values)
1001
+ _designerUiSchema() {
1002
+ // Common font-family suggestions (similar to dev console suggestions)
1003
+ const fontSuggestions = [
1004
+ "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
1005
+ "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial",
1006
+ "'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
1007
+ "Roboto, 'Helvetica Neue', Arial, sans-serif",
1008
+ "Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
1009
+ "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
1010
+ ];
1011
+
1012
+ // UI schema (paths use the pds-jsonform path notation, e.g. /typography/fontFamilyHeadings)
1013
+ const ui = {};
1014
+
1015
+ // Font family fields: use datalist via ui.datalist
1016
+ ui["/typography/fontFamilyHeadings"] = { "ui:datalist": fontSuggestions };
1017
+ ui["/typography/fontFamilyBody"] = { "ui:datalist": fontSuggestions };
1018
+ ui["/typography/fontFamilyMono"] = {
1019
+ "ui:datalist": [
1020
+ "ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace",
1021
+ "Consolas, 'Liberation Mono', Menlo, monospace",
1022
+ "'Fira Code', 'Cascadia Code', 'Source Code Pro', monospace",
1023
+ ],
1024
+ };
1025
+
1026
+ // Numeric fields rendered as ranges for better UX
1027
+ ui["/typography/baseFontSize"] = {
1028
+ "ui:widget": "input-range",
1029
+ "ui:min": 12,
1030
+ "ui:max": 24,
1031
+ };
1032
+ ui["/typography/fontScale"] = {
1033
+ "ui:widget": "input-range",
1034
+ "ui:min": 1.1,
1035
+ "ui:max": 1.618,
1036
+ "ui:step": 0.01,
1037
+ };
1038
+ ui["/spatialRhythm/baseUnit"] = {
1039
+ "ui:widget": "input-range",
1040
+ "ui:min": 4,
1041
+ "ui:max": 32,
1042
+ };
1043
+
1044
+ // Dark mode color overrides
1045
+ ui["/colors/darkMode/background"] = { "ui:widget": "input-color" };
1046
+ ui["/colors/darkMode/secondary"] = { "ui:widget": "input-color" };
1047
+
1048
+ // Advanced: container padding
1049
+ ui["/spatialRhythm/containerPadding"] = {
1050
+ "ui:widget": "input-range",
1051
+ "ui:min": 0,
1052
+ "ui:max": 4,
1053
+ };
1054
+
1055
+ return ui;
1056
+ }
1057
+ }
1058
+ );