@profpowell/vb-project-planning 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -0
  3. package/dist/vb-project-planning.css +1603 -0
  4. package/dist/vb-project-planning.js +11138 -0
  5. package/dist/vb-project-planning.min.css +1 -0
  6. package/dist/vb-project-planning.min.js +3312 -0
  7. package/package.json +80 -0
  8. package/src/index.css +14 -0
  9. package/src/index.js +26 -0
  10. package/src/lib/bundle-registry.js +156 -0
  11. package/src/lib/diff-by-key.js +86 -0
  12. package/src/lib/portrait-url.js +87 -0
  13. package/src/lib/vb-collection.js +122 -0
  14. package/src/lib/vb-element.js +96 -0
  15. package/src/lib/vb-store.js +200 -0
  16. package/src/lib/vb-view-transition.js +44 -0
  17. package/src/utils/copy-init.js +287 -0
  18. package/src/web-components/_ux-base.js +106 -0
  19. package/src/web-components/adr-wc/README.md +77 -0
  20. package/src/web-components/adr-wc/api.json +26 -0
  21. package/src/web-components/adr-wc/logic.js +376 -0
  22. package/src/web-components/adr-wc/static.html +17 -0
  23. package/src/web-components/adr-wc/styles.js +338 -0
  24. package/src/web-components/burndown-chart/api.json +74 -0
  25. package/src/web-components/burndown-chart/logic.js +198 -0
  26. package/src/web-components/burndown-chart/static.html +31 -0
  27. package/src/web-components/burndown-chart/styles.css +59 -0
  28. package/src/web-components/capacity-plan/_capacity-utils.js +56 -0
  29. package/src/web-components/capacity-plan/api.json +20 -0
  30. package/src/web-components/capacity-plan/logic.js +222 -0
  31. package/src/web-components/capacity-plan/static.html +24 -0
  32. package/src/web-components/capacity-plan/styles.css +84 -0
  33. package/src/web-components/empathy-map/api.json +31 -0
  34. package/src/web-components/empathy-map/logic.js +502 -0
  35. package/src/web-components/empathy-map/static.html +32 -0
  36. package/src/web-components/empathy-map/styles.js +534 -0
  37. package/src/web-components/gantt-chart/README.md +91 -0
  38. package/src/web-components/gantt-chart/api.json +20 -0
  39. package/src/web-components/gantt-chart/logic.js +812 -0
  40. package/src/web-components/gantt-chart/static.html +60 -0
  41. package/src/web-components/gantt-chart/styles.css +391 -0
  42. package/src/web-components/impact-effort/api.json +21 -0
  43. package/src/web-components/impact-effort/logic.js +392 -0
  44. package/src/web-components/impact-effort/static.html +23 -0
  45. package/src/web-components/impact-effort/styles.css +193 -0
  46. package/src/web-components/iron-triangle/_capacity.js +83 -0
  47. package/src/web-components/iron-triangle/_triangle-geometry.js +322 -0
  48. package/src/web-components/iron-triangle/api.json +26 -0
  49. package/src/web-components/iron-triangle/logic.js +474 -0
  50. package/src/web-components/iron-triangle/static.html +28 -0
  51. package/src/web-components/iron-triangle/styles.css +158 -0
  52. package/src/web-components/kanban-board/api.json +24 -0
  53. package/src/web-components/kanban-board/logic.js +698 -0
  54. package/src/web-components/kanban-board/static.html +20 -0
  55. package/src/web-components/kanban-board/styles.css +242 -0
  56. package/src/web-components/product-roadmap/api.json +118 -0
  57. package/src/web-components/product-roadmap/logic.js +460 -0
  58. package/src/web-components/product-roadmap/static.html +44 -0
  59. package/src/web-components/product-roadmap/styles.css +158 -0
  60. package/src/web-components/quadrant-grid/api.json +125 -0
  61. package/src/web-components/quadrant-grid/logic.js +397 -0
  62. package/src/web-components/quadrant-grid/static.html +49 -0
  63. package/src/web-components/quadrant-grid/styles.css +184 -0
  64. package/src/web-components/quality-target/_quality-utils.js +460 -0
  65. package/src/web-components/quality-target/api.json +30 -0
  66. package/src/web-components/quality-target/logic.js +516 -0
  67. package/src/web-components/quality-target/static.html +22 -0
  68. package/src/web-components/quality-target/styles.css +203 -0
  69. package/src/web-components/requirement-card/api.json +25 -0
  70. package/src/web-components/requirement-card/logic.js +166 -0
  71. package/src/web-components/requirement-card/static.html +36 -0
  72. package/src/web-components/requirement-card/styles.js +114 -0
  73. package/src/web-components/review-surface/README.md +178 -0
  74. package/src/web-components/review-surface/api.json +30 -0
  75. package/src/web-components/review-surface/logic.js +807 -0
  76. package/src/web-components/review-surface/static.html +33 -0
  77. package/src/web-components/review-surface/styles.js +459 -0
  78. package/src/web-components/risk-register/api.json +47 -0
  79. package/src/web-components/risk-register/logic.js +180 -0
  80. package/src/web-components/risk-register/static.html +46 -0
  81. package/src/web-components/risk-register/styles.css +37 -0
  82. package/src/web-components/story-map/api.json +22 -0
  83. package/src/web-components/story-map/logic.js +445 -0
  84. package/src/web-components/story-map/static.html +35 -0
  85. package/src/web-components/story-map/styles.css +169 -0
  86. package/src/web-components/traceability-matrix/api.json +84 -0
  87. package/src/web-components/traceability-matrix/logic.js +243 -0
  88. package/src/web-components/traceability-matrix/static.html +41 -0
  89. package/src/web-components/traceability-matrix/styles.css +94 -0
  90. package/src/web-components/user-journey/api.json +84 -0
  91. package/src/web-components/user-journey/logic.js +368 -0
  92. package/src/web-components/user-journey/static.html +14 -0
  93. package/src/web-components/user-journey/styles.js +317 -0
  94. package/src/web-components/user-persona/api.json +35 -0
  95. package/src/web-components/user-persona/logic.js +502 -0
  96. package/src/web-components/user-persona/static.html +20 -0
  97. package/src/web-components/user-persona/styles.js +299 -0
  98. package/src/web-components/user-story/api.json +35 -0
  99. package/src/web-components/user-story/logic.js +567 -0
  100. package/src/web-components/user-story/static.html +27 -0
  101. package/src/web-components/user-story/styles.js +332 -0
  102. package/src/web-components/work-item/README.md +85 -0
  103. package/src/web-components/work-item/api.json +30 -0
  104. package/src/web-components/work-item/logic.js +414 -0
  105. package/src/web-components/work-item/static.html +24 -0
  106. package/src/web-components/work-item/styles.js +376 -0
@@ -0,0 +1,125 @@
1
+ {
2
+ "$schema": "../../../schemas/api.schema.json",
3
+ "element": "quadrant-grid",
4
+ "type": "web-component",
5
+ "description": "Generic 2x2 quadrant grid primitive (SWOT, stakeholder-map, custom)",
6
+ "attributes": [
7
+ {
8
+ "name": "x-label",
9
+ "kind": "host-api",
10
+ "purpose": "config",
11
+ "type": "string",
12
+ "description": "X-axis label"
13
+ },
14
+ {
15
+ "name": "y-label",
16
+ "kind": "host-api",
17
+ "purpose": "config",
18
+ "type": "string",
19
+ "description": "Y-axis label"
20
+ },
21
+ {
22
+ "name": "x-low",
23
+ "kind": "host-api",
24
+ "purpose": "config",
25
+ "type": "string",
26
+ "description": "X-axis low-end marker (default: \"Low\")"
27
+ },
28
+ {
29
+ "name": "x-high",
30
+ "kind": "host-api",
31
+ "purpose": "config",
32
+ "type": "string",
33
+ "description": "X-axis high-end marker (default: \"High\")"
34
+ },
35
+ {
36
+ "name": "y-low",
37
+ "kind": "host-api",
38
+ "purpose": "config",
39
+ "type": "string",
40
+ "description": "Y-axis low-end marker (default: \"Low\")"
41
+ },
42
+ {
43
+ "name": "y-high",
44
+ "kind": "host-api",
45
+ "purpose": "config",
46
+ "type": "string",
47
+ "description": "Y-axis high-end marker (default: \"High\")"
48
+ },
49
+ {
50
+ "name": "q1-label",
51
+ "kind": "host-api",
52
+ "purpose": "config",
53
+ "type": "string",
54
+ "description": "Top-right quadrant title"
55
+ },
56
+ {
57
+ "name": "q2-label",
58
+ "kind": "host-api",
59
+ "purpose": "config",
60
+ "type": "string",
61
+ "description": "Top-left quadrant title"
62
+ },
63
+ {
64
+ "name": "q3-label",
65
+ "kind": "host-api",
66
+ "purpose": "config",
67
+ "type": "string",
68
+ "description": "Bottom-left quadrant title"
69
+ },
70
+ {
71
+ "name": "q4-label",
72
+ "kind": "host-api",
73
+ "purpose": "config",
74
+ "type": "string",
75
+ "description": "Bottom-right quadrant title"
76
+ },
77
+ {
78
+ "name": "draggable",
79
+ "kind": "host-api",
80
+ "purpose": "config",
81
+ "type": "boolean",
82
+ "description": "Enable drag-and-drop between quadrants (composes <drag-surface>)"
83
+ }
84
+ ],
85
+ "childAttributes": [
86
+ {
87
+ "name": "data-quadrant",
88
+ "on": "*",
89
+ "type": "number",
90
+ "description": "Explicit quadrant index 0..3 (Cartesian Q1..Q4)"
91
+ },
92
+ {
93
+ "name": "data-x",
94
+ "on": "*",
95
+ "type": "number",
96
+ "description": "Normalized x coordinate 0..1; quadrant is computed from coords"
97
+ },
98
+ {
99
+ "name": "data-y",
100
+ "on": "*",
101
+ "type": "number",
102
+ "description": "Normalized y coordinate 0..1; quadrant is computed from coords"
103
+ },
104
+ {
105
+ "name": "data-id",
106
+ "on": "*",
107
+ "type": "string",
108
+ "description": "Stable identifier emitted in move events"
109
+ }
110
+ ],
111
+ "events": [
112
+ {
113
+ "name": "quadrant-grid:move",
114
+ "detail": "{ item, itemId, from, to }",
115
+ "description": "Fired when a draggable child moves between quadrants"
116
+ }
117
+ ],
118
+ "structure": [],
119
+ "htmlvalidate": {
120
+ "flow": true,
121
+ "permittedContent": [
122
+ "@flow"
123
+ ]
124
+ }
125
+ }
@@ -0,0 +1,397 @@
1
+ /**
2
+ * quadrant-grid: Generic 2x2 quadrant grid primitive
3
+ *
4
+ * Backs SWOT, stakeholder-map, custom 2x2 visualizations. Sister to
5
+ * <impact-effort> (which keeps its hardcoded quick-wins/big-bets API
6
+ * for backward compatibility).
7
+ *
8
+ * Children are placed by either:
9
+ * - data-quadrant="0..3" — explicit bucket (Cartesian: Q1=top-right,
10
+ * Q2=top-left, Q3=bottom-left, Q4=bottom-right; numbers 0..3 map to
11
+ * Q1..Q4)
12
+ * - data-x="0..1" data-y="0..1" — normalized coordinates; component
13
+ * computes the quadrant AND positions the child as a labelled dot
14
+ * inside the cell
15
+ *
16
+ * Optional drag-and-drop via the `draggable` host attribute; composes
17
+ * <drag-surface> the same way <impact-effort> does. Children become
18
+ * draggable cards that can be moved between quadrants.
19
+ *
20
+ * Progressive enhancement:
21
+ * 1. HTML — children with data-quadrant remain visible without JS;
22
+ * a <dl> static fallback enumerates each quadrant.
23
+ * 2. CSS — :not(:defined) selector keeps the layout sensible
24
+ * pre-upgrade; the grid layout is applied once the component
25
+ * defines.
26
+ * 3. JS — adds axis labels, computes quadrants for data-x/data-y
27
+ * children, wires up drag-and-drop when requested, fires
28
+ * `quadrant-grid:move`.
29
+ *
30
+ * @attr {string} x-label - X-axis label (default: "")
31
+ * @attr {string} y-label - Y-axis label (default: "")
32
+ * @attr {string} x-low - X-axis low-end marker (default: "Low")
33
+ * @attr {string} x-high - X-axis high-end marker (default: "High")
34
+ * @attr {string} y-low - Y-axis low-end marker (default: "Low")
35
+ * @attr {string} y-high - Y-axis high-end marker (default: "High")
36
+ * @attr {string} q1-label - Top-right quadrant title
37
+ * @attr {string} q2-label - Top-left quadrant title
38
+ * @attr {string} q3-label - Bottom-left quadrant title
39
+ * @attr {string} q4-label - Bottom-right quadrant title
40
+ * @attr {boolean} draggable - Enable DnD between quadrants
41
+ *
42
+ * Children attributes:
43
+ * @attr {number} data-quadrant - 0..3 mapping to Q1..Q4
44
+ * @attr {number} data-x - 0..1 normalized x coordinate
45
+ * @attr {number} data-y - 0..1 normalized y coordinate
46
+ * @attr {string} data-id - Stable identifier for moves
47
+ *
48
+ * @fires quadrant-grid:move - { item, itemId, from, to, x?, y? }
49
+ */
50
+
51
+ import { registerComponent } from '../../lib/bundle-registry.js';
52
+ import { VBElement } from '../../lib/vb-element.js';
53
+
54
+ class QuadrantGrid extends VBElement {
55
+ static #instanceCount = 0;
56
+
57
+ setup() {
58
+ this.#groupId = `qg-${++QuadrantGrid.#instanceCount}`;
59
+
60
+ const xLabel = this.getAttribute('x-label') || '';
61
+ const yLabel = this.getAttribute('y-label') || '';
62
+ const xLow = this.getAttribute('x-low') || 'Low';
63
+ const xHigh = this.getAttribute('x-high') || 'High';
64
+ const yLow = this.getAttribute('y-low') || 'Low';
65
+ const yHigh = this.getAttribute('y-high') || 'High';
66
+ const qLabels = [
67
+ this.getAttribute('q1-label') || 'Q1',
68
+ this.getAttribute('q2-label') || 'Q2',
69
+ this.getAttribute('q3-label') || 'Q3',
70
+ this.getAttribute('q4-label') || 'Q4',
71
+ ];
72
+ const draggable = this.hasAttribute('draggable');
73
+
74
+ const children = [...this.querySelectorAll(':scope > [data-quadrant], :scope > [data-x][data-y]')];
75
+
76
+ const wrapper = document.createElement('section');
77
+ wrapper.className = 'qg-wrapper';
78
+ wrapper.setAttribute('role', 'region');
79
+ wrapper.setAttribute('aria-label', `${yLabel} × ${xLabel} quadrant grid`);
80
+
81
+ const yLabelEl = document.createElement('header');
82
+ yLabelEl.className = 'qg-y-label';
83
+ yLabelEl.setAttribute('aria-hidden', 'true');
84
+ yLabelEl.textContent = `↑ ${yLabel}`;
85
+
86
+ const yScale = document.createElement('aside');
87
+ yScale.className = 'qg-y-scale';
88
+ yScale.setAttribute('aria-hidden', 'true');
89
+ yScale.innerHTML = `<span>${yHigh}</span><span>${yLow}</span>`;
90
+
91
+ const grid = document.createElement('div');
92
+ grid.className = 'qg-grid';
93
+
94
+ const xScale = document.createElement('aside');
95
+ xScale.className = 'qg-x-scale';
96
+ xScale.setAttribute('aria-hidden', 'true');
97
+ xScale.innerHTML = `<span>${xLow}</span><span>${xHigh}</span>`;
98
+
99
+ const xLabelEl = document.createElement('footer');
100
+ xLabelEl.className = 'qg-x-label';
101
+ xLabelEl.setAttribute('aria-hidden', 'true');
102
+ xLabelEl.textContent = `${xLabel} →`;
103
+
104
+ /* Cartesian DOM order: place sections in the visual grid by index.
105
+ Indices 0..3 correspond to Q1..Q4. CSS grid-area positions them. */
106
+ for (let q = 0; q < 4; q++) {
107
+ const section = document.createElement('section');
108
+ section.className = 'qg-quadrant';
109
+ section.dataset.quadrantZone = String(q);
110
+ section.setAttribute('aria-label', qLabels[q]);
111
+
112
+ const header = document.createElement('header');
113
+ header.className = 'qg-quadrant-label';
114
+ header.textContent = qLabels[q];
115
+
116
+ section.appendChild(header);
117
+
118
+ if (draggable) {
119
+ const surface = document.createElement('drag-surface');
120
+ surface.setAttribute('group', this.#groupId);
121
+ surface.setAttribute('aria-label', qLabels[q]);
122
+ surface.setAttribute('data-layout', 'stack');
123
+ surface.setAttribute('data-layout-gap', 'xs');
124
+ section.appendChild(surface);
125
+ this.#surfaces[q] = surface;
126
+ } else {
127
+ const list = document.createElement('ul');
128
+ list.className = 'qg-items';
129
+ section.appendChild(list);
130
+ this.#lists[q] = list;
131
+ }
132
+
133
+ grid.appendChild(section);
134
+ this.#sections[q] = section;
135
+ }
136
+
137
+ /* Distribute captured children into matching quadrants. */
138
+ children.forEach((/** @type {Element} */ rawChild, i) => {
139
+ const child = /** @type {HTMLElement} */ (rawChild);
140
+ const q = this.#resolveQuadrant(child);
141
+ const target = draggable ? this.#surfaces[q] : this.#lists[q];
142
+
143
+ if (draggable) {
144
+ if (!child.hasAttribute('draggable')) child.setAttribute('draggable', 'true');
145
+ }
146
+ if (!child.hasAttribute('data-id')) child.dataset.id = `qg-item-${i}`;
147
+
148
+ // Coordinate-positioned children: compute section-local coords
149
+ // (each quadrant spans half the grid; convert document 0..1 to
150
+ // section 0..1) and pin via --qg-local-x / --qg-local-y.
151
+ if (child.hasAttribute('data-x') && child.hasAttribute('data-y')) {
152
+ const x = parseFloat(child.getAttribute('data-x') ?? '');
153
+ const y = parseFloat(child.getAttribute('data-y') ?? '');
154
+ if (Number.isFinite(x) && Number.isFinite(y)) {
155
+ const localX = q === 0 || q === 3 ? (x - 0.5) * 2 : x * 2;
156
+ const localY = q === 0 || q === 1 ? (y - 0.5) * 2 : y * 2;
157
+ child.style.setProperty('--qg-local-x', localX.toFixed(4));
158
+ child.style.setProperty('--qg-local-y', localY.toFixed(4));
159
+ child.classList.add('qg-pinned');
160
+ // Deterministic per-pin hue so identical-style pins remain
161
+ // distinguishable even when stacked. Hash data-id (or text)
162
+ // to a 0..359 hue offset that CSS applies via hue-rotate.
163
+ if (!child.style.getPropertyValue('--qg-pin-hue')) {
164
+ const key = child.dataset.id || (child.textContent || '').trim();
165
+ child.style.setProperty('--qg-pin-hue', `${QuadrantGrid.#hueFromString(key)}deg`);
166
+ }
167
+ }
168
+ }
169
+
170
+ // Wrap non-drag children in <li> so list semantics hold.
171
+ if (draggable) {
172
+ target.appendChild(child);
173
+ } else {
174
+ const li = document.createElement('li');
175
+ li.appendChild(child);
176
+ target.appendChild(li);
177
+ }
178
+ });
179
+
180
+ /* Live region for screen-reader announcements on move. */
181
+ const liveRegion = document.createElement('div');
182
+ liveRegion.className = 'qg-live-region visually-hidden';
183
+ liveRegion.setAttribute('role', 'status');
184
+ liveRegion.setAttribute('aria-live', 'polite');
185
+
186
+ /* Assembly: 3-col grid (y-scale | grid | spacer) over 3 rows (label | grid | x-scale + label) */
187
+ wrapper.appendChild(yLabelEl);
188
+ wrapper.appendChild(yScale);
189
+ wrapper.appendChild(grid);
190
+ wrapper.appendChild(xScale);
191
+ wrapper.appendChild(xLabelEl);
192
+ this.appendChild(wrapper);
193
+ this.appendChild(liveRegion);
194
+
195
+ this.#grid = grid;
196
+ this.#liveRegion = liveRegion;
197
+ this.#qLabels = qLabels;
198
+
199
+ if (draggable) {
200
+ this.listen(this, 'drag-surface:transfer', this.#onTransfer);
201
+ }
202
+
203
+ /* Measure pinned tokens so CSS can clamp their center position by
204
+ half-width / half-height, keeping the bounding box inside the
205
+ quadrant at extreme coordinates. ResizeObserver keeps the
206
+ measurement live across font load, container resize, and content
207
+ changes. */
208
+ this.#observePins();
209
+ }
210
+
211
+ #observePins() {
212
+ if (typeof ResizeObserver === 'undefined') return;
213
+ const measure = (el) => {
214
+ const w = el.offsetWidth;
215
+ const h = el.offsetHeight;
216
+ if (w > 0) el.style.setProperty('--qg-pin-half-w', `${w / 2}px`);
217
+ if (h > 0) el.style.setProperty('--qg-pin-half-h', `${h / 2}px`);
218
+ };
219
+ this.#pinObserver = new ResizeObserver((entries) => {
220
+ for (const entry of entries) measure(entry.target);
221
+ this.#scheduleClusterPass();
222
+ });
223
+ for (const pin of this.querySelectorAll('.qg-pinned')) {
224
+ this.#pinObserver.observe(pin);
225
+ }
226
+ this.#scheduleClusterPass();
227
+ }
228
+
229
+ /* Coalesce cluster passes within a frame; ResizeObserver and font
230
+ loads can fire multiple times during initial layout. */
231
+ #scheduleClusterPass() {
232
+ if (this.#clusterRaf) return;
233
+ this.#clusterRaf = requestAnimationFrame(() => {
234
+ this.#clusterRaf = 0;
235
+ this.#detectClusters();
236
+ });
237
+ }
238
+
239
+ /* Group overlapping pins per quadrant so CSS can fan them on hover
240
+ or focus. Cluster pairs by bounding-rect intersection — robust to
241
+ pin size, padding, and inline-size differences. */
242
+ #detectClusters() {
243
+ for (const rawPin of this.querySelectorAll('.qg-pinned')) {
244
+ const pin = /** @type {HTMLElement} */ (rawPin);
245
+ pin.classList.remove('qg-clustered');
246
+ pin.style.removeProperty('--qg-cluster-i');
247
+ pin.style.removeProperty('--qg-cluster-n');
248
+ delete pin.dataset.cluster;
249
+ if (pin.dataset.qgTabbed !== undefined) {
250
+ pin.removeAttribute('tabindex');
251
+ delete pin.dataset.qgTabbed;
252
+ }
253
+ }
254
+
255
+ let clusterSeq = 0;
256
+ for (let q = 0; q < 4; q++) {
257
+ const section = this.#sections[q];
258
+ if (!section) continue;
259
+ const pins = /** @type {HTMLElement[]} */ ([...section.querySelectorAll('.qg-pinned')]);
260
+ if (pins.length < 2) continue;
261
+ const rects = pins.map((p) => p.getBoundingClientRect());
262
+ const parent = Array.from({ length: pins.length }, (_, i) => i);
263
+ /** @type {(i: number) => number} */
264
+ const find = (i) => (parent[i] === i ? i : (parent[i] = find(parent[i])));
265
+ const union = (a, b) => { const ra = find(a), rb = find(b); if (ra !== rb) parent[ra] = rb; };
266
+ for (let i = 0; i < pins.length; i++) {
267
+ for (let j = i + 1; j < pins.length; j++) {
268
+ if (QuadrantGrid.#rectsIntersect(rects[i], rects[j])) union(i, j);
269
+ }
270
+ }
271
+ /** @type {Map<number, number[]>} */
272
+ const groups = new Map();
273
+ for (let i = 0; i < pins.length; i++) {
274
+ const root = find(i);
275
+ if (!groups.has(root)) groups.set(root, []);
276
+ groups.get(root)?.push(i);
277
+ }
278
+ for (const members of groups.values()) {
279
+ if (members.length < 2) continue;
280
+ const id = `c${++clusterSeq}`;
281
+ members.forEach((idx, i) => {
282
+ const pin = pins[idx];
283
+ pin.classList.add('qg-clustered');
284
+ pin.dataset.cluster = id;
285
+ pin.style.setProperty('--qg-cluster-i', String(i));
286
+ pin.style.setProperty('--qg-cluster-n', String(members.length));
287
+ if (!pin.hasAttribute('tabindex') && !pin.matches('a, button, [contenteditable]')) {
288
+ pin.setAttribute('tabindex', '0');
289
+ pin.dataset.qgTabbed = '';
290
+ }
291
+ });
292
+ }
293
+ }
294
+ }
295
+
296
+ teardown() {
297
+ this.#pinObserver?.disconnect();
298
+ this.#pinObserver = null;
299
+ if (this.#clusterRaf) cancelAnimationFrame(this.#clusterRaf);
300
+ this.#clusterRaf = 0;
301
+ }
302
+
303
+ static #rectsIntersect(a, b) {
304
+ return !(a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top);
305
+ }
306
+
307
+ /* Stable string hash → hue degree in [0, 360). djb2-ish; small and
308
+ deterministic across runs so per-pin tints don't flicker on
309
+ re-render. */
310
+ static #hueFromString(s) {
311
+ let h = 5381;
312
+ for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
313
+ return ((h % 360) + 360) % 360;
314
+ }
315
+
316
+ #onTransfer = (e) => {
317
+ const { item, fromSurface, toSurface } = e.detail;
318
+ const from = this.#findQuadrantForSurface(fromSurface);
319
+ const to = this.#findQuadrantForSurface(toSurface);
320
+ if (from == null || to == null) return;
321
+
322
+ item.setAttribute('data-quadrant', String(to));
323
+ this.#announce(`Moved ${this.#itemLabel(item)} to ${this.#qLabels[to]}`);
324
+
325
+ this.dispatchEvent(new CustomEvent('quadrant-grid:move', {
326
+ bubbles: true,
327
+ detail: {
328
+ item,
329
+ itemId: item.dataset.id,
330
+ from,
331
+ to,
332
+ },
333
+ }));
334
+ };
335
+
336
+ #resolveQuadrant(child) {
337
+ const q = parseInt(child.getAttribute('data-quadrant'), 10);
338
+ if (q >= 0 && q <= 3) return q;
339
+ if (child.hasAttribute('data-x') && child.hasAttribute('data-y')) {
340
+ const x = parseFloat(child.getAttribute('data-x'));
341
+ const y = parseFloat(child.getAttribute('data-y'));
342
+ return QuadrantGrid.quadrantFor(x, y);
343
+ }
344
+ return 0;
345
+ }
346
+
347
+ /**
348
+ * Map normalized (x, y) coordinates in [0..1] to a Cartesian quadrant
349
+ * index 0..3 (Q1=top-right, Q2=top-left, Q3=bottom-left, Q4=bottom-right).
350
+ * The midpoint (0.5) belongs to the higher quadrant.
351
+ */
352
+ static quadrantFor(x, y) {
353
+ const right = x >= 0.5;
354
+ const top = y >= 0.5;
355
+ if (top && right) return 0;
356
+ if (top && !right) return 1;
357
+ if (!top && !right) return 2;
358
+ return 3;
359
+ }
360
+
361
+ #findQuadrantForSurface(surface) {
362
+ for (let q = 0; q < 4; q++) {
363
+ if (this.#surfaces[q] === surface) return q;
364
+ }
365
+ return null;
366
+ }
367
+
368
+ #itemLabel(item) {
369
+ return (item.textContent || '').trim().split('\n')[0] || 'item';
370
+ }
371
+
372
+ #announce(message) {
373
+ if (this.#liveRegion) this.#liveRegion.textContent = message;
374
+ }
375
+
376
+ #groupId = '';
377
+ /** @type {Record<number, HTMLElement>} */
378
+ #surfaces = {};
379
+ /** @type {Record<number, HTMLElement>} */
380
+ #lists = {};
381
+ /** @type {Record<number, HTMLElement>} */
382
+ #sections = {};
383
+ /** @type {HTMLElement | null} */
384
+ #grid = null;
385
+ /** @type {HTMLElement | null} */
386
+ #liveRegion = null;
387
+ /** @type {string[]} */
388
+ #qLabels = [];
389
+ /** @type {ResizeObserver | null} */
390
+ #pinObserver = null;
391
+ /** @type {number} */
392
+ #clusterRaf = 0;
393
+ }
394
+
395
+ registerComponent('quadrant-grid', QuadrantGrid);
396
+
397
+ export { QuadrantGrid };
@@ -0,0 +1,49 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Quadrant Grid — Static Fallback</title>
7
+ <style>
8
+ body { font-family: system-ui, sans-serif; padding: 2rem; }
9
+ dl { display: grid; grid-template-columns: max-content 1fr; gap: 0.25rem 1rem; max-width: 36rem; }
10
+ dt { font-weight: 600; }
11
+ dd { margin: 0; color: #555; }
12
+ </style>
13
+ </head>
14
+ <body>
15
+ <!--
16
+ Static fallback for <quadrant-grid>.
17
+
18
+ Upgrade relationship:
19
+ - Without JS or CSS: the <dl> below remains a readable list of
20
+ quadrant titles and their items. Children with data-quadrant are
21
+ grouped in author order.
22
+ - With CSS but no JS: the :not(:defined) selector keeps the host
23
+ visible; without the grid layout the items appear in source order
24
+ grouped under their data-quadrant heading.
25
+ - With JS: the component upgrades, builds the 2x2 grid with axis
26
+ labels, distributes children into quadrant sections, optionally
27
+ wires <drag-surface> for moves, and fires quadrant-grid:move.
28
+ -->
29
+
30
+ <quadrant-grid
31
+ x-label="Effort"
32
+ y-label="Impact"
33
+ q1-label="Big Bets"
34
+ q2-label="Quick Wins"
35
+ q3-label="Fill-Ins"
36
+ q4-label="Money Pit">
37
+ <dl>
38
+ <dt>Big Bets</dt>
39
+ <dd>High impact, high effort. Long-term initiatives.</dd>
40
+ <dt>Quick Wins</dt>
41
+ <dd>High impact, low effort. Do these first.</dd>
42
+ <dt>Fill-Ins</dt>
43
+ <dd>Low impact, low effort. Background work.</dd>
44
+ <dt>Money Pit</dt>
45
+ <dd>Low impact, high effort. Avoid.</dd>
46
+ </dl>
47
+ </quadrant-grid>
48
+ </body>
49
+ </html>