@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
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "@profpowell/vb-project-planning",
3
+ "version": "0.1.0",
4
+ "description": "Project- and UX-planning Web Components for Vanilla Breeze: personas, stories, journeys, empathy maps, impact/effort and quadrant grids, risk and traceability matrices, burndown, roadmap, kanban, story map, gantt, review surface, work items, ADRs, iron triangle, capacity plans, quality targets, and requirement cards.",
5
+ "license": "MIT",
6
+ "author": "Patrick Powell",
7
+ "type": "module",
8
+ "homepage": "https://github.com/ProfPowell/vb-project-planning#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/ProfPowell/vb-project-planning.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/ProfPowell/vb-project-planning/issues"
15
+ },
16
+ "keywords": [
17
+ "vanilla-breeze",
18
+ "web-components",
19
+ "project-planning",
20
+ "ux",
21
+ "agile",
22
+ "kanban",
23
+ "roadmap"
24
+ ],
25
+ "files": [
26
+ "src",
27
+ "dist"
28
+ ],
29
+ "main": "./dist/vb-project-planning.js",
30
+ "module": "./src/index.js",
31
+ "style": "./dist/vb-project-planning.css",
32
+ "exports": {
33
+ ".": {
34
+ "import": "./src/index.js",
35
+ "default": "./dist/vb-project-planning.js"
36
+ },
37
+ "./css": "./src/index.css",
38
+ "./dist/css": "./dist/vb-project-planning.css",
39
+ "./user-persona": "./src/web-components/user-persona/logic.js",
40
+ "./user-story": "./src/web-components/user-story/logic.js",
41
+ "./user-journey": "./src/web-components/user-journey/logic.js",
42
+ "./empathy-map": "./src/web-components/empathy-map/logic.js",
43
+ "./impact-effort": "./src/web-components/impact-effort/logic.js",
44
+ "./quadrant-grid": "./src/web-components/quadrant-grid/logic.js",
45
+ "./risk-register": "./src/web-components/risk-register/logic.js",
46
+ "./traceability-matrix": "./src/web-components/traceability-matrix/logic.js",
47
+ "./burndown-chart": "./src/web-components/burndown-chart/logic.js",
48
+ "./product-roadmap": "./src/web-components/product-roadmap/logic.js",
49
+ "./kanban-board": "./src/web-components/kanban-board/logic.js",
50
+ "./story-map": "./src/web-components/story-map/logic.js",
51
+ "./review-surface": "./src/web-components/review-surface/logic.js",
52
+ "./gantt-chart": "./src/web-components/gantt-chart/logic.js",
53
+ "./work-item": "./src/web-components/work-item/logic.js",
54
+ "./adr-wc": "./src/web-components/adr-wc/logic.js",
55
+ "./iron-triangle": "./src/web-components/iron-triangle/logic.js",
56
+ "./capacity-plan": "./src/web-components/capacity-plan/logic.js",
57
+ "./quality-target": "./src/web-components/quality-target/logic.js",
58
+ "./requirement-card": "./src/web-components/requirement-card/logic.js",
59
+ "./package.json": "./package.json"
60
+ },
61
+ "scripts": {
62
+ "build": "npm run build:js && npm run build:css",
63
+ "build:js": "esbuild src/index.js --bundle --format=esm --outfile=dist/vb-project-planning.js && esbuild src/index.js --bundle --format=esm --minify --outfile=dist/vb-project-planning.min.js",
64
+ "build:css": "esbuild src/index.css --bundle --outfile=dist/vb-project-planning.css && esbuild src/index.css --bundle --minify --outfile=dist/vb-project-planning.min.css",
65
+ "prepublishOnly": "npm run build",
66
+ "test:components": "playwright test"
67
+ },
68
+ "peerDependencies": {
69
+ "vanilla-breeze": ">=0.1.0"
70
+ },
71
+ "peerDependenciesMeta": {
72
+ "vanilla-breeze": {
73
+ "optional": true
74
+ }
75
+ },
76
+ "devDependencies": {
77
+ "@playwright/test": "^1.61.1",
78
+ "esbuild": "^0.27.0"
79
+ }
80
+ }
package/src/index.css ADDED
@@ -0,0 +1,14 @@
1
+ /* @profpowell/vb-project-planning — light-DOM component styles.
2
+ (Shadow-DOM components bundle their own CSS via logic.js.) */
3
+ @import "./web-components/impact-effort/styles.css";
4
+ @import "./web-components/quadrant-grid/styles.css";
5
+ @import "./web-components/risk-register/styles.css";
6
+ @import "./web-components/traceability-matrix/styles.css";
7
+ @import "./web-components/burndown-chart/styles.css";
8
+ @import "./web-components/product-roadmap/styles.css";
9
+ @import "./web-components/kanban-board/styles.css";
10
+ @import "./web-components/story-map/styles.css";
11
+ @import "./web-components/gantt-chart/styles.css";
12
+ @import "./web-components/iron-triangle/styles.css";
13
+ @import "./web-components/capacity-plan/styles.css";
14
+ @import "./web-components/quality-target/styles.css";
package/src/index.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @profpowell/vb-project-planning — barrel.
3
+ * Side-effect imports register every project-planning component.
4
+ * Consumes Vanilla Breeze tokens/themes via CSS custom properties.
5
+ * Drag-to-reorder components require <drag-surface> (Vanilla Breeze peer).
6
+ */
7
+ import './web-components/user-persona/logic.js';
8
+ import './web-components/user-story/logic.js';
9
+ import './web-components/user-journey/logic.js';
10
+ import './web-components/empathy-map/logic.js';
11
+ import './web-components/impact-effort/logic.js';
12
+ import './web-components/quadrant-grid/logic.js';
13
+ import './web-components/risk-register/logic.js';
14
+ import './web-components/traceability-matrix/logic.js';
15
+ import './web-components/burndown-chart/logic.js';
16
+ import './web-components/product-roadmap/logic.js';
17
+ import './web-components/kanban-board/logic.js';
18
+ import './web-components/story-map/logic.js';
19
+ import './web-components/review-surface/logic.js';
20
+ import './web-components/gantt-chart/logic.js';
21
+ import './web-components/work-item/logic.js';
22
+ import './web-components/adr-wc/logic.js';
23
+ import './web-components/iron-triangle/logic.js';
24
+ import './web-components/capacity-plan/logic.js';
25
+ import './web-components/quality-target/logic.js';
26
+ import './web-components/requirement-card/logic.js';
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Bundle Registry — Vanilla Breeze
3
+ *
4
+ * Provides infrastructure for modular bundles to register effects,
5
+ * components, and activate full bundles at runtime.
6
+ */
7
+
8
+ // ─── Effect registry ─────────────────────────────────────────────────────────
9
+
10
+ const effects = new Map()
11
+ let observer = null
12
+ const reducedMotionMq = window.matchMedia('(prefers-reduced-motion: reduce)')
13
+
14
+ function startObserver() {
15
+ if (observer) return
16
+ observer = new MutationObserver(mutations => {
17
+ for (const mutation of mutations) {
18
+ for (const node of mutation.addedNodes) {
19
+ if (node.nodeType !== Node.ELEMENT_NODE) continue
20
+ const el = /** @type {Element} */ (node)
21
+ matchEffects(el)
22
+ el.querySelectorAll('*').forEach(matchEffects)
23
+ }
24
+ for (const node of mutation.removedNodes) {
25
+ if (node.nodeType !== Node.ELEMENT_NODE) continue
26
+ const el = /** @type {Element} */ (node)
27
+ unmatchEffects(el)
28
+ el.querySelectorAll('*').forEach(unmatchEffects)
29
+ }
30
+ }
31
+ })
32
+ observer.observe(document.body, { childList: true, subtree: true })
33
+ }
34
+
35
+ function matchEffects(el) {
36
+ for (const [, def] of effects) {
37
+ if (el.matches?.(def.selector)) {
38
+ if (reducedMotionMq.matches && def.reducedMotionFallback) {
39
+ def.reducedMotionFallback(el)
40
+ } else {
41
+ def.init(el)
42
+ }
43
+ }
44
+ }
45
+ }
46
+
47
+ function unmatchEffects(el) {
48
+ for (const [, def] of effects) {
49
+ if (el.matches?.(def.selector) && def.destroy) {
50
+ def.destroy(el)
51
+ }
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Register a DOM effect that auto-initializes on matching elements.
57
+ *
58
+ * @param {string} name - Unique effect name
59
+ * @param {{ selector: string, init: (el: Element) => void, destroy?: (el: Element) => void, reducedMotionFallback?: (el: Element) => void }} def
60
+ */
61
+ export function registerEffect(name, def) {
62
+ effects.set(name, def)
63
+
64
+ // Initialize on existing matching elements
65
+ document.querySelectorAll(def.selector).forEach(el => {
66
+ if (reducedMotionMq.matches && def.reducedMotionFallback) {
67
+ def.reducedMotionFallback(el)
68
+ } else {
69
+ def.init(el)
70
+ }
71
+ })
72
+
73
+ // Start shared observer lazily
74
+ if (document.body) {
75
+ startObserver()
76
+ } else {
77
+ document.addEventListener('DOMContentLoaded', startObserver, { once: true })
78
+ }
79
+ }
80
+
81
+ // ─── Component registry ──────────────────────────────────────────────────────
82
+
83
+ const components = new Map()
84
+
85
+ /**
86
+ * Register a custom element with priority-based conflict resolution.
87
+ * First-wins at equal priority. Higher priority wins over lower.
88
+ *
89
+ * @param {string} tag - Custom element tag name
90
+ * @param {typeof HTMLElement} impl - The element class
91
+ * @param {{ bundle?: string, contract?: string, priority?: number }} [opts]
92
+ */
93
+ export function registerComponent(tag, impl, opts = {}) {
94
+ const priority = opts.priority ?? 10
95
+ const meta = { impl, bundle: opts.bundle, contract: opts.contract, priority }
96
+
97
+ const existing = components.get(tag)
98
+
99
+ if (customElements.get(tag)) {
100
+ // Already defined in the browser — cannot redefine
101
+ if (!existing || existing.priority >= priority) {
102
+ if (existing && existing.priority === priority && existing.impl !== impl) {
103
+ console.warn(
104
+ `[VB Bundle] Tag <${tag}> already registered by "${existing.bundle}" (priority ${existing.priority}). Skipping "${opts.bundle}".`
105
+ )
106
+ }
107
+ return
108
+ }
109
+ // Higher priority but can't redefine — warn
110
+ console.warn(
111
+ `[VB Bundle] Tag <${tag}> defined by "${existing.bundle}" cannot be replaced (customElements.define is permanent). "${opts.bundle}" has higher priority but arrived late.`
112
+ )
113
+ return
114
+ }
115
+
116
+ if (existing && existing.priority >= priority) {
117
+ if (existing.priority === priority) {
118
+ console.warn(
119
+ `[VB Bundle] Tag <${tag}> already registered by "${existing.bundle}". Skipping "${opts.bundle}" (first wins at equal priority).`
120
+ )
121
+ }
122
+ return
123
+ }
124
+
125
+ components.set(tag, meta)
126
+ customElements.define(tag, impl)
127
+ }
128
+
129
+ /**
130
+ * Activate a full pack by loading its CSS and JS from the CDN path.
131
+ * Convention: cdn/packs/{name}.full.css and cdn/packs/{name}.full.js
132
+ *
133
+ * @param {string} bundleName
134
+ * @param {{ basePath?: string }} [opts]
135
+ * @returns {Promise<void>}
136
+ */
137
+ export async function activateBundle(bundleName, opts = {}) {
138
+ const base = opts.basePath ?? '/cdn/packs'
139
+
140
+ // Load CSS
141
+ const cssHref = `${base}/${bundleName}.full.css`
142
+ if (!document.querySelector(`link[href="${cssHref}"]`)) {
143
+ const link = document.createElement('link')
144
+ link.rel = 'stylesheet'
145
+ link.href = cssHref
146
+ await new Promise((resolve, reject) => {
147
+ link.onload = resolve
148
+ link.onerror = () => reject(new Error(`Failed to load bundle CSS: ${cssHref}`))
149
+ document.head.appendChild(link)
150
+ })
151
+ }
152
+
153
+ // Load JS
154
+ const jsSrc = `${base}/${bundleName}.full.js`
155
+ await import(jsSrc)
156
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Keyed list diff with move-not-recreate semantics.
3
+ *
4
+ * Reconciles a `nodes` map (key → Element) against `newItems` so that
5
+ * each key in `newItems` ends up rendered in its target container at
6
+ * its target index. Existing nodes are moved rather than recreated;
7
+ * only new keys cause renderItem() to fire.
8
+ *
9
+ * Formal guarantee: a node whose key appears in both the current map
10
+ * and `newItems` is never removed. It may be moved (different parent
11
+ * or different position within the same parent), but the underlying
12
+ * DOM node identity is preserved — so in-flight drag state, focus,
13
+ * and CSS animations survive.
14
+ *
15
+ * The DOM operations used are minimal: `Node.insertBefore`, `Element.remove`,
16
+ * and reads of `parentElement` / `children`. Subclasses are responsible for
17
+ * removing non-managed children (placeholders, etc.) before the diff if
18
+ * they want clean indexing.
19
+ *
20
+ * @template T
21
+ * @param {object} opts
22
+ * @param {T[]} opts.newItems
23
+ * @param {Map<unknown, Element>} opts.nodes Mutated in place.
24
+ * @param {(item: T) => unknown} opts.keyOf
25
+ * @param {(item: T) => Element} opts.renderItem Called only for new keys.
26
+ * @param {(item: T, existingEl: Element | null) => Element} opts.containerFor
27
+ * @returns {{ added: unknown[], moved: unknown[], removed: unknown[] }}
28
+ */
29
+ export function diffByKey({ newItems, nodes, keyOf, renderItem, containerFor }) {
30
+ const added = [];
31
+ const moved = [];
32
+ const removed = [];
33
+
34
+ // 1. Compute new key set for membership checks.
35
+ const newKeys = new Set();
36
+ for (const item of newItems) newKeys.add(keyOf(item));
37
+
38
+ // 2. Remove nodes whose keys are gone. (No node with a still-live key is
39
+ // ever removed — that's the formal preservation guarantee.)
40
+ for (const [key, el] of [...nodes]) {
41
+ if (!newKeys.has(key)) {
42
+ el.remove();
43
+ nodes.delete(key);
44
+ removed.push(key);
45
+ }
46
+ }
47
+
48
+ // 3. Resolve each item's target container. Group entries per container
49
+ // so we can reconcile each container's order in one pass.
50
+ /** @type {Map<Element, Array<{ item: T, key: unknown, existing: Element | null }>>} */
51
+ const byContainer = new Map();
52
+ for (const item of newItems) {
53
+ const key = keyOf(item);
54
+ const existing = nodes.get(key) || null;
55
+ const container = containerFor(item, existing);
56
+ if (!byContainer.has(container)) byContainer.set(container, []);
57
+ byContainer.get(container)?.push({ item, key, existing });
58
+ }
59
+
60
+ // 4. For each container, place each entry at its target index.
61
+ // insertBefore is a no-op when the node is already at that position,
62
+ // and a move (preserving identity) when it isn't.
63
+ for (const [container, entries] of byContainer) {
64
+ for (let i = 0; i < entries.length; i++) {
65
+ const { item, key, existing } = entries[i];
66
+ let el = existing;
67
+ if (!el) {
68
+ el = renderItem(item);
69
+ nodes.set(key, el);
70
+ added.push(key);
71
+ } else if (el.parentElement !== container) {
72
+ moved.push(key);
73
+ } else {
74
+ // Same container: detect intra-container reorder.
75
+ if (container.children[i] !== el) moved.push(key);
76
+ }
77
+
78
+ const target = container.children[i];
79
+ if (target !== el) {
80
+ container.insertBefore(el, target || null);
81
+ }
82
+ }
83
+ }
84
+
85
+ return { added, moved, removed };
86
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Portrait URL utility
3
+ *
4
+ * Generates deterministic portrait image URLs from the faker-js
5
+ * person-portrait CDN assets (CC0 licensed, AI-generated).
6
+ *
7
+ * 200 unique portraits (100 male, 100 female) at 5 sizes:
8
+ * 32, 64, 128, 256, 512 px (square).
9
+ *
10
+ * @example
11
+ * portraitUrl('Sarah Chen');
12
+ * // → 'https://cdn.jsdelivr.net/gh/faker-js/assets-person-portrait/female/128/37.jpg'
13
+ *
14
+ * portraitUrl('Marcus Johnson', 256);
15
+ * // → 'https://cdn.jsdelivr.net/gh/faker-js/assets-person-portrait/male/256/82.jpg'
16
+ */
17
+
18
+ const CDN = 'https://cdn.jsdelivr.net/gh/faker-js/assets-person-portrait';
19
+ const SIZES = [32, 64, 128, 256, 512];
20
+
21
+ /**
22
+ * Simple string hash (same algorithm as _ux-base.js hashColor).
23
+ * @param {string} str
24
+ * @returns {number}
25
+ */
26
+ function hash(str) {
27
+ let h = 0;
28
+ for (let i = 0; i < str.length; i++) {
29
+ h = str.charCodeAt(i) + ((h << 5) - h);
30
+ }
31
+ return h;
32
+ }
33
+
34
+ /**
35
+ * Snap a pixel value to the nearest available portrait size.
36
+ * @param {number} px - Desired size in pixels
37
+ * @returns {number} Closest available size (32, 64, 128, 256, or 512)
38
+ */
39
+ function snapSize(px) {
40
+ let best = SIZES[0];
41
+ let bestDist = Math.abs(px - best);
42
+ for (let i = 1; i < SIZES.length; i++) {
43
+ const dist = Math.abs(px - SIZES[i]);
44
+ if (dist < bestDist) {
45
+ best = SIZES[i];
46
+ bestDist = dist;
47
+ }
48
+ }
49
+ return best;
50
+ }
51
+
52
+ /**
53
+ * Generate a deterministic portrait URL from a seed string.
54
+ *
55
+ * The same seed always produces the same portrait. Sex and index are
56
+ * derived from the seed hash, so names cluster naturally — different
57
+ * names get different faces.
58
+ *
59
+ * @param {string} seed - Any string (name, ID, index, etc.)
60
+ * @param {number} [size=128] - Desired image size in px (snapped to nearest available)
61
+ * @returns {string} CDN URL for the portrait JPEG
62
+ */
63
+ export function portraitUrl(seed, size = 128) {
64
+ const h = hash(String(seed));
65
+ const index = ((h % 100) + 100) % 100;
66
+ const sex = ((h >>> 16) & 1) === 0 ? 'female' : 'male';
67
+ const snapped = snapSize(size);
68
+ return `${CDN}/${sex}/${snapped}/${index}.jpg`;
69
+ }
70
+
71
+ /**
72
+ * Get a portrait URL by explicit numeric index (0–199).
73
+ * 0–99 = female, 100–199 = male.
74
+ *
75
+ * @param {number} index - Portrait index (0–199)
76
+ * @param {number} [size=128] - Desired image size in px
77
+ * @returns {string} CDN URL
78
+ */
79
+ export function portraitByIndex(index, size = 128) {
80
+ const i = ((index % 200) + 200) % 200;
81
+ const sex = i < 100 ? 'female' : 'male';
82
+ const fileIndex = i < 100 ? i : i - 100;
83
+ const snapped = snapSize(size);
84
+ return `${CDN}/${sex}/${snapped}/${fileIndex}.jpg`;
85
+ }
86
+
87
+ export { SIZES as PORTRAIT_SIZES, CDN as PORTRAIT_CDN };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * VBCollection — Mixin for "keyed list of children" components.
3
+ *
4
+ * Subclass implements:
5
+ * _renderItem(item) → Element Build a fresh element for a new key.
6
+ * _containerFor(item, existingEl) (Optional) the parent each item lives in.
7
+ * _postRender({ added, moved, removed, items }) (Optional) refresh placeholders / counts.
8
+ *
9
+ * Subclass declares (optional):
10
+ * static keyOf = (item) => item.id Diff key. Default: item.id.
11
+ *
12
+ * Provides:
13
+ * .items getter/setter — assignment runs a keyed diff.
14
+ * _emit(name, detail, source) — tags events with source for loop avoidance.
15
+ *
16
+ * Formal guarantee: any element whose key is present in both the previous
17
+ * and the next items list is preserved (never removed and recreated). The
18
+ * same DOM node may be moved across containers or reordered, but its
19
+ * identity — and any in-flight drag, focus, or animation state — survives.
20
+ */
21
+
22
+ import { diffByKey } from './diff-by-key.js';
23
+
24
+ /**
25
+ * @template {new (...args: any[]) => HTMLElement} TBase
26
+ * @param {TBase} Base
27
+ */
28
+ export function VBCollection(Base) {
29
+ const BaseAny = /** @type {any} */ (Base);
30
+ return class extends BaseAny {
31
+ static keyOf(item) { return item.id; }
32
+
33
+ /** @type {any[]} */
34
+ #items = [];
35
+ /** @type {Map<unknown, Element>} */
36
+ #nodes = new Map();
37
+
38
+ get items() { return this.#items; }
39
+ set items(value) {
40
+ const next = value || [];
41
+ const result = diffByKey({
42
+ newItems: next,
43
+ nodes: this.#nodes,
44
+ keyOf: /** @type {any} */ (this.constructor).keyOf,
45
+ renderItem: (item) => /** @type {any} */ (this._renderItem(item)),
46
+ containerFor: (item, existing) => /** @type {any} */ (this._containerFor(item, existing)),
47
+ });
48
+ this.#items = next;
49
+ this._postRender({ ...result, items: next });
50
+ this._emit('items-changed', { items: next }, 'api');
51
+ }
52
+
53
+ /**
54
+ * Subclass hook. Build a fresh element for a new item.
55
+ * Must return Element. Strings/templates are not accepted (see VB-XG5X).
56
+ */
57
+ _renderItem(_item) {
58
+ throw new Error(`${this.constructor.name}: must implement _renderItem(item)`);
59
+ }
60
+
61
+ /**
62
+ * Subclass hook. Where this item lives. Default: the element itself.
63
+ * Override to route items into specific child containers (e.g. kanban columns).
64
+ */
65
+ _containerFor(_item, _existingEl) {
66
+ return this;
67
+ }
68
+
69
+ /**
70
+ * Subclass hook. Called after every diff. Use to refresh placeholders,
71
+ * counts, WIP markers, empty-state UI.
72
+ */
73
+ _postRender(_diff) {}
74
+
75
+ /**
76
+ * Dispatch a tagged event so consumers can avoid feedback loops.
77
+ * @param {string} name Event suffix (e.g. 'items-changed').
78
+ * @param {object} detail Custom payload.
79
+ * @param {'api' | 'attribute' | 'drag' | 'internal' | string} [source='api']
80
+ */
81
+ _emit(name, detail, source = 'api') {
82
+ this.dispatchEvent(new CustomEvent(`${this.localName}:${name}`, {
83
+ detail: { ...detail, source },
84
+ bubbles: true,
85
+ }));
86
+ }
87
+
88
+ /**
89
+ * Read-only access to the rendered node for a given key.
90
+ * Useful for subclass hooks that want to update an item in place.
91
+ */
92
+ _nodeFor(key) {
93
+ return this.#nodes.get(key) || null;
94
+ }
95
+
96
+ /**
97
+ * Seed the collection from existing DOM (HTML-first upgrade path).
98
+ * Subclass calls this once after parsing initial children so the next
99
+ * `.items = ...` assignment diffs against existing rendered nodes
100
+ * instead of recreating them.
101
+ *
102
+ * @param {any[]} items
103
+ * @param {Map<unknown, Element>} nodeMap key → existing element
104
+ */
105
+ _seedCollection(items, nodeMap) {
106
+ this.#items = items;
107
+ this.#nodes.clear();
108
+ for (const [key, el] of nodeMap) this.#nodes.set(key, el);
109
+ }
110
+
111
+ /**
112
+ * Update the in-memory items array without running a diff (e.g. after
113
+ * a drag mutates the DOM). Subclass is expected to keep the DOM and
114
+ * the items array in sync; this method just records the new model.
115
+ *
116
+ * @param {any[]} items
117
+ */
118
+ _setItemsSilently(items) {
119
+ this.#items = items;
120
+ }
121
+ };
122
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * VBElement — Minimal base class for Vanilla Breeze web components
3
+ *
4
+ * Provides:
5
+ * - data-upgraded guard (prevents double initialization on reconnect)
6
+ * - listen() method with automatic cleanup on disconnect
7
+ * - setup()/teardown() lifecycle hooks
8
+ *
9
+ * Does NOT provide: props reflection, template rendering, reactivity.
10
+ * Components that need observedAttributes/attributeChangedCallback
11
+ * declare them normally alongside setup()/teardown().
12
+ */
13
+ class VBElement extends HTMLElement {
14
+ #cleanups = [];
15
+ /** @type {ElementInternals | undefined} */
16
+ #internals;
17
+
18
+ connectedCallback() {
19
+ if (this.hasAttribute('data-upgraded')) return;
20
+ if (this.setup() === false) return;
21
+ this.setAttribute('data-upgraded', '');
22
+ // Dispatch :upgraded in a microtask so any sibling component definitions
23
+ // queued in the same synchronous script (e.g. a pack's registerComponent
24
+ // calls) complete before consumers react. Without this, listeners that
25
+ // immediately assign .items can hit a race where downstream components
26
+ // (work-item, etc.) aren't defined yet and the default renderer falls
27
+ // back to a plain element.
28
+ queueMicrotask(() => {
29
+ this.dispatchEvent(new CustomEvent(`${this.localName}:upgraded`, { bubbles: true }));
30
+ });
31
+ }
32
+
33
+ disconnectedCallback() {
34
+ for (const fn of this.#cleanups) fn();
35
+ this.#cleanups = [];
36
+ this.removeAttribute('data-upgraded');
37
+ this.teardown();
38
+ }
39
+
40
+ /**
41
+ * Track an event listener for automatic cleanup on disconnect.
42
+ * @param {EventTarget} target
43
+ * @param {string} event
44
+ * @param {EventListenerOrEventListenerObject} handler
45
+ * @param {AddEventListenerOptions} [opts]
46
+ */
47
+ listen(target, event, handler, opts) {
48
+ target.addEventListener(event, handler, opts);
49
+ this.#cleanups.push(() => target.removeEventListener(event, handler, opts));
50
+ }
51
+
52
+ /**
53
+ * Override in subclass. Return false to abort upgrade.
54
+ * @returns {boolean | void}
55
+ */
56
+ setup() {}
57
+
58
+ /** Override in subclass for cleanup beyond event listeners. */
59
+ teardown() {}
60
+
61
+ /**
62
+ * Toggle a CustomStateSet entry targetable via the `:state(name)` CSS selector.
63
+ * Use for component-private flags. For author-facing state, keep using
64
+ * data-* / aria-* attributes — see admin/specs/custom-state-set-research.md.
65
+ *
66
+ * Lazily attaches ElementInternals on first call; subclasses that already
67
+ * attached internals (form-associated components) must hand them over via
68
+ * `_adoptInternals(this.attachInternals())` in their constructor to avoid
69
+ * the double-attach throw.
70
+ *
71
+ * @param {string} name
72
+ * @param {boolean} on
73
+ */
74
+ setState(name, on) {
75
+ if (!this.#internals) this.#internals = this.attachInternals();
76
+ const states = this.#internals.states;
77
+ try {
78
+ if (on) states.add(name); else states.delete(name);
79
+ } catch {
80
+ // Legacy --name syntax for older Chromium/WebKit releases
81
+ const legacy = `--${name}`;
82
+ if (on) states.add(legacy); else states.delete(legacy);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Hand pre-attached ElementInternals to the base class. Form-associated
88
+ * subclasses call this in their constructor right after attachInternals().
89
+ * @param {ElementInternals} internals
90
+ */
91
+ _adoptInternals(internals) {
92
+ if (!this.#internals) this.#internals = internals;
93
+ }
94
+ }
95
+
96
+ export { VBElement };