@vanduo-oss/framework 1.5.0 → 1.6.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 (40) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +33 -6
  3. package/css/components/expanding-cards.css +94 -25
  4. package/css/components/navbar.css +11 -0
  5. package/css/components/popover.css +97 -0
  6. package/css/components/theme-customizer.css +53 -0
  7. package/css/core/colors-fib-base.css +524 -0
  8. package/css/core/colors-palette.css +786 -0
  9. package/css/core/colors.css +54 -46
  10. package/css/core/tokens.css +19 -19
  11. package/css/vanduo.css +8 -1
  12. package/dist/build-info.json +3 -3
  13. package/dist/vanduo-core.css +1588 -86
  14. package/dist/vanduo-core.css.map +1 -1
  15. package/dist/vanduo-core.min.css +2 -2
  16. package/dist/vanduo-core.min.css.map +1 -1
  17. package/dist/vanduo.cjs.js +394 -9
  18. package/dist/vanduo.cjs.js.map +3 -3
  19. package/dist/vanduo.cjs.min.js +16 -10
  20. package/dist/vanduo.cjs.min.js.map +4 -4
  21. package/dist/vanduo.css +1588 -86
  22. package/dist/vanduo.css.map +1 -1
  23. package/dist/vanduo.esm.js +394 -9
  24. package/dist/vanduo.esm.js.map +3 -3
  25. package/dist/vanduo.esm.min.js +16 -10
  26. package/dist/vanduo.esm.min.js.map +4 -4
  27. package/dist/vanduo.js +394 -9
  28. package/dist/vanduo.js.map +3 -3
  29. package/dist/vanduo.min.css +2 -2
  30. package/dist/vanduo.min.css.map +1 -1
  31. package/dist/vanduo.min.js +16 -10
  32. package/dist/vanduo.min.js.map +4 -4
  33. package/js/components/doc-search.js +9 -4
  34. package/js/components/expanding-cards.js +15 -3
  35. package/js/components/popover.js +295 -0
  36. package/js/components/search.js +108 -0
  37. package/js/components/theme-customizer.js +54 -1
  38. package/js/index.js +2 -0
  39. package/js/utils/helpers.js +12 -4
  40. package/package.json +7 -4
@@ -717,12 +717,17 @@
717
717
  }
718
718
 
719
719
  /**
720
- * Escape HTML entities
720
+ * Escape HTML entities. Escapes quotes too, so output is safe in the
721
+ * attribute contexts this module uses it in (data-category="…", class="…").
721
722
  */
722
723
  function escapeHtml(text) {
723
- const div = document.createElement('div');
724
- div.textContent = text;
725
- return div.innerHTML;
724
+ if (text === null || text === undefined) return '';
725
+ return String(text)
726
+ .replace(/&/g, '&')
727
+ .replace(/</g, '&lt;')
728
+ .replace(/>/g, '&gt;')
729
+ .replace(/"/g, '&quot;')
730
+ .replace(/'/g, '&#39;');
726
731
  }
727
732
 
728
733
  /**
@@ -45,7 +45,14 @@
45
45
  };
46
46
 
47
47
  const onKeydown = function (e) {
48
- if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'Home' && e.key !== 'End') {
48
+ if (
49
+ e.key !== 'ArrowLeft' &&
50
+ e.key !== 'ArrowRight' &&
51
+ e.key !== 'ArrowUp' &&
52
+ e.key !== 'ArrowDown' &&
53
+ e.key !== 'Home' &&
54
+ e.key !== 'End'
55
+ ) {
49
56
  return;
50
57
  }
51
58
  const cards = getCards().filter(function (c) {
@@ -61,10 +68,15 @@
61
68
  }
62
69
  if (idx < 0) idx = 0;
63
70
 
64
- if (e.key === 'ArrowLeft') {
71
+ const isVertical = window.getComputedStyle(container).flexDirection === 'column';
72
+ if (!isVertical && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
73
+ return;
74
+ }
75
+
76
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
65
77
  e.preventDefault();
66
78
  setActive(cards[Math.max(0, idx - 1)]);
67
- } else if (e.key === 'ArrowRight') {
79
+ } else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
68
80
  e.preventDefault();
69
81
  setActive(cards[Math.min(cards.length - 1, idx + 1)]);
70
82
  } else if (e.key === 'Home') {
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Vanduo Framework - Popover Component
3
+ *
4
+ * Multi-trigger popover (click | hover | focus) with auto-placement flip.
5
+ * Distinct from .vd-bubble (which is click-only rich HTML); popover is the
6
+ * general-purpose primitive documented in `sections/components/popover.html`.
7
+ *
8
+ * Markup:
9
+ * <button class="vd-popover-trigger"
10
+ * data-vd-popover-target="#my-panel"
11
+ * data-vd-popover-placement="top|bottom|left|right"
12
+ * data-vd-popover-trigger="click|hover|focus"
13
+ * aria-haspopup="dialog">…</button>
14
+ * <div id="my-panel" class="vd-popover-panel" role="dialog" hidden>…</div>
15
+ *
16
+ * Public API (window.VanduoPopover):
17
+ * init(root) Scan root for triggers, wire instances. Idempotent.
18
+ * destroy(trigger) Tear down one instance.
19
+ * destroyAll(root) Tear down every instance inside root.
20
+ * show(trigger) Open one trigger's panel.
21
+ * hide(trigger) Close one trigger's panel.
22
+ * flipPlacement(trigger) Recompute placement; flips when overflow detected.
23
+ */
24
+
25
+ (function () {
26
+ 'use strict';
27
+
28
+ const PLACEMENTS = ['top', 'bottom', 'left', 'right'];
29
+ const TRIGGERS = ['click', 'hover', 'focus'];
30
+ const DEFAULT_GAP = 8;
31
+
32
+ function resolvePlacement(preferred) {
33
+ return PLACEMENTS.indexOf(preferred) !== -1 ? preferred : 'bottom';
34
+ }
35
+
36
+ function resolveTrigger(value) {
37
+ if (!value) return ['click', 'focus'];
38
+ return value
39
+ .split(/\s+/)
40
+ .filter(function (t) { return TRIGGERS.indexOf(t) !== -1; });
41
+ }
42
+
43
+ function findPanel(trigger) {
44
+ const targetSelector = trigger.getAttribute('data-vd-popover-target');
45
+ if (!targetSelector) return null;
46
+ const doc = trigger.ownerDocument || document;
47
+ const panel = doc.querySelector(targetSelector);
48
+ if (!panel) return null;
49
+ if (!panel.classList.contains('vd-popover-panel')) {
50
+ panel.classList.add('vd-popover-panel');
51
+ }
52
+ return panel;
53
+ }
54
+
55
+ const Popover = {
56
+ instances: new Map(),
57
+ _globalCleanups: [],
58
+
59
+ init: function (root) {
60
+ const scope = root || document;
61
+ const triggers = (scope.querySelectorAll
62
+ ? scope.querySelectorAll('.vd-popover-trigger')
63
+ : document.querySelectorAll('.vd-popover-trigger'));
64
+
65
+ Array.prototype.forEach.call(triggers, function (trigger) {
66
+ if (Popover.instances.has(trigger)) return;
67
+ Popover.initInstance(trigger);
68
+ });
69
+
70
+ if (Popover._globalCleanups.length === 0) {
71
+ const outsideClick = function (event) {
72
+ Popover.instances.forEach(function (inst, trigger) {
73
+ if (inst.trigger !== 'click') return;
74
+ if (inst.panel.contains(event.target) || trigger.contains(event.target)) return;
75
+ Popover.hide(trigger);
76
+ });
77
+ };
78
+ const escHandler = function (event) {
79
+ if (event.key !== 'Escape') return;
80
+ let lastOpen = null;
81
+ Popover.instances.forEach(function (inst, trigger) {
82
+ if (inst.panel.hasAttribute('hidden') === false) lastOpen = trigger;
83
+ });
84
+ if (lastOpen) Popover.hide(lastOpen);
85
+ };
86
+ document.addEventListener('click', outsideClick, true);
87
+ document.addEventListener('keydown', escHandler);
88
+ Popover._globalCleanups.push(function () {
89
+ document.removeEventListener('click', outsideClick, true);
90
+ });
91
+ Popover._globalCleanups.push(function () {
92
+ document.removeEventListener('keydown', escHandler);
93
+ });
94
+ }
95
+ },
96
+
97
+ initInstance: function (trigger) {
98
+ const panel = findPanel(trigger);
99
+ if (!panel) return;
100
+ const cleanup = [];
101
+ const placement = resolvePlacement(trigger.getAttribute('data-vd-popover-placement'));
102
+ const triggers = resolveTrigger(trigger.getAttribute('data-vd-popover-trigger'));
103
+ const allowFlip = trigger.getAttribute('data-vd-popover-flip') !== 'false';
104
+
105
+ if (!panel.id) {
106
+ panel.id = 'vd-popover-' + Math.random().toString(36).slice(2, 9);
107
+ }
108
+ if (!panel.hasAttribute('role')) panel.setAttribute('role', 'dialog');
109
+ if (!panel.hasAttribute('aria-modal')) panel.setAttribute('aria-modal', 'false');
110
+
111
+ trigger.setAttribute('aria-haspopup', 'dialog');
112
+ trigger.setAttribute('aria-expanded', 'false');
113
+ trigger.setAttribute('aria-controls', panel.id);
114
+
115
+ if (triggers.indexOf('click') !== -1) {
116
+ const clickHandler = function (event) {
117
+ event.stopPropagation();
118
+ const expanded = trigger.getAttribute('aria-expanded') === 'true';
119
+ if (expanded) Popover.hide(trigger);
120
+ else {
121
+ Popover._closeOthers(trigger);
122
+ Popover.show(trigger);
123
+ }
124
+ };
125
+ trigger.addEventListener('click', clickHandler);
126
+ cleanup.push(function () { trigger.removeEventListener('click', clickHandler); });
127
+ }
128
+
129
+ if (triggers.indexOf('hover') !== -1) {
130
+ const enterHandler = function () { Popover.show(trigger); };
131
+ const leaveHandler = function () {
132
+ setTimeout(function () {
133
+ if (!panel.matches(':hover') && !trigger.matches(':hover')) Popover.hide(trigger);
134
+ }, 80);
135
+ };
136
+ trigger.addEventListener('mouseenter', enterHandler);
137
+ trigger.addEventListener('mouseleave', leaveHandler);
138
+ panel.addEventListener('mouseenter', enterHandler);
139
+ panel.addEventListener('mouseleave', leaveHandler);
140
+ cleanup.push(function () { trigger.removeEventListener('mouseenter', enterHandler); });
141
+ cleanup.push(function () { trigger.removeEventListener('mouseleave', leaveHandler); });
142
+ cleanup.push(function () { panel.removeEventListener('mouseenter', enterHandler); });
143
+ cleanup.push(function () { panel.removeEventListener('mouseleave', leaveHandler); });
144
+ }
145
+
146
+ if (triggers.indexOf('focus') !== -1) {
147
+ const focusHandler = function () { Popover.show(trigger); };
148
+ const blurHandler = function (event) {
149
+ if (panel.contains(event.relatedTarget)) return;
150
+ Popover.hide(trigger);
151
+ };
152
+ trigger.addEventListener('focus', focusHandler);
153
+ trigger.addEventListener('blur', blurHandler);
154
+ cleanup.push(function () { trigger.removeEventListener('focus', focusHandler); });
155
+ cleanup.push(function () { trigger.removeEventListener('blur', blurHandler); });
156
+ }
157
+
158
+ const resizeHandler = function () { Popover.flipPlacement(trigger); };
159
+ window.addEventListener('resize', resizeHandler);
160
+ window.addEventListener('scroll', resizeHandler, true);
161
+ cleanup.push(function () { window.removeEventListener('resize', resizeHandler); });
162
+ cleanup.push(function () { window.removeEventListener('scroll', resizeHandler, true); });
163
+
164
+ Popover.instances.set(trigger, {
165
+ panel: panel,
166
+ cleanup: cleanup,
167
+ placement: placement,
168
+ trigger: triggers.join(' '),
169
+ allowFlip: allowFlip
170
+ });
171
+ },
172
+
173
+ show: function (trigger) {
174
+ const inst = Popover.instances.get(trigger);
175
+ if (!inst) return;
176
+ inst.panel.hidden = false;
177
+ // Two rAFs: first lets layout settle, second positions after styles applied.
178
+ requestAnimationFrame(function () {
179
+ Popover.position(trigger, inst.panel, inst.placement);
180
+ trigger.setAttribute('aria-expanded', 'true');
181
+ inst.panel.setAttribute('data-placement', inst.placement);
182
+ trigger.dispatchEvent(new CustomEvent('popover:show', {
183
+ bubbles: true,
184
+ detail: { trigger: trigger, placement: inst.placement }
185
+ }));
186
+ });
187
+ },
188
+
189
+ hide: function (trigger) {
190
+ const inst = Popover.instances.get(trigger);
191
+ if (!inst) return;
192
+ inst.panel.hidden = true;
193
+ trigger.setAttribute('aria-expanded', 'false');
194
+ trigger.dispatchEvent(new CustomEvent('popover:hide', {
195
+ bubbles: true,
196
+ detail: { trigger: trigger }
197
+ }));
198
+ },
199
+
200
+ _closeOthers: function (currentTrigger) {
201
+ Popover.instances.forEach(function (_inst, trigger) {
202
+ if (trigger !== currentTrigger) Popover.hide(trigger);
203
+ });
204
+ },
205
+
206
+ position: function (trigger, panel, placement) {
207
+ const rect = trigger.getBoundingClientRect();
208
+ const popRect = panel.getBoundingClientRect();
209
+ const gap = DEFAULT_GAP;
210
+ let top;
211
+ let left;
212
+ const win = panel.ownerDocument.defaultView || window;
213
+ const scrollX = win.pageXOffset || 0;
214
+ const scrollY = win.pageYOffset || 0;
215
+
216
+ switch (placement) {
217
+ case 'top':
218
+ top = rect.top - popRect.height - gap + scrollY;
219
+ left = rect.left + (rect.width - popRect.width) / 2 + scrollX;
220
+ break;
221
+ case 'left':
222
+ top = rect.top + (rect.height - popRect.height) / 2 + scrollY;
223
+ left = rect.left - popRect.width - gap + scrollX;
224
+ break;
225
+ case 'right':
226
+ top = rect.top + (rect.height - popRect.height) / 2 + scrollY;
227
+ left = rect.right + gap + scrollX;
228
+ break;
229
+ default: // bottom
230
+ top = rect.bottom + gap + scrollY;
231
+ left = rect.left + (rect.width - popRect.width) / 2 + scrollX;
232
+ }
233
+
234
+ left = Math.max(8, Math.min(left, win.innerWidth - popRect.width - 8));
235
+ top = Math.max(8, top);
236
+
237
+ panel.style.position = 'absolute';
238
+ panel.style.top = top + 'px';
239
+ panel.style.left = left + 'px';
240
+ },
241
+
242
+ flipPlacement: function (trigger) {
243
+ const inst = Popover.instances.get(trigger);
244
+ if (!inst || !inst.allowFlip) return;
245
+ if (inst.panel.hidden) return;
246
+ const win = inst.panel.ownerDocument.defaultView || window;
247
+ const rect = trigger.getBoundingClientRect();
248
+ const popRect = inst.panel.getBoundingClientRect();
249
+ const gap = DEFAULT_GAP;
250
+ let flipped = null;
251
+ const current = inst.placement;
252
+
253
+ if (current === 'top' && rect.top - popRect.height - gap < 0) flipped = 'bottom';
254
+ else if (current === 'bottom' && rect.bottom + popRect.height + gap > win.innerHeight) flipped = 'top';
255
+ else if (current === 'left' && rect.left - popRect.width - gap < 0) flipped = 'right';
256
+ else if (current === 'right' && rect.right + popRect.width + gap > win.innerWidth) flipped = 'left';
257
+
258
+ if (flipped) {
259
+ inst.placement = flipped;
260
+ Popover.position(trigger, inst.panel, flipped);
261
+ inst.panel.setAttribute('data-placement', flipped);
262
+ }
263
+ },
264
+
265
+ destroy: function (trigger) {
266
+ const inst = Popover.instances.get(trigger);
267
+ if (!inst) return;
268
+ inst.cleanup.forEach(function (fn) { fn(); });
269
+ Popover.hide(trigger);
270
+ trigger.removeAttribute('aria-haspopup');
271
+ trigger.removeAttribute('aria-expanded');
272
+ trigger.removeAttribute('aria-controls');
273
+ Popover.instances.delete(trigger);
274
+ },
275
+
276
+ destroyAll: function (root) {
277
+ const triggers = [];
278
+ Popover.instances.forEach(function (_inst, trigger) {
279
+ if (!root || (trigger.closest && trigger.closest(root))) triggers.push(trigger);
280
+ });
281
+ triggers.forEach(function (trigger) { Popover.destroy(trigger); });
282
+ if (Popover.instances.size === 0) {
283
+ Popover._globalCleanups.forEach(function (fn) { fn(); });
284
+ Popover._globalCleanups = [];
285
+ }
286
+ }
287
+ };
288
+
289
+ if (typeof window !== 'undefined') {
290
+ if (typeof window.Vanduo !== 'undefined' && typeof window.Vanduo.register === 'function') {
291
+ window.Vanduo.register('popover', Popover);
292
+ }
293
+ window.VanduoPopover = Popover;
294
+ }
295
+ })();
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Vanduo Framework - Search Helper
3
+ *
4
+ * Small registry that lets consumers plug named data sources into a search
5
+ * overlay. The framework does not ship a UI — overlays like
6
+ * `GlobalSearchModal` consume the registry.
7
+ *
8
+ * Source shape:
9
+ * {
10
+ * name: 'sections', // unique key
11
+ * label: 'Documentation Sections', // display name in groups
12
+ * icon: 'ph-book-open', // optional Phosphor class
13
+ * limit: 10, // optional cap (default 10)
14
+ * fetch: async (query, signal) => [...] // returns Result[]
15
+ * }
16
+ *
17
+ * Result shape:
18
+ * { title, subtitle?, href, group?, icon? }
19
+ *
20
+ * Public API (window.VanduoSearch):
21
+ * register(source) Add a source. Throws on duplicate name.
22
+ * unregister(name) Remove a source. Returns boolean.
23
+ * list() Return registered sources (frozen array).
24
+ * query(text, options?) Search every source in parallel.
25
+ * options: { signal?, limitPerSource? }
26
+ * Returns { sources: { name, results, error? }[] }
27
+ */
28
+
29
+ (function () {
30
+ 'use strict';
31
+
32
+ const DEFAULT_LIMIT = 10;
33
+
34
+ const sources = new Map();
35
+
36
+ function register(source) {
37
+ if (!source || typeof source.name !== 'string' || source.name.length === 0) {
38
+ throw new Error('VanduoSearch.register: source.name is required');
39
+ }
40
+ if (typeof source.fetch !== 'function') {
41
+ throw new Error('VanduoSearch.register: source.fetch must be a function');
42
+ }
43
+ if (sources.has(source.name)) {
44
+ throw new Error('VanduoSearch.register: source "' + source.name + '" already registered');
45
+ }
46
+ sources.set(source.name, Object.freeze({
47
+ name: source.name,
48
+ label: source.label || source.name,
49
+ icon: source.icon || null,
50
+ limit: typeof source.limit === 'number' ? source.limit : DEFAULT_LIMIT,
51
+ fetch: source.fetch
52
+ }));
53
+ }
54
+
55
+ function unregister(name) {
56
+ return sources.delete(name);
57
+ }
58
+
59
+ function list() {
60
+ return Object.freeze(Array.from(sources.values()));
61
+ }
62
+
63
+ function query(text, options) {
64
+ options = options || {};
65
+ const signal = options.signal;
66
+ const limitPerSource = typeof options.limitPerSource === 'number'
67
+ ? options.limitPerSource
68
+ : null;
69
+ const queryText = (text || '').trim();
70
+ const allSources = Array.from(sources.values());
71
+
72
+ if (queryText.length === 0) {
73
+ return Promise.resolve({
74
+ text: queryText,
75
+ sources: allSources.map(function (src) {
76
+ return { name: src.name, label: src.label, results: [] };
77
+ })
78
+ });
79
+ }
80
+
81
+ const promises = allSources.map(function (src) {
82
+ const effectiveLimit = limitPerSource != null ? limitPerSource : src.limit;
83
+ return Promise.resolve()
84
+ .then(function () { return src.fetch(queryText, { signal: signal, limit: effectiveLimit }); })
85
+ .then(function (results) {
86
+ const safe = Array.isArray(results) ? results : [];
87
+ return { name: src.name, label: src.label, results: safe };
88
+ })
89
+ .catch(function (error) {
90
+ if (error && error.name === 'AbortError') throw error;
91
+ return { name: src.name, label: src.label, results: [], error: error.message || 'fetch failed' };
92
+ });
93
+ });
94
+
95
+ return Promise.all(promises).then(function (perSource) {
96
+ return { text: queryText, sources: perSource };
97
+ });
98
+ }
99
+
100
+ const Search = { register: register, unregister: unregister, list: list, query: query };
101
+
102
+ if (typeof window !== 'undefined') {
103
+ if (typeof window.Vanduo !== 'undefined' && typeof window.Vanduo.register === 'function') {
104
+ window.Vanduo.register('search', Search);
105
+ }
106
+ window.VanduoSearch = Search;
107
+ }
108
+ })();
@@ -10,6 +10,7 @@
10
10
  const ThemeCustomizer = {
11
11
  // Storage keys
12
12
  STORAGE_KEYS: {
13
+ PALETTE: 'vanduo-palette',
13
14
  PRIMARY: 'vanduo-primary-color',
14
15
  NEUTRAL: 'vanduo-neutral-color',
15
16
  RADIUS: 'vanduo-radius',
@@ -19,6 +20,7 @@
19
20
 
20
21
  // Default values
21
22
  DEFAULTS: {
23
+ PALETTE: 'open-color',
22
24
  PRIMARY_LIGHT: 'black',
23
25
  PRIMARY_DARK: 'amber',
24
26
  NEUTRAL: 'charcoal',
@@ -27,7 +29,13 @@
27
29
  THEME: 'system'
28
30
  },
29
31
 
30
- // Primary color definitions (Open Color based)
32
+ // Palette options (the Open Color default + the optional Fibonacci palette)
33
+ PALETTE_OPTIONS: {
34
+ 'open-color': { name: 'Open Color', description: 'The Open Color palette (MIT) — the Vanduo default.' },
35
+ 'fibonacci': { name: 'Fibonacci', description: 'Golden-angle generated palette — optional, opt in via data-palette="fibonacci".' }
36
+ },
37
+
38
+ // Primary color definitions (palette-aware: shown over the active palette)
31
39
  PRIMARY_COLORS: {
32
40
  'black': { name: 'Black', color: '#000000' },
33
41
  'red': { name: 'Red', color: '#fa5252' },
@@ -76,6 +84,7 @@
76
84
 
77
85
  // State
78
86
  state: {
87
+ palette: null,
79
88
  primary: null,
80
89
  neutral: null,
81
90
  radius: null,
@@ -194,6 +203,7 @@
194
203
  * Load preferences from localStorage
195
204
  */
196
205
  loadPreferences: function () {
206
+ this.state.palette = this.getStorageValue(this.STORAGE_KEYS.PALETTE, this.DEFAULTS.PALETTE);
197
207
  this.state.theme = this.getStorageValue(this.STORAGE_KEYS.THEME, this.DEFAULTS.THEME);
198
208
  this.state.primary = this.getStorageValue(this.STORAGE_KEYS.PRIMARY, this.getDefaultPrimary(this.state.theme));
199
209
  this._normalizeDefaultPrimaryIfStaleWithStoredTheme();
@@ -213,6 +223,7 @@
213
223
  * Apply all preferences
214
224
  */
215
225
  applyAllPreferences: function () {
226
+ this.applyPalette(this.state.palette);
216
227
  this.applyPrimary(this.state.primary);
217
228
  this.applyNeutral(this.state.neutral);
218
229
  this.applyRadius(this.state.radius);
@@ -220,6 +231,21 @@
220
231
  this.applyTheme(this.state.theme);
221
232
  },
222
233
 
234
+ /**
235
+ * Apply color palette (Fibonacci default / Open Color optional)
236
+ */
237
+ applyPalette: function (paletteKey) {
238
+ if (!this.PALETTE_OPTIONS[paletteKey]) {
239
+ paletteKey = this.DEFAULTS.PALETTE;
240
+ }
241
+
242
+ this.state.palette = paletteKey;
243
+ document.documentElement.setAttribute('data-palette', paletteKey);
244
+ this.savePreference(this.STORAGE_KEYS.PALETTE, paletteKey);
245
+
246
+ this.dispatchEvent('palette-change', { palette: paletteKey });
247
+ },
248
+
223
249
  /**
224
250
  * Apply primary color
225
251
  */
@@ -492,6 +518,14 @@
492
518
 
493
519
  this.elements.panel.setAttribute('data-customizer-initialized', 'true');
494
520
 
521
+ // Palette buttons
522
+ this.elements.panel.querySelectorAll('[data-palette]').forEach(btn => {
523
+ this.addListener(btn, 'click', () => {
524
+ this.applyPalette(btn.dataset.palette);
525
+ this.updateUI();
526
+ });
527
+ });
528
+
495
529
  // Primary color swatches
496
530
  this.elements.panel.querySelectorAll('[data-color]').forEach(swatch => {
497
531
  this.addListener(swatch, 'click', () => {
@@ -569,6 +603,12 @@
569
603
  return '#000000';
570
604
  };
571
605
 
606
+ // Generate palette options
607
+ let paletteOptions = '';
608
+ for (const [key, value] of Object.entries(this.PALETTE_OPTIONS)) {
609
+ paletteOptions += `<button class="tc-palette-btn${key === this.state.palette ? ' is-active' : ''}" data-palette="${esc(key)}" title="${esc(value.description)}">${esc(value.name)}</button>`;
610
+ }
611
+
572
612
  // Generate primary color swatches
573
613
  let primarySwatches = '';
574
614
  for (const [key, value] of Object.entries(this.PRIMARY_COLORS)) {
@@ -602,6 +642,12 @@
602
642
  </div>
603
643
  <div class="tc-body">
604
644
 
645
+ <div class="tc-section">
646
+ <label class="tc-label">Palette</label>
647
+ <div class="tc-palette-group">
648
+ ${paletteOptions}
649
+ </div>
650
+ </div>
605
651
  <div class="tc-section">
606
652
  <label class="tc-label">Primary Color</label>
607
653
  <div class="tc-color-grid">
@@ -798,6 +844,11 @@
798
844
  updateUI: function () {
799
845
  if (!this.elements.panel) return;
800
846
 
847
+ // Update palette buttons
848
+ this.elements.panel.querySelectorAll('[data-palette]').forEach(btn => {
849
+ btn.classList.toggle('is-active', btn.dataset.palette === this.state.palette);
850
+ });
851
+
801
852
  // Update primary color swatches
802
853
  this.elements.panel.querySelectorAll('[data-color]').forEach(swatch => {
803
854
  swatch.classList.toggle('is-active', swatch.dataset.color === this.state.primary);
@@ -825,6 +876,7 @@
825
876
  * Reset all preferences to defaults
826
877
  */
827
878
  reset: function () {
879
+ this.applyPalette(this.DEFAULTS.PALETTE);
828
880
  this.applyTheme(this.DEFAULTS.THEME);
829
881
  this.applyPrimary(this.getDefaultPrimary(this.DEFAULTS.THEME));
830
882
  this.applyNeutral(this.DEFAULTS.NEUTRAL);
@@ -846,6 +898,7 @@
846
898
  * Programmatically set preferences
847
899
  */
848
900
  setPreferences: function (prefs) {
901
+ if (prefs.palette) this.applyPalette(prefs.palette);
849
902
  if (prefs.primary) this.applyPrimary(prefs.primary);
850
903
  if (prefs.neutral) this.applyNeutral(prefs.neutral);
851
904
  if (prefs.radius) this.applyRadius(prefs.radius);
package/js/index.js CHANGED
@@ -68,6 +68,8 @@ import './components/rating.js';
68
68
  import './components/transfer.js';
69
69
  import './components/tree.js';
70
70
  import './components/spotlight.js';
71
+ import './components/popover.js';
72
+ import './components/search.js';
71
73
 
72
74
  // Re-export for ESM / CJS consumers
73
75
  const Vanduo = window.Vanduo;
@@ -248,9 +248,14 @@ function getPosition(element) {
248
248
  */
249
249
  function escapeHtml(str) {
250
250
  if (!str) return '';
251
- const div = document.createElement('div');
252
- div.appendChild(document.createTextNode(str));
253
- return div.innerHTML;
251
+ // Escape quotes too, so output is safe in attribute context (e.g.
252
+ // title="${escapeHtml(x)}"), not just element-text context.
253
+ return String(str)
254
+ .replace(/&/g, '&amp;')
255
+ .replace(/</g, '&lt;')
256
+ .replace(/>/g, '&gt;')
257
+ .replace(/"/g, '&quot;')
258
+ .replace(/'/g, '&#39;');
254
259
  }
255
260
 
256
261
  /**
@@ -271,7 +276,10 @@ function sanitizeHtml(input, options = {}) {
271
276
  return escapeHtml(input);
272
277
  }
273
278
  const allowSvg = options && options.allowSvg === true;
274
- const allowStyle = !options || options.allowStyle !== false;
279
+ // Secure default: deny inline `style` unless explicitly opted in. (Inline
280
+ // style can't run JS but enables CSS exfiltration/clickjacking on untrusted
281
+ // HTML.) Callers that need rich styling pass { allowStyle: true }.
282
+ const allowStyle = options && options.allowStyle === true;
275
283
  const baseAllowed = ['B', 'STRONG', 'I', 'EM', 'BR', 'A', 'SPAN', 'U', 'DIV', 'P', 'KBD', 'CODE', 'SMALL', 'MARK'];
276
284
  const svgAllowed = ['SVG', 'PATH', 'LINE', 'CIRCLE', 'POLYLINE', 'RECT', 'G'];
277
285
  const allowed = allowSvg ? baseAllowed.concat(svgAllowed) : baseAllowed;
package/package.json CHANGED
@@ -1,6 +1,9 @@
1
1
  {
2
2
  "name": "@vanduo-oss/framework",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
4
7
  "description": "Zero-dependency CSS/JS framework built on Fibonacci/Golden Ratio design system with Open Color integration",
5
8
  "keywords": [
6
9
  "css",
@@ -44,12 +47,12 @@
44
47
  ],
45
48
  "devDependencies": {
46
49
  "@eslint/js": "^10.0.1",
47
- "@playwright/test": "^1.60.0",
50
+ "@playwright/test": "^1.61.1",
48
51
  "esbuild": "^0.28.1",
49
- "eslint": "^10.4.1",
52
+ "eslint": "^10.5.0",
50
53
  "husky": "^9.1.7",
51
54
  "lightningcss": "^1.32.0",
52
- "stylelint": "^17.12.0",
55
+ "stylelint": "^17.13.0",
53
56
  "stylelint-config-standard": "^40.0.0"
54
57
  },
55
58
  "engines": {