create-pmos 0.510.0 → 0.511.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.
- package/package.json +231 -124
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/briefing-premium-board-test.js +6 -7
- package/workspace/changelog.json +336 -912
- package/workspace/ev-everywhere-test.js +4 -4
- package/workspace/guide/home.md +2 -1
- package/workspace/guide/initiative-boards.md +3 -1
- package/workspace/guide/settings.md +9 -5
- package/workspace/lib/board-premium.js +3 -2
- package/workspace/lib/kit-update.js +5 -2
- package/workspace/lib/routes-boards.js +15 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/panels-test.js +68 -1
- package/workspace/public/pm/bd-cols.js +2 -1
- package/workspace/public/pm/bd-deliveries.js +1 -1
- package/workspace/public/pm/bd-span.js +15 -10
- package/workspace/public/pm/cozy-ev.css +24 -10
- package/workspace/public/pm/cozy-live-pill.css +9 -0
- package/workspace/public/pm/cozy-live-pill.js +50 -3
- package/workspace/public/pm/cozy-panels.css +9 -0
- package/workspace/public/pm/cozy-panels.js +99 -16
- package/workspace/public/pm/cozy-settings.js +34 -15
- package/workspace/public/pm/cozy-viewer.js +15 -3
- package/workspace/public/pm/home-chat.js +4 -0
- package/workspace/public/pm/page-chat.css +9 -0
- package/workspace/public/pm/page-chat.js +90 -0
- package/workspace/public/pm/premium-board.js +5 -0
- package/workspace/public/pm/upd-weeks.js +21 -7
- package/workspace/public/premium-board.html +2 -2
- package/workspace/test-lists/closed-not-late.json +5 -0
|
@@ -27,6 +27,12 @@
|
|
|
27
27
|
* Remembered Home: each initiative reopens with its panels and shares (localStorage 'cz-pn:<initiative id>', like Android app pairs).
|
|
28
28
|
* A page: only the divider share (localStorage 'cz-pn:<key>'); the open Viewers are not restored. A private window
|
|
29
29
|
* forgets it, and the page falls back to today's split.
|
|
30
|
+
* Dock side a page (Board, Intelligence): each Viewer docks on the right of the page (as today) or on its left. Drag the Viewer's
|
|
31
|
+
* header (not a press on it) over the left or the right half: that half turns blue, and the drop docks it there. The ⧉
|
|
32
|
+
* menu has the same two places as icons (Dock on the left, Dock on the right), so a keyboard reaches it. A Viewer that
|
|
33
|
+
* slides over slides over on its side. The side is kept per kind of item (localStorage 'cz-pn-side': { kind: 'left' }),
|
|
34
|
+
* so the initiative panel or the chat comes back on the side it was left on after a reload. The divider resizes it on
|
|
35
|
+
* either side (its drag, its keys, a drag to the edge).
|
|
30
36
|
* Present Home: while a recording presents (BroadcastChannel 'pmos-present': present or show), the chat slides away by itself; on
|
|
31
37
|
* Stop (off, or the recording ends) it comes back.
|
|
32
38
|
*
|
|
@@ -40,7 +46,7 @@
|
|
|
40
46
|
* (560 px at first) but never so wide that the page goes under fit(). key: the name the share is kept under ('board', 'intel').
|
|
41
47
|
* desk defaults to the page's parent; a Viewer host (data-cz-viewer-host) is added to it when it has none.
|
|
42
48
|
* window.czPanels = { list(), place(id, 'full'|'split'|'over'), slideOver(item?), openBeside(row|item), putAway(), away(), onChange(fn),
|
|
43
|
-
* scope(id) }
|
|
49
|
+
* scope(id), dock(id, 'left'|'right') → true when it docked (a page only), side(id) → 'left'|'right' }
|
|
44
50
|
* panel ids: 'chat' or 'page' (the first panel), 'v0', 'v1', 'v2' (v0 is #czViewer). list() → [{ id, kind: 'chat'|'page'|'viewer',
|
|
45
51
|
* mode: 'split'|'over'|'away', full, active, title }]. slideOver(item): a Live pill row (item.sid, a session) opens in the chat, and the
|
|
46
52
|
* chat floats over when it is not in the row; a Viewer item ({ kind, title, render | html }) opens in a Viewer that floats over; no
|
|
@@ -59,8 +65,9 @@ const HOME_SRC = '#hmList button[data-hm-sel], #czLiveList a[data-lv], #hmChat [
|
|
|
59
65
|
const IC = {
|
|
60
66
|
full: 'M3.5 5h17v14h-17z', split: 'M3.5 5h17v14h-17zM12 5v14', over: 'M3.5 5h17v14h-17zM14 8h4v8h-4z',
|
|
61
67
|
place: 'M3.5 5h17v14h-17zM12 5v14', close: 'M6 6l12 12M18 6L6 18',
|
|
68
|
+
left: 'M3.5 5h17v14h-17zM10 5v14M5.5 9h2M5.5 12h2', right: 'M3.5 5h17v14h-17zM14 5v14M16.5 9h2M16.5 12h2',
|
|
62
69
|
};
|
|
63
|
-
const NAME = { full: 'Full screen', split: 'Split', over: 'Slide over' };
|
|
70
|
+
const NAME = { full: 'Full screen', split: 'Split', over: 'Slide over', left: 'Dock on the left', right: 'Dock on the right' };
|
|
64
71
|
const svg = (d, w = 18) => `<svg aria-hidden="true" width="${w}" height="${w}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="${d}"/></svg>`;
|
|
65
72
|
const store = {
|
|
66
73
|
get(k) { try { return JSON.parse(localStorage.getItem(k) || 'null'); } catch { return null; } },
|
|
@@ -72,6 +79,27 @@ const maxRow = () => (innerWidth >= WIDE ? 3 : 2);
|
|
|
72
79
|
let C = 'chat';
|
|
73
80
|
const S = { on: false, page: false, desk: null, chat: null, src: HOME_SRC, clicks: '', fitFn: null, key: '', row: ['chat'], over: null, away: [], fr: [], used: {}, subs: [], go: null, reopen: null, pend: null, scope: null, auto: false, restoring: false, keepList: false, noClick: 0, divs: [] };
|
|
74
81
|
|
|
82
|
+
/* ── the side a Viewer docks on (a page): kept per kind of item ── */
|
|
83
|
+
const SIDES = 'cz-pn-side';
|
|
84
|
+
const kindOf = (id) => { const v = P.list().find((p) => p.id === id); return String((v && v.item && v.item.kind) || '').trim().toLowerCase(); };
|
|
85
|
+
/** 'left' or 'right' (today's place). Only a page docks: Home keeps the chat first. */
|
|
86
|
+
function sideOf(id) {
|
|
87
|
+
if (!S.page || !isV(id)) return 'right';
|
|
88
|
+
const m = store.get(SIDES) || {}, k = kindOf(id);
|
|
89
|
+
return k && m[k] === 'left' ? 'left' : 'right';
|
|
90
|
+
}
|
|
91
|
+
/** Dock the Viewer id on a side, keep it for its kind, and lay the panels out again. */
|
|
92
|
+
function dock(id, side) {
|
|
93
|
+
if (!S.on || !S.page || isPhone() || !isV(id) || !known(id) || !['left', 'right'].includes(side)) return false;
|
|
94
|
+
const k = kindOf(id), m = { ...(store.get(SIDES) || {}) };
|
|
95
|
+
if (k) { if (side === 'left') m[k] = 'left'; else delete m[k]; store.set(SIDES, Object.keys(m).length ? m : null); }
|
|
96
|
+
mark(id); S.fr = [];
|
|
97
|
+
layout();
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The page's own sheet sets its margin with !important (cozy-panels.css), so the gap after a Viewer docked left is set the same way. */
|
|
102
|
+
const gapLeft = (el, on) => { if (on) el.style.setProperty('margin-left', GAP + 'px', 'important'); else el.style.removeProperty('margin-left'); };
|
|
75
103
|
/* ── the panels and their elements ── */
|
|
76
104
|
const isV = (id) => /^v\d$/.test(id || '');
|
|
77
105
|
const vs = () => P.list().filter((p) => p.open);
|
|
@@ -83,7 +111,9 @@ const drop = (id) => { S.row = S.row.filter((x) => x !== id); if (S.over === id)
|
|
|
83
111
|
const mark = (id) => { if (id) S.used[id] = performance.now(); };
|
|
84
112
|
const lru = (ids) => ids.slice().sort((a, b) => (S.used[a] || 0) - (S.used[b] || 0))[0];
|
|
85
113
|
/** Today's split, laid out by cozy-viewer.js: the first panel alone, or it then one Viewer, nothing over and nothing put away. */
|
|
86
|
-
const simple = () => (S.page
|
|
114
|
+
const simple = () => (S.page ? S.row.includes(C) : !S.over && !S.away.length && S.row[0] === C) && S.row.length <= 2;
|
|
115
|
+
/** Today's split on a page with its one Viewer docked on the left. */
|
|
116
|
+
const leftSplit = () => S.page && !managed() && S.row.length === 2 && S.row[1] === C; // a page: a Viewer over leaves the split as it is
|
|
87
117
|
const managed = () => S.on && !isPhone() && !simple();
|
|
88
118
|
const pageW = () => (S.fitFn ? Math.max(0, +S.fitFn() || 0) : 0);
|
|
89
119
|
|
|
@@ -97,7 +127,10 @@ function sync() {
|
|
|
97
127
|
else { const v = [...S.away].reverse().find(isV); if (v) { drop(v); S.row = [v]; } else { drop(C); S.row = [C]; } }
|
|
98
128
|
}
|
|
99
129
|
if (!open.length && !S.row.includes(C)) { drop(C); S.row.unshift(C); } // no Viewer: the chat (or the page) is the page, as today
|
|
100
|
-
if (S.page && S.row.includes(C)
|
|
130
|
+
if (S.page && S.row.includes(C)) { // the Viewers docked left, the page, then the ones docked right
|
|
131
|
+
const want = [...S.row.filter((x) => x !== C && sideOf(x) === 'left'), C, ...S.row.filter((x) => x !== C && sideOf(x) !== 'left')];
|
|
132
|
+
if (want.join() !== S.row.join()) { S.row = want; S.fr = []; }
|
|
133
|
+
}
|
|
101
134
|
fit();
|
|
102
135
|
}
|
|
103
136
|
/** Over the limit (a new panel, a narrower window): the panel used least recently gives way. The chat slides over (or away when a panel
|
|
@@ -138,11 +171,14 @@ function layout() {
|
|
|
138
171
|
el.style.order = !phone && man && at >= 0 ? String(at * 2) : '';
|
|
139
172
|
el.style.flex = !phone && man && at >= 0 ? `${fr[at]} 1 0px` : '';
|
|
140
173
|
if (S.page && id !== C) el.style.marginLeft = !phone && man && at > 0 ? GAP + 'px' : ''; // a page's row has no flex gap (stray body children)
|
|
174
|
+
if (S.page && id === C) gapLeft(el, !phone && (man ? at > 0 : leftSplit())); // the page after a Viewer docked left
|
|
175
|
+
if (S.page && isV(id)) el.classList.toggle('cz-pn-left', !phone && sideOf(id) === 'left');
|
|
141
176
|
grab(el, !phone && S.over === id);
|
|
142
177
|
});
|
|
143
178
|
const host = S.desk.querySelector('[data-cz-viewer-host]');
|
|
144
|
-
|
|
145
|
-
if (host
|
|
179
|
+
const lft = !phone && leftSplit();
|
|
180
|
+
if (host) host.style.order = !phone && !man ? (lft ? '-2' : '2') : '';
|
|
181
|
+
if (host && S.page) host.style.marginLeft = !phone && !man && !lft ? GAP + 'px' : '';
|
|
146
182
|
if (S.chat) S.chat.style.order = !phone && !man ? '0' : S.chat.style.order;
|
|
147
183
|
divs(phone ? 0 : S.row.length - 1, man);
|
|
148
184
|
if (man && !phone && !S.page) {
|
|
@@ -219,7 +255,7 @@ function divs(n, man) {
|
|
|
219
255
|
while (S.divs.length < n) S.divs.push(divider());
|
|
220
256
|
S.divs.forEach((d, k) => {
|
|
221
257
|
d.dataset.k = String(k);
|
|
222
|
-
d.style.order = man ? String(k * 2 + 1) : '1';
|
|
258
|
+
d.style.order = man ? String(k * 2 + 1) : leftSplit() ? '-1' : '1';
|
|
223
259
|
if (d.__first !== (k === 0)) { d.__first = k === 0; d.querySelector('.cz-pn-in').innerHTML = dividerHtml({ first: k === 0 }); }
|
|
224
260
|
if (d.parentNode !== S.desk) S.desk.appendChild(d);
|
|
225
261
|
});
|
|
@@ -257,8 +293,8 @@ function snap(x, g) {
|
|
|
257
293
|
* the share is what is kept (rule 7), not the kind's width. */
|
|
258
294
|
function setAt(k, x, g, keep) {
|
|
259
295
|
if (!managed()) {
|
|
260
|
-
const w = g.R - x - g.gap / 2;
|
|
261
|
-
if (S.page) { if (keep) store.set('cz-pn:' + S.key, { v: 2, share: Math.min(0.95, Math.max(0.05, w / (g.R - g.L - g.gap))) }); P.size(
|
|
296
|
+
const v = isV(g.b) ? g.b : g.a, w = v === g.b ? g.R - x - g.gap / 2 : x - g.L - g.gap / 2; // a Viewer docked left grows to the right
|
|
297
|
+
if (S.page) { if (keep) store.set('cz-pn:' + S.key, { v: 2, share: Math.min(0.95, Math.max(0.05, w / (g.R - g.L - g.gap))) }); P.size(v, w, false); if (keep) layout(); return; }
|
|
262
298
|
P.size(g.b, w, keep); return;
|
|
263
299
|
}
|
|
264
300
|
const wa = Math.max(60, x - g.ra.left - g.gap / 2), wb = Math.max(60, g.rb.right - x - g.gap / 2), fr = shares(), s = fr[k] + fr[k + 1];
|
|
@@ -286,7 +322,7 @@ function dragDiv(d, e) {
|
|
|
286
322
|
d.classList.remove('drag', 'snap'); document.body.classList.remove('cz-pn-drag', 'cz-vw-drag');
|
|
287
323
|
[g.a, g.b].forEach((id) => { const el = elOf(id); if (el) el.classList.remove('cz-pn-shut'); });
|
|
288
324
|
if (!last) return;
|
|
289
|
-
if (last.shut && pinned(last.shut)) place(g.b, 'full'); // the page never closes: the Viewer takes the screen
|
|
325
|
+
if (last.shut && pinned(last.shut)) place(last.shut === g.a ? g.b : g.a, 'full'); // the page never closes: the Viewer takes the screen
|
|
290
326
|
else if (last.shut) shut(last.shut); else setAt(k, last.x, g, true);
|
|
291
327
|
};
|
|
292
328
|
d.addEventListener('pointermove', mv); d.addEventListener('pointerup', up); d.addEventListener('pointercancel', up);
|
|
@@ -326,8 +362,14 @@ function openMenu(id, anchor) {
|
|
|
326
362
|
const now = modeOf(id);
|
|
327
363
|
const m = document.createElement('div');
|
|
328
364
|
m.className = 'cz-pn-menu'; m.setAttribute('role', 'menu'); m.setAttribute('aria-label', 'Arrange'); m.dataset.escLayer = '1';
|
|
329
|
-
|
|
330
|
-
|
|
365
|
+
const btn = (k, on, at) => `<button type="button" role="menuitemradio" aria-checked="${on}" ${at}="${k}" aria-label="${NAME[k]}" title="${NAME[k]}">${svg(IC[k], 20)}</button>`;
|
|
366
|
+
// a page's Viewer also docks on the left or the right of the page: the keyboard's way to what a drag of its header does
|
|
367
|
+
const sides = S.page && isV(id) ? '<i class="cz-pn-sep" aria-hidden="true"></i>' + ['left', 'right'].map((k) => btn(k, sideOf(id) === k, 'data-cz-pn-dock')).join('') : '';
|
|
368
|
+
m.innerHTML = ['full', 'split', 'over'].map((k) => btn(k, now === k, 'data-cz-pn-place')).join('') + sides;
|
|
369
|
+
m.addEventListener('click', (e) => {
|
|
370
|
+
const d = e.target.closest('[data-cz-pn-dock]'); if (d) { closeMenu(false); dock(id, d.dataset.czPnDock); return; }
|
|
371
|
+
const b = e.target.closest('[data-cz-pn-place]'); if (!b) return; closeMenu(false); place(id, b.dataset.czPnPlace);
|
|
372
|
+
});
|
|
331
373
|
m.addEventListener('keydown', (e) => {
|
|
332
374
|
const bs = [...m.querySelectorAll('button')], at = bs.indexOf(document.activeElement);
|
|
333
375
|
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeMenu(true); }
|
|
@@ -464,14 +506,14 @@ function grab(el, want) {
|
|
|
464
506
|
let g = el.querySelector(':scope > .cz-pn-grab');
|
|
465
507
|
if (!want) { if (g) g.remove(); el.style.transform = ''; return; }
|
|
466
508
|
if (g) return;
|
|
467
|
-
g = document.createElement('div'); g.className = 'cz-pn-grab'; g.setAttribute('aria-hidden', 'true'); g.title = 'Drag
|
|
509
|
+
g = document.createElement('div'); g.className = 'cz-pn-grab'; g.setAttribute('aria-hidden', 'true'); g.title = 'Drag off the edge to put it away';
|
|
468
510
|
g.innerHTML = '<i></i>';
|
|
469
511
|
g.addEventListener('pointerdown', (e) => {
|
|
470
512
|
if (e.button !== 0) return;
|
|
471
513
|
e.preventDefault(); g.setPointerCapture(e.pointerId);
|
|
472
|
-
const x0 = e.clientX; let dx = 0;
|
|
514
|
+
const x0 = e.clientX, f = el.classList.contains('cz-pn-left') ? -1 : 1; let dx = 0; // docked left: off the left edge
|
|
473
515
|
el.classList.add('cz-pn-moving');
|
|
474
|
-
const mv = (ev) => { dx = Math.max(0, ev.clientX - x0); el.style.transform = `translateX(${dx}px)`; };
|
|
516
|
+
const mv = (ev) => { dx = Math.max(0, (ev.clientX - x0) * f); el.style.transform = `translateX(${dx * f}px)`; };
|
|
475
517
|
const up = () => {
|
|
476
518
|
g.removeEventListener('pointermove', mv); g.removeEventListener('pointerup', up); g.removeEventListener('pointercancel', up);
|
|
477
519
|
el.classList.remove('cz-pn-moving'); el.style.transform = '';
|
|
@@ -590,6 +632,46 @@ function dragWire() {
|
|
|
590
632
|
}
|
|
591
633
|
const besideSel = () => [S.src, S.clicks].filter(Boolean).join(', ');
|
|
592
634
|
|
|
635
|
+
/* ── dock a Viewer on the left or the right (a page): drag its header; the half it will dock on turns blue ── */
|
|
636
|
+
const NODRAG = 'button.cz-vw-ib, .cz-vw-pri, .cz-vw-chip, .cz-vw-chk, input, textarea, select, a[href], .cz-vw-menu, .cz-vw-trail, .cz-pn-grab';
|
|
637
|
+
let K = null;
|
|
638
|
+
function dockBox(side) {
|
|
639
|
+
const a = area(); if (!a) return null;
|
|
640
|
+
const top = Math.max(a.top, 0), h = Math.min(a.bottom, innerHeight) - top, w = (a.right - a.left) / 2;
|
|
641
|
+
return { left: side === 'left' ? a.left : a.left + w, top, width: w, height: h };
|
|
642
|
+
}
|
|
643
|
+
function dockWire() {
|
|
644
|
+
document.addEventListener('pointerdown', (e) => {
|
|
645
|
+
if (!S.on || !S.page || isPhone() || e.button !== 0 || e.pointerType === 'touch') return;
|
|
646
|
+
const hd = e.target.closest && e.target.closest('.cz-vw-hd'); if (!hd || e.target.closest(NODRAG)) return;
|
|
647
|
+
const p = hd.closest('[data-cz-panel]'); if (!p || !isV(p.dataset.czPanel) || !known(p.dataset.czPanel)) return;
|
|
648
|
+
K = { id: p.dataset.czPanel, el: p, x0: e.clientX, y0: e.clientY, pid: e.pointerId, on: false, side: null };
|
|
649
|
+
}, true);
|
|
650
|
+
document.addEventListener('pointermove', (e) => {
|
|
651
|
+
if (!K || e.pointerId !== K.pid) return;
|
|
652
|
+
if (!K.on) {
|
|
653
|
+
if (Math.hypot(e.clientX - K.x0, e.clientY - K.y0) < 8) return;
|
|
654
|
+
K.on = true; K.zone = document.createElement('div'); K.zone.className = 'cz-pn-drop'; K.zone.hidden = true;
|
|
655
|
+
document.body.appendChild(K.zone); document.body.classList.add('cz-pn-dragrow'); K.el.classList.add('cz-pn-docking');
|
|
656
|
+
}
|
|
657
|
+
e.preventDefault();
|
|
658
|
+
const a = area(); if (!a) return;
|
|
659
|
+
K.side = e.clientX < (a.left + a.right) / 2 ? 'left' : 'right';
|
|
660
|
+
const b = dockBox(K.side); K.zone.hidden = !b;
|
|
661
|
+
if (b) { Object.assign(K.zone.style, { left: b.left + 'px', top: b.top + 'px', width: b.width + 'px', height: b.height + 'px' }); K.zone.dataset.side = K.side; }
|
|
662
|
+
}, true);
|
|
663
|
+
const end = (e, ok) => {
|
|
664
|
+
if (!K || (e && e.pointerId !== K.pid)) return;
|
|
665
|
+
const k = K; K = null;
|
|
666
|
+
if (!k.on) return;
|
|
667
|
+
k.zone.remove(); document.body.classList.remove('cz-pn-dragrow'); k.el.classList.remove('cz-pn-docking');
|
|
668
|
+
S.noClick = Date.now(); // the release over the title is not also its rename press
|
|
669
|
+
if (ok && k.side && k.side !== sideOf(k.id)) dock(k.id, k.side);
|
|
670
|
+
};
|
|
671
|
+
document.addEventListener('pointerup', (e) => end(e, true), true);
|
|
672
|
+
document.addEventListener('pointercancel', (e) => end(e, false), true);
|
|
673
|
+
}
|
|
674
|
+
|
|
593
675
|
/* ── remembered per initiative on Home (rule 7) ── */
|
|
594
676
|
function snapshot() {
|
|
595
677
|
return {
|
|
@@ -675,6 +757,7 @@ export function mountPanels(o = {}) {
|
|
|
675
757
|
if (p) { mark(p.dataset.czPanel); used(p.dataset.czPanel); } else if (S.chat.contains(t)) mark(C);
|
|
676
758
|
}, { capture: true, passive: true }));
|
|
677
759
|
dragWire();
|
|
760
|
+
if (S.page) dockWire();
|
|
678
761
|
let rz = 0;
|
|
679
762
|
addEventListener('resize', () => { cancelAnimationFrame(rz); rz = requestAnimationFrame(() => { fit(null, true); S.keepList = false; layout(); }); });
|
|
680
763
|
if (S.page && typeof ResizeObserver !== 'undefined') new ResizeObserver(() => narrow()).observe(S.chat);
|
|
@@ -686,7 +769,7 @@ export function mountPanels(o = {}) {
|
|
|
686
769
|
onRecording((s) => { if (!s || !s.on) presentOff(); });
|
|
687
770
|
}
|
|
688
771
|
window.czPanels = {
|
|
689
|
-
list, place, slideOver, putAway, scope, openBeside,
|
|
772
|
+
list, place, slideOver, putAway, scope, openBeside, dock, side: sideOf,
|
|
690
773
|
away: lastAway,
|
|
691
774
|
onChange(fn) { if (typeof fn === 'function') S.subs.push(fn); },
|
|
692
775
|
};
|
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
* Workspaces the repositories PMOS works in (GET and POST /api/workspaces, POST /api/workspaces/forget; the folder is never touched)
|
|
18
18
|
* Workflows a link to its own page, /premium-workflows.html (pm/wf-page.js, D99): the stages each kind of initiative goes through
|
|
19
19
|
* Updates the versions and Check now, Update, Restart to update (GET /api/kit/status, POST /api/kit/check | update | upgrade-app); opening it
|
|
20
|
-
* asks for the latest kit. Under it, what's new
|
|
21
|
-
* week
|
|
20
|
+
* asks for the latest kit. Under it, what's new (design E, UpE): this week's summary lines, the newest kit at version level,
|
|
21
|
+
* then "Earlier this week" as one flat list of Claude's plain-language stories (a blue dot New, an amber dot Fixed, no dot
|
|
22
|
+
* Improved), and every earlier week folded into an Archive (pm/upd-weeks.js). No PR numbers and no kit ranges.
|
|
22
23
|
* Adler's moments (batch 17 item 4-B, AdM4): one face per section, where Claude speaks. Updates: glasses while it checks or installs,
|
|
23
24
|
* holding the card when a version is ready, calm when up to date. General: holding the update card, else the Jira promise (only after
|
|
24
25
|
* Commit). A failure in the status line takes the honest face, and then the section's face steps back (one Adler per screen).
|
|
@@ -33,7 +34,7 @@ import { esc, svg, ICON, toast, wsParam, wsQ } from './cozy-evui.js';
|
|
|
33
34
|
import { sourceRows, staleOf, partsOf } from './cozy-evmodel.js';
|
|
34
35
|
import * as AD from './cozy-adler.js';
|
|
35
36
|
import * as SE from './cozy-seasons.js';
|
|
36
|
-
import { weeksOf, lineOf,
|
|
37
|
+
import { weeksOf, lineOf, storiesOf } from './upd-weeks.js';
|
|
37
38
|
|
|
38
39
|
/** PUT or DELETE with the page's launch nonce (pressFetch). Only the key row needs them. */
|
|
39
40
|
async function pmSend(method, path, body) {
|
|
@@ -44,7 +45,7 @@ const SECTIONS = [['general', 'General'], ['claude', 'Claude'], ['checks', 'Clau
|
|
|
44
45
|
const PAGE_LINKS = { workflows: '/premium-workflows.html' }; // a section that is a page of its own: the row goes there
|
|
45
46
|
const SRC = { calendar: 'Calendar', wispr: 'Wispr Flow', mail: 'Mail', slack: 'Slack', jira: 'Jira', confluence: 'Confluence', github: 'GitHub', drive: 'Google Drive', figma: 'Figma', website: 'Your websites' };
|
|
46
47
|
const VIEWER_KEY = 'pmos.cz.viewer';
|
|
47
|
-
const T = { el: null, sec: 'general', ws: null, st: {}, src: null, con: {}, conn: [], set: null, prods: null, msg: '', busy: false, phase: '', say: '', back: null, fresh: null, reading: '', note: {}, lines: null, dress: false, more: false, bday: false, dset: {}, wkOpen: new Set(), arc: false };
|
|
48
|
+
const T = { el: null, sec: 'general', ws: null, st: {}, src: null, con: {}, conn: [], set: null, prods: null, msg: '', busy: false, phase: '', say: '', back: null, fresh: null, reading: '', note: {}, lines: null, dress: false, more: false, bday: false, dset: {}, wkOpen: new Set(), wkMore: new Set(), arc: false };
|
|
48
49
|
const cmpVer = (a, b) => { const x = String(a || '0').split('-')[0].split('.').map(Number), y = String(b || '0').split('-')[0].split('.').map(Number); for (let i = 0; i < 3; i++) if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) < (y[i] || 0) ? -1 : 1; return 0; };
|
|
49
50
|
const home = () => (T.ws && T.ws[0] ? T.st[T.ws[0].id] : null);
|
|
50
51
|
const latest = () => { const s = home(); return (s && (s.latest || (s.lastCheck && s.lastCheck.latest))) || null; };
|
|
@@ -211,24 +212,41 @@ function updates() {
|
|
|
211
212
|
const last = s.lastCheck ? 'Last checked ' + new Date(s.lastCheck.at).toLocaleString() + '. ' : '';
|
|
212
213
|
return `<div class="ev-card ev-upd first">${mood ? adFace(mood) : ''}<div class="ev-tx ev-grow">${head}</div>${acts}</div>${kits}<p class="ev-small ev-mut">${esc(last)}${s.checkDaily ? 'PMOS checks when you open Updates and once a day while a page is open; Check now asks again.' : 'PMOS checks only when you press Check now.'}</p>${weeks()}`;
|
|
213
214
|
}
|
|
214
|
-
/**
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
215
|
+
/** Design E (UpE). A story line: a blue dot for New, an amber dot for Fixed, no dot for Improved; Claude's plain title, then the sentence. */
|
|
216
|
+
const SPARK = 'M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z';
|
|
217
|
+
const DOT = { new: ['you', 'New'], fix: ['risk', 'Fixed'] };
|
|
218
|
+
function story(p) {
|
|
219
|
+
const x = lineOf(p), d = DOT[p.k];
|
|
220
|
+
return `<li class="ev-sy"><span class="ev-dc">${d ? `<i class="ev-dot ${d[0]}" title="${d[1]}"></i><span class="ev-vh">${d[1]}: </span>` : ''}</span><span class="ev-sx">${x.b ? `<b>${esc(x.b)}</b>` : ''}${x.b && x.s ? '<br>' : ''}${esc(x.s)}</span></li>`;
|
|
219
221
|
}
|
|
220
|
-
|
|
222
|
+
/** A flat list of stories, the first SHOWN of them, then a "N more" row that opens the rest. */
|
|
223
|
+
const SHOWN = 7;
|
|
224
|
+
function storyList(list, key) {
|
|
225
|
+
const all = T.wkMore.has(key), vis = all ? list : list.slice(0, SHOWN), rest = list.length - vis.length;
|
|
226
|
+
return `<ul class="ev-sl">${vis.map(story).join('')}</ul>${rest > 0 ? `<button type="button" class="ev-more" data-ev-more="${esc(key)}">${rest} more</button>` : ''}`;
|
|
227
|
+
}
|
|
228
|
+
const sumCard = (w) => (w.sum.length ? `<div class="ev-sum">${w.sum.map((l) => `<p>${svg(SPARK, 14)}<span>${esc(l)}</span></p>`).join('')}</div>` : '');
|
|
229
|
+
const changesOf = (w) => w.count + (w.count === 1 ? ' change' : ' changes');
|
|
230
|
+
/** The newest week: summary lines, the newest kit at version level, then "Earlier this week" as one flat list. */
|
|
231
|
+
function weekTop(w) {
|
|
232
|
+
const { kit, latest: top, earlier } = storiesOf(w), dots = w.groups.new.length || w.groups.fix.length;
|
|
233
|
+
const legend = dots ? '<div class="ev-leg" aria-hidden="true"><span><i class="ev-dot you"></i>New</span><span><i class="ev-dot risk"></i>Fixed</span><span>no dot: improved</span></div>' : '';
|
|
234
|
+
const kitRow = top.length ? `<div class="ev-rel"><div class="ev-rv"><b>${esc(kit)}</b>${kit === latest() ? '<span class="ev-small">latest</span>' : ''}</div><ul class="ev-sl">${top.map(story).join('')}</ul></div>` : '';
|
|
235
|
+
const more = earlier.length ? `<div class="ev-grp"><div class="ev-gh"><b>Earlier ${w.name === 'This week' ? 'this' : 'that'} week</b><span class="ev-small ev-mut">${earlier.length}</span></div>${storyList(earlier, w.key)}</div>` : '';
|
|
236
|
+
return sumCard(w) + legend + kitRow + more;
|
|
237
|
+
}
|
|
238
|
+
/** An archived week: its newest kit's stories, then the rest with the highlights first. */
|
|
239
|
+
const archList = (x) => { const o = storiesOf(x); return [...o.latest, ...o.earlier]; };
|
|
221
240
|
/** What's new, under the update card: this week (the newest week) open, and every earlier week folded into the Archive. */
|
|
222
241
|
function weeks() {
|
|
223
242
|
const s = home(), mine = s && s.app && !fromGit() ? s.app.version : null;
|
|
224
243
|
const W = weeksOf(changes(), Date.now(), mine);
|
|
225
244
|
if (!W.length) return '<p class="ev-small ev-mut">No kit summaries yet. Each kit published from now on carries one, and Check now reads the newest ones.</p>';
|
|
226
245
|
const [w, ...old] = W.slice(0, 13);
|
|
227
|
-
const head =
|
|
228
|
-
const
|
|
229
|
-
const rows = old.map((x) => { const on = T.wkOpen.has(x.key); return `<div class="ev-wkr"><button type="button" class="ev-wkt" data-ev-wk="${esc(x.key)}" aria-expanded="${on}"><b>${esc(x.name)}</b><span class="ev-small ev-mut">${esc(x.range)}</span><span class="ev-small ev-mut ev-tl">${esc(tally(x))}</span>${svg(ICON.down, 14)}</button>${on ? weekBody(x) : ''}</div>`; }).join('');
|
|
246
|
+
const head = `<div class="ev-wkh"><b>${esc(w.name)}</b><span class="ev-small ev-mut">${esc([w.range, changesOf(w)].filter(Boolean).join(' · '))}</span>${w.newer ? '<span class="ev-pill y">Ready to install</span>' : ''}</div>`;
|
|
247
|
+
const rows = old.map((x) => { const on = T.wkOpen.has(x.key); return `<div class="ev-wkr"><button type="button" class="ev-wkt" data-ev-wk="${esc(x.key)}" aria-expanded="${on}"><b>${esc(x.name)}</b><span class="ev-small ev-mut">${esc(x.range)}</span><span class="ev-small ev-mut ev-tl">${esc(changesOf(x))}</span>${svg(ICON.down, 14)}</button>${on ? sumCard(x) + storyList(archList(x), x.key) : ''}</div>`; }).join('');
|
|
230
248
|
const arc = old.length ? `<div class="ev-arc"><button type="button" class="ev-arcb" data-ev-arc aria-expanded="${T.arc}"><b>Archive</b><span class="ev-small ev-mut">${old.length} earlier ${old.length === 1 ? 'week' : 'weeks'}</span>${svg(ICON.down, 14)}</button>${T.arc ? rows : ''}</div>` : '';
|
|
231
|
-
return `<div class="ev-wk">${head
|
|
249
|
+
return `<div class="ev-wk">${head}${weekTop(w)}${arc}</div>`;
|
|
232
250
|
}
|
|
233
251
|
/* Settings > Claude: which Claude profile PMOS starts Claude sessions with, and the API key for Claude's own runs (D101).
|
|
234
252
|
* The key is typed here and goes to this computer only; the page never gets it back, only whether one is set and where from. */
|
|
@@ -315,6 +333,7 @@ async function press(e) {
|
|
|
315
333
|
if ('evClose' in t.dataset) { closeSettings(); return; }
|
|
316
334
|
if (t.dataset.evWk) { const k = t.dataset.evWk; if (T.wkOpen.has(k)) T.wkOpen.delete(k); else T.wkOpen.add(k); draw(); return; }
|
|
317
335
|
if ('evArc' in t.dataset) { T.arc = !T.arc; draw(); return; }
|
|
336
|
+
if (t.dataset.evMore) { T.wkMore.add(t.dataset.evMore); draw(); return; }
|
|
318
337
|
if (t.dataset.evGo) { T.sec = t.dataset.evGo; draw(); return; }
|
|
319
338
|
if (t.dataset.evSeg === 'theme') { const M = await import('./cozy-nav.js'); M.setTheme(t.dataset.v); draw(); return; }
|
|
320
339
|
if (t.dataset.evSeg && t.dataset.evSeg.startsWith('grill.')) {
|
|
@@ -458,7 +477,7 @@ async function load() {
|
|
|
458
477
|
export function openSettings(section) {
|
|
459
478
|
const el = mount();
|
|
460
479
|
if (section === 'dress') { T.sec = 'adler'; T.dress = true; } else if (section && BODY[section]) { T.sec = section; T.dress = false; }
|
|
461
|
-
if (el.hidden) { const ae = document.activeElement; T.back = ae && ae !== document.body ? ae : null; T.ws = null; T.src = null; T.msg = ''; T.phase = ''; T.auto = false; T.wkOpen = new Set(); T.arc = false; T.fresh = null; T.note = {}; T.dset = {}; T.cl = null; T.gr = null; JL.reset(); }
|
|
480
|
+
if (el.hidden) { const ae = document.activeElement; T.back = ae && ae !== document.body ? ae : null; T.ws = null; T.src = null; T.msg = ''; T.phase = ''; T.auto = false; T.wkOpen = new Set(); T.wkMore = new Set(); T.arc = false; T.fresh = null; T.note = {}; T.dset = {}; T.cl = null; T.gr = null; JL.reset(); }
|
|
462
481
|
el.hidden = false; document.getElementById('evStScrim').hidden = false; el.setAttribute('data-esc-layer', '');
|
|
463
482
|
draw();
|
|
464
483
|
const f = el.querySelector('[aria-current="page"]'); if (f) f.focus();
|
|
@@ -44,6 +44,9 @@
|
|
|
44
44
|
* nothing leaves until the caller runs go() (canvas MsB5Leave: Save or Discard). false or none: it leaves at once.
|
|
45
45
|
* render called with the body element to draw into; html is the same as a string. Neither: the body is left empty for the caller.
|
|
46
46
|
* id optional; current() returns it, so a caller can tell whether its item is the one shown
|
|
47
|
+
* outward true: a press inside this Viewer opens what it names in the other Viewer used last, not here (a chat panel, as Home's chat
|
|
48
|
+
* column opens into the Viewer); with no other Viewer it drills in here
|
|
49
|
+
* into a panel id ('v0'..'v2'): the item goes in that Viewer (the Board's one chat panel changes its conversation in place)
|
|
47
50
|
* onClose called once when this item leaves the card (Close, Escape, close(), or another open())
|
|
48
51
|
* close() · back() · forward() · isOpen() · current() → { id, kind, title, url } | null · body() → the body element or null
|
|
49
52
|
* drill() the next open() keeps the item shown now behind it, as back: true does (a page opens a task or a document from its own
|
|
@@ -117,7 +120,7 @@ const kindKey = (k) => String(k || 'item').trim().toLowerCase().replace(/^file
|
|
|
117
120
|
* key or the last open() went to, so the exported API always speaks of the Viewer used last. G is what the page has once: its list's rail,
|
|
118
121
|
* the listeners, the rail's card. Only the first instance (#czViewer) writes the browser history; another keeps its own ← → trail.
|
|
119
122
|
* Without cozy-panels.js (Board, Intelligence) the limit stays 1 and there is one Viewer, as before. */
|
|
120
|
-
const G = { subs: [], card: null, railKey: null, rail: false, lw: 0, hold: 0, tick: 0, limit: 1, beside: null, place: null, managed: null, own: null };
|
|
123
|
+
const G = { chat: null, subs: [], card: null, railKey: null, rail: false, lw: 0, hold: 0, tick: 0, limit: 1, beside: null, place: null, managed: null, own: null };
|
|
121
124
|
/** The list's fold, when the page owns it (Home, batch 20 item 6): { folded() → bool, show() }. Then only the list's own icon folds or unfolds it:
|
|
122
125
|
* a Viewer's width, its reset, open wide and its close never do. null: the Viewer folds the list to a rail as it needs (a page without one). */
|
|
123
126
|
export function listFold(o) { G.own = o && typeof o.folded === 'function' ? o : null; }
|
|
@@ -145,7 +148,12 @@ function pick(o) {
|
|
|
145
148
|
return ALL.filter(shown).sort((a, c) => a.used - c.used)[0];
|
|
146
149
|
}
|
|
147
150
|
const p = H.press && Date.now() - H.press.t < 10000 ? H.press : null;
|
|
148
|
-
if (p && p.inst && shown(p.inst) && !o.fresh)
|
|
151
|
+
if (p && p.inst && shown(p.inst) && !o.fresh) {
|
|
152
|
+
// a Viewer that holds a chat (outward: the Board's chat panel) opens what it names in the other Viewer, as Home's chat column does;
|
|
153
|
+
// with no other Viewer it drills in, and ← comes back to the chat
|
|
154
|
+
const other = p.inst.cur && p.inst.cur.args && p.inst.cur.args.outward ? ALL.filter((x) => shown(x) && x !== p.inst).sort((a, c) => c.used - a.used)[0] : null;
|
|
155
|
+
return other || p.inst;
|
|
156
|
+
}
|
|
149
157
|
return mru() || ALL[0];
|
|
150
158
|
}
|
|
151
159
|
|
|
@@ -431,10 +439,14 @@ function moreMenu(b) {
|
|
|
431
439
|
export const aboutPrefix = (k) => /initiative/.test(k) ? 'init' : /(^|-)(doc|document|prd|brd|spec|file|page)(-|$)/.test(k) ? 'doc'
|
|
432
440
|
: /(task|work|epic|ticket|item)/.test(k) ? 'work' : /(insight|goal|metric|measure|report|chart|dashboard)/.test(k) ? 'ins'
|
|
433
441
|
: /(program|board|stage|lane|release)/.test(k) ? 'board' : 'chat';
|
|
442
|
+
/** A page that opens the chat itself (the Board, batch 26 item 4): fn({ id, kind, title }) → true when it opened the chat about the item
|
|
443
|
+
* on the page, false to go to Home as before. Only for discuss: true, and not on a phone. */
|
|
444
|
+
export function chatHere(fn) { G.chat = typeof fn === 'function' ? fn : null; }
|
|
434
445
|
function discuss() {
|
|
435
446
|
const d = V.cur && V.cur.discuss; if (!d) return;
|
|
436
447
|
if (typeof d === 'function') { d(); return; }
|
|
437
448
|
if (d && d.href) { location.href = d.href; return; }
|
|
449
|
+
if (G.chat && !isPhone() && G.chat({ id: V.cur.id, kind: V.cur.kind, title: V.cur.title }) !== false) return;
|
|
438
450
|
// Home opens the chat about it, with the item in the Viewer beside it (XvABoardChat): ?about=<prefix>:<id> is the chat's key, and ?vk/?vid
|
|
439
451
|
// carry the item's own kind and id, so Home can reopen it (an initiative, a document by its path, a work-item)
|
|
440
452
|
const k = kindKey(V.cur.kind), raw = String(V.cur.id || V.cur.title);
|
|
@@ -572,7 +584,7 @@ function retitle0(id, title) { if (V.cur && V.cur.id === String(id) && title) {
|
|
|
572
584
|
/** Show one item in the Viewer, in place of the one shown. Returns the body element the content is in. */
|
|
573
585
|
export function open(it) {
|
|
574
586
|
const o = it || {};
|
|
575
|
-
use(o._inst || pick(o));
|
|
587
|
+
use(o._inst || (o.into && byP(o.into)) || pick(o)); // into: a panel id ('v1'), the Viewer that shows it (the Board's chat panel)
|
|
576
588
|
// an item with unsaved changes asks first; the open goes on when it says so (and keeps a drill-in asked for before it)
|
|
577
589
|
if (!o._asked && V.cur && typeof V.cur.guard === 'function') {
|
|
578
590
|
const dr = V.drill; let now = true, ran = false;
|
|
@@ -576,6 +576,8 @@ export function mountChat(el, host) {
|
|
|
576
576
|
send(v).then((ok) => { const box = foot.querySelector('textarea'); if (!ok && box && !box.value) { box.value = v; grow(box); } }); });
|
|
577
577
|
const shutAdd = (focus) => { if (!C.addOpen) return; C.addOpen = false; drawFoot({ role: role() }); if (focus) { const a = foot.querySelector('#hmAdd'); if (a) a.focus(); } };
|
|
578
578
|
document.addEventListener('click', (e) => { if (C.addOpen && e.target.isConnected && !e.target.closest('.hm-addw')) shutAdd(false); }, true);
|
|
579
|
+
// Escape closes the goal choice or method menu wherever the focus is: the redraw that opens the choice can drop the focus to the page
|
|
580
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !e.defaultPrevented && el.isConnected && !el.contains(e.target) && !document.querySelector('[data-esc-layer]') && GU.escape()) e.preventDefault(); });
|
|
579
581
|
el.addEventListener('keydown', (e) => {
|
|
580
582
|
if (e.key === 'Escape' && C.addOpen) { e.preventDefault(); e.stopPropagation(); shutAdd(true); return; }
|
|
581
583
|
if (e.key === 'Escape' && GU.escape()) { e.preventDefault(); e.stopPropagation(); return; }
|
|
@@ -673,6 +675,8 @@ export function mountChat(el, host) {
|
|
|
673
675
|
draw(); read();
|
|
674
676
|
},
|
|
675
677
|
refresh() { if (C.t) draw(); },
|
|
678
|
+
/** Stop reading: the chat left the screen (the Board's chat panel closed). open() starts again. */
|
|
679
|
+
stop() { clearTimeout(C.timer); C.seq += 1; },
|
|
676
680
|
reread() { read(); },
|
|
677
681
|
focus() { const ta = foot.querySelector('textarea'); if (ta) ta.focus(); },
|
|
678
682
|
send,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/* pm/page-chat.css — Home's chat in a Viewer of its own on a page with no chat column (page-chat.js, batch 26 item 4). The chat's own
|
|
2
|
+
* look is cozy-home.css; here only its place: the Viewer's body does not scroll, the conversation does, and the box stays at the foot. */
|
|
3
|
+
.cz-vw .cz-vw-body.pc-body { display: flex; flex-direction: column; overflow: hidden; padding: 0; }
|
|
4
|
+
.pc-body > .pc-chat { flex: 1 1 auto; min-height: 0; padding: 4px 20px 16px; }
|
|
5
|
+
.pc-body > .pc-chat > .hm-reopen { display: none; }
|
|
6
|
+
/* the box shows focus as Home's does (its own well), not with the page's focus ring */
|
|
7
|
+
.pc-chat form.hm-box textarea:focus-visible { outline: none; }
|
|
8
|
+
/* "In:" names the item the chat is about; on the Board it cannot change, so it is not a press */
|
|
9
|
+
.pc-chat [data-hm-scope] { pointer-events: none; }
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/* pm/page-chat.js — the chat as a panel on a page that has no chat column (the Board; batch 26 item 4, Wawan 2026-11-03: "User should be
|
|
2
|
+
* able to open chat panel on board page").
|
|
3
|
+
*
|
|
4
|
+
* "Discuss in chat" on the Board's initiative or program panel opens the chat about that item in a Viewer of its own, beside the panel
|
|
5
|
+
* (1600 px and wider) or sliding over it (a laptop), through the panels engine (cozy-panels.js openBeside). The page stays where it is.
|
|
6
|
+
* It is Home's chat (home-chat.js mountChat), not a second one: the same conversation key Home opens for that item (cozy-viewer.js
|
|
7
|
+
* aboutPrefix: init:<id> for an initiative, board:<id> for a program), so the words are the same on both pages. One chat panel at a time:
|
|
8
|
+
* Discuss on another item changes the conversation in it. A file or a link the chat opens goes in the other Viewer (outward), as on Home.
|
|
9
|
+
* ⋯ keeps "Open in a new tab": the same conversation on Home. A phone goes to Home as before (cozy-viewer.js chatHere is desktop only).
|
|
10
|
+
*
|
|
11
|
+
* openChat({ id, kind, title }, { ws? }) → true when it opened (false: no item to talk about; the caller goes to Home)
|
|
12
|
+
* The chat's styles are Home's (cozy-home.css, home-line.css); they load once, the first time the chat opens. */
|
|
13
|
+
import * as VW from './cozy-viewer.js';
|
|
14
|
+
import { pmGet, pmPost, pmHeaders } from './http.js';
|
|
15
|
+
|
|
16
|
+
export const CHAT_ID = 'page-chat';
|
|
17
|
+
const CLAUDE = { key: 'claude', name: 'Claude', initial: 'C' };
|
|
18
|
+
const CHAT_ICON = 'M5 6.5A2.5 2.5 0 0 1 7.5 4h9A2.5 2.5 0 0 1 19 6.5v7a2.5 2.5 0 0 1-2.5 2.5H11l-4 3.5V16h.5A2.5 2.5 0 0 1 5 13.5z';
|
|
19
|
+
const FLAG = 'M5 21V4h11l-2 4 2 4H5';
|
|
20
|
+
const SHEETS = ['./cozy-home.css', './home-line.css', './page-chat.css'];
|
|
21
|
+
const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
22
|
+
const Q = { el: null, chat: null, t: null, mod: null };
|
|
23
|
+
|
|
24
|
+
/** The conversation key Home opens for the item (cozy-viewer.js discuss: ?about=). */
|
|
25
|
+
export function keyOf(it) {
|
|
26
|
+
const raw = String((it && (it.id || it.title)) || '');
|
|
27
|
+
const k = String((it && it.kind) || 'item').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
28
|
+
const id = raw.replace(/[^A-Za-z0-9._:-]+/g, '-').replace(/^[^A-Za-z0-9]+/, '').slice(0, 100);
|
|
29
|
+
return id ? VW.aboutPrefix(k) + ':' + id : '';
|
|
30
|
+
}
|
|
31
|
+
/** The chat's target (home-chat.js mountChat open): the item's name is its scope; Claude answers. */
|
|
32
|
+
export function targetOf(it, ws) {
|
|
33
|
+
const title = String((it && it.title) || 'this').slice(0, 80);
|
|
34
|
+
return { key: keyOf(it), ws: ws || undefined, title, about: title, scope: { text: title, icon: FLAG }, role: CLAUDE, rich: false,
|
|
35
|
+
placeholder: 'Message Claude about ' + title, empty: 'Ask about ' + title + '. Claude reads its records and works in a real Claude Code session.' };
|
|
36
|
+
}
|
|
37
|
+
/** Home's own address for the same conversation (⋯ › Open in a new tab). */
|
|
38
|
+
const homeHref = (it, key) => '/premium.html?' + new URLSearchParams({ about: key, label: String(it.title || '').slice(0, 80) }).toString();
|
|
39
|
+
|
|
40
|
+
function sheets() {
|
|
41
|
+
for (const s of SHEETS) {
|
|
42
|
+
const href = new URL(s, import.meta.url).href;
|
|
43
|
+
if (![...document.querySelectorAll('link[rel="stylesheet"]')].some((l) => l.href === href)) { const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = href; document.head.appendChild(l); }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const toast = (m) => { const t = document.getElementById('toast'); if (!t) return; t.textContent = m; t.classList.add('on'); setTimeout(() => t.classList.remove('on'), 4200); };
|
|
47
|
+
function host() {
|
|
48
|
+
return {
|
|
49
|
+
esc, toast, pmGet, pmPost, pressHeaders: () => { const h = pmHeaders(); delete h['Content-Type']; return h; },
|
|
50
|
+
roles: () => [], seen: () => null, markSeen: () => {}, session: () => null, queued: () => [],
|
|
51
|
+
touch: (sid) => { if (sid) pmPost('/api/session/touch', { id: sid }).then(() => document.dispatchEvent(new CustomEvent('cz:live'))).catch(() => {}); },
|
|
52
|
+
onScope: () => {}, onTo: () => {}, onLink: () => {},
|
|
53
|
+
workItem: () => toast('Make a work-item from Home, in the initiative’s own chat.'),
|
|
54
|
+
viewerClosed: () => false, reopenViewer: () => {}, desk: () => true, head: () => '',
|
|
55
|
+
// a finished turn: the page reads its records again, so what Claude changed shows with no refresh (as Home, batch 25 item 3)
|
|
56
|
+
turnEnded: () => document.dispatchEvent(new CustomEvent('cz:chat-turn')),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
async function mod() {
|
|
60
|
+
if (!Q.mod) Q.mod = import('./home-chat.js');
|
|
61
|
+
return Q.mod;
|
|
62
|
+
}
|
|
63
|
+
const shownAt = () => VW.panels.list().find((p) => p.open && p.item && p.item.id === CHAT_ID) || null;
|
|
64
|
+
|
|
65
|
+
export async function openChat(it, o = {}) {
|
|
66
|
+
const key = keyOf(it); if (!key) return false;
|
|
67
|
+
sheets();
|
|
68
|
+
const M = await mod();
|
|
69
|
+
if (!Q.el) {
|
|
70
|
+
Q.el = document.createElement('section'); Q.el.className = 'hm-chat pc-chat'; Q.el.setAttribute('aria-label', 'Conversation');
|
|
71
|
+
Q.chat = M.mountChat(Q.el, host());
|
|
72
|
+
}
|
|
73
|
+
Q.t = targetOf(it, o.ws);
|
|
74
|
+
const item = {
|
|
75
|
+
id: CHAT_ID, kind: 'Chat', title: Q.t.title, icon: CHAT_ICON, url: homeHref(it, key), outward: true, fresh: true,
|
|
76
|
+
render: (body) => { body.classList.add('pc-body'); body.appendChild(Q.el); Q.chat.open(Q.t); },
|
|
77
|
+
onClose: () => { if (Q.chat) Q.chat.stop(); },
|
|
78
|
+
};
|
|
79
|
+
const at = shownAt();
|
|
80
|
+
if (at) { // one chat panel: it changes to this item's conversation in place (its title and its Home address with it)
|
|
81
|
+
VW.open({ ...item, into: at.id });
|
|
82
|
+
if (window.czPanels && (window.czPanels.list().find((p) => p.id === at.id) || {}).mode === 'away') window.czPanels.slideOver();
|
|
83
|
+
setTimeout(() => Q.chat && Q.chat.focus(), 60);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
const P = window.czPanels;
|
|
87
|
+
if (!(P && P.openBeside && P.openBeside(item))) VW.open(item);
|
|
88
|
+
setTimeout(() => Q.chat && Q.chat.focus(), 60);
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
@@ -95,6 +95,11 @@ mountLivePill();
|
|
|
95
95
|
* name drags beside, or ⌘/Ctrl-click opens it beside (a By stage card's drag moves the stage, so it is no source). fit(): the width the
|
|
96
96
|
* view needs whole, so the Viewer opens no wider than that leaves. */
|
|
97
97
|
mountPanels({ page: document.getElementById('stage'), key: 'board', sources: BP2.SOURCES, fit: BP2.fit });
|
|
98
|
+
/* Discuss in chat on an initiative's or a program's panel opens Home's chat about it in a panel of its own, beside the panel or sliding over
|
|
99
|
+
* it (batch 26 item 4, page-chat.js); the Board stays. A finished turn reads the board again. A phone goes to Home as before. */
|
|
100
|
+
VW.chatHere((it) => { const r = M && (M.rows || []).find((x) => x.id === it.id); import('./page-chat.js').then((C) => C.openChat(it, { ws: (r && r.ws) || (M && M.board.ws) || undefined })); return true; });
|
|
101
|
+
document.addEventListener('cz:chat-turn', () => { clearTimeout(chatT); chatT = setTimeout(() => load(), 400); });
|
|
102
|
+
let chatT = null;
|
|
98
103
|
// a product head's goal dots (PgBoard .gd) count that product's open initiatives, the way Home's product header does
|
|
99
104
|
PG.goalsFrom((name) => (M ? M.initiatives.list.rows.filter((r) => !M.board.all || r.product === name) : null));
|
|
100
105
|
const SV = (d) => `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${d}"/></svg>`;
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/* pm/upd-weeks.js — the published kits' change log, grouped by calendar week (Settings › Updates; initiative premium-updates, the weekly log).
|
|
2
2
|
* Pure: no DOM, no network. The two Settings screens (cozy-settings.js, premium-shell.js) draw what it returns.
|
|
3
|
-
* weeksOf(entries, now, mine) → [{ key, name, range, kits: [oldest, newest], newer, groups: { new, imp, fix }, count }] newest week first
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* weeksOf(entries, now, mine) → [{ key, name, range, kits: [oldest, newest], newer, groups: { new, imp, fix }, count, items, sum }] newest week first
|
|
4
|
+
* items: every line of the week as one flat list, the newest kit first; sum: up to three summary lines, from the highlights (top)
|
|
5
|
+
* storiesOf(w) → { kit, latest: [items of the newest kit], earlier: [the rest, highlights first] } design E: the newest kit at version level
|
|
6
|
+
* lineOf(item) → { b, s } a bold name and the sentence under it: Claude's story (h, d) when the kit has one, else the PR's title and
|
|
7
|
+
* its summary, or just the sentence
|
|
8
|
+
* An entry is { v, at, prs: [{ n, t, s, k, h, d, top }] } as scripts/kit-changelog.js writes it. A week runs Monday to Sunday in this computer's time zone.
|
|
6
9
|
* Each PR becomes one line in New, Improved or Fixed by its kind k; chore, docs, test and CI PRs (k = x) are left out, and a week with no line
|
|
7
10
|
* is not listed (zero is silence). A kit published before kinds were kept has no k, so its title decides: a fix word means Fixed, else Improved. */
|
|
8
11
|
export const KINDS = [['new', 'New'], ['imp', 'Improved'], ['fix', 'Fixed']];
|
|
@@ -19,6 +22,7 @@ export function kindOf(p) {
|
|
|
19
22
|
* ("`pmos x` names y" and "Pmos x names y."): the line is then the title once, without its ticks, so a command keeps its own case. */
|
|
20
23
|
const same = (a, b) => { const f = (x) => x.replace(/`/g, '').replace(/[.!?]+$/, '').replace(/\s+/g, ' ').trim().toLowerCase(); return f(a) === f(b); };
|
|
21
24
|
export function lineOf(p) {
|
|
25
|
+
if (p && p.h) { const d = String(p.d || ''), ps = String(p.s || ''); return { b: String(p.h), s: d || (same(p.h, ps) || same(String(p.t || ''), ps) ? '' : ps) }; }
|
|
22
26
|
const s = String((p && p.s) || ''), t = String((p && p.t) || '').replace(/`([^`]*)`/g, '$1');
|
|
23
27
|
if (!t) return { b: '', s };
|
|
24
28
|
return same(t, s) ? { b: '', s: t } : { b: t, s };
|
|
@@ -37,15 +41,16 @@ export function weeksOf(entries, now = Date.now(), mine = null) {
|
|
|
37
41
|
for (const e of Array.isArray(entries) ? entries : []) {
|
|
38
42
|
if (!e || !e.v) continue;
|
|
39
43
|
const d = e.at ? new Date(e.at) : null, dated = d && !isNaN(d), mon = dated ? mondayOf(d) : null, key = dated ? keyOf(mon) : 'undated';
|
|
40
|
-
if (!by.has(key)) by.set(key, { key, mon, kits: [], newer: false, groups: { new: [], imp: [], fix: [] }, seen: new Set() });
|
|
44
|
+
if (!by.has(key)) by.set(key, { key, mon, kits: [], newer: false, groups: { new: [], imp: [], fix: [] }, items: [], seen: new Set() });
|
|
41
45
|
const w = by.get(key);
|
|
42
46
|
w.kits.push(e.v);
|
|
43
47
|
if (mine && cmpVer(e.v, mine) > 0) w.newer = true;
|
|
44
48
|
for (const p of e.prs || []) {
|
|
45
|
-
const k = kindOf(p); if (k === 'x') continue;
|
|
49
|
+
const k = p.h && p.top ? 'new' : kindOf(p); if (k === 'x') continue; // a highlight is told as a New entry
|
|
46
50
|
if (p.n && w.seen.has(p.n)) continue;
|
|
47
51
|
if (p.n) w.seen.add(p.n);
|
|
48
|
-
|
|
52
|
+
const it = { n: p.n || null, k, t: p.t || '', s: p.s || '', v: e.v, ...(p.h ? { h: p.h } : {}), ...(p.d ? { d: p.d } : {}), ...(p.h && p.top ? { top: p.top } : {}) };
|
|
53
|
+
w.groups[k].push(it); w.items.push(it);
|
|
49
54
|
}
|
|
50
55
|
}
|
|
51
56
|
const out = [];
|
|
@@ -54,7 +59,16 @@ export function weeksOf(entries, now = Date.now(), mine = null) {
|
|
|
54
59
|
if (!count) continue;
|
|
55
60
|
const sorted = [...w.kits].sort(cmpVer);
|
|
56
61
|
const name = !w.mon ? 'Date not recorded' : keyOf(w.mon) === keyOf(thisMon) ? 'This week' : keyOf(w.mon) === keyOf(lastMon) ? 'Last week' : 'Week of ' + dm(w.mon);
|
|
57
|
-
|
|
62
|
+
const items = w.items.map((it, i) => [it, i]).sort((a, b) => cmpVer(b[0].v, a[0].v) || a[1] - b[1]).map(([it]) => it);
|
|
63
|
+
const sum = items.filter((it) => it.top).slice(0, 3).map((it) => it.top);
|
|
64
|
+
out.push({ key: w.key, name, range: w.mon ? rangeOf(w.mon) : '', kits: [sorted[0], sorted[sorted.length - 1]], newer: w.newer, groups: w.groups, count, items, sum, mon: w.mon });
|
|
58
65
|
}
|
|
59
66
|
return out.sort((a, b) => (a.mon && b.mon ? b.mon - a.mon : a.mon ? -1 : b.mon ? 1 : 0)).map(({ mon, ...w }) => w);
|
|
60
67
|
}
|
|
68
|
+
|
|
69
|
+
/** Design E: the week's newest kit on its own, at version level, then the rest of the week as one flat list with the highlights first. */
|
|
70
|
+
export function storiesOf(w) {
|
|
71
|
+
const kit = w && w.kits ? w.kits[1] : null, items = (w && w.items) || [];
|
|
72
|
+
const latest = items.filter((it) => it.v === kit), rest = items.filter((it) => it.v !== kit);
|
|
73
|
+
return { kit, latest, earlier: [...rest.filter((it) => it.top), ...rest.filter((it) => !it.top)] };
|
|
74
|
+
}
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
<title>PMOS · Board</title>
|
|
8
8
|
<!-- The premium Board in the Agreed design (initiative premium-agreed-design, B1 B2 B3 N1 N2). One toolbar and no page header; each initiative is a row
|
|
9
9
|
across the six stages and two milestones, with progress toward its current output from Jira. It reads GET /api/board-premium. The floating place menu
|
|
10
|
-
(pm/cozy-nav.js) replaces the rail on screen; the rail stays in the markup so premium-shell.js keeps its wiring.
|
|
11
|
-
|
|
10
|
+
(pm/cozy-nav.js) replaces the rail on screen; the rail stays in the markup so premium-shell.js keeps its wiring. Discuss in chat on a panel opens Home's chat
|
|
11
|
+
about it in a panel on the Board (pm/page-chat.js); a phone goes to Home. -->
|
|
12
12
|
<link rel="preload" href="/vendor/nunito-var.woff2" as="font" type="font/woff2" crossorigin />
|
|
13
13
|
<link rel="stylesheet" href="/pm/cozy.css" />
|
|
14
14
|
<link rel="stylesheet" href="/pm/cozy-pick.css" />
|