@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
|
@@ -129,6 +129,44 @@ product-roadmap > section[data-lane] {
|
|
|
129
129
|
border-end-end-radius: var(--radius-s, 0.25rem);
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
.rm-bar:focus-visible {
|
|
133
|
+
outline: var(--border-width-medium, 2px) solid var(--color-focus, var(--color-interactive, #0066cc));
|
|
134
|
+
outline-offset: 2px;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.rm-bar[data-dragging] {
|
|
138
|
+
cursor: grabbing;
|
|
139
|
+
z-index: 3;
|
|
140
|
+
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.25);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* Status as text too, so it is not conveyed by colour alone. */
|
|
144
|
+
.rm-bar-status {
|
|
145
|
+
flex: none;
|
|
146
|
+
font-size: var(--font-size-xs, 0.75rem);
|
|
147
|
+
font-weight: 600;
|
|
148
|
+
opacity: 0.9;
|
|
149
|
+
white-space: nowrap;
|
|
150
|
+
padding-inline-end: var(--size-xs, 0.5rem);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.rm-track[data-drop-target] {
|
|
154
|
+
outline: var(--border-width-medium, 2px) dashed var(--color-interactive, #0066cc);
|
|
155
|
+
outline-offset: -2px;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.rm-sr-only {
|
|
159
|
+
position: absolute;
|
|
160
|
+
inline-size: 1px;
|
|
161
|
+
block-size: 1px;
|
|
162
|
+
margin: -1px;
|
|
163
|
+
padding: 0;
|
|
164
|
+
overflow: hidden;
|
|
165
|
+
clip-path: inset(50%);
|
|
166
|
+
white-space: nowrap;
|
|
167
|
+
border: 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
132
170
|
.rm-bar[data-status="planned"] { background: var(--color-info, oklch(60% 0.12 240)); }
|
|
133
171
|
.rm-bar[data-status="in-progress"] { background: var(--color-accent, oklch(50% 0.16 260)); }
|
|
134
172
|
.rm-bar[data-status="at-risk"] { background: var(--color-warning, oklch(65% 0.16 60)); color: var(--color-text, #1a1a1a); }
|
|
@@ -152,6 +190,8 @@ product-roadmap > section[data-lane] {
|
|
|
152
190
|
/* Read-only roadmap: bars don't show grab cursor or resize handle. */
|
|
153
191
|
product-roadmap:not([editable]) .rm-bar { cursor: default; }
|
|
154
192
|
product-roadmap:not([editable]) .rm-bar-handle { display: none; }
|
|
193
|
+
/* Editable bars handle their own pointer gestures (no touch scroll/pan). */
|
|
194
|
+
product-roadmap[editable] .rm-bar { touch-action: none; }
|
|
155
195
|
|
|
156
196
|
@media (prefers-reduced-motion: reduce) {
|
|
157
197
|
product-roadmap .rm-bar { transition: none; }
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"kind": "host-api",
|
|
80
80
|
"purpose": "config",
|
|
81
81
|
"type": "boolean",
|
|
82
|
-
"description": "Enable drag-and-drop between quadrants (composes <drag-surface>)"
|
|
82
|
+
"description": "Enable drag-and-drop between quadrants (composes one <drag-surface> per quadrant)"
|
|
83
83
|
}
|
|
84
84
|
],
|
|
85
85
|
"childAttributes": [
|
|
@@ -93,13 +93,13 @@
|
|
|
93
93
|
"name": "data-x",
|
|
94
94
|
"on": "*",
|
|
95
95
|
"type": "number",
|
|
96
|
-
"description": "Normalized x coordinate 0..1; quadrant is computed from coords"
|
|
96
|
+
"description": "Normalized x coordinate 0..1; quadrant is computed from coords; removed when the child is dragged to another quadrant"
|
|
97
97
|
},
|
|
98
98
|
{
|
|
99
99
|
"name": "data-y",
|
|
100
100
|
"on": "*",
|
|
101
101
|
"type": "number",
|
|
102
|
-
"description": "Normalized y coordinate 0..1; quadrant is computed from coords"
|
|
102
|
+
"description": "Normalized y coordinate 0..1; quadrant is computed from coords; removed when the child is dragged to another quadrant"
|
|
103
103
|
},
|
|
104
104
|
{
|
|
105
105
|
"name": "data-id",
|
|
@@ -112,7 +112,12 @@
|
|
|
112
112
|
{
|
|
113
113
|
"name": "quadrant-grid:move",
|
|
114
114
|
"detail": "{ item, itemId, from, to }",
|
|
115
|
-
"description": "Fired when a draggable child moves between quadrants"
|
|
115
|
+
"description": "Fired when a draggable child moves between quadrants (from / to are indices 0..3). A coordinate-pinned child dropped in another quadrant loses its data-x / data-y and joins that quadrant's stack"
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"name": "quadrant-grid:upgraded",
|
|
119
|
+
"detail": "",
|
|
120
|
+
"description": "Fired (in a microtask) each time the grid connects and upgrades"
|
|
116
121
|
}
|
|
117
122
|
],
|
|
118
123
|
"structure": [],
|
|
@@ -45,7 +45,9 @@
|
|
|
45
45
|
* @attr {number} data-y - 0..1 normalized y coordinate
|
|
46
46
|
* @attr {string} data-id - Stable identifier for moves
|
|
47
47
|
*
|
|
48
|
-
* @fires quadrant-grid:move - { item, itemId, from, to
|
|
48
|
+
* @fires quadrant-grid:move - { item, itemId, from, to }. A coordinate-pinned
|
|
49
|
+
* child that is dragged to another quadrant drops its data-x/data-y (a drop
|
|
50
|
+
* has no position) and joins that quadrant's stack as a regular card.
|
|
49
51
|
*/
|
|
50
52
|
|
|
51
53
|
import { registerComponent } from '../../lib/bundle-registry.js';
|
|
@@ -54,8 +56,19 @@ import { VBElement } from '../../lib/vb-element.js';
|
|
|
54
56
|
class QuadrantGrid extends VBElement {
|
|
55
57
|
static #instanceCount = 0;
|
|
56
58
|
|
|
59
|
+
/** Quadrant indices in visual reading order: TL (Q2), TR (Q1), BL (Q3), BR (Q4). */
|
|
60
|
+
static VISUAL_ORDER = [1, 0, 2, 3];
|
|
61
|
+
|
|
57
62
|
setup() {
|
|
58
|
-
this.#groupId = `qg-${++QuadrantGrid.#instanceCount}`;
|
|
63
|
+
if (!this.#groupId) this.#groupId = `qg-${++QuadrantGrid.#instanceCount}`;
|
|
64
|
+
|
|
65
|
+
// Reconnect (or a repeated setup() call): the grid is already built and
|
|
66
|
+
// still holds every child, so only re-attach listeners and observers.
|
|
67
|
+
if (this.#built) {
|
|
68
|
+
this.#wire();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
this.#built = true;
|
|
59
72
|
|
|
60
73
|
const xLabel = this.getAttribute('x-label') || '';
|
|
61
74
|
const yLabel = this.getAttribute('y-label') || '';
|
|
@@ -86,7 +99,7 @@ class QuadrantGrid extends VBElement {
|
|
|
86
99
|
const yScale = document.createElement('aside');
|
|
87
100
|
yScale.className = 'qg-y-scale';
|
|
88
101
|
yScale.setAttribute('aria-hidden', 'true');
|
|
89
|
-
yScale.
|
|
102
|
+
yScale.append(QuadrantGrid.#span(yHigh), QuadrantGrid.#span(yLow));
|
|
90
103
|
|
|
91
104
|
const grid = document.createElement('div');
|
|
92
105
|
grid.className = 'qg-grid';
|
|
@@ -94,16 +107,18 @@ class QuadrantGrid extends VBElement {
|
|
|
94
107
|
const xScale = document.createElement('aside');
|
|
95
108
|
xScale.className = 'qg-x-scale';
|
|
96
109
|
xScale.setAttribute('aria-hidden', 'true');
|
|
97
|
-
xScale.
|
|
110
|
+
xScale.append(QuadrantGrid.#span(xLow), QuadrantGrid.#span(xHigh));
|
|
98
111
|
|
|
99
112
|
const xLabelEl = document.createElement('footer');
|
|
100
113
|
xLabelEl.className = 'qg-x-label';
|
|
101
114
|
xLabelEl.setAttribute('aria-hidden', 'true');
|
|
102
115
|
xLabelEl.textContent = `${xLabel} →`;
|
|
103
116
|
|
|
104
|
-
/*
|
|
105
|
-
|
|
106
|
-
|
|
117
|
+
/* Indices 0..3 are Cartesian Q1..Q4 (Q1 top-right, Q2 top-left).
|
|
118
|
+
Append in visual reading order (top-left, top-right, bottom-left,
|
|
119
|
+
bottom-right) so DOM, reading and tab order match what is seen;
|
|
120
|
+
CSS still places each zone by data-quadrant-zone. */
|
|
121
|
+
for (const q of QuadrantGrid.VISUAL_ORDER) {
|
|
107
122
|
const section = document.createElement('section');
|
|
108
123
|
section.className = 'qg-quadrant';
|
|
109
124
|
section.dataset.quadrantZone = String(q);
|
|
@@ -167,11 +182,14 @@ class QuadrantGrid extends VBElement {
|
|
|
167
182
|
}
|
|
168
183
|
}
|
|
169
184
|
|
|
170
|
-
// Wrap non-drag children in <li> so list semantics hold.
|
|
185
|
+
// Wrap non-drag children in <li> so list semantics hold. A pinned
|
|
186
|
+
// child is absolutely positioned out of its <li>, so that <li> must
|
|
187
|
+
// not draw a chip of its own.
|
|
171
188
|
if (draggable) {
|
|
172
189
|
target.appendChild(child);
|
|
173
190
|
} else {
|
|
174
191
|
const li = document.createElement('li');
|
|
192
|
+
if (child.classList.contains('qg-pinned')) li.className = 'qg-pin-item';
|
|
175
193
|
li.appendChild(child);
|
|
176
194
|
target.appendChild(li);
|
|
177
195
|
}
|
|
@@ -192,11 +210,18 @@ class QuadrantGrid extends VBElement {
|
|
|
192
210
|
this.appendChild(wrapper);
|
|
193
211
|
this.appendChild(liveRegion);
|
|
194
212
|
|
|
195
|
-
this.#grid = grid;
|
|
196
213
|
this.#liveRegion = liveRegion;
|
|
197
214
|
this.#qLabels = qLabels;
|
|
215
|
+
this.#draggable = draggable;
|
|
198
216
|
|
|
199
|
-
|
|
217
|
+
this.#wire();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Attach listeners and pin observers once per connection. */
|
|
221
|
+
#wire() {
|
|
222
|
+
if (this.#wired) return;
|
|
223
|
+
this.#wired = true;
|
|
224
|
+
if (this.#draggable) {
|
|
200
225
|
this.listen(this, 'drag-surface:transfer', this.#onTransfer);
|
|
201
226
|
}
|
|
202
227
|
|
|
@@ -208,6 +233,13 @@ class QuadrantGrid extends VBElement {
|
|
|
208
233
|
this.#observePins();
|
|
209
234
|
}
|
|
210
235
|
|
|
236
|
+
/** @param {string} text */
|
|
237
|
+
static #span(text) {
|
|
238
|
+
const span = document.createElement('span');
|
|
239
|
+
span.textContent = text;
|
|
240
|
+
return span;
|
|
241
|
+
}
|
|
242
|
+
|
|
211
243
|
#observePins() {
|
|
212
244
|
if (typeof ResizeObserver === 'undefined') return;
|
|
213
245
|
const measure = (el) => {
|
|
@@ -250,6 +282,10 @@ class QuadrantGrid extends VBElement {
|
|
|
250
282
|
pin.removeAttribute('tabindex');
|
|
251
283
|
delete pin.dataset.qgTabbed;
|
|
252
284
|
}
|
|
285
|
+
if (pin.dataset.qgRole !== undefined) {
|
|
286
|
+
pin.removeAttribute('role');
|
|
287
|
+
delete pin.dataset.qgRole;
|
|
288
|
+
}
|
|
253
289
|
}
|
|
254
290
|
|
|
255
291
|
let clusterSeq = 0;
|
|
@@ -284,9 +320,16 @@ class QuadrantGrid extends VBElement {
|
|
|
284
320
|
pin.dataset.cluster = id;
|
|
285
321
|
pin.style.setProperty('--qg-cluster-i', String(i));
|
|
286
322
|
pin.style.setProperty('--qg-cluster-n', String(members.length));
|
|
323
|
+
// Clustered pins become focusable so keyboard users can fan the
|
|
324
|
+
// cluster out (:focus-within); a focusable generic element needs a
|
|
325
|
+
// role, so expose it as a group.
|
|
287
326
|
if (!pin.hasAttribute('tabindex') && !pin.matches('a, button, [contenteditable]')) {
|
|
288
327
|
pin.setAttribute('tabindex', '0');
|
|
289
328
|
pin.dataset.qgTabbed = '';
|
|
329
|
+
if (!pin.hasAttribute('role')) {
|
|
330
|
+
pin.setAttribute('role', 'group');
|
|
331
|
+
pin.dataset.qgRole = '';
|
|
332
|
+
}
|
|
290
333
|
}
|
|
291
334
|
});
|
|
292
335
|
}
|
|
@@ -294,6 +337,9 @@ class QuadrantGrid extends VBElement {
|
|
|
294
337
|
}
|
|
295
338
|
|
|
296
339
|
teardown() {
|
|
340
|
+
// Listeners are removed by VBElement; the built grid and its children
|
|
341
|
+
// stay in place so a moved grid keeps its content.
|
|
342
|
+
this.#wired = false;
|
|
297
343
|
this.#pinObserver?.disconnect();
|
|
298
344
|
this.#pinObserver = null;
|
|
299
345
|
if (this.#clusterRaf) cancelAnimationFrame(this.#clusterRaf);
|
|
@@ -320,6 +366,8 @@ class QuadrantGrid extends VBElement {
|
|
|
320
366
|
if (from == null || to == null) return;
|
|
321
367
|
|
|
322
368
|
item.setAttribute('data-quadrant', String(to));
|
|
369
|
+
if (item.classList.contains('qg-pinned')) this.#unpin(item);
|
|
370
|
+
this.#scheduleClusterPass();
|
|
323
371
|
this.#announce(`Moved ${this.#itemLabel(item)} to ${this.#qLabels[to]}`);
|
|
324
372
|
|
|
325
373
|
this.dispatchEvent(new CustomEvent('quadrant-grid:move', {
|
|
@@ -333,6 +381,25 @@ class QuadrantGrid extends VBElement {
|
|
|
333
381
|
}));
|
|
334
382
|
};
|
|
335
383
|
|
|
384
|
+
/**
|
|
385
|
+
* A dropped pin has no coordinates in its new quadrant: drop data-x /
|
|
386
|
+
* data-y and the pin styling so it joins the quadrant's stack.
|
|
387
|
+
* @param {HTMLElement} item
|
|
388
|
+
*/
|
|
389
|
+
#unpin(item) {
|
|
390
|
+
this.#pinObserver?.unobserve(item);
|
|
391
|
+
item.removeAttribute('data-x');
|
|
392
|
+
item.removeAttribute('data-y');
|
|
393
|
+
item.classList.remove('qg-pinned', 'qg-clustered');
|
|
394
|
+
for (const prop of ['--qg-local-x', '--qg-local-y', '--qg-pin-half-w', '--qg-pin-half-h',
|
|
395
|
+
'--qg-cluster-i', '--qg-cluster-n']) {
|
|
396
|
+
item.style.removeProperty(prop);
|
|
397
|
+
}
|
|
398
|
+
delete item.dataset.cluster;
|
|
399
|
+
if (item.dataset.qgTabbed !== undefined) { item.removeAttribute('tabindex'); delete item.dataset.qgTabbed; }
|
|
400
|
+
if (item.dataset.qgRole !== undefined) { item.removeAttribute('role'); delete item.dataset.qgRole; }
|
|
401
|
+
}
|
|
402
|
+
|
|
336
403
|
#resolveQuadrant(child) {
|
|
337
404
|
const q = parseInt(child.getAttribute('data-quadrant'), 10);
|
|
338
405
|
if (q >= 0 && q <= 3) return q;
|
|
@@ -374,6 +441,9 @@ class QuadrantGrid extends VBElement {
|
|
|
374
441
|
}
|
|
375
442
|
|
|
376
443
|
#groupId = '';
|
|
444
|
+
#built = false;
|
|
445
|
+
#wired = false;
|
|
446
|
+
#draggable = false;
|
|
377
447
|
/** @type {Record<number, HTMLElement>} */
|
|
378
448
|
#surfaces = {};
|
|
379
449
|
/** @type {Record<number, HTMLElement>} */
|
|
@@ -381,8 +451,6 @@ class QuadrantGrid extends VBElement {
|
|
|
381
451
|
/** @type {Record<number, HTMLElement>} */
|
|
382
452
|
#sections = {};
|
|
383
453
|
/** @type {HTMLElement | null} */
|
|
384
|
-
#grid = null;
|
|
385
|
-
/** @type {HTMLElement | null} */
|
|
386
454
|
#liveRegion = null;
|
|
387
455
|
/** @type {string[]} */
|
|
388
456
|
#qLabels = [];
|
|
@@ -122,6 +122,13 @@ quadrant-grid {
|
|
|
122
122
|
font-size: var(--font-size-sm, 0.875rem);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/* The <li> around a coordinate-pinned child: the pin is absolutely
|
|
126
|
+
positioned against the quadrant, so the <li> draws no chip and takes
|
|
127
|
+
no space in the list. */
|
|
128
|
+
.qg-items > li.qg-pin-item {
|
|
129
|
+
display: contents;
|
|
130
|
+
}
|
|
131
|
+
|
|
125
132
|
/* Coordinate-pinned children get absolute placement.
|
|
126
133
|
Section-local coords (0..1) are computed by JS and stored as
|
|
127
134
|
--qg-local-x / --qg-local-y. The component also measures each pin
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Combines the prior _nfr-utils.js (default ilities, default cost
|
|
5
5
|
* weights, validation invariants) with _radar-geometry.js (axis
|
|
6
|
-
* angles, vector
|
|
6
|
+
* angles, vector points, SVG builder), with the addition
|
|
7
7
|
* of click-to-edit dialog state helpers.
|
|
8
8
|
*
|
|
9
9
|
* Domain-neutral schema property names are unchanged: vector,
|
|
@@ -37,7 +37,7 @@ export const LEVEL_LABELS = Object.freeze({
|
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
/** Default cost weights when picking an ility as Critical. Sum = 40. */
|
|
40
|
-
|
|
40
|
+
const DEFAULT_COST_WEIGHTS = Object.freeze({
|
|
41
41
|
accessibility: 3,
|
|
42
42
|
performance: 5,
|
|
43
43
|
security: 5,
|
|
@@ -65,20 +65,6 @@ const ILITY_LABELS = Object.freeze({
|
|
|
65
65
|
privacy: 'Privacy',
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
-
const ILITY_ABBR = Object.freeze({
|
|
69
|
-
accessibility: 'a11y',
|
|
70
|
-
internationalization: 'i18n',
|
|
71
|
-
maintainability: 'maint',
|
|
72
|
-
observability: 'obs',
|
|
73
|
-
compatibility: 'compat',
|
|
74
|
-
portability: 'porta',
|
|
75
|
-
performance: 'perf',
|
|
76
|
-
reliability: 'rely',
|
|
77
|
-
scalability: 'scale',
|
|
78
|
-
security: 'sec',
|
|
79
|
-
privacy: 'priv',
|
|
80
|
-
});
|
|
81
|
-
|
|
82
68
|
const LEVEL_RATIO = Object.freeze({
|
|
83
69
|
critical: 1,
|
|
84
70
|
important: 0.6,
|
|
@@ -95,10 +81,6 @@ export function ilityLabel(ility) {
|
|
|
95
81
|
.replace(/\b\w/g, c => c.toUpperCase());
|
|
96
82
|
}
|
|
97
83
|
|
|
98
|
-
export function ilityAbbr(ility) {
|
|
99
|
-
return ILITY_ABBR[ility] || ility;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
84
|
// ── Cost weights ──────────────────────────────────────────────────
|
|
103
85
|
|
|
104
86
|
export function mergeCostWeights(ilities, overrides = {}) {
|
|
@@ -213,20 +195,6 @@ export function axisAngles(n) {
|
|
|
213
195
|
return out;
|
|
214
196
|
}
|
|
215
197
|
|
|
216
|
-
/**
|
|
217
|
-
* Fraction of `radius` the capacity envelope reaches per axis.
|
|
218
|
-
* Returns null when capacity is unbounded.
|
|
219
|
-
*/
|
|
220
|
-
export function envelopeRatio({ costWeights = {}, capacityPoints, ilities }) {
|
|
221
|
-
if (!Number.isFinite(capacityPoints)) return null;
|
|
222
|
-
const total = ilities.reduce((s, k) => {
|
|
223
|
-
const w = Number(costWeights[k]);
|
|
224
|
-
return s + (Number.isFinite(w) && w > 0 ? w : 0);
|
|
225
|
-
}, 0);
|
|
226
|
-
if (total <= 0) return 0;
|
|
227
|
-
return Math.min(1, Math.max(0, capacityPoints / total));
|
|
228
|
-
}
|
|
229
|
-
|
|
230
198
|
/** Per-ility {x, y, ility, level} points for the chosen vector. */
|
|
231
199
|
export function vectorPoints({ ilities, vector = {}, radius }) {
|
|
232
200
|
const angles = axisAngles(ilities.length);
|
|
@@ -242,18 +210,6 @@ export function vectorPoints({ ilities, vector = {}, radius }) {
|
|
|
242
210
|
});
|
|
243
211
|
}
|
|
244
212
|
|
|
245
|
-
/** Per-ility {x, y, ility} points for the capacity envelope. */
|
|
246
|
-
export function envelopePoints({ ilities, costWeights, capacityPoints, radius }) {
|
|
247
|
-
const ratio = envelopeRatio({ costWeights, capacityPoints, ilities });
|
|
248
|
-
if (ratio == null) return null;
|
|
249
|
-
const angles = axisAngles(ilities.length);
|
|
250
|
-
return ilities.map((k, i) => ({
|
|
251
|
-
ility: k,
|
|
252
|
-
x: round(Math.cos(angles[i]) * radius * ratio),
|
|
253
|
-
y: round(Math.sin(angles[i]) * radius * ratio),
|
|
254
|
-
}));
|
|
255
|
-
}
|
|
256
|
-
|
|
257
213
|
/** Outer axis endpoints — used for the spoke + label positions. */
|
|
258
214
|
export function axisOuterPoints({ ilities, radius }) {
|
|
259
215
|
const angles = axisAngles(ilities.length);
|
|
@@ -92,13 +92,15 @@ With JavaScript:
|
|
|
92
92
|
|
|
93
93
|
| Event | Detail | Description |
|
|
94
94
|
|-------|--------|-------------|
|
|
95
|
-
| `review-surface:
|
|
95
|
+
| `review-surface:upgraded` | — | Once, on first connect |
|
|
96
|
+
| `review-surface:ready` | `{ pinCount }` | Once, after the initial pins render |
|
|
96
97
|
| `review-surface:add` | `{ pin }` | Pin created |
|
|
97
98
|
| `review-surface:update` | `{ pin, changes }` | Pin modified |
|
|
98
99
|
| `review-surface:remove` | `{ pin }` | Pin deleted |
|
|
99
100
|
| `review-surface:resolve` | `{ pin }` | Pin resolved |
|
|
100
101
|
| `review-surface:select` | `{ pin }` | Popover opened |
|
|
101
102
|
| `review-surface:mode` | `{ mode }` | Mode toggled |
|
|
103
|
+
| `review-surface:pins-changed` | `{ pins, source }` | After `.pins` assignment |
|
|
102
104
|
|
|
103
105
|
## Adapters
|
|
104
106
|
|
|
@@ -121,22 +123,24 @@ Set via property:
|
|
|
121
123
|
```js
|
|
122
124
|
document.querySelector('review-surface').adapter = myCustomAdapter;
|
|
123
125
|
```
|
|
124
|
-
Must implement: `load()`, `save(pin)`, `update(id, changes)`, `remove(id)`.
|
|
126
|
+
Must implement: `load()`, `save(pin)`, `update(id, changes, pin)`, `remove(id)`.
|
|
127
|
+
`update` receives the component's full pin as a third argument so it can
|
|
128
|
+
upsert pins it did not create (loaded via `src` or `.pins`).
|
|
125
129
|
|
|
126
130
|
## Failure Modes
|
|
127
131
|
|
|
128
132
|
- **No JavaScript:** Child content renders normally; pins are not shown.
|
|
129
133
|
- **No CSS:** Shadow DOM styles are self-contained; layout degrades gracefully.
|
|
130
|
-
- **Keyboard Only:** Tab navigates pins; Enter opens popover; Escape closes.
|
|
134
|
+
- **Keyboard Only:** Tab navigates pins; Enter opens popover (focus moves into it); Escape closes and returns focus to the pin.
|
|
131
135
|
- **Screen Reader:** Pin markers announced as "Pin N: [text preview]";
|
|
132
136
|
live region announces create/delete/resolve actions.
|
|
133
137
|
|
|
134
138
|
## Accessibility
|
|
135
139
|
|
|
136
|
-
- Pin markers are `<button>` with `aria-label`, `aria-expanded`, `aria-haspopup="dialog"`
|
|
140
|
+
- Pin markers are `<button>` with `aria-label`, `aria-expanded`, `aria-haspopup="dialog"`, inside a `role="group"` overlay labelled "Review pins"
|
|
137
141
|
- Popover uses `role="dialog"` with `aria-labelledby`
|
|
138
142
|
- Toolbar uses `role="toolbar"` with `aria-label`
|
|
139
|
-
- `aria-live="polite"` region announces pin actions
|
|
143
|
+
- One persistent `aria-live="polite"` region announces pin actions
|
|
140
144
|
- All transitions respect `prefers-reduced-motion: reduce`
|
|
141
145
|
|
|
142
146
|
## CSS Tokens
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"description": "Pin-based annotation overlay for design review — wraps any content and adds interactive comment pins with pluggable persistence",
|
|
6
6
|
"htmlvalidate": { "flow": true, "permittedContent": ["@flow"] },
|
|
7
7
|
"attributes": [
|
|
8
|
-
{ "name": "src", "kind": "host-api", "purpose": "config", "type": "string", "description": "URL to JSON file containing pin data" },
|
|
8
|
+
{ "name": "src", "kind": "host-api", "purpose": "config", "type": "string", "description": "URL to JSON file containing pin data (an array, or { pins }). A newer value aborts an older in-flight request; removing it, or assigning .pins / importPins(), aborts a pending request (removing it mid-load falls back to the adapter)" },
|
|
9
9
|
{ "name": "editable", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Enables annotate mode toolbar and pin creation" },
|
|
10
10
|
{ "name": "adapter", "kind": "host-api", "purpose": "config", "type": "string", "description": "Persistence backend: memory | local | rest" },
|
|
11
11
|
{ "name": "endpoint", "kind": "host-api", "purpose": "config", "type": "string", "description": "REST endpoint URL (when adapter=rest)" },
|
|
@@ -19,12 +19,14 @@
|
|
|
19
19
|
{ "name": "(default)", "description": "The content to annotate (any HTML)" }
|
|
20
20
|
],
|
|
21
21
|
"events": [
|
|
22
|
-
{ "name": "review-surface:
|
|
23
|
-
{ "name": "review-surface:
|
|
24
|
-
{ "name": "review-surface:
|
|
25
|
-
{ "name": "review-surface:
|
|
26
|
-
{ "name": "review-surface:
|
|
27
|
-
{ "name": "review-surface:
|
|
28
|
-
{ "name": "review-surface:
|
|
22
|
+
{ "name": "review-surface:upgraded", "detail": "—", "description": "Fired once, on first connect, after the adapter is resolved and before the initial pin load. Not re-fired when the element is moved." },
|
|
23
|
+
{ "name": "review-surface:ready", "detail": "{ pinCount }", "description": "Fired once, after the initial pins (from src, the adapter, or a pre-connect .pins assignment) have loaded and rendered. Not re-fired on later renders." },
|
|
24
|
+
{ "name": "review-surface:add", "detail": "{ pin }", "description": "Fired when a pin is created (annotate-mode click or addPin())" },
|
|
25
|
+
{ "name": "review-surface:update", "detail": "{ pin, changes }", "description": "Fired when a pin is modified (reply added, text set, re-opened)" },
|
|
26
|
+
{ "name": "review-surface:remove", "detail": "{ pin }", "description": "Fired when a pin is deleted" },
|
|
27
|
+
{ "name": "review-surface:resolve", "detail": "{ pin }", "description": "Fired when a pin is marked resolved" },
|
|
28
|
+
{ "name": "review-surface:select", "detail": "{ pin }", "description": "Fired when a pin popover is opened" },
|
|
29
|
+
{ "name": "review-surface:mode", "detail": "{ mode: 'view' | 'annotate' }", "description": "Fired when view/annotate mode toggles" },
|
|
30
|
+
{ "name": "review-surface:pins-changed", "detail": "{ pins, source: 'property' }", "description": "Fired after a .pins assignment (not for adapter loads or addPin())" }
|
|
29
31
|
]
|
|
30
32
|
}
|