@profpowell/vb-project-planning 0.2.1 → 0.2.3

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 (66) hide show
  1. package/README.md +10 -5
  2. package/dist/vb-project-planning.css +69 -3
  3. package/dist/vb-project-planning.js +3191 -2544
  4. package/dist/vb-project-planning.min.css +1 -1
  5. package/dist/vb-project-planning.min.js +221 -197
  6. package/package.json +7 -4
  7. package/src/lib/board-surface.js +158 -0
  8. package/src/lib/bundle-registry.js +2 -75
  9. package/src/lib/dates.js +125 -0
  10. package/src/lib/escape.js +34 -0
  11. package/src/lib/hash.js +16 -0
  12. package/src/lib/portrait-url.js +3 -29
  13. package/src/lib/src-loader.js +78 -0
  14. package/src/lib/vb-view-transition.js +7 -1
  15. package/src/utils/copy-init.js +56 -17
  16. package/src/web-components/_ux-base.js +409 -24
  17. package/src/web-components/adr-wc/README.md +18 -16
  18. package/src/web-components/adr-wc/api.json +7 -5
  19. package/src/web-components/adr-wc/logic.js +50 -136
  20. package/src/web-components/burndown-chart/api.json +8 -6
  21. package/src/web-components/burndown-chart/logic.js +75 -39
  22. package/src/web-components/empathy-map/api.json +13 -13
  23. package/src/web-components/empathy-map/logic.js +161 -168
  24. package/src/web-components/empathy-map/static.html +3 -3
  25. package/src/web-components/empathy-map/styles.js +1 -0
  26. package/src/web-components/gantt-chart/README.md +12 -6
  27. package/src/web-components/gantt-chart/api.json +23 -7
  28. package/src/web-components/gantt-chart/logic.js +456 -431
  29. package/src/web-components/gantt-chart/styles.css +14 -1
  30. package/src/web-components/impact-effort/api.json +9 -7
  31. package/src/web-components/impact-effort/logic.js +238 -209
  32. package/src/web-components/impact-effort/static.html +12 -4
  33. package/src/web-components/iron-triangle/_capacity.js +0 -18
  34. package/src/web-components/kanban-board/api.json +8 -6
  35. package/src/web-components/kanban-board/logic.js +289 -333
  36. package/src/web-components/product-roadmap/api.json +14 -13
  37. package/src/web-components/product-roadmap/logic.js +393 -246
  38. package/src/web-components/product-roadmap/styles.css +40 -0
  39. package/src/web-components/quadrant-grid/api.json +9 -4
  40. package/src/web-components/quadrant-grid/logic.js +80 -12
  41. package/src/web-components/quadrant-grid/styles.css +7 -0
  42. package/src/web-components/quality-target/_quality-utils.js +2 -46
  43. package/src/web-components/review-surface/README.md +9 -5
  44. package/src/web-components/review-surface/api.json +10 -8
  45. package/src/web-components/review-surface/logic.js +349 -218
  46. package/src/web-components/risk-register/api.json +1 -6
  47. package/src/web-components/risk-register/logic.js +71 -24
  48. package/src/web-components/risk-register/styles.css +9 -1
  49. package/src/web-components/story-map/api.json +8 -6
  50. package/src/web-components/story-map/logic.js +280 -281
  51. package/src/web-components/story-map/static.html +15 -5
  52. package/src/web-components/traceability-matrix/api.json +10 -3
  53. package/src/web-components/traceability-matrix/logic.js +112 -63
  54. package/src/web-components/traceability-matrix/styles.css +22 -1
  55. package/src/web-components/user-journey/api.json +25 -24
  56. package/src/web-components/user-journey/logic.js +76 -127
  57. package/src/web-components/user-journey/static.html +2 -2
  58. package/src/web-components/user-persona/api.json +15 -14
  59. package/src/web-components/user-persona/logic.js +71 -117
  60. package/src/web-components/user-persona/styles.js +1 -0
  61. package/src/web-components/user-story/api.json +13 -11
  62. package/src/web-components/user-story/logic.js +164 -195
  63. package/src/web-components/user-story/styles.js +28 -1
  64. package/src/web-components/work-item/README.md +3 -3
  65. package/src/web-components/work-item/api.json +13 -11
  66. package/src/web-components/work-item/logic.js +58 -154
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profpowell/vb-project-planning",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
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
5
  "license": "MIT",
6
6
  "author": "Thomas A. Powell",
@@ -63,8 +63,10 @@
63
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
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
65
  "prepublishOnly": "npm run build",
66
+ "typecheck": "tsc -p tsconfig.json",
67
+ "test:unit": "TZ=America/Los_Angeles node --test 'tests/unit/*.test.mjs'",
66
68
  "test:components": "playwright test",
67
- "test": "playwright test"
69
+ "test": "npm run test:unit && playwright test"
68
70
  },
69
71
  "peerDependencies": {
70
72
  "vanilla-breeze": ">=0.3.0"
@@ -75,7 +77,8 @@
75
77
  }
76
78
  },
77
79
  "devDependencies": {
78
- "@playwright/test": "^1.61.1",
79
- "esbuild": "^0.27.0"
80
+ "@playwright/test": "^1.64.1",
81
+ "esbuild": "^0.28.2",
82
+ "typescript": "^5.9.3"
80
83
  }
81
84
  }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Shared helpers for the drag-surface "board" components
3
+ * (kanban-board, story-map, impact-effort, quadrant-grid).
4
+ *
5
+ * Small, DOM-only utilities: live-region announcements, human labels for
6
+ * cards, empty-column placeholders, and building a <user-story> card from a
7
+ * JSON record using the slots user-story actually reads.
8
+ */
9
+
10
+ /**
11
+ * Convert a hyphenated or lowercase id to Title Case.
12
+ * @param {string} str
13
+ * @returns {string}
14
+ */
15
+ export function titleCase(str) {
16
+ return String(str ?? '')
17
+ .replace(/[-_]/g, ' ')
18
+ .replace(/\b\w/g, (c) => c.toUpperCase());
19
+ }
20
+
21
+ /**
22
+ * Create a polite, atomic status region for move announcements.
23
+ * @param {string} className
24
+ * @returns {HTMLDivElement}
25
+ */
26
+ export function createLiveRegion(className) {
27
+ const region = document.createElement('div');
28
+ region.className = className;
29
+ region.setAttribute('role', 'status');
30
+ region.setAttribute('aria-live', 'polite');
31
+ region.setAttribute('aria-atomic', 'true');
32
+ return region;
33
+ }
34
+
35
+ /**
36
+ * Announce a message: clear, then set on the next frame so repeated
37
+ * identical messages are re-read.
38
+ * @param {HTMLElement | null} region
39
+ * @param {string} msg
40
+ */
41
+ export function announce(region, msg) {
42
+ if (!region) return;
43
+ region.textContent = '';
44
+ requestAnimationFrame(() => { region.textContent = msg; });
45
+ }
46
+
47
+ /**
48
+ * A human-readable label for a card: its title slot, its action slot, an
49
+ * aria-label, or its visible text. Generated ids are only a last resort.
50
+ * @param {Element | null | undefined} item
51
+ * @returns {string}
52
+ */
53
+ export function itemLabel(item) {
54
+ if (!item) return 'item';
55
+ const clean = (/** @type {string | null | undefined} */ s) => (s || '').replace(/\s+/g, ' ').trim();
56
+ const slotted = clean(item.querySelector(':scope > [slot="title"]')?.textContent)
57
+ || clean(item.querySelector(':scope > [slot="action"]')?.textContent);
58
+ if (slotted) return truncate(slotted);
59
+ const aria = clean(item.getAttribute('aria-label'));
60
+ if (aria) return truncate(aria);
61
+ const text = clean(item.textContent);
62
+ if (text) return truncate(text);
63
+ return item.getAttribute('story-id') || item.getAttribute('data-id') || 'item';
64
+ }
65
+
66
+ /** @param {string} s */
67
+ function truncate(s) {
68
+ return s.length > 60 ? `${s.slice(0, 59).trimEnd()}…` : s;
69
+ }
70
+
71
+ /**
72
+ * Add or remove the "empty" placeholder so it shows only when the surface
73
+ * holds no draggable cards.
74
+ * @param {Element} surface
75
+ * @param {string} className
76
+ * @param {string} text
77
+ */
78
+ export function syncPlaceholder(surface, className, text) {
79
+ const hasCards = !!surface.querySelector(':scope > [draggable="true"]');
80
+ const placeholder = surface.querySelector(`:scope > .${className}`);
81
+ if (!hasCards && !placeholder) {
82
+ const p = document.createElement('p');
83
+ p.className = className;
84
+ p.textContent = text;
85
+ surface.appendChild(p);
86
+ } else if (hasCards && placeholder) {
87
+ placeholder.remove();
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Does this JSON record describe a user story (rather than a plain card)?
93
+ * @param {Record<string, any>} record
94
+ */
95
+ export function isStoryRecord(record) {
96
+ return !!(record && (record.persona || record.action || record.benefit || record.storyId));
97
+ }
98
+
99
+ /**
100
+ * Build a `<user-story>` from a JSON record. State goes on the attributes
101
+ * user-story observes; text content goes into the slots it reads
102
+ * (persona, action, benefit, title, acceptance-criteria, tasks, notes).
103
+ * Works whether or not user-story has been defined yet.
104
+ *
105
+ * @param {Record<string, any>} record
106
+ * @param {string} [defaultDetail] - detail level when the record has none
107
+ * @returns {HTMLElement}
108
+ */
109
+ export function createStoryCard(record, defaultDetail) {
110
+ const el = document.createElement('user-story');
111
+ /** @type {Array<[string, string]>} */
112
+ const attrs = [
113
+ ['storyId', 'story-id'], ['personaId', 'persona-id'], ['priority', 'priority'],
114
+ ['status', 'status'], ['points', 'points'], ['epic', 'epic'],
115
+ ];
116
+ for (const [key, attr] of attrs) {
117
+ if (record[key] != null && record[key] !== '') el.setAttribute(attr, String(record[key]));
118
+ }
119
+ const detail = record.detail || defaultDetail;
120
+ if (detail) el.setAttribute('detail', String(detail));
121
+
122
+ for (const slot of ['persona', 'action', 'benefit']) {
123
+ if (record[slot]) el.appendChild(slotted('span', slot, record[slot]));
124
+ }
125
+ if (record.title) el.appendChild(slotted('h3', 'title', record.title));
126
+ for (const [key, slot] of [['acceptanceCriteria', 'acceptance-criteria'], ['tasks', 'tasks'], ['notes', 'notes']]) {
127
+ const value = record[key];
128
+ if (!value) continue;
129
+ if (Array.isArray(value)) {
130
+ const ul = document.createElement('ul');
131
+ ul.slot = slot;
132
+ for (const entry of value) {
133
+ const li = document.createElement('li');
134
+ li.textContent = String(entry);
135
+ ul.appendChild(li);
136
+ }
137
+ el.appendChild(ul);
138
+ } else {
139
+ el.appendChild(slotted('p', slot, value));
140
+ }
141
+ }
142
+ return el;
143
+ }
144
+
145
+ /**
146
+ * @param {string} tag
147
+ * @param {string} slot
148
+ * @param {unknown} text
149
+ */
150
+ function slotted(tag, slot, text) {
151
+ const el = document.createElement(tag);
152
+ el.slot = slot;
153
+ el.textContent = String(text);
154
+ return el;
155
+ }
156
+
157
+ // SrcLoader lives in its own module; re-exported here for existing imports.
158
+ export { SrcLoader } from './src-loader.js';
@@ -1,83 +1,10 @@
1
1
  /**
2
2
  * Bundle Registry — Vanilla Breeze
3
3
  *
4
- * Provides infrastructure for modular bundles to register effects,
5
- * components, and activate full bundles at runtime.
4
+ * Provides infrastructure for modular bundles to register components
5
+ * and activate full bundles at runtime.
6
6
  */
7
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
8
  // ─── Component registry ──────────────────────────────────────────────────────
82
9
 
83
10
  const components = new Map()
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Calendar-date helpers shared by the date-driven components
3
+ * (gantt-chart, burndown-chart, product-roadmap, adr-wc).
4
+ *
5
+ * Planning dates are calendar days, not instants. Two rules keep them
6
+ * stable in every time zone:
7
+ *
8
+ * 1. `YYYY-MM-DD` is parsed as LOCAL midnight. `new Date('2026-04-01')`
9
+ * is UTC midnight, which renders as March 31 anywhere west of UTC.
10
+ * 2. Days are stepped with setDate(), never by adding 86 400 000 ms,
11
+ * which repeats or skips a day across a daylight-saving change.
12
+ */
13
+
14
+ const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})(?![\d:T])/;
15
+ const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
16
+
17
+ /**
18
+ * Parse a calendar date. `YYYY-MM-DD` becomes local midnight; Date objects
19
+ * are copied and truncated to local midnight; ISO timestamps
20
+ * (`YYYY-MM-DDTHH:mm…`) and numbers are accepted and truncated too.
21
+ * Everything else returns null: the native Date parser is far too lenient
22
+ * (it turns "<img 1>" into 2001-01-01).
23
+ * @param {unknown} value
24
+ * @returns {Date | null}
25
+ */
26
+ export function parseISODate(value) {
27
+ if (value == null || value === '') return null;
28
+ if (value instanceof Date) {
29
+ return Number.isNaN(value.getTime()) ? null : startOfDay(value);
30
+ }
31
+ if (typeof value === 'number') {
32
+ return Number.isFinite(value) ? startOfDay(new Date(value)) : null;
33
+ }
34
+ const s = String(value).trim();
35
+ const m = s.match(ISO_DATE);
36
+ if (m) {
37
+ const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
38
+ // Reject roll-overs such as 2026-02-31.
39
+ if (d.getMonth() !== Number(m[2]) - 1 || d.getDate() !== Number(m[3])) return null;
40
+ return d;
41
+ }
42
+ if (!ISO_TIMESTAMP.test(s)) return null;
43
+ const d = new Date(s);
44
+ return Number.isNaN(d.getTime()) ? null : startOfDay(d);
45
+ }
46
+
47
+ /** @param {Date} d @returns {Date} local midnight of the same calendar day */
48
+ export function startOfDay(d) {
49
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate());
50
+ }
51
+
52
+ /** @param {Date} d @returns {string} `YYYY-MM-DD` in local time */
53
+ export function toISODate(d) {
54
+ const m = String(d.getMonth() + 1).padStart(2, '0');
55
+ const day = String(d.getDate()).padStart(2, '0');
56
+ return `${d.getFullYear()}-${m}-${day}`;
57
+ }
58
+
59
+ /** Calendar-day addition (DST-safe). @param {Date} d @param {number} n @returns {Date} */
60
+ export function addDays(d, n) {
61
+ const out = startOfDay(d);
62
+ out.setDate(out.getDate() + n);
63
+ return out;
64
+ }
65
+
66
+ /**
67
+ * Whole calendar days from `a` to `b` (b − a), ignoring DST hour shifts.
68
+ * @param {Date} a @param {Date} b @returns {number}
69
+ */
70
+ export function daysBetween(a, b) {
71
+ const ua = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());
72
+ const ub = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());
73
+ return Math.round((ub - ua) / 86_400_000);
74
+ }
75
+
76
+ /**
77
+ * Every calendar day from `start` to `end` inclusive, as `YYYY-MM-DD`.
78
+ * @param {Date} start @param {Date} end @returns {string[]}
79
+ */
80
+ export function enumerateDays(start, end) {
81
+ const out = [];
82
+ for (let d = startOfDay(start); d <= end; d = addDays(d, 1)) out.push(toISODate(d));
83
+ return out;
84
+ }
85
+
86
+ /** @param {Date} d @returns {Date} first day of the month */
87
+ export function startOfMonth(d) {
88
+ return new Date(d.getFullYear(), d.getMonth(), 1);
89
+ }
90
+
91
+ /** @param {Date} d @returns {Date} last day of the month */
92
+ export function endOfMonth(d) {
93
+ return new Date(d.getFullYear(), d.getMonth() + 1, 0);
94
+ }
95
+
96
+ /** @param {Date} d @returns {Date} first day of the calendar quarter */
97
+ export function startOfQuarter(d) {
98
+ return new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3, 1);
99
+ }
100
+
101
+ /** @param {Date} d @returns {Date} last day of the calendar quarter */
102
+ export function endOfQuarter(d) {
103
+ return new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3 + 3, 0);
104
+ }
105
+
106
+ /**
107
+ * Add whole months, clamping the day so Jan 31 + 1 month is Feb 28/29,
108
+ * not March 3.
109
+ * @param {Date} d @param {number} n @returns {Date}
110
+ */
111
+ export function addMonths(d, n) {
112
+ const first = new Date(d.getFullYear(), d.getMonth() + n, 1);
113
+ const last = endOfMonth(first).getDate();
114
+ return new Date(first.getFullYear(), first.getMonth(), Math.min(d.getDate(), last));
115
+ }
116
+
117
+ /**
118
+ * Locale display of a calendar date. Defaults to e.g. "April 1, 2026".
119
+ * @param {Date} d
120
+ * @param {Intl.DateTimeFormatOptions} [opts]
121
+ * @param {string | string[]} [locale]
122
+ */
123
+ export function formatDate(d, opts = { year: 'numeric', month: 'long', day: 'numeric' }, locale = undefined) {
124
+ return d.toLocaleDateString(locale, opts);
125
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The pack's one HTML escaper. Use it for every author- or data-supplied
3
+ * string interpolated into an innerHTML template, in both text and
4
+ * attribute positions (single- or double-quoted).
5
+ *
6
+ * Do NOT use it for values passed to setAttribute() or textContent: those
7
+ * are already literal, and escaping them shows "&amp;" to users and screen
8
+ * readers.
9
+ *
10
+ * @param {unknown} value
11
+ * @returns {string}
12
+ */
13
+ export function esc(value) {
14
+ return String(value ?? '').replace(/[&<>"']/g, (c) => ESCAPES[c]);
15
+ }
16
+
17
+ /** @type {Record<string, string>} */
18
+ const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
19
+
20
+ /**
21
+ * Escape a URL for use inside a CSS `url("…")` token in an inline style.
22
+ * Returns the full `url("…")` so callers can't forget the quotes. Only
23
+ * http(s), protocol-relative, relative, and data:image URLs are allowed;
24
+ * anything else yields `none`.
25
+ * @param {unknown} value
26
+ * @returns {string}
27
+ */
28
+ export function cssUrl(value) {
29
+ const s = String(value ?? '').trim();
30
+ if (!s) return 'none';
31
+ if (/^[a-z][a-z0-9+.-]*:/i.test(s) && !/^(https?:|data:image\/)/i.test(s)) return 'none';
32
+ const escaped = s.replace(/["\\\n\r\f]/g, (c) => `\\${c.charCodeAt(0).toString(16)} `);
33
+ return esc(`url("${escaped}")`);
34
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Small deterministic string hash (Java-style `h * 31 + c`, written as
3
+ * `(h << 5) - h`). Used for stable avatar colours and portrait picks, so the
4
+ * exact output matters: changing it changes every generated avatar.
5
+ * @param {string} str
6
+ * @returns {number} integer (deliberately not truncated to 32 bits, to match
7
+ * the original copies byte for byte)
8
+ */
9
+ export function stringHash(str) {
10
+ let h = 0;
11
+ const s = String(str ?? '');
12
+ for (let i = 0; i < s.length; i++) {
13
+ h = s.charCodeAt(i) + ((h << 5) - h);
14
+ }
15
+ return h;
16
+ }
@@ -15,22 +15,11 @@
15
15
  * // → 'https://cdn.jsdelivr.net/gh/faker-js/assets-person-portrait/male/256/82.jpg'
16
16
  */
17
17
 
18
+ import { stringHash } from './hash.js';
19
+
18
20
  const CDN = 'https://cdn.jsdelivr.net/gh/faker-js/assets-person-portrait';
19
21
  const SIZES = [32, 64, 128, 256, 512];
20
22
 
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
23
  /**
35
24
  * Snap a pixel value to the nearest available portrait size.
36
25
  * @param {number} px - Desired size in pixels
@@ -61,27 +50,12 @@ function snapSize(px) {
61
50
  * @returns {string} CDN URL for the portrait JPEG
62
51
  */
63
52
  export function portraitUrl(seed, size = 128) {
64
- const h = hash(String(seed));
53
+ const h = stringHash(String(seed));
65
54
  const index = ((h % 100) + 100) % 100;
66
55
  const sex = ((h >>> 16) & 1) === 0 ? 'female' : 'male';
67
56
  const snapped = snapSize(size);
68
57
  return `${CDN}/${sex}/${snapped}/${index}.jpg`;
69
58
  }
70
59
 
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
60
 
87
61
  export { SIZES as PORTRAIT_SIZES, CDN as PORTRAIT_CDN };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * SrcLoader — one in-flight `src` fetch per component.
3
+ *
4
+ * Every component with a `src` attribute (the UXShadowElement cards, the
5
+ * boards, gantt-chart, review-surface) loads through this so the same
6
+ * rules hold everywhere:
7
+ *
8
+ * - A newer `start()` / `fetchJson()` aborts the previous request, and the
9
+ * superseded response (if it still arrives) is dropped.
10
+ * - `cancel()` aborts the in-flight request without starting another (the
11
+ * `src` attribute was removed, or `.tasks` / `.pins` took over).
12
+ * - `url` is the last URL requested, so connect-time code can skip a
13
+ * refetch of the same value; it is cleared on failure (allowing a retry)
14
+ * and by `cancel()`.
15
+ */
16
+ export class SrcLoader {
17
+ /** @type {AbortController | null} */
18
+ #ctrl = null;
19
+ /** @type {string | null} */
20
+ url = null;
21
+
22
+ /** True while a fetchJson() request is in flight (not settled or cancelled). */
23
+ #busy = false;
24
+ get pending() { return this.#busy; }
25
+
26
+ /**
27
+ * Abort any earlier request and begin tracking a new one.
28
+ * @param {string} url
29
+ * @returns {{ signal: AbortSignal, isCurrent: () => boolean }}
30
+ */
31
+ start(url) {
32
+ this.#ctrl?.abort();
33
+ const ctrl = new AbortController();
34
+ this.#ctrl = ctrl;
35
+ this.#busy = false;
36
+ this.url = url;
37
+ return { signal: ctrl.signal, isCurrent: () => this.#ctrl === ctrl && !ctrl.signal.aborted };
38
+ }
39
+
40
+ /**
41
+ * Abort the in-flight request (if any) and forget the URL. Returns true
42
+ * when a request was actually in flight.
43
+ * @returns {boolean}
44
+ */
45
+ cancel() {
46
+ const wasPending = this.pending;
47
+ this.#ctrl?.abort();
48
+ this.#ctrl = null;
49
+ this.#busy = false;
50
+ this.url = null;
51
+ return wasPending;
52
+ }
53
+
54
+ /**
55
+ * Fetch JSON for `url`, aborting any earlier request. Resolves to
56
+ * `undefined` when superseded, cancelled or aborted; rejects (only while
57
+ * still current) on a network error, a non-2xx status or invalid JSON.
58
+ * @param {string} url
59
+ * @returns {Promise<{ data: any, isCurrent: () => boolean } | undefined>}
60
+ */
61
+ async fetchJson(url) {
62
+ const { signal, isCurrent } = this.start(url);
63
+ this.#busy = true;
64
+ try {
65
+ const res = await fetch(url, { signal });
66
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
67
+ const data = await res.json();
68
+ if (!isCurrent()) return undefined;
69
+ this.#busy = false;
70
+ return { data, isCurrent };
71
+ } catch (err) {
72
+ if (/** @type {any} */ (err)?.name === 'AbortError' || !isCurrent()) return undefined;
73
+ this.#busy = false;
74
+ this.url = null;
75
+ throw err;
76
+ }
77
+ }
78
+ }
@@ -35,7 +35,13 @@ export function viewTransitionSwap(host, swap, prefix = 'vb-vt') {
35
35
  const name = `${prefix}-${++counter}`;
36
36
  host.style.viewTransitionName = name;
37
37
  const tx = document.startViewTransition(swap);
38
- tx.finished.finally(() => {
38
+ // The browser aborts a transition when, e.g., the viewport resizes or
39
+ // another transition starts. That rejects `ready` (and can reject
40
+ // `finished`); unhandled, it surfaces as an uncaught exception even though
41
+ // the DOM swap itself already ran. The swap's own errors still reach
42
+ // callers through `updateCallbackDone`.
43
+ tx.ready.catch(() => {});
44
+ tx.finished.catch(() => {}).finally(() => {
39
45
  if (host.style.viewTransitionName === name) {
40
46
  host.style.viewTransitionName = '';
41
47
  }