@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.
- package/README.md +5 -2
- package/dist/vb-project-planning.css +56 -2
- package/dist/vb-project-planning.js +3188 -2544
- package/dist/vb-project-planning.min.css +1 -1
- package/dist/vb-project-planning.min.js +221 -197
- package/package.json +7 -4
- package/src/lib/board-surface.js +158 -0
- package/src/lib/bundle-registry.js +2 -75
- package/src/lib/dates.js +125 -0
- package/src/lib/escape.js +34 -0
- package/src/lib/hash.js +16 -0
- package/src/lib/portrait-url.js +3 -29
- package/src/lib/src-loader.js +78 -0
- package/src/utils/copy-init.js +56 -17
- package/src/web-components/_ux-base.js +409 -24
- package/src/web-components/adr-wc/README.md +18 -16
- package/src/web-components/adr-wc/api.json +7 -5
- package/src/web-components/adr-wc/logic.js +50 -136
- package/src/web-components/burndown-chart/api.json +8 -6
- package/src/web-components/burndown-chart/logic.js +75 -39
- package/src/web-components/empathy-map/api.json +13 -13
- package/src/web-components/empathy-map/logic.js +161 -168
- package/src/web-components/empathy-map/static.html +3 -3
- package/src/web-components/empathy-map/styles.js +1 -0
- package/src/web-components/gantt-chart/README.md +12 -6
- package/src/web-components/gantt-chart/api.json +23 -7
- package/src/web-components/gantt-chart/logic.js +456 -431
- package/src/web-components/gantt-chart/styles.css +9 -1
- package/src/web-components/impact-effort/api.json +9 -7
- package/src/web-components/impact-effort/logic.js +238 -209
- package/src/web-components/impact-effort/static.html +12 -4
- package/src/web-components/iron-triangle/_capacity.js +0 -18
- package/src/web-components/kanban-board/api.json +8 -6
- package/src/web-components/kanban-board/logic.js +289 -333
- package/src/web-components/product-roadmap/api.json +14 -13
- package/src/web-components/product-roadmap/logic.js +393 -246
- package/src/web-components/product-roadmap/styles.css +40 -0
- package/src/web-components/quadrant-grid/api.json +9 -4
- package/src/web-components/quadrant-grid/logic.js +80 -12
- package/src/web-components/quadrant-grid/styles.css +7 -0
- package/src/web-components/quality-target/_quality-utils.js +2 -46
- package/src/web-components/review-surface/README.md +9 -5
- package/src/web-components/review-surface/api.json +10 -8
- package/src/web-components/review-surface/logic.js +349 -218
- package/src/web-components/risk-register/api.json +1 -6
- package/src/web-components/risk-register/logic.js +71 -24
- package/src/web-components/story-map/api.json +8 -6
- package/src/web-components/story-map/logic.js +280 -281
- package/src/web-components/story-map/static.html +15 -5
- package/src/web-components/traceability-matrix/api.json +10 -3
- package/src/web-components/traceability-matrix/logic.js +112 -63
- package/src/web-components/traceability-matrix/styles.css +14 -1
- package/src/web-components/user-journey/api.json +25 -24
- package/src/web-components/user-journey/logic.js +76 -127
- package/src/web-components/user-journey/static.html +2 -2
- package/src/web-components/user-persona/api.json +15 -14
- package/src/web-components/user-persona/logic.js +71 -117
- package/src/web-components/user-persona/styles.js +1 -0
- package/src/web-components/user-story/api.json +13 -11
- package/src/web-components/user-story/logic.js +164 -195
- package/src/web-components/user-story/styles.js +28 -1
- package/src/web-components/work-item/README.md +3 -3
- package/src/web-components/work-item/api.json +13 -11
- package/src/web-components/work-item/logic.js +58 -154
package/src/utils/copy-init.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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:
|
|
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
|
-
*
|
|
247
|
-
*
|
|
248
|
-
* @
|
|
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
|
|
251
|
-
|
|
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.
|
|
255
|
-
el.
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
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
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
export
|
|
15
|
-
return String(s)
|
|
16
|
-
.replace(/&/g, '&')
|
|
17
|
-
.replace(/</g, '<')
|
|
18
|
-
.replace(/>/g, '>')
|
|
19
|
-
.replace(/"/g, '"');
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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 (
|
|
41
|
-
| `
|
|
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` |
|
|
47
|
-
| `compact` | boolean | -- | Alias for
|
|
48
|
-
| `src` | string | -- | JSON
|
|
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-
|
|
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
|
|