@profpowell/vb-project-planning 0.2.1 → 0.2.2

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 (64) hide show
  1. package/README.md +5 -2
  2. package/dist/vb-project-planning.css +56 -2
  3. package/dist/vb-project-planning.js +3188 -2544
  4. package/dist/vb-project-planning.min.css +1 -1
  5. package/dist/vb-project-planning.min.js +221 -197
  6. package/package.json +7 -4
  7. package/src/lib/board-surface.js +158 -0
  8. package/src/lib/bundle-registry.js +2 -75
  9. package/src/lib/dates.js +125 -0
  10. package/src/lib/escape.js +34 -0
  11. package/src/lib/hash.js +16 -0
  12. package/src/lib/portrait-url.js +3 -29
  13. package/src/lib/src-loader.js +78 -0
  14. package/src/utils/copy-init.js +56 -17
  15. package/src/web-components/_ux-base.js +409 -24
  16. package/src/web-components/adr-wc/README.md +18 -16
  17. package/src/web-components/adr-wc/api.json +7 -5
  18. package/src/web-components/adr-wc/logic.js +50 -136
  19. package/src/web-components/burndown-chart/api.json +8 -6
  20. package/src/web-components/burndown-chart/logic.js +75 -39
  21. package/src/web-components/empathy-map/api.json +13 -13
  22. package/src/web-components/empathy-map/logic.js +161 -168
  23. package/src/web-components/empathy-map/static.html +3 -3
  24. package/src/web-components/empathy-map/styles.js +1 -0
  25. package/src/web-components/gantt-chart/README.md +12 -6
  26. package/src/web-components/gantt-chart/api.json +23 -7
  27. package/src/web-components/gantt-chart/logic.js +456 -431
  28. package/src/web-components/gantt-chart/styles.css +9 -1
  29. package/src/web-components/impact-effort/api.json +9 -7
  30. package/src/web-components/impact-effort/logic.js +238 -209
  31. package/src/web-components/impact-effort/static.html +12 -4
  32. package/src/web-components/iron-triangle/_capacity.js +0 -18
  33. package/src/web-components/kanban-board/api.json +8 -6
  34. package/src/web-components/kanban-board/logic.js +289 -333
  35. package/src/web-components/product-roadmap/api.json +14 -13
  36. package/src/web-components/product-roadmap/logic.js +393 -246
  37. package/src/web-components/product-roadmap/styles.css +40 -0
  38. package/src/web-components/quadrant-grid/api.json +9 -4
  39. package/src/web-components/quadrant-grid/logic.js +80 -12
  40. package/src/web-components/quadrant-grid/styles.css +7 -0
  41. package/src/web-components/quality-target/_quality-utils.js +2 -46
  42. package/src/web-components/review-surface/README.md +9 -5
  43. package/src/web-components/review-surface/api.json +10 -8
  44. package/src/web-components/review-surface/logic.js +349 -218
  45. package/src/web-components/risk-register/api.json +1 -6
  46. package/src/web-components/risk-register/logic.js +71 -24
  47. package/src/web-components/story-map/api.json +8 -6
  48. package/src/web-components/story-map/logic.js +280 -281
  49. package/src/web-components/story-map/static.html +15 -5
  50. package/src/web-components/traceability-matrix/api.json +10 -3
  51. package/src/web-components/traceability-matrix/logic.js +112 -63
  52. package/src/web-components/traceability-matrix/styles.css +14 -1
  53. package/src/web-components/user-journey/api.json +25 -24
  54. package/src/web-components/user-journey/logic.js +76 -127
  55. package/src/web-components/user-journey/static.html +2 -2
  56. package/src/web-components/user-persona/api.json +15 -14
  57. package/src/web-components/user-persona/logic.js +71 -117
  58. package/src/web-components/user-persona/styles.js +1 -0
  59. package/src/web-components/user-story/api.json +13 -11
  60. package/src/web-components/user-story/logic.js +164 -195
  61. package/src/web-components/user-story/styles.js +28 -1
  62. package/src/web-components/work-item/README.md +3 -3
  63. package/src/web-components/work-item/api.json +13 -11
  64. package/src/web-components/work-item/logic.js +58 -154
@@ -26,6 +26,16 @@
26
26
  * <input id="dest" type="text">
27
27
  * <button data-paste-target="#dest">Paste</button>
28
28
  *
29
+ * Events: successful copies dispatch `vb:copy` ({ text } or { payload })
30
+ * and pastes dispatch `vb:paste` ({ text }) from the button, bubbling.
31
+ * (Before 0.2.2 these were synthetic `copy` / `paste` events, which
32
+ * collided with native ClipboardEvent handlers that read
33
+ * `e.clipboardData`.)
34
+ *
35
+ * Announcements go to one persistent, visually hidden live region
36
+ * appended to <body>, never inside the button, so the button's
37
+ * accessible name is unchanged.
38
+ *
29
39
  * @example Programmatic
30
40
  * import { copyText, copyRich, pasteFromClipboard } from '/src/utils/copy-init.js';
31
41
  * await copyText('hello', { button: myButton });
@@ -34,7 +44,10 @@
34
44
  */
35
45
 
36
46
  const COPIED_DURATION = 1500;
37
- const ANNOUNCE_DURATION = 1000;
47
+ /** How long an announcement stays in the live region before it is cleared. */
48
+ const ANNOUNCE_DURATION = 5000;
49
+ const COPY_EVENT = 'vb:copy';
50
+ const PASTE_EVENT = 'vb:paste';
38
51
  const SELECTOR = '[data-copy], [data-copy-target], [data-paste-target]';
39
52
  const DEFAULT_ANNOUNCE = 'Copied to clipboard';
40
53
  const DEFAULT_PASTE_ANNOUNCE = 'Pasted from clipboard';
@@ -45,8 +58,8 @@ const resetTimers = new WeakMap();
45
58
  * Copy text to the clipboard with VB's standard feedback semantics.
46
59
  *
47
60
  * 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
61
+ * announces to screen readers, and dispatches a `vb:copy` CustomEvent
62
+ * from the button. Safe to call when the Clipboard API is unavailable or
50
63
  * permission is denied — returns false silently.
51
64
  *
52
65
  * @param {string} text - The text to copy.
@@ -122,10 +135,10 @@ async function copyRich(payload, options = {}) {
122
135
 
123
136
  /**
124
137
  * Apply the VB-standard post-write feedback: data-state, screen-reader
125
- * announce, and a CustomEvent from the button.
138
+ * announce, and a `vb:copy` / `vb:paste` CustomEvent from the button.
126
139
  * @param {{ button?: HTMLElement, duration: number, announceMessage: string, eventDetail: object, state?: string, eventName?: string }} args
127
140
  */
128
- function applyFeedback({ button, duration, announceMessage, eventDetail, state = 'copied', eventName = 'copy' }) {
141
+ function applyFeedback({ button, duration, announceMessage, eventDetail, state = 'copied', eventName = COPY_EVENT }) {
129
142
  if (button) {
130
143
  button.dataset.state = state;
131
144
  const prior = resetTimers.get(button);
@@ -141,7 +154,7 @@ function applyFeedback({ button, duration, announceMessage, eventDetail, state =
141
154
  }));
142
155
  }
143
156
 
144
- announce(announceMessage, button ?? document.body);
157
+ announce(announceMessage);
145
158
  }
146
159
 
147
160
  /**
@@ -151,7 +164,7 @@ function applyFeedback({ button, duration, announceMessage, eventDetail, state =
151
164
  * elements receive `.textContent`. After writing, an `input` event
152
165
  * is dispatched on the target so framework listeners notice the change.
153
166
  * Sets data-state="pasted" on the button, announces to screen readers,
154
- * and dispatches a `paste` CustomEvent from the button.
167
+ * and dispatches a `vb:paste` CustomEvent from the button.
155
168
  *
156
169
  * Safe to call when the Clipboard API is unavailable or the user
157
170
  * denies permission — returns null silently.
@@ -188,7 +201,7 @@ async function pasteFromClipboard(target, options = {}) {
188
201
  announceMessage,
189
202
  eventDetail: { text },
190
203
  state: 'pasted',
191
- eventName: 'paste',
204
+ eventName: PASTE_EVENT,
192
205
  });
193
206
  return text;
194
207
  }
@@ -242,19 +255,45 @@ function getText(button) {
242
255
  return '';
243
256
  }
244
257
 
258
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
259
+ let clearTimer;
260
+
245
261
  /**
246
- * Announce a message to screen readers
247
- * @param {string} message
248
- * @param {Element} context - Element to append announcement to
262
+ * The page's single clipboard live region: visually hidden, appended to
263
+ * <body> once and reused (shared across copies of this module).
264
+ * @returns {HTMLElement}
249
265
  */
250
- function announce(message, context) {
251
- const el = document.createElement('div');
266
+ function liveRegion() {
267
+ let el = /** @type {HTMLElement | null} */ (document.querySelector('body > [data-vb-clipboard-status]'));
268
+ if (el) return el;
269
+ el = document.createElement('div');
270
+ el.setAttribute('data-vb-clipboard-status', '');
252
271
  el.setAttribute('role', 'status');
253
272
  el.setAttribute('aria-live', 'polite');
254
- el.className = 'visually-hidden';
255
- el.textContent = message;
256
- context.appendChild(el);
257
- setTimeout(() => el.remove(), ANNOUNCE_DURATION);
273
+ el.setAttribute('aria-atomic', 'true');
274
+ el.style.cssText = 'position:absolute;inline-size:1px;block-size:1px;margin:-1px;padding:0;' +
275
+ 'overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0;';
276
+ document.body.appendChild(el);
277
+ return el;
278
+ }
279
+
280
+ /**
281
+ * Announce a message to screen readers. The region already exists (or is
282
+ * created now, empty) and the text is set on the next frame, so the
283
+ * change is announced even on first use and for repeated messages.
284
+ * @param {string} message
285
+ */
286
+ function announce(message) {
287
+ if (!document.body) return;
288
+ const region = liveRegion();
289
+ region.textContent = '';
290
+ requestAnimationFrame(() => {
291
+ region.textContent = message;
292
+ clearTimeout(clearTimer);
293
+ clearTimer = setTimeout(() => {
294
+ if (region.textContent === message) region.textContent = '';
295
+ }, ANNOUNCE_DURATION);
296
+ });
258
297
  }
259
298
 
260
299
  // Auto-init on DOMContentLoaded
@@ -1,23 +1,24 @@
1
1
  /**
2
- * Shared utilities for UX planning web components
3
- * (user-persona, user-story, user-journey, empathy-map, impact-effort, story-map)
2
+ * Shared utilities for the pack's planning web components.
4
3
  *
5
- * Shadow DOM components extend HTMLElement directly.
6
- * Light DOM components (impact-effort, story-map) extend VBElement.
4
+ * - Helpers (`esc`, `initials`, `hashColor`, `lucideSvg`, `UX_ICONS`,
5
+ * `QUADRANT_META`, `EMOTION_META`) are used by user-persona, user-story,
6
+ * user-journey, empathy-map, work-item, adr-wc, impact-effort, story-map
7
+ * and review-surface.
8
+ * - `UXShadowElement` is the Shadow DOM base class for the six record cards
9
+ * (user-persona, user-story, user-journey, empathy-map, work-item, adr-wc).
10
+ * It owns the one-time setup / reconnect guard, the single-fetch `src`
11
+ * loader, the once-only `*:ready` event and the `*:upgraded` event.
12
+ * Light DOM components (impact-effort, story-map, kanban-board, …) extend
13
+ * VBElement from src/lib/vb-element.js instead.
7
14
  */
8
15
 
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
- }
16
+ import { stringHash } from '../lib/hash.js';
17
+ import { SrcLoader } from '../lib/src-loader.js';
18
+
19
+ import { esc } from '../lib/escape.js';
20
+
21
+ export { esc };
21
22
 
22
23
  /**
23
24
  * Extract up to 2 initials from a name.
@@ -39,18 +40,11 @@ export function initials(name) {
39
40
  * @returns {string} HSL color string
40
41
  */
41
42
  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
- }
43
+ const hash = stringHash(name);
46
44
  const hue = ((hash % 360) + 360) % 360;
47
45
  return `hsl(${hue}, 65%, 55%)`;
48
46
  }
49
47
 
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
48
  /**
55
49
  * Build a Lucide-style inline SVG string (24x24, stroke-based).
56
50
  * @param {string} inner - SVG child elements (path, circle, line, etc.)
@@ -104,3 +98,394 @@ export const EMOTION_META = {
104
98
  frustrated: { emoji: '😤', score: 0.18, color: '#ef4444' },
105
99
  angry: { emoji: '😠', score: 0.05, color: '#dc2626' },
106
100
  };
101
+
102
+ /**
103
+ * Look up emotion metadata by name. Own-property lookup only, so data such
104
+ * as `"__proto__"` or `"constructor"` falls back to `neutral`.
105
+ * @param {unknown} name
106
+ * @returns {{ emoji: string, score: number, color: string }}
107
+ */
108
+ export function emotionMeta(name) {
109
+ const key = String(name ?? '').toLowerCase().trim();
110
+ return Object.hasOwn(EMOTION_META, key) ? EMOTION_META[key] : EMOTION_META.neutral;
111
+ }
112
+
113
+ /**
114
+ * Whether `name` is a known emotion (own property of EMOTION_META).
115
+ * @param {unknown} name
116
+ * @returns {boolean}
117
+ */
118
+ export function isEmotion(name) {
119
+ return Object.hasOwn(EMOTION_META, String(name ?? '').toLowerCase().trim());
120
+ }
121
+
122
+ /* ── Card status / priority vocabularies ─────────────────────────── */
123
+
124
+ /** Priority badges shared by user-story and work-item. */
125
+ export const PRIORITIES = Object.freeze({
126
+ critical: { label: 'Critical', color: '#dc2626', bg: 'rgba(220, 38, 38, 0.1)' },
127
+ high: { label: 'High', color: '#ea580c', bg: 'rgba(234, 88, 12, 0.1)' },
128
+ medium: { label: 'Medium', color: '#ca8a04', bg: 'rgba(202, 138, 4, 0.1)' },
129
+ low: { label: 'Low', color: '#16a34a', bg: 'rgba(22, 163, 74, 0.1)' },
130
+ });
131
+
132
+ /** Workflow statuses shared by user-story and work-item (work-item adds `blocked`). */
133
+ export const STATUSES = Object.freeze({
134
+ backlog: { label: 'Backlog', color: '#6b7280', bg: 'rgba(107, 114, 128, 0.1)' },
135
+ 'to-do': { label: 'To Do', color: '#3b82f6', bg: 'rgba(59, 130, 246, 0.1)' },
136
+ 'in-progress': { label: 'In Progress', color: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.1)' },
137
+ review: { label: 'Review', color: '#f59e0b', bg: 'rgba(245, 158, 11, 0.1)' },
138
+ done: { label: 'Done', color: '#22c55e', bg: 'rgba(34, 197, 94, 0.1)' },
139
+ });
140
+
141
+ /**
142
+ * Own-property lookup in a vocabulary table, with a fallback key.
143
+ * @template T
144
+ * @param {Record<string, T>} table
145
+ * @param {string} key
146
+ * @param {string} fallback
147
+ * @returns {T}
148
+ */
149
+ export function lookup(table, key, fallback) {
150
+ return Object.hasOwn(table, key) ? table[key] : table[fallback];
151
+ }
152
+
153
+ /* ── Small DOM / data helpers ────────────────────────────────────── */
154
+
155
+ /**
156
+ * `href` value for an in-page fragment link to an element id, escaped for
157
+ * an attribute position in an innerHTML template.
158
+ * @param {unknown} id
159
+ * @returns {string}
160
+ */
161
+ export function fragmentHref(id) {
162
+ return esc(`#${encodeURIComponent(String(id ?? ''))}`);
163
+ }
164
+
165
+ /**
166
+ * Split a comma-separated attribute value into trimmed, non-empty parts.
167
+ * @param {string | null} raw
168
+ * @returns {string[]}
169
+ */
170
+ export function splitList(raw) {
171
+ return raw ? raw.split(',').map(s => s.trim()).filter(Boolean) : [];
172
+ }
173
+
174
+ /**
175
+ * Normalise a data field to an array of strings: arrays are kept, a single
176
+ * scalar becomes a one-item array, null/undefined/'' become [].
177
+ * @param {unknown} value
178
+ * @returns {string[]}
179
+ */
180
+ export function asList(value) {
181
+ if (Array.isArray(value)) return value.map(v => String(v ?? ''));
182
+ if (value == null || value === '') return [];
183
+ return [String(value)];
184
+ }
185
+
186
+ /**
187
+ * The light-DOM child assigned to `slot`, if any (direct children only:
188
+ * nothing deeper can be slotted).
189
+ * @param {Element} host
190
+ * @param {string} slot
191
+ * @returns {Element | undefined}
192
+ */
193
+ export function slottedChild(host, slot) {
194
+ return [...host.children].find(c => c.getAttribute('slot') === slot);
195
+ }
196
+
197
+ /**
198
+ * Create (or, with `replace`, update) the light-DOM child for a slot from a
199
+ * data value. Arrays become a `<ul>` of `<li>`s; anything else becomes
200
+ * `tag` with the value as text. Text is set with textContent, never parsed.
201
+ *
202
+ * @param {Element} host
203
+ * @param {string} slot
204
+ * @param {unknown} value
205
+ * @param {{ tag?: string, list?: boolean, replace?: boolean }} [opts]
206
+ * tag: element for scalar values (default `p`); list: always render a
207
+ * list (wrapping a scalar); replace: update an existing child instead of
208
+ * leaving it alone.
209
+ */
210
+ export function fillSlot(host, slot, value, { tag = 'p', list = false, replace = false } = {}) {
211
+ if (value == null || value === '') return;
212
+ const existing = slottedChild(host, slot);
213
+ if (existing && !replace) return;
214
+ const wantList = list || Array.isArray(value);
215
+ const isList = (/** @type {Element} */ el) => el.tagName === 'UL' || el.tagName === 'OL';
216
+ let el = existing;
217
+ if (!el || wantList !== isList(el)) {
218
+ const fresh = document.createElement(wantList ? 'ul' : tag);
219
+ fresh.slot = slot;
220
+ if (el) el.replaceWith(fresh); else host.appendChild(fresh);
221
+ el = fresh;
222
+ }
223
+ if (wantList) {
224
+ el.replaceChildren(...asList(value).map(item => {
225
+ const li = document.createElement('li');
226
+ li.textContent = item;
227
+ return li;
228
+ }));
229
+ } else {
230
+ el.textContent = String(value);
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Read a slotted child back as data: list items as a string array, any
236
+ * other element as its trimmed text. Returns undefined when absent/empty.
237
+ * @param {Element} host
238
+ * @param {string} slot
239
+ * @returns {string | string[] | undefined}
240
+ */
241
+ export function readSlot(host, slot) {
242
+ const el = slottedChild(host, slot);
243
+ if (!el) return undefined;
244
+ if (el.tagName === 'UL' || el.tagName === 'OL') {
245
+ const items = [...el.querySelectorAll('li')].map(li => li.textContent?.trim() ?? '').filter(Boolean);
246
+ return items.length ? items : undefined;
247
+ }
248
+ return el.textContent?.trim() || undefined;
249
+ }
250
+
251
+ /**
252
+ * Detail level from the `detail` / `compact` attributes.
253
+ * @param {Element} host
254
+ * @returns {string} full | compact | minimal
255
+ */
256
+ export function detailLevel(host) {
257
+ return host.getAttribute('detail') || (host.hasAttribute('compact') ? 'compact' : 'full');
258
+ }
259
+
260
+ /**
261
+ * Mark every section whose first `<slot>` has no assigned nodes with
262
+ * `data-empty` (compact cards hide those).
263
+ * @param {ParentNode} root
264
+ * @param {string} sectionSelector
265
+ */
266
+ export function markEmptySections(root, sectionSelector) {
267
+ for (const section of root.querySelectorAll(sectionSelector)) {
268
+ const slot = section.querySelector('slot');
269
+ if (slot && slot.assignedNodes().length === 0) section.setAttribute('data-empty', '');
270
+ }
271
+ }
272
+
273
+ /* ── Base class for the Shadow DOM record cards ──────────────────── */
274
+
275
+ /**
276
+ * Shadow DOM base for user-persona, user-story, user-journey, empathy-map,
277
+ * work-item and adr-wc.
278
+ *
279
+ * Lifecycle contract:
280
+ * - First connect runs `_setup()`, then either renders and fires the ready
281
+ * event, or (with `src`) fetches once and fires ready after the fetched
282
+ * data renders. Then `_connect()` runs, `data-upgraded` is set and
283
+ * `<tag>:upgraded` is dispatched in a microtask.
284
+ * - Reconnects (e.g. a kanban drag moving the card) only run `_connect()`:
285
+ * no re-fetch, no re-render, no second ready event.
286
+ * - Disconnect runs `_disconnect()` (stop observers). A pending fetch keeps
287
+ * going, so a card moved mid-load still fills in.
288
+ * - Attribute changes before first setup are ignored (setup reads them).
289
+ * After setup, a `src` change refetches (aborting the earlier request and
290
+ * dropping its response, via SrcLoader); removing `src` aborts a pending
291
+ * fetch and renders the authored content (firing ready if it has not
292
+ * fired yet). Other changes call `_attributeEffects(name)` and then
293
+ * `_attributeChanged(name)`.
294
+ * - A data record (`.data` or `src`) sets attributes without a render per
295
+ * attribute; once it is applied, `_attributeEffects(name)` runs for each
296
+ * attribute it changed, then the card renders once.
297
+ *
298
+ * Subclasses set `static readyEvent` and implement `_render()`,
299
+ * `_readyDetail()` and, if they load JSON, `_applyData(data)`. Side effects
300
+ * of an attribute (observers, …) belong in `_attributeEffects()`, not in
301
+ * `_attributeChanged()`, so they also run when a data record sets it.
302
+ */
303
+ export class UXShadowElement extends HTMLElement {
304
+ /** Ready event name, e.g. `story-ready`. */
305
+ static readyEvent = '';
306
+
307
+ /** Fallback text for slots whose light-DOM child was removed after setup. */
308
+ _slotCache = new Map();
309
+
310
+ #setUp = false;
311
+ #readyFired = false;
312
+ #loader = new SrcLoader();
313
+ #quiet = 0;
314
+ /** Attributes changed while quiet (a data record being applied). */
315
+ /** @type {Set<string>} */
316
+ #quietChanges = new Set();
317
+
318
+ constructor() {
319
+ super();
320
+ this.attachShadow({ mode: 'open' });
321
+ }
322
+
323
+ /** True once first-connect setup has run. */
324
+ get _isSetUp() { return this.#setUp; }
325
+
326
+ connectedCallback() {
327
+ if (this.#setUp) {
328
+ this._connect();
329
+ return;
330
+ }
331
+ this.#setUp = true;
332
+ this._cacheSlotValues();
333
+ this._setup();
334
+ const src = this.getAttribute('src');
335
+ if (src) {
336
+ this._loadSrc(src);
337
+ } else {
338
+ this._render();
339
+ this._fireReady();
340
+ }
341
+ this._connect();
342
+ this.setAttribute('data-upgraded', '');
343
+ queueMicrotask(() => {
344
+ this.dispatchEvent(new CustomEvent(`${this.localName}:upgraded`, { bubbles: true }));
345
+ });
346
+ }
347
+
348
+ disconnectedCallback() {
349
+ this._disconnect();
350
+ }
351
+
352
+ /**
353
+ * @param {string} name
354
+ * @param {string | null} oldValue
355
+ * @param {string | null} newValue
356
+ */
357
+ attributeChangedCallback(name, oldValue, newValue) {
358
+ if (!this.#setUp || oldValue === newValue) return;
359
+ if (this.#quiet) {
360
+ if (name !== 'src') this.#quietChanges.add(name);
361
+ return;
362
+ }
363
+ if (name === 'src') {
364
+ this._loadSrc(newValue);
365
+ return;
366
+ }
367
+ this._attributeEffects(name);
368
+ this._attributeChanged(name);
369
+ }
370
+
371
+ /* ── Hooks (override as needed) ─────────────────── */
372
+
373
+ /** One-time setup before the first render (e.g. id from story-id). */
374
+ _setup() {}
375
+ /** Every connect, after setup on the first: (re)attach observers. */
376
+ _connect() {}
377
+ /** Every disconnect: detach observers. */
378
+ _disconnect() {}
379
+ /**
380
+ * Non-render side effects of an attribute change (e.g. re-pointing an
381
+ * observer). Runs before `_attributeChanged()`, and also after a data
382
+ * record that changed the attribute has been applied.
383
+ * @param {string} _name
384
+ */
385
+ _attributeEffects(_name) {}
386
+ /** @param {string} _name */
387
+ _attributeChanged(_name) { this._render(); }
388
+ /** Render the full shadow tree. */
389
+ _render() {}
390
+ /** @returns {object} detail for the ready event */
391
+ _readyDetail() { return {}; }
392
+ /** @param {Record<string, any>} _data apply a JSON record to attributes/slots/properties */
393
+ _applyData(_data) {}
394
+ /** Shown while `src` loads. Default: render what is already known. */
395
+ _renderLoading() { this._render(); }
396
+ /** @param {Error} err shown when `src` fails. Default: a console warning. */
397
+ _renderError(err) {
398
+ console.warn(`[${this.localName}] Failed to load src="${this.getAttribute('src')}":`, err);
399
+ }
400
+
401
+ /* ── Shared behaviour ───────────────────────────── */
402
+
403
+ _cacheSlotValues() {
404
+ for (const child of this.children) {
405
+ const slot = child.getAttribute('slot');
406
+ if (slot) this._slotCache.set(slot, child.textContent?.trim() ?? '');
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Trimmed text of the slotted child for `slot`, else the cached value.
412
+ * @param {string} slot
413
+ * @returns {string}
414
+ */
415
+ _slotText(slot) {
416
+ return slottedChild(this, slot)?.textContent?.trim() || this._slotCache.get(slot) || '';
417
+ }
418
+
419
+ /**
420
+ * Run `fn` without attribute changes triggering re-renders (used while a
421
+ * data record sets several attributes; the caller renders once after).
422
+ * @param {() => void} fn
423
+ */
424
+ _quietly(fn) {
425
+ this.#quiet++;
426
+ try { fn(); } finally { this.#quiet--; }
427
+ if (this.#quiet === 0 && this.#quietChanges.size) {
428
+ const changed = [...this.#quietChanges];
429
+ this.#quietChanges.clear();
430
+ for (const name of changed) this._attributeEffects(name);
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Apply a record assigned to `.data`, re-render, and emit
436
+ * `<tag>:data-changed` { data, source: 'property' }.
437
+ * @param {unknown} value
438
+ */
439
+ _setData(value) {
440
+ if (!value || typeof value !== 'object') return;
441
+ this._quietly(() => this._applyData(/** @type {Record<string, any>} */ (value)));
442
+ this._cacheSlotValues();
443
+ this._render();
444
+ this.dispatchEvent(new CustomEvent(`${this.localName}:data-changed`, {
445
+ detail: { data: /** @type {any} */ (this).data, source: 'property' },
446
+ bubbles: true, composed: true,
447
+ }));
448
+ }
449
+
450
+ /** Dispatch the ready event, at most once per element. */
451
+ _fireReady() {
452
+ if (this.#readyFired) return;
453
+ const type = /** @type {typeof UXShadowElement} */ (this.constructor).readyEvent;
454
+ if (!type) return;
455
+ this.#readyFired = true;
456
+ this.dispatchEvent(new CustomEvent(type, {
457
+ detail: this._readyDetail(),
458
+ bubbles: true, composed: true,
459
+ }));
460
+ }
461
+
462
+ /**
463
+ * Fetch `url` as JSON and apply it. Each call supersedes the previous
464
+ * one: the earlier request is aborted and its response, if it still
465
+ * arrives, is ignored. A null/empty `url` (src removed) aborts any
466
+ * pending request and renders the authored content instead.
467
+ * @param {string | null} url
468
+ */
469
+ async _loadSrc(url) {
470
+ if (!url) {
471
+ this.#loader.cancel();
472
+ this._render();
473
+ this._fireReady();
474
+ return;
475
+ }
476
+ this._renderLoading();
477
+ let result;
478
+ try {
479
+ result = await this.#loader.fetchJson(url);
480
+ } catch (err) {
481
+ this._renderError(err instanceof Error ? err : new Error(String(err)));
482
+ return;
483
+ }
484
+ if (!result) return;
485
+ const { data } = result;
486
+ this._quietly(() => this._applyData(data && typeof data === 'object' ? data : {}));
487
+ this._cacheSlotValues();
488
+ this._render();
489
+ this._fireReady();
490
+ }
491
+ }
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  title: "ADR Record Specification"
3
- component: adr-record
3
+ component: adr-wc
4
4
  version: 1.0.0
5
5
  status: stable
6
6
  ---
7
7
 
8
- # ADR Record
8
+ # ADR Record (`<adr-wc>`)
9
9
 
10
10
  MADR-format Architectural Decision Record card with status tracking and cross-referencing.
11
11
 
@@ -18,48 +18,50 @@ Part of the **UX Planning Pack**. Works standalone or inside `<kanban-board>`, `
18
18
  ## Static HTML Form
19
19
 
20
20
  ```html
21
- <adr-record
21
+ <adr-wc
22
22
  adr-id="ADR-001"
23
- title="Use PostgreSQL for persistence"
24
23
  status="accepted"
25
- date="2026-01-15"
26
24
  superseded-by="ADR-003">
25
+ <h2 slot="title">Use PostgreSQL for persistence</h2>
26
+ <time slot="date" datetime="2026-01-15">January 15, 2026</time>
27
27
  <p slot="context">We need a relational database for complex queries.</p>
28
28
  <p slot="decision">Use PostgreSQL 16 with pgvector extension.</p>
29
29
  <ul slot="consequences">
30
30
  <li>Good: mature ecosystem, strong SQL support</li>
31
31
  <li>Bad: operational overhead vs managed NoSQL</li>
32
32
  </ul>
33
- </adr-record>
33
+ </adr-wc>
34
34
  ```
35
35
 
36
36
  ## Attributes
37
37
 
38
38
  | Attribute | Type | Default | Description |
39
39
  |-----------|------|---------|-------------|
40
- | `adr-id` | string | -- | ADR identifier (also sets HTML `id`) |
41
- | `title` | string | -- | Decision title |
42
- | `status` | string | `"proposed"` | `proposed`, `accepted`, `deprecated`, `superseded` |
43
- | `date` | string | -- | ISO date string |
40
+ | `adr-id` | string | -- | ADR identifier (e.g., ADR-001); sets the HTML id on first connect when none is set |
41
+ | `status` | `proposed`, `accepted`, `deprecated`, `superseded` | `"proposed"` | Decision status |
44
42
  | `supersedes` | string | -- | Comma-separated ADR IDs this supersedes |
45
43
  | `superseded-by` | string | -- | Comma-separated ADR IDs that supersede this |
46
- | `detail` | string | `"full"` | `full`, `compact`, `minimal` |
47
- | `compact` | boolean | -- | Alias for `detail="compact"` |
48
- | `src` | string | -- | JSON data URL |
44
+ | `detail` | `full`, `compact`, `minimal` | `"full"` | Detail level. minimal shows id, status and title only. |
45
+ | `compact` | boolean | -- | Alias for detail=compact |
46
+ | `src` | string | -- | URL of a JSON ADR record. Fetched once per value; a newer value aborts the older request. A YYYY-MM-DD date is shown as that calendar day in every time zone. |
49
47
 
50
48
  ## Slots
51
49
 
52
50
  | Slot | Description |
53
51
  |------|-------------|
52
+ | `title` | Decision heading (e.g., `<h2>`) |
53
+ | `date` | Decision date (e.g., `<time datetime="...">`) |
54
54
  | `context` | Why the decision was needed |
55
55
  | `decision` | What was decided |
56
56
  | `consequences` | Outcomes (positive and negative) |
57
57
 
58
58
  ## Events
59
59
 
60
- | Event | Detail |
61
- |-------|--------|
62
- | `adr-record:ready` | `{ adrId, title, status }` |
60
+ | Event | Detail | When |
61
+ |-------|--------|------|
62
+ | `adr-wc:ready` | `{ adrId, title, status }` | Once per element, after the first render at any detail level. With src, after the fetched data renders (not fired if the fetch fails). Not re-fired by attribute changes or moves. |
63
+ | `adr-wc:upgraded` | -- | Once, in a microtask after first connect. Wait for this before assigning .data from a framework. |
64
+ | `adr-wc:data-changed` | `{ data, source: 'property' }` | After .data is assigned |
63
65
 
64
66
  ## Status Colors
65
67