@profpowell/vb-project-planning 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -0
  3. package/dist/vb-project-planning.css +1603 -0
  4. package/dist/vb-project-planning.js +11138 -0
  5. package/dist/vb-project-planning.min.css +1 -0
  6. package/dist/vb-project-planning.min.js +3312 -0
  7. package/package.json +80 -0
  8. package/src/index.css +14 -0
  9. package/src/index.js +26 -0
  10. package/src/lib/bundle-registry.js +156 -0
  11. package/src/lib/diff-by-key.js +86 -0
  12. package/src/lib/portrait-url.js +87 -0
  13. package/src/lib/vb-collection.js +122 -0
  14. package/src/lib/vb-element.js +96 -0
  15. package/src/lib/vb-store.js +200 -0
  16. package/src/lib/vb-view-transition.js +44 -0
  17. package/src/utils/copy-init.js +287 -0
  18. package/src/web-components/_ux-base.js +106 -0
  19. package/src/web-components/adr-wc/README.md +77 -0
  20. package/src/web-components/adr-wc/api.json +26 -0
  21. package/src/web-components/adr-wc/logic.js +376 -0
  22. package/src/web-components/adr-wc/static.html +17 -0
  23. package/src/web-components/adr-wc/styles.js +338 -0
  24. package/src/web-components/burndown-chart/api.json +74 -0
  25. package/src/web-components/burndown-chart/logic.js +198 -0
  26. package/src/web-components/burndown-chart/static.html +31 -0
  27. package/src/web-components/burndown-chart/styles.css +59 -0
  28. package/src/web-components/capacity-plan/_capacity-utils.js +56 -0
  29. package/src/web-components/capacity-plan/api.json +20 -0
  30. package/src/web-components/capacity-plan/logic.js +222 -0
  31. package/src/web-components/capacity-plan/static.html +24 -0
  32. package/src/web-components/capacity-plan/styles.css +84 -0
  33. package/src/web-components/empathy-map/api.json +31 -0
  34. package/src/web-components/empathy-map/logic.js +502 -0
  35. package/src/web-components/empathy-map/static.html +32 -0
  36. package/src/web-components/empathy-map/styles.js +534 -0
  37. package/src/web-components/gantt-chart/README.md +91 -0
  38. package/src/web-components/gantt-chart/api.json +20 -0
  39. package/src/web-components/gantt-chart/logic.js +812 -0
  40. package/src/web-components/gantt-chart/static.html +60 -0
  41. package/src/web-components/gantt-chart/styles.css +391 -0
  42. package/src/web-components/impact-effort/api.json +21 -0
  43. package/src/web-components/impact-effort/logic.js +392 -0
  44. package/src/web-components/impact-effort/static.html +23 -0
  45. package/src/web-components/impact-effort/styles.css +193 -0
  46. package/src/web-components/iron-triangle/_capacity.js +83 -0
  47. package/src/web-components/iron-triangle/_triangle-geometry.js +322 -0
  48. package/src/web-components/iron-triangle/api.json +26 -0
  49. package/src/web-components/iron-triangle/logic.js +474 -0
  50. package/src/web-components/iron-triangle/static.html +28 -0
  51. package/src/web-components/iron-triangle/styles.css +158 -0
  52. package/src/web-components/kanban-board/api.json +24 -0
  53. package/src/web-components/kanban-board/logic.js +698 -0
  54. package/src/web-components/kanban-board/static.html +20 -0
  55. package/src/web-components/kanban-board/styles.css +242 -0
  56. package/src/web-components/product-roadmap/api.json +118 -0
  57. package/src/web-components/product-roadmap/logic.js +460 -0
  58. package/src/web-components/product-roadmap/static.html +44 -0
  59. package/src/web-components/product-roadmap/styles.css +158 -0
  60. package/src/web-components/quadrant-grid/api.json +125 -0
  61. package/src/web-components/quadrant-grid/logic.js +397 -0
  62. package/src/web-components/quadrant-grid/static.html +49 -0
  63. package/src/web-components/quadrant-grid/styles.css +184 -0
  64. package/src/web-components/quality-target/_quality-utils.js +460 -0
  65. package/src/web-components/quality-target/api.json +30 -0
  66. package/src/web-components/quality-target/logic.js +516 -0
  67. package/src/web-components/quality-target/static.html +22 -0
  68. package/src/web-components/quality-target/styles.css +203 -0
  69. package/src/web-components/requirement-card/api.json +25 -0
  70. package/src/web-components/requirement-card/logic.js +166 -0
  71. package/src/web-components/requirement-card/static.html +36 -0
  72. package/src/web-components/requirement-card/styles.js +114 -0
  73. package/src/web-components/review-surface/README.md +178 -0
  74. package/src/web-components/review-surface/api.json +30 -0
  75. package/src/web-components/review-surface/logic.js +807 -0
  76. package/src/web-components/review-surface/static.html +33 -0
  77. package/src/web-components/review-surface/styles.js +459 -0
  78. package/src/web-components/risk-register/api.json +47 -0
  79. package/src/web-components/risk-register/logic.js +180 -0
  80. package/src/web-components/risk-register/static.html +46 -0
  81. package/src/web-components/risk-register/styles.css +37 -0
  82. package/src/web-components/story-map/api.json +22 -0
  83. package/src/web-components/story-map/logic.js +445 -0
  84. package/src/web-components/story-map/static.html +35 -0
  85. package/src/web-components/story-map/styles.css +169 -0
  86. package/src/web-components/traceability-matrix/api.json +84 -0
  87. package/src/web-components/traceability-matrix/logic.js +243 -0
  88. package/src/web-components/traceability-matrix/static.html +41 -0
  89. package/src/web-components/traceability-matrix/styles.css +94 -0
  90. package/src/web-components/user-journey/api.json +84 -0
  91. package/src/web-components/user-journey/logic.js +368 -0
  92. package/src/web-components/user-journey/static.html +14 -0
  93. package/src/web-components/user-journey/styles.js +317 -0
  94. package/src/web-components/user-persona/api.json +35 -0
  95. package/src/web-components/user-persona/logic.js +502 -0
  96. package/src/web-components/user-persona/static.html +20 -0
  97. package/src/web-components/user-persona/styles.js +299 -0
  98. package/src/web-components/user-story/api.json +35 -0
  99. package/src/web-components/user-story/logic.js +567 -0
  100. package/src/web-components/user-story/static.html +27 -0
  101. package/src/web-components/user-story/styles.js +332 -0
  102. package/src/web-components/work-item/README.md +85 -0
  103. package/src/web-components/work-item/api.json +30 -0
  104. package/src/web-components/work-item/logic.js +414 -0
  105. package/src/web-components/work-item/static.html +24 -0
  106. package/src/web-components/work-item/styles.js +376 -0
@@ -0,0 +1,200 @@
1
+ /**
2
+ * VBStore — unified client-side storage for Vanilla Breeze components.
3
+ *
4
+ * Static async API over a pluggable backend. Default backend is localStorage.
5
+ * Keys are namespaced (`vb:{namespace}:{key}`); values are wrapped in an
6
+ * envelope `{ data, timestamp }` so `get()` can enforce expiry via maxAge.
7
+ *
8
+ * Async (await) even though localStorage is synchronous — this lets the
9
+ * backend swap to IndexedDB or a network store later without touching
10
+ * component code.
11
+ *
12
+ * @example
13
+ * import { VBStore } from './lib/vb-store.js';
14
+ *
15
+ * await VBStore.set('notifications', 'dismissed-v3', { by: 'user' });
16
+ * const val = await VBStore.get('notifications', 'dismissed-v3');
17
+ * const fresh = await VBStore.get('consent', 'banner', { maxAge: 365 * 86400_000 });
18
+ * await VBStore.clear('notifications');
19
+ * await VBStore.clearAll();
20
+ *
21
+ * Spec: admin/plans/april13-plan/vb-store.md
22
+ */
23
+
24
+ const PREFIX = 'vb:';
25
+
26
+ /** @typedef {{ data: unknown, timestamp: number }} Envelope */
27
+
28
+ /**
29
+ * @typedef {object} Backend
30
+ * @property {(key: string) => Promise<string | null>} getRaw
31
+ * @property {(key: string, value: string) => Promise<void>} setRaw
32
+ * @property {(key: string) => Promise<void>} removeRaw
33
+ * @property {(prefix: string) => Promise<string[]>} keys
34
+ */
35
+
36
+ /** @returns {Backend} */
37
+ function createLocalStorageBackend() {
38
+ const storage = globalThis.localStorage;
39
+ if (!storage) {
40
+ throw new Error('VBStore: localStorage is not available in this environment');
41
+ }
42
+ return {
43
+ async getRaw(key) {
44
+ return storage.getItem(key);
45
+ },
46
+ async setRaw(key, value) {
47
+ storage.setItem(key, value);
48
+ },
49
+ async removeRaw(key) {
50
+ storage.removeItem(key);
51
+ },
52
+ async keys(prefix) {
53
+ const out = [];
54
+ for (let i = 0; i < storage.length; i++) {
55
+ const k = storage.key(i);
56
+ if (k && k.startsWith(prefix)) out.push(k);
57
+ }
58
+ return out;
59
+ },
60
+ };
61
+ }
62
+
63
+ /** @type {Backend | null} */
64
+ let backend = null;
65
+
66
+ function getBackend() {
67
+ if (!backend) backend = createLocalStorageBackend();
68
+ return backend;
69
+ }
70
+
71
+ function fullKey(namespace, key) {
72
+ if (typeof namespace !== 'string' || !namespace) {
73
+ throw new TypeError('VBStore: namespace must be a non-empty string');
74
+ }
75
+ if (typeof key !== 'string' || !key) {
76
+ throw new TypeError('VBStore: key must be a non-empty string');
77
+ }
78
+ return `${PREFIX}${namespace}:${key}`;
79
+ }
80
+
81
+ function namespacePrefix(namespace) {
82
+ if (typeof namespace !== 'string' || !namespace) {
83
+ throw new TypeError('VBStore: namespace must be a non-empty string');
84
+ }
85
+ return `${PREFIX}${namespace}:`;
86
+ }
87
+
88
+ /**
89
+ * @param {string} raw
90
+ * @returns {Envelope | null}
91
+ */
92
+ function parseEnvelope(raw) {
93
+ try {
94
+ const parsed = JSON.parse(raw);
95
+ if (parsed && typeof parsed === 'object' && typeof parsed.timestamp === 'number') {
96
+ return /** @type {Envelope} */ (parsed);
97
+ }
98
+ } catch {
99
+ /* fall through to null */
100
+ }
101
+ return null;
102
+ }
103
+
104
+ export const VBStore = {
105
+ /**
106
+ * Swap the backend. Testing helper; not intended for normal use.
107
+ * Pass `null` to reset to the default localStorage backend.
108
+ * @param {{ backend?: Backend | null }} [config]
109
+ */
110
+ configure(config = {}) {
111
+ backend = config.backend ?? null;
112
+ },
113
+
114
+ /**
115
+ * Persist a value under `namespace:key`. Always stamps a timestamp.
116
+ * @param {string} namespace
117
+ * @param {string} key
118
+ * @param {unknown} value
119
+ */
120
+ async set(namespace, key, value) {
121
+ /** @type {Envelope} */
122
+ const envelope = { data: value, timestamp: Date.now() };
123
+ await getBackend().setRaw(fullKey(namespace, key), JSON.stringify(envelope));
124
+ },
125
+
126
+ /**
127
+ * Read a value under `namespace:key`. Returns `null` if missing, unparseable,
128
+ * or older than `options.maxAge` milliseconds.
129
+ * @param {string} namespace
130
+ * @param {string} key
131
+ * @param {{ maxAge?: number }} [options]
132
+ * @returns {Promise<unknown | null>}
133
+ */
134
+ async get(namespace, key, options) {
135
+ const raw = await getBackend().getRaw(fullKey(namespace, key));
136
+ if (raw == null) return null;
137
+ const envelope = parseEnvelope(raw);
138
+ if (!envelope) return null;
139
+ if (options?.maxAge != null && Date.now() - envelope.timestamp > options.maxAge) {
140
+ return null;
141
+ }
142
+ return envelope.data;
143
+ },
144
+
145
+ /**
146
+ * Delete a single key.
147
+ * @param {string} namespace
148
+ * @param {string} key
149
+ */
150
+ async remove(namespace, key) {
151
+ await getBackend().removeRaw(fullKey(namespace, key));
152
+ },
153
+
154
+ /**
155
+ * List every `{ key, data, timestamp }` entry in a namespace.
156
+ * Unparseable entries are skipped silently.
157
+ * @param {string} namespace
158
+ * @returns {Promise<Array<{ key: string, data: unknown, timestamp: number }>>}
159
+ */
160
+ async list(namespace) {
161
+ const prefix = namespacePrefix(namespace);
162
+ const keys = await getBackend().keys(prefix);
163
+ const out = [];
164
+ for (const full of keys) {
165
+ const raw = await getBackend().getRaw(full);
166
+ if (raw == null) continue;
167
+ const env = parseEnvelope(raw);
168
+ if (!env) continue;
169
+ out.push({ key: full.slice(prefix.length), data: env.data, timestamp: env.timestamp });
170
+ }
171
+ return out;
172
+ },
173
+
174
+ /**
175
+ * Remove every key under a namespace.
176
+ * @param {string} namespace
177
+ */
178
+ async clear(namespace) {
179
+ const prefix = namespacePrefix(namespace);
180
+ const keys = await getBackend().keys(prefix);
181
+ for (const k of keys) await getBackend().removeRaw(k);
182
+ },
183
+
184
+ /** Remove every `vb:*` key. Leaves unrelated storage alone. */
185
+ async clearAll() {
186
+ const keys = await getBackend().keys(PREFIX);
187
+ for (const k of keys) await getBackend().removeRaw(k);
188
+ },
189
+
190
+ /**
191
+ * Persist many entries under the same namespace.
192
+ * @param {string} namespace
193
+ * @param {Array<[string, unknown]>} entries
194
+ */
195
+ async setMany(namespace, entries) {
196
+ for (const [key, value] of entries) {
197
+ await VBStore.set(namespace, key, value);
198
+ }
199
+ },
200
+ };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * viewTransitionSwap — wrap a DOM mutation in document.startViewTransition
3
+ * so the swap crossfades instead of flashing.
4
+ *
5
+ * Falls back to a plain swap when:
6
+ * - the host is not connected,
7
+ * - startViewTransition is unavailable (e.g. Firefox today),
8
+ * - prefers-reduced-motion is set.
9
+ *
10
+ * Same pattern used inline by diagram-wc, flow-diagram, gantt-chart,
11
+ * and site-map (vanilla-breeze-4tsb); extracted for the components
12
+ * covered by vanilla-breeze-7ixr.
13
+ *
14
+ * @param {HTMLElement} host - Element to pin the transition to. The browser
15
+ * snapshots the host's painted area (including shadow content) and uses
16
+ * the unique view-transition-name to pair the old and new snapshots.
17
+ * @param {() => void} swap - Synchronous DOM mutation. Runs before the
18
+ * browser captures the "new" snapshot.
19
+ * @param {string} [prefix='vb-vt'] - Name prefix; aids debugging in DevTools.
20
+ * @returns {ViewTransition | null} The live transition, or null when the
21
+ * fallback path was taken.
22
+ */
23
+ let counter = 0;
24
+
25
+ export function viewTransitionSwap(host, swap, prefix = 'vb-vt') {
26
+ if (
27
+ !host?.isConnected
28
+ || typeof document === 'undefined'
29
+ || !('startViewTransition' in document)
30
+ || matchMedia('(prefers-reduced-motion: reduce)').matches
31
+ ) {
32
+ swap();
33
+ return null;
34
+ }
35
+ const name = `${prefix}-${++counter}`;
36
+ host.style.viewTransitionName = name;
37
+ const tx = document.startViewTransition(swap);
38
+ tx.finished.finally(() => {
39
+ if (host.style.viewTransitionName === name) {
40
+ host.style.viewTransitionName = '';
41
+ }
42
+ });
43
+ return tx;
44
+ }
@@ -0,0 +1,287 @@
1
+ /**
2
+ * copy-init: Clipboard copy + paste via data attributes + programmatic helpers
3
+ *
4
+ * Enhances buttons with data-copy / data-copy-target / data-paste-target
5
+ * to interact with the clipboard. Also exports copyText(), copyRich(),
6
+ * and pasteFromClipboard() for components that need clipboard access
7
+ * with VB's standard a11y + feedback semantics.
8
+ *
9
+ * @attr {string} data-copy - Static text to copy
10
+ * @attr {string} data-copy-target - CSS selector for element whose textContent to copy
11
+ * @attr {string} data-copy-attr - When paired with data-copy-target, copy the named
12
+ * attribute of the target instead of its textContent
13
+ * @attr {string} data-paste-target - CSS selector for element to receive pasted clipboard text
14
+ *
15
+ * @example Static text
16
+ * <button data-copy="npm install vanilla-breeze">Copy</button>
17
+ *
18
+ * @example Target element
19
+ * <button data-copy-target="#code-block">Copy code</button>
20
+ *
21
+ * @example Target attribute
22
+ * <output id="swatch" value="#3366cc">#3366cc</output>
23
+ * <button data-copy-target="#swatch" data-copy-attr="value">Copy hex</button>
24
+ *
25
+ * @example Paste into a target
26
+ * <input id="dest" type="text">
27
+ * <button data-paste-target="#dest">Paste</button>
28
+ *
29
+ * @example Programmatic
30
+ * import { copyText, copyRich, pasteFromClipboard } from '/src/utils/copy-init.js';
31
+ * await copyText('hello', { button: myButton });
32
+ * await copyRich({ text: '#3366cc', blob: pngBlob }, { button: myButton });
33
+ * await pasteFromClipboard(inputEl, { button: myButton });
34
+ */
35
+
36
+ const COPIED_DURATION = 1500;
37
+ const ANNOUNCE_DURATION = 1000;
38
+ const SELECTOR = '[data-copy], [data-copy-target], [data-paste-target]';
39
+ const DEFAULT_ANNOUNCE = 'Copied to clipboard';
40
+ const DEFAULT_PASTE_ANNOUNCE = 'Pasted from clipboard';
41
+
42
+ const resetTimers = new WeakMap();
43
+
44
+ /**
45
+ * Copy text to the clipboard with VB's standard feedback semantics.
46
+ *
47
+ * Performs writeText, sets data-state="copied" on the button (if given),
48
+ * announces to screen readers, and dispatches a `copy` CustomEvent from
49
+ * the button. Safe to call when the Clipboard API is unavailable or
50
+ * permission is denied — returns false silently.
51
+ *
52
+ * @param {string} text - The text to copy.
53
+ * @param {object} [options]
54
+ * @param {HTMLElement} [options.button] - Button to receive data-state and dispatch the event.
55
+ * @param {string} [options.announceMessage] - Screen-reader message. Defaults to "Copied to clipboard".
56
+ * @param {number} [options.duration] - Milliseconds to hold data-state="copied". Defaults to 1500.
57
+ * @returns {Promise<boolean>} True if the write succeeded.
58
+ */
59
+ async function copyText(text, options = {}) {
60
+ if (text == null || text === '') return false;
61
+ const { button, announceMessage = DEFAULT_ANNOUNCE, duration = COPIED_DURATION } = options;
62
+
63
+ try {
64
+ await navigator.clipboard.writeText(text);
65
+ } catch {
66
+ return false;
67
+ }
68
+
69
+ applyFeedback({ button, duration, announceMessage, eventDetail: { text } });
70
+ return true;
71
+ }
72
+
73
+ /**
74
+ * Copy a multi-format payload to the clipboard.
75
+ *
76
+ * Writes a ClipboardItem with any combination of text/plain, text/html,
77
+ * and a binary blob (typically image/png). When ClipboardItem is
78
+ * unsupported or the browser rejects the specific MIME types, falls
79
+ * back to text-only copy via copyText.
80
+ *
81
+ * Common use cases: copying a color as hex text + a swatch PNG so it
82
+ * can be pasted into design tools as an actual color, or copying a
83
+ * QR code as an image alongside its encoded URL.
84
+ *
85
+ * @param {object} payload
86
+ * @param {string} [payload.text] - text/plain content
87
+ * @param {string} [payload.html] - text/html content
88
+ * @param {Blob} [payload.blob] - Arbitrary blob (e.g. image/png). MIME type comes from blob.type.
89
+ * @param {object} [options]
90
+ * @param {HTMLElement} [options.button]
91
+ * @param {string} [options.announceMessage]
92
+ * @param {number} [options.duration]
93
+ * @returns {Promise<boolean>} True if any clipboard write succeeded (rich or text fallback).
94
+ */
95
+ async function copyRich(payload, options = {}) {
96
+ const { text, html, blob } = payload ?? {};
97
+ if (!text && !html && !blob) return false;
98
+ const { button, announceMessage = DEFAULT_ANNOUNCE, duration = COPIED_DURATION } = options;
99
+
100
+ const richSupported = typeof ClipboardItem !== 'undefined' && typeof navigator.clipboard?.write === 'function';
101
+
102
+ if (richSupported) {
103
+ /** @type {Record<string, Blob>} */
104
+ const items = {};
105
+ if (text) items['text/plain'] = new Blob([text], { type: 'text/plain' });
106
+ if (html) items['text/html'] = new Blob([html], { type: 'text/html' });
107
+ if (blob) items[blob.type] = blob;
108
+
109
+ try {
110
+ await navigator.clipboard.write([new ClipboardItem(items)]);
111
+ applyFeedback({ button, duration, announceMessage, eventDetail: { payload } });
112
+ return true;
113
+ } catch {
114
+ // Browser may support ClipboardItem but reject some MIME types
115
+ // (Safari historically restricts image types). Fall through to text.
116
+ }
117
+ }
118
+
119
+ if (text) return copyText(text, options);
120
+ return false;
121
+ }
122
+
123
+ /**
124
+ * Apply the VB-standard post-write feedback: data-state, screen-reader
125
+ * announce, and a CustomEvent from the button.
126
+ * @param {{ button?: HTMLElement, duration: number, announceMessage: string, eventDetail: object, state?: string, eventName?: string }} args
127
+ */
128
+ function applyFeedback({ button, duration, announceMessage, eventDetail, state = 'copied', eventName = 'copy' }) {
129
+ if (button) {
130
+ button.dataset.state = state;
131
+ const prior = resetTimers.get(button);
132
+ if (prior) clearTimeout(prior);
133
+ resetTimers.set(button, setTimeout(() => {
134
+ delete button.dataset.state;
135
+ resetTimers.delete(button);
136
+ }, duration));
137
+
138
+ button.dispatchEvent(new CustomEvent(eventName, {
139
+ bubbles: true,
140
+ detail: eventDetail,
141
+ }));
142
+ }
143
+
144
+ announce(announceMessage, button ?? document.body);
145
+ }
146
+
147
+ /**
148
+ * Read text from the clipboard and write it into a target element.
149
+ *
150
+ * Form controls (input, textarea, select) receive `.value`; other
151
+ * elements receive `.textContent`. After writing, an `input` event
152
+ * is dispatched on the target so framework listeners notice the change.
153
+ * Sets data-state="pasted" on the button, announces to screen readers,
154
+ * and dispatches a `paste` CustomEvent from the button.
155
+ *
156
+ * Safe to call when the Clipboard API is unavailable or the user
157
+ * denies permission — returns null silently.
158
+ *
159
+ * @param {Element | null | undefined} target - Element to receive the pasted text.
160
+ * @param {object} [options]
161
+ * @param {HTMLElement} [options.button]
162
+ * @param {string} [options.announceMessage]
163
+ * @param {number} [options.duration]
164
+ * @returns {Promise<string | null>} The text that was pasted, or null on failure.
165
+ */
166
+ async function pasteFromClipboard(target, options = {}) {
167
+ const { button, announceMessage = DEFAULT_PASTE_ANNOUNCE, duration = COPIED_DURATION } = options;
168
+
169
+ let text;
170
+ try {
171
+ text = await navigator.clipboard.readText();
172
+ } catch {
173
+ return null;
174
+ }
175
+
176
+ if (target) {
177
+ if ('value' in target && (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement)) {
178
+ target.value = text;
179
+ } else {
180
+ target.textContent = text;
181
+ }
182
+ target.dispatchEvent(new Event('input', { bubbles: true }));
183
+ }
184
+
185
+ applyFeedback({
186
+ button,
187
+ duration,
188
+ announceMessage,
189
+ eventDetail: { text },
190
+ state: 'pasted',
191
+ eventName: 'paste',
192
+ });
193
+ return text;
194
+ }
195
+
196
+ /**
197
+ * Initialize copy/paste buttons within a root element
198
+ * @param {Element|Document} root - Root element to search within
199
+ */
200
+ function initCopyButtons(root = document) {
201
+ root.querySelectorAll(SELECTOR).forEach(enhanceButton);
202
+ }
203
+
204
+ /**
205
+ * Enhance a single button with copy or paste behavior
206
+ * @param {HTMLElement} button - The button element to enhance
207
+ */
208
+ function enhanceButton(button) {
209
+ if (button.hasAttribute('data-copy-init')) return;
210
+ button.setAttribute('data-copy-init', '');
211
+
212
+ button.addEventListener('click', () => {
213
+ if (button.dataset.pasteTarget) {
214
+ const target = document.querySelector(button.dataset.pasteTarget);
215
+ if (!target) return;
216
+ pasteFromClipboard(target, { button });
217
+ return;
218
+ }
219
+
220
+ const text = getText(button);
221
+ if (!text) return;
222
+ copyText(text, { button });
223
+ });
224
+ }
225
+
226
+ /**
227
+ * Get text to copy from a button's data attributes
228
+ * @param {HTMLElement} button
229
+ * @returns {string}
230
+ */
231
+ function getText(button) {
232
+ if (button.dataset.copy) return button.dataset.copy;
233
+
234
+ if (button.dataset.copyTarget) {
235
+ const target = document.querySelector(button.dataset.copyTarget);
236
+ if (!target) return '';
237
+ const attr = button.dataset.copyAttr;
238
+ if (attr) return target.getAttribute(attr) ?? '';
239
+ return target.textContent ?? '';
240
+ }
241
+
242
+ return '';
243
+ }
244
+
245
+ /**
246
+ * Announce a message to screen readers
247
+ * @param {string} message
248
+ * @param {Element} context - Element to append announcement to
249
+ */
250
+ function announce(message, context) {
251
+ const el = document.createElement('div');
252
+ el.setAttribute('role', 'status');
253
+ el.setAttribute('aria-live', 'polite');
254
+ el.className = 'visually-hidden';
255
+ el.textContent = message;
256
+ context.appendChild(el);
257
+ setTimeout(() => el.remove(), ANNOUNCE_DURATION);
258
+ }
259
+
260
+ // Auto-init on DOMContentLoaded
261
+ if (document.readyState === 'loading') {
262
+ document.addEventListener('DOMContentLoaded', () => initCopyButtons());
263
+ } else {
264
+ initCopyButtons();
265
+ }
266
+
267
+ // Watch for dynamically added copy buttons
268
+ const observer = new MutationObserver((mutations) => {
269
+ for (const mutation of mutations) {
270
+ for (const node of mutation.addedNodes) {
271
+ if (node.nodeType !== Node.ELEMENT_NODE) continue;
272
+
273
+ // Check the node itself
274
+ const el = /** @type {Element} */ (node);
275
+ if (el.matches(SELECTOR)) {
276
+ enhanceButton(/** @type {HTMLElement} */ (el));
277
+ }
278
+
279
+ // Check descendants
280
+ el.querySelectorAll(SELECTOR).forEach(child => enhanceButton(/** @type {HTMLElement} */ (child)));
281
+ }
282
+ }
283
+ });
284
+
285
+ observer.observe(document.documentElement, { childList: true, subtree: true });
286
+
287
+ export { initCopyButtons, copyText, copyRich, pasteFromClipboard };
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Shared utilities for UX planning web components
3
+ * (user-persona, user-story, user-journey, empathy-map, impact-effort, story-map)
4
+ *
5
+ * Shadow DOM components extend HTMLElement directly.
6
+ * Light DOM components (impact-effort, story-map) extend VBElement.
7
+ */
8
+
9
+ /**
10
+ * HTML-escape a string to prevent XSS in shadow DOM innerHTML.
11
+ * @param {string} s
12
+ * @returns {string}
13
+ */
14
+ export function esc(s) {
15
+ return String(s)
16
+ .replace(/&/g, '&amp;')
17
+ .replace(/</g, '&lt;')
18
+ .replace(/>/g, '&gt;')
19
+ .replace(/"/g, '&quot;');
20
+ }
21
+
22
+ /**
23
+ * Extract up to 2 initials from a name.
24
+ * @param {string} name
25
+ * @returns {string}
26
+ */
27
+ export function initials(name) {
28
+ return name
29
+ .split(' ')
30
+ .map(n => n[0])
31
+ .join('')
32
+ .toUpperCase()
33
+ .slice(0, 2);
34
+ }
35
+
36
+ /**
37
+ * Generate a deterministic HSL color from a name string.
38
+ * @param {string} name
39
+ * @returns {string} HSL color string
40
+ */
41
+ export function hashColor(name) {
42
+ let hash = 0;
43
+ for (let i = 0; i < name.length; i++) {
44
+ hash = name.charCodeAt(i) + ((hash << 5) - hash);
45
+ }
46
+ const hue = ((hash % 360) + 360) % 360;
47
+ return `hsl(${hue}, 65%, 55%)`;
48
+ }
49
+
50
+ /**
51
+ * Emotion metadata used by user-journey (and potentially other planning components).
52
+ * Maps emotion names to emoji, normalized score (0–1), and color.
53
+ */
54
+ /**
55
+ * Build a Lucide-style inline SVG string (24x24, stroke-based).
56
+ * @param {string} inner - SVG child elements (path, circle, line, etc.)
57
+ * @returns {string}
58
+ */
59
+ export function lucideSvg(inner) {
60
+ return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
61
+ }
62
+
63
+ /** Shared Lucide SVG fragments for reuse across UX components. */
64
+ export const UX_ICONS = {
65
+ user: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
66
+ pencil: '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>',
67
+ check: '<path d="M20 6 9 17l-5-5"/>',
68
+ target: '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>',
69
+ alertTriangle: '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
70
+ messageCircle: '<path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"/>',
71
+ lightbulb: '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>',
72
+ wrench: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z"/>',
73
+ heart: '<path d="M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5"/>',
74
+ mapPin: '<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>',
75
+ checkCircle: '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/>',
76
+ x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
77
+ download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
78
+ send: '<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/>',
79
+ };
80
+
81
+ /**
82
+ * Empathy map quadrant metadata used by empathy-map.
83
+ * Maps quadrant keys to label, Lucide SVG icon inner content, and accent color.
84
+ */
85
+ export const QUADRANT_META = {
86
+ says: { label: 'Says', icon: UX_ICONS.messageCircle, color: '#3b82f6' },
87
+ thinks: { label: 'Thinks', icon: UX_ICONS.lightbulb, color: '#8b5cf6' },
88
+ does: { label: 'Does', icon: UX_ICONS.wrench, color: '#f59e0b' },
89
+ feels: { label: 'Feels', icon: UX_ICONS.heart, color: '#ef4444' },
90
+ };
91
+
92
+ /**
93
+ * Emotion metadata used by user-journey (and potentially other planning components).
94
+ * Maps emotion names to emoji, normalized score (0–1), and color.
95
+ */
96
+ export const EMOTION_META = {
97
+ delighted: { emoji: '😄', score: 0.95, color: '#16a34a' },
98
+ satisfied: { emoji: '😊', score: 0.80, color: '#22c55e' },
99
+ hopeful: { emoji: '🙂', score: 0.68, color: '#84cc16' },
100
+ curious: { emoji: '🤔', score: 0.55, color: '#eab308' },
101
+ neutral: { emoji: '😐', score: 0.50, color: '#94a3b8' },
102
+ uncertain: { emoji: '😕', score: 0.40, color: '#f97316' },
103
+ confused: { emoji: '😵', score: 0.30, color: '#fb923c' },
104
+ frustrated: { emoji: '😤', score: 0.18, color: '#ef4444' },
105
+ angry: { emoji: '😠', score: 0.05, color: '#dc2626' },
106
+ };