@preference-sl/pref-viewer 2.18.0 → 3.0.0-beta.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.
Files changed (35) hide show
  1. package/README.md +267 -31
  2. package/index.d.ts +75 -6
  3. package/package.json +8 -2
  4. package/src/babylonjs-animation-controller.js +18 -10
  5. package/src/babylonjs-animation-opening-menu.js +80 -14
  6. package/src/babylonjs-animation-opening.js +3 -3
  7. package/src/babylonjs-ar-floor-bounds.js +169 -0
  8. package/src/babylonjs-ar-glass.js +128 -0
  9. package/src/babylonjs-ar-placement-feedback.js +449 -0
  10. package/src/babylonjs-ar-placement.js +1038 -0
  11. package/src/babylonjs-camera-wheel-motion.js +35 -2
  12. package/src/babylonjs-controller.js +542 -125
  13. package/src/babylonjs-orbital-pointers-input.js +186 -0
  14. package/src/{render-math.js → babylonjs-render-math.js} +11 -11
  15. package/src/babylonjs-selection-controller.js +4 -4
  16. package/src/babylonjs-view-cube-canvas-controller.js +1287 -0
  17. package/src/babylonjs-view-cube-canvas-orbit-math.js +220 -0
  18. package/src/babylonjs-view-cube-canvas-renderer.js +775 -0
  19. package/src/babylonjs-view-cube-controller.js +74 -1103
  20. package/src/babylonjs-view-cube-svg-controller.js +1012 -0
  21. package/src/file-storage.js +22 -4
  22. package/src/gltf-resolver.js +83 -13
  23. package/src/localization/translations.js +12 -0
  24. package/src/panzoom-controller.js +4 -4
  25. package/src/pref-viewer-2d.js +2 -2
  26. package/src/pref-viewer-3d.js +55 -9
  27. package/src/pref-viewer-controls-presentation.js +292 -0
  28. package/src/pref-viewer-dialog.js +86 -7
  29. package/src/pref-viewer-menu-3d.js +134 -17
  30. package/src/pref-viewer-style-controller.js +320 -0
  31. package/src/pref-viewer.js +133 -109
  32. package/src/quick-look-action.js +291 -0
  33. package/src/styles.js +702 -98
  34. package/src/babylonjs-view-cube-renderer.js +0 -548
  35. package/src/view-cube-orbit-math.js +0 -199
@@ -5,6 +5,8 @@ import I18n from "./localization/i18n.js";
5
5
  /**
6
6
  * PrefViewerMenu3D - Localized floating control panel for Babylon.js render settings.
7
7
  *
8
+ * Shares its viewer control presentation; supports explicit touch open/close and hover on fine pointers.
9
+ * Owns temporary touch-press feedback until release/cancellation, focus loss or disconnection.
8
10
  * Renders a gear-icon toggle button that appears on viewer hover and opens a panel with:
9
11
  * - Boolean switches for every boolean render setting (AA, SSAO, IBL, shadows, highlight, etc.).
10
12
  * - A hue slider for the highlight color.
@@ -62,6 +64,54 @@ export default class PrefViewerMenu3D extends HTMLElement {
62
64
  onToggleEnter: null,
63
65
  };
64
66
 
67
+ #touchPressedControl = null;
68
+ #touchPointerId = null;
69
+
70
+ /**
71
+ * Marks one enabled touch control while the finger is down, independent of native :active timing.
72
+ * A second touch cancels the current feedback without changing values or preventing native scrolling/input.
73
+ * @param {PointerEvent} event - Pointer down bubbling from this menu.
74
+ * @returns {void}
75
+ */
76
+ #onTouchPress = event => {
77
+ if (event.pointerType !== "touch") {
78
+ return;
79
+ }
80
+ if (this.#touchPressedControl) {
81
+ this.#clearTouchPress();
82
+ return;
83
+ }
84
+ const control = event.target.closest?.('button, input[type="range"], .menu-switch');
85
+ if (!control || !this.contains(control) || control.matches(':disabled, [data-disabled]')
86
+ || control.querySelector('input:disabled')) {
87
+ return;
88
+ }
89
+ this.#touchPressedControl = control;
90
+ this.#touchPointerId = event.pointerId;
91
+ control.setAttribute("data-touch-pressed", "");
92
+ };
93
+
94
+ /**
95
+ * Releases feedback for the active finger on pointer up or cancellation, including outside the menu.
96
+ * @param {PointerEvent} event - Document-level pointer completion.
97
+ * @returns {void}
98
+ */
99
+ #onTouchRelease = event => {
100
+ if (event.pointerId === this.#touchPointerId) {
101
+ this.#clearTouchPress();
102
+ }
103
+ };
104
+
105
+ /**
106
+ * Removes only this menu's owned touch-press state; also handles window blur and disconnection.
107
+ * @returns {void} Idempotent; does not change focus, settings, click handling or native range dragging.
108
+ */
109
+ #clearTouchPress = () => {
110
+ this.#touchPressedControl?.removeAttribute("data-touch-pressed");
111
+ this.#touchPressedControl = null;
112
+ this.#touchPointerId = null;
113
+ };
114
+
65
115
  #DEFAULT_RENDER_SETTINGS = { ...BabylonJSController.config.render.defaultSettings };
66
116
  // Only include boolean settings as switches; scalar settings are handled by sliders.
67
117
  #MENU_SWITCHES = Object.keys(this.#DEFAULT_RENDER_SETTINGS).filter(key =>
@@ -144,7 +194,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
144
194
  }
145
195
 
146
196
  /**
147
- * Builds the internal DOM structure for the toggle button, panel, switches, color slider, and lighting controls.
197
+ * Builds localized toggle/close controls and an initially inert panel with switches and sliders.
148
198
  * @private
149
199
  * @returns {void}
150
200
  */
@@ -152,11 +202,12 @@ export default class PrefViewerMenu3D extends HTMLElement {
152
202
  const wrapper = document.createElement("div");
153
203
  wrapper.className = "menu-wrapper";
154
204
  wrapper.innerHTML = `
155
- <button class="menu-toggle" type="button" aria-label="${this.#texts.title}">
205
+ <button class="menu-toggle" type="button" aria-expanded="false" aria-label="${this.#texts.title}">
156
206
  <svg viewBox="0 0 24 24"><path d="M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8M12,10A2,2 0 0,0 10,12A2,2 0 0,0 12,14A2,2 0 0,0 14,12A2,2 0 0,0 12,10M10,22C9.75,22 9.54,21.82 9.5,21.58L9.13,18.93C8.5,18.68 7.96,18.34 7.44,17.94L4.95,18.95C4.73,19.03 4.46,18.95 4.34,18.73L2.34,15.27C2.21,15.05 2.27,14.78 2.46,14.63L4.57,12.97L4.5,12L4.57,11L2.46,9.37C2.27,9.22 2.21,8.95 2.34,8.73L4.34,5.27C4.46,5.05 4.73,4.96 4.95,5.05L7.44,6.05C7.96,5.66 8.5,5.32 9.13,5.07L9.5,2.42C9.54,2.18 9.75,2 10,2H14C14.25,2 14.46,2.18 14.5,2.42L14.87,5.07C15.5,5.32 16.04,5.66 16.56,6.05L19.05,5.05C19.27,4.96 19.54,5.05 19.66,5.27L21.66,8.73C21.79,8.95 21.73,9.22 21.54,9.37L19.43,11L19.5,12L19.43,13L21.54,14.63C21.73,14.78 21.79,15.05 21.66,15.27L19.66,18.73C19.54,18.95 19.27,19.04 19.05,18.95L16.56,17.95C16.04,18.34 15.5,18.68 14.87,18.93L14.5,21.58C14.46,21.82 14.25,22 14,22H10M11.25,4L10.88,6.61C9.68,6.86 8.62,7.5 7.85,8.39L5.44,7.35L4.69,8.65L6.8,10.2C6.4,11.37 6.4,12.64 6.8,13.8L4.68,15.36L5.43,16.66L7.86,15.62C8.63,16.5 9.68,17.14 10.87,17.38L11.24,20H12.76L13.13,17.39C14.32,17.14 15.37,16.5 16.14,15.62L18.57,16.66L19.32,15.36L17.2,13.81C17.6,12.64 17.6,11.37 17.2,10.2L19.31,8.65L18.56,7.35L16.15,8.39C15.38,7.5 14.32,6.86 13.12,6.62L12.75,4H11.25Z" /></svg>
157
207
  </button>
158
- <div class="menu-panel" role="dialog" aria-label="${this.#texts.title}">
208
+ <div class="menu-panel" inert aria-hidden="true" role="dialog" aria-label="${this.#texts.title}">
159
209
  <div class="menu-header">
210
+ <button class="menu-close" type="button" aria-label="${this.#texts.actions.close}">×</button>
160
211
  <p class="menu-title">${this.#texts.title}</p>
161
212
  <p class="menu-subtitle">${this.#texts.subtitle}</p>
162
213
  </div>
@@ -231,7 +282,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
231
282
  /**
232
283
  * Normalizes locale data into a predictable shape consumed by the UI helpers.
233
284
  * @private
234
- * @returns {{title: string, subtitle: string, actions: {play: string, pause: string}, features: object}} Structured copy bundle for titles, actions, and feature labels.
285
+ * @returns {{title: string, subtitle: string, actions: {play: string, pause: string, close: string}, features: object}} Structured copy bundle for titles, actions, and feature labels.
235
286
  */
236
287
  #getMenuTexts() {
237
288
  const data = this.#getMenuNamespace();
@@ -241,13 +292,14 @@ export default class PrefViewerMenu3D extends HTMLElement {
241
292
  actions: {
242
293
  play: data.actions?.play || "",
243
294
  pause: data.actions?.pause || "",
295
+ close: data.actions?.close || "Close",
244
296
  },
245
297
  features: data.features || {},
246
298
  };
247
299
  }
248
300
 
249
301
  /**
250
- * Replaces all textual UI nodes with the latest localized strings.
302
+ * Replaces all textual UI nodes, including the close action, with the latest localized strings.
251
303
  * @private
252
304
  * @returns {void}
253
305
  */
@@ -257,6 +309,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
257
309
  }
258
310
  this.#elements.toggleButton.setAttribute("aria-label", this.#texts.title);
259
311
  this.#elements.panel?.setAttribute("aria-label", this.#texts.title);
312
+ this.#elements.closeButton?.setAttribute("aria-label", this.#texts.actions.close);
260
313
  if (this.#elements.title) {
261
314
  this.#elements.title.textContent = this.#texts.title;
262
315
  }
@@ -308,13 +361,14 @@ export default class PrefViewerMenu3D extends HTMLElement {
308
361
  }
309
362
 
310
363
  /**
311
- * Caches references to frequently used DOM nodes for event binding and UI synchronization.
364
+ * Caches toggle/close actions and settings nodes for event binding and UI synchronization.
312
365
  * @private
313
366
  * @returns {void}
314
367
  */
315
368
  #cacheElements() {
316
369
  this.#elements.toggleButton = this.querySelector(".menu-toggle");
317
370
  this.#elements.panel = this.querySelector(".menu-panel");
371
+ this.#elements.closeButton = this.querySelector(".menu-close");
318
372
  this.#elements.title = this.querySelector(".menu-title");
319
373
  this.#elements.subtitle = this.querySelector(".menu-subtitle");
320
374
 
@@ -348,7 +402,8 @@ export default class PrefViewerMenu3D extends HTMLElement {
348
402
  }
349
403
 
350
404
  /**
351
- * Attaches all interactive event listeners for hover, focus, switch, color, and lighting controls.
405
+ * Attaches hover/focus, touch-press feedback, explicit toggle/close and settings listeners.
406
+ * Retains document completion/window blur subscriptions for feedback cleanup during teardown.
352
407
  * @private
353
408
  * @returns {void}
354
409
  */
@@ -356,9 +411,37 @@ export default class PrefViewerMenu3D extends HTMLElement {
356
411
  if (!this.#elements.toggleButton || !this.#elements.panel) {
357
412
  return;
358
413
  }
359
- this.#handles.onToggleEnter = () => this.#openMenu();
360
- this.#handles.onPanelEnter = () => this.#openMenu();
414
+ /** Opens on hover/focus only for compact fine-pointer presentation. @returns {void} */
415
+ this.#handles.onToggleEnter = () => {
416
+ if (!this.#usesTouchControls()) {
417
+ this.#openMenu();
418
+ }
419
+ };
420
+ /** Toggles on trusted click/tap without requiring synthetic mouse hover. @returns {void} */
421
+ this.#handles.onToggleClick = () => {
422
+ if (this.#usesTouchControls()) {
423
+ this.hasAttribute("data-open") ? this.#closeMenu() : this.#openMenu();
424
+ }
425
+ };
426
+ /** Closes explicitly and returns keyboard focus to the trigger. @returns {void} */
427
+ this.#handles.onCloseClick = () => { this.#elements.toggleButton.focus(); this.#closeMenu(); };
428
+ /** Closes the settings panel when Escape is pressed. @param {KeyboardEvent} event - Panel key event. @returns {void} */
429
+ this.#handles.onPanelKeyDown = event => {
430
+ if (event.key === "Escape") {
431
+ this.#handles.onCloseClick();
432
+ }
433
+ };
434
+ /** Opens settings on actual fine-pointer hover. @returns {void} */
435
+ this.#handles.onPanelEnter = () => {
436
+ if (!this.#usesTouchControls()) {
437
+ this.#openMenu();
438
+ }
439
+ };
440
+ /** Keeps touch panels open on pointer leave. @param {MouseEvent} event - Leave event. @returns {void} */
361
441
  this.#handles.onHostLeave = (event) => {
442
+ if (this.#usesTouchControls()) {
443
+ return;
444
+ }
362
445
  if (event.relatedTarget && this.contains(event.relatedTarget)) {
363
446
  return;
364
447
  }
@@ -369,6 +452,13 @@ export default class PrefViewerMenu3D extends HTMLElement {
369
452
  this.#handles.onLightingSliderInput = (event) => this.#handleLightingSliderInput(event);
370
453
  this.#handles.onLightingPlayClick = () => this.#toggleLightingPlayback();
371
454
 
455
+ this.addEventListener("pointerdown", this.#onTouchPress);
456
+ this.ownerDocument.addEventListener("pointerup", this.#onTouchRelease);
457
+ this.ownerDocument.addEventListener("pointercancel", this.#onTouchRelease);
458
+ this.ownerDocument.defaultView?.addEventListener("blur", this.#clearTouchPress);
459
+ this.#elements.toggleButton.addEventListener("click", this.#handles.onToggleClick);
460
+ this.#elements.closeButton.addEventListener("click", this.#handles.onCloseClick);
461
+ this.#elements.panel.addEventListener("keydown", this.#handles.onPanelKeyDown);
372
462
  this.#elements.toggleButton.addEventListener("mouseenter", this.#handles.onToggleEnter);
373
463
  this.#elements.toggleButton.addEventListener("focus", this.#handles.onToggleEnter);
374
464
  this.#elements.panel.addEventListener("mouseenter", this.#handles.onPanelEnter);
@@ -380,7 +470,8 @@ export default class PrefViewerMenu3D extends HTMLElement {
380
470
  }
381
471
 
382
472
  /**
383
- * Removes previously attached event listeners, guarding against partially initialized states.
473
+ * Removes hover, touch feedback, explicit toggle/close, keyboard and settings listeners, guarding partial initialization.
474
+ * Clears owned press state and document/window subscriptions before disconnection.
384
475
  * @private
385
476
  * @returns {void}
386
477
  */
@@ -388,6 +479,14 @@ export default class PrefViewerMenu3D extends HTMLElement {
388
479
  if (!this.#elements.toggleButton) {
389
480
  return;
390
481
  }
482
+ this.removeEventListener("pointerdown", this.#onTouchPress);
483
+ this.ownerDocument.removeEventListener("pointerup", this.#onTouchRelease);
484
+ this.ownerDocument.removeEventListener("pointercancel", this.#onTouchRelease);
485
+ this.ownerDocument.defaultView?.removeEventListener("blur", this.#clearTouchPress);
486
+ this.#clearTouchPress();
487
+ this.#elements.toggleButton.removeEventListener("click", this.#handles.onToggleClick);
488
+ this.#elements.closeButton?.removeEventListener("click", this.#handles.onCloseClick);
489
+ this.#elements.panel?.removeEventListener("keydown", this.#handles.onPanelKeyDown);
391
490
  this.#elements.toggleButton.removeEventListener("mouseenter", this.#handles.onToggleEnter);
392
491
  this.#elements.toggleButton.removeEventListener("focus", this.#handles.onToggleEnter);
393
492
  this.#elements.panel?.removeEventListener("mouseenter", this.#handles.onPanelEnter);
@@ -399,24 +498,40 @@ export default class PrefViewerMenu3D extends HTMLElement {
399
498
  }
400
499
 
401
500
  /**
402
- * Opens the menu panel when the viewer is hovered and the menu is enabled.
501
+ * Reads inherited presentation state without installing a second capability observer.
502
+ * @private
503
+ * @returns {boolean} True for comfortable controls or an actual primary coarse pointer.
504
+ */
505
+ #usesTouchControls() {
506
+ const surface = this.closest("[data-controls-density]");
507
+ return surface?.dataset.controlsDensity === "comfortable" || surface?.dataset.controlsTouch === "true";
508
+ }
509
+
510
+ /**
511
+ * Opens enabled settings for touch presentation or an actually hovered viewer; exposes panel focus targets.
403
512
  * @private
404
513
  * @returns {void}
405
514
  */
406
515
  #openMenu() {
407
- if (!this.#isEnabled || !this.hasAttribute("data-viewer-hover")) {
516
+ if (!this.#isEnabled || (!this.#usesTouchControls() && !this.hasAttribute("data-viewer-hover"))) {
408
517
  return;
409
518
  }
410
519
  this.setAttribute("data-open", "true");
520
+ this.#elements.toggleButton?.setAttribute("aria-expanded", "true");
521
+ this.#elements.panel?.removeAttribute("inert");
522
+ this.#elements.panel?.setAttribute("aria-hidden", "false");
411
523
  }
412
524
 
413
525
  /**
414
- * Closes the menu panel and removes its open state attribute.
526
+ * Closes settings, synchronizes expanded state and removes hidden panel controls from keyboard navigation.
415
527
  * @private
416
528
  * @returns {void}
417
529
  */
418
530
  #closeMenu() {
419
531
  this.removeAttribute("data-open");
532
+ this.#elements.toggleButton?.setAttribute("aria-expanded", "false");
533
+ this.#elements.panel?.setAttribute("inert", "");
534
+ this.#elements.panel?.setAttribute("aria-hidden", "true");
420
535
  }
421
536
 
422
537
  /**
@@ -748,7 +863,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
748
863
  /**
749
864
  * Sets the locale used for menu labels and helper text.
750
865
  * @public
751
- * @param {string} cultureId - Locale identifier, e.g., "en-EN".
866
+ * @param {string} [cultureId=I18n.DEFAULT_LOCALE] - Locale identifier, e.g., "en-EN".
752
867
  * @returns {void}
753
868
  */
754
869
  setCulture(cultureId = I18n.DEFAULT_LOCALE) {
@@ -778,7 +893,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
778
893
  /**
779
894
  * Applies a new render settings snapshot to the UI and clears the applying state.
780
895
  * @public
781
- * @param {object} settings - Render settings snapshot from PrefViewer or BabylonJSController.
896
+ * @param {object} [settings={}] - Render settings snapshot from PrefViewer or BabylonJSController.
782
897
  * @returns {void}
783
898
  */
784
899
  setSettings(settings = {}) {
@@ -796,7 +911,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
796
911
  }
797
912
 
798
913
  /**
799
- * Receives viewer hover state so the menu only opens when the user interacts with the 3D viewport.
914
+ * Receives hover state; pointer leave closes fine-pointer settings while touch panels require explicit close.
800
915
  * @public
801
916
  * @param {boolean} isHovering - True when the viewer is hovered.
802
917
  * @returns {void}
@@ -809,7 +924,9 @@ export default class PrefViewerMenu3D extends HTMLElement {
809
924
  this.setAttribute("data-viewer-hover", "true");
810
925
  } else {
811
926
  this.removeAttribute("data-viewer-hover");
812
- this.#closeMenu();
927
+ if (!this.#usesTouchControls()) {
928
+ this.#closeMenu();
929
+ }
813
930
  }
814
931
  }
815
932
 
@@ -0,0 +1,320 @@
1
+ import * as ViewerStyles from "./styles.js";
2
+
3
+ /**
4
+ * Owns one root viewer's validated appearance, explicit patches and optional saved preferences.
5
+ * Projects values onto a shadow-internal boundary; never mutates shared defaults or the public host style.
6
+ * No observers or graphics resources are owned. Reattach after reconnect to replace the owned boundary stylesheet.
7
+ */
8
+ export default class PrefViewerStyleController {
9
+ static #DEFAULT_PRIMARY = ViewerStyles.PrefViewerViewCubePalette.colors.hover;
10
+ static #FIELDS = Object.freeze({
11
+ colorPrimary: ["color", "--pref-viewer-color-primary"],
12
+ "viewCube.colors.face": ["color", "--pref-viewer-view-cube-fill-color"],
13
+ "viewCube.colors.edge": ["color", "--pref-viewer-view-cube-edge-color"],
14
+ "viewCube.colors.corner": ["color", "--pref-viewer-view-cube-corner-color"],
15
+ "viewCube.colors.border": ["color", "--pref-viewer-view-cube-border-color"],
16
+ "viewCube.colors.text": ["color", "--pref-viewer-view-cube-text-color"],
17
+ "viewCube.colors.hover": ["color", "--pref-viewer-view-cube-hover-color"],
18
+ "viewCube.colors.active": ["color", "--pref-viewer-view-cube-active-color"],
19
+ "viewCube.hoverOpacity": ["opacity", "--pref-viewer-view-cube-hover-opacity"],
20
+ "viewCube.activeOpacity": ["opacity", "--pref-viewer-view-cube-active-opacity"],
21
+ "viewCube.typography.fontFamily": ["family", "--pref-viewer-view-cube-text-font-family"],
22
+ "viewCube.typography.fontSize": ["size", "--pref-viewer-view-cube-text-font-size"],
23
+ "viewCube.typography.fontWeight": ["weight", "--pref-viewer-view-cube-text-font-weight"],
24
+ "viewCube.typography.letterSpacing": ["spacing", "--pref-viewer-view-cube-text-letter-spacing"],
25
+ "viewCube.typography.textTransform": ["transform", "--pref-viewer-view-cube-text-transform"],
26
+ });
27
+ #document;
28
+ #surface = null;
29
+ #boundary = null;
30
+ #primaryColor = PrefViewerStyleController.#DEFAULT_PRIMARY;
31
+ #saved = new Map();
32
+ #explicit = new Map();
33
+ #persistence = { enabled: true, key: ViewerStyles.PREF_VIEWER_STYLES_STORAGE_KEY };
34
+
35
+ /** @param {Document} document - Owning document, used for CSS validation and best-effort origin storage. */
36
+ constructor(document) {
37
+ this.#document = document;
38
+ }
39
+
40
+ /** @returns {object} Detached merged overrides, including the effective primary default; omitted leaves use component defaults. */
41
+ get styles() {
42
+ return { colorPrimary: PrefViewerStyleController.#DEFAULT_PRIMARY, ...PrefViewerStyleController.#expand(this.#values()) };
43
+ }
44
+
45
+ /** @returns {string} Opaque sRGB hex primary for Babylon materials; resolved when projecting the current API styles. */
46
+ get primaryColor() {
47
+ return this.#primaryColor;
48
+ }
49
+
50
+ /** @returns {{enabled: boolean, key: string}} Detached persistence configuration; keys address this origin's localStorage. */
51
+ get persistence() {
52
+ return { ...this.#persistence };
53
+ }
54
+
55
+ /**
56
+ * Configures storage without writing it. An attached viewer reloads the selected key, preserving explicit patches/reset markers.
57
+ * @param {{enabled?: boolean, key?: string}} value - Partial options; invalid leaves are ignored. Disable before insertion to avoid reads.
58
+ * @returns {void} Storage failures leave explicit styles and defaults usable.
59
+ */
60
+ setPersistence(value) {
61
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
62
+ return;
63
+ }
64
+ if (typeof value.enabled === "boolean") {
65
+ this.#persistence.enabled = value.enabled;
66
+ }
67
+ if (typeof value.key === "string" && value.key.trim()) {
68
+ this.#persistence.key = value.key.trim();
69
+ }
70
+ if (this.#surface) {
71
+ this.#restore();
72
+ this.#project();
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Installs the CSS inheritance boundary and restores the selected saved layer without writing preferences.
78
+ * Internal density/layout rules are appended later and override reset tokens at the same specificity.
79
+ * @param {HTMLElement} surface - The root viewer's wrapper inside its ShadowRoot, never the public host.
80
+ * @returns {void} Reattachment releases the previous owned stylesheet and preserves explicit patches.
81
+ */
82
+ attach(surface) {
83
+ this.#boundary?.remove();
84
+ this.#surface = surface;
85
+ this.#boundary = this.#document.createElement("style");
86
+ const templates = Object.values(ViewerStyles).filter(value => typeof value === "string");
87
+ templates.push(ViewerStyles.PrefViewerViewCubePalette.styles, ViewerStyles.PrefViewerViewCubeTypography.styles);
88
+ const tokens = new Set(templates.join("\n").match(/--[a-zA-Z][\w-]*/g));
89
+ tokens.add("--pref-viewer-view-cube-inset-right");
90
+ this.#boundary.textContent = `.pref-viewer-wrapper {\n all: initial;\n direction: ltr;\n unicode-bidi: isolate;\n${[...tokens].map(token => ` ${token}: initial;`).join("\n")}\n}`;
91
+ surface.prepend(this.#boundary);
92
+ this.#restore();
93
+ this.#project();
94
+ }
95
+
96
+ /**
97
+ * Applies a partial patch. Null leaves/groups reset matching saved and explicit customizations; unknown/invalid values are ignored.
98
+ * @param {object} patch - Supported style tree, already parsed by the public facade.
99
+ * @returns {boolean} Whether at least one valid leaf or reset was accepted; accepted patches persist best-effort.
100
+ */
101
+ update(patch) {
102
+ const changes = this.#normalize(patch);
103
+ if (!changes.size) {
104
+ return false;
105
+ }
106
+ for (const [path, value] of changes) {
107
+ this.#explicit.set(path, value);
108
+ }
109
+ this.#project();
110
+ this.#persist();
111
+ return true;
112
+ }
113
+
114
+ /**
115
+ * Removes only the configured storage entry; current appearance remains intact and a later patch may save it again.
116
+ * @returns {boolean} True on successful removal; false when persistence is disabled or storage is unavailable.
117
+ */
118
+ clearPersisted() {
119
+ if (!this.#persistence.enabled) {
120
+ return false;
121
+ }
122
+ try {
123
+ const storage = this.#document.defaultView?.localStorage;
124
+ if (!storage) {
125
+ return false;
126
+ }
127
+ storage.removeItem(this.#persistence.key);
128
+ return true;
129
+ } catch (_error) {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ /** @returns {Map<string, string|number>} Effective overrides after applying explicit values and reset markers to stored values. */
135
+ #values() {
136
+ const values = new Map(this.#saved);
137
+ for (const [path, value] of this.#explicit) {
138
+ if (value === null) {
139
+ values.delete(path);
140
+ } else {
141
+ values.set(path, value);
142
+ }
143
+ }
144
+ return values;
145
+ }
146
+
147
+ /**
148
+ * Whitelists known leaves; a null group expands to individual reset markers so later partial changes cannot revive saved siblings.
149
+ * @param {object} input - Candidate style tree; arrays and non-objects are ignored.
150
+ * @returns {Map<string, string|number|null>} Accepted flattened leaves; no caller object references are retained.
151
+ */
152
+ #normalize(input) {
153
+ const result = new Map();
154
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
155
+ return result;
156
+ }
157
+ for (const [path, [kind]] of Object.entries(PrefViewerStyleController.#FIELDS)) {
158
+ let value = input;
159
+ for (const segment of path.split(".")) {
160
+ if (value === null) {
161
+ break;
162
+ }
163
+ if (!value || typeof value !== "object" || Array.isArray(value) || !Object.hasOwn(value, segment)) {
164
+ value = undefined;
165
+ break;
166
+ }
167
+ value = value[segment];
168
+ }
169
+ if (value === null || this.#valid(kind, value)) {
170
+ result.set(path, typeof value === "string" ? value.trim() : value);
171
+ }
172
+ }
173
+ return result;
174
+ }
175
+
176
+ /**
177
+ * Validates a leaf with native CSS parsing for colors/families and explicit numeric/enum constraints elsewhere.
178
+ * @param {string} kind - Internal schema validation kind.
179
+ * @param {*} value - Candidate leaf; CSS references and inheritance keywords are rejected.
180
+ * @returns {boolean} Whether the value is independent of external CSS and valid for the supported field.
181
+ */
182
+ #valid(kind, value) {
183
+ if (["opacity", "size", "weight", "spacing"].includes(kind)) {
184
+ if (typeof value !== "number" || !Number.isFinite(value)) {
185
+ return false;
186
+ }
187
+ if (kind === "opacity") {
188
+ return value >= 0 && value <= 1;
189
+ }
190
+ if (kind === "weight") {
191
+ return Number.isInteger(value) && value >= 1 && value <= 1000;
192
+ }
193
+ return kind === "size" ? value > 0 : value >= 0;
194
+ }
195
+ if (typeof value !== "string" || !value.trim() || /[;{}]|\b(?:var|env)\s*\(|\b(?:inherit|initial|unset|revert|revert-layer|currentcolor)\b/i.test(value)) {
196
+ return false;
197
+ }
198
+ if (kind === "transform") {
199
+ return ["none", "uppercase", "lowercase"].includes(value.trim());
200
+ }
201
+ const style = this.#document.createElement("span").style;
202
+ const property = kind === "color" ? "color" : "font-family";
203
+ style.setProperty(property, value.trim());
204
+ return Boolean(style.getPropertyValue(property));
205
+ }
206
+
207
+ /**
208
+ * Expands a whitelist-generated flat map to a detached JSON-safe object.
209
+ * @param {Map<string, string|number>} values - Known schema paths, excluding reset markers.
210
+ * @returns {object} Nested override tree suitable for getters and storage.
211
+ */
212
+ static #expand(values) {
213
+ const result = {};
214
+ for (const [path, value] of values) {
215
+ const segments = path.split(".");
216
+ let target = result;
217
+ for (const segment of segments.slice(0, -1)) {
218
+ target = target[segment] ??= {};
219
+ }
220
+ target[segments.at(-1)] = value;
221
+ }
222
+ return result;
223
+ }
224
+
225
+ /** @returns {void} Replaces the saved layer from the configured key; corrupt/unsupported data and inaccessible storage are ignored. */
226
+ #restore() {
227
+ this.#saved.clear();
228
+ if (!this.#persistence.enabled) {
229
+ return;
230
+ }
231
+ try {
232
+ const serialized = this.#document.defaultView?.localStorage?.getItem(this.#persistence.key);
233
+ const data = serialized ? JSON.parse(serialized) : null;
234
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
235
+ return;
236
+ }
237
+ const patch = data.version === 1 ? data.styles : !Object.hasOwn(data, "version") ? { colorPrimary: data.colorPrimary } : null;
238
+ this.#saved = new Map([...this.#normalize(patch)].filter(([, value]) => value !== null));
239
+ } catch (_error) {
240
+ // Storage is optional; explicit styles remain available.
241
+ }
242
+ }
243
+
244
+ /** @returns {void} Writes only effective overrides with schema version 1; blocked/full storage never interrupts viewer work. */
245
+ #persist() {
246
+ if (!this.#persistence.enabled) {
247
+ return;
248
+ }
249
+ try {
250
+ const styles = PrefViewerStyleController.#expand(this.#values());
251
+ this.#document.defaultView?.localStorage?.setItem(this.#persistence.key, JSON.stringify({ version: 1, styles }));
252
+ } catch (_error) {
253
+ // Rendering and queue processing continue when storage cannot be written.
254
+ }
255
+ }
256
+
257
+ /** @returns {void} Projects API values and fixed inherited typography onto the internal wrapper without changing the public host. */
258
+ #project() {
259
+ if (!this.#surface) {
260
+ return;
261
+ }
262
+ const values = this.#values();
263
+ const style = this.#surface.style;
264
+ style.fontFamily = ViewerStyles.PrefViewerStyleVariables.fontFamily;
265
+ style.fontSize = ViewerStyles.PrefViewerStyleVariables.fontSize;
266
+ style.fontWeight = "400";
267
+ style.fontStyle = "normal";
268
+ style.lineHeight = "1.2";
269
+ style.color = "#000000";
270
+ style.letterSpacing = "normal";
271
+ style.wordSpacing = "normal";
272
+ style.textTransform = "none";
273
+ style.textShadow = "none";
274
+ style.textAlign = "start";
275
+ for (const [path, [kind, token]] of Object.entries(PrefViewerStyleController.#FIELDS)) {
276
+ if (values.has(path)) {
277
+ const value = values.get(path);
278
+ style.setProperty(token, ["size", "spacing"].includes(kind) ? `${value}px` : String(value));
279
+ } else {
280
+ style.removeProperty(token);
281
+ }
282
+ }
283
+ this.#primaryColor = this.#resolvePrimary(this.styles.colorPrimary);
284
+ style.setProperty("--pref-viewer-color-primary", this.#primaryColor);
285
+ }
286
+
287
+ /**
288
+ * Resolves a validated CSS color to opaque sRGB for Babylon's hex-only material consumers.
289
+ * Uses computed RGB for common syntaxes and a one-pixel sRGB canvas for modern color functions.
290
+ * @param {string} color - Valid independent CSS color, including named colors and short hex.
291
+ * @returns {string} Six-digit hex, falling back to the default when browser normalization is unavailable.
292
+ */
293
+ #resolvePrimary(color) {
294
+ const probe = this.#document.createElement("span");
295
+ probe.style.color = color;
296
+ probe.style.display = "none";
297
+ this.#surface.append(probe);
298
+ try {
299
+ const computed = this.#document.defaultView.getComputedStyle(probe).color;
300
+ const rgb = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i.exec(computed);
301
+ let channels;
302
+ if (rgb) {
303
+ channels = rgb.slice(1, 4).map(Number);
304
+ } else {
305
+ const canvas = this.#document.createElement("canvas");
306
+ canvas.width = canvas.height = 1;
307
+ const context = canvas.getContext("2d", { willReadFrequently: true });
308
+ if (!context) {
309
+ return PrefViewerStyleController.#DEFAULT_PRIMARY;
310
+ }
311
+ context.fillStyle = color;
312
+ context.fillRect(0, 0, 1, 1);
313
+ channels = Array.from(context.getImageData(0, 0, 1, 1).data).slice(0, 3);
314
+ }
315
+ return `#${channels.map(channel => Math.max(0, Math.min(255, Math.round(channel))).toString(16).padStart(2, "0")).join("")}`;
316
+ } finally {
317
+ probe.remove();
318
+ }
319
+ }
320
+ }