create-pmos 0.152.0 → 0.153.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.152.0",
3
+ "version": "0.153.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@159c05c
1
+ kernel-source: pmos@f059e84
2
2
  assembled: 2026-10-12
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-12T03:39:15Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T04:29:06Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-12T03:39:15Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T04:29:06Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-12T03:39:15Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T04:29:06Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -13,13 +13,13 @@ The Calendar tab of the console page (`/c3.html`) shows time. It shows what happ
13
13
 
14
14
  The switch at the start of the bar has two parts. **Calendar** shows the grid. **Notes** shows your notes.
15
15
 
16
- In **Notes**, the list is at the left. The notes are in groups, one for each initiative. **No initiative** is the last group. The number shows how many notes the list shows. Type in **Search notes** to keep only the notes whose title, initiative or summary has the words. A row shows when the note was made and its length. A row also shows **recording** or **paused** while the note is held, **no meeting** when the note has no meeting, and a warning mark when a statement differs from a file.
16
+ In **Notes**, the list is at the left, under the heading **Notes**. The notes are in groups by what they are about: **Portfolio** first, then one group for each board, in the order of the portfolio. **Not linked** is the last group. A note linked to an initiative from an earlier version, with no board, is in **Initiatives**. The number shows how many notes the list shows. Type in **Search notes** to keep only the notes whose title, board, initiative or summary has the words. A row shows when the note was made and its length. In a board group, a row shows its initiative, or **board** when the note is about the whole board. A row also shows **recording** or **paused** while the note is held, **no meeting** when the note has no meeting, and a warning mark when a statement differs from a file.
17
17
 
18
18
  Press a row to open the note at the right. The note has three stages. Press **Before**, **During** or **After** to change the stage.
19
19
 
20
- Under the title, the note shows its meeting and its initiative. Press **Link to a meeting** or **Link to an initiative** to link the note. When the note has a link, press **Change** to link it to another one.
20
+ Under the title, the note shows its meeting. Under the meeting, it shows what the note is about: the **Portfolio**, a board (for example a stand-up or a sprint planning), or an initiative on its board (for example `IDPF › Partner API rate limits`). Press **Link to a meeting** to link the note to a meeting. Press **Choose what it is about** to open one menu: **Portfolio** (every board), each board (the whole board), and the current and next initiatives of each board under it. When the note has a link, press **Change** to choose another one, or **Not linked** in the menu to remove it. The note moves into its group.
21
21
 
22
- - **Before** shows what the note kept about its meeting: the agenda, what the meeting is for, what to do to be ready, what to read first, the people, and the meeting: its time, its place and who organized it. A tick shows an agenda item that was heard in the transcript. A note with no meeting says so, and has **Link to a meeting** and **Link to an initiative**.
22
+ - **Before** shows what the note kept about its meeting: the agenda, what the meeting is for, what to do to be ready, what to read first, the people, and the meeting: its time, its place and who organized it. A tick shows an agenda item that was heard in the transcript. A note with no meeting says so, and has **Link to a meeting** and **Choose what it is about**.
23
23
  - **During** shows the live notes while the note is recorded or paused. At other times it shows the transcript.
24
24
  - **After** shows **Replay** first when the sound of the note is kept on this machine. Then it shows the overview, the decisions with why, the next steps with owner and due date, the open questions, the statements that differ from a file, the highlights and the screenshots of the shared screen (**Screen**; press one to see it large). The transcript is folded at the bottom. Click a text to correct it: the summary, a decision and its why, or a next step, its owner and its due day. Press `Enter` or leave the text to save it. Press `Esc` to cancel. Empty an owner or a due day to clear it. A decision caught while the meeting was recorded has no why. Before the final notes are written, press **Write a summary of the meeting** to write your own summary; empty it to go back to the caught one. **Make task** adds a next step to the note's initiative.
25
25
 
@@ -151,7 +151,7 @@ function stateFields(n, now, resumeMs) {
151
151
  function rowOf(n, now = Date.now(), resumeMs = RESUME_MS) {
152
152
  return { id: n.id, title: n.title || 'Untitled note', created_at: n.created_at, updated_at: n.updated_at, private: !!n.private,
153
153
  words: wordsOf(n.body), segments: (n.transcript || []).length, summary: !!(n.summary || (n.agent && n.agent.markdown)),
154
- meeting: n.meeting || null, initiative: n.initiative || null, ws: n.ws || null,
154
+ meeting: n.meeting || null, initiative: n.initiative || null, board: n.board || null, portfolio: !!n.portfolio, ws: n.ws || null,
155
155
  // the meeting time so far (a paused note's clock) and how many statements differ from a repository file (the Notes menu)
156
156
  meeting_ms: n.recorded_from ? meetingMs(n) : 0, conflicts: n.private ? 0 : conflictsOf(n).length,
157
157
  // a private note shows its title on the calendar and the initiative, never its summary
@@ -237,6 +237,14 @@ function cleanInitiative(x, by) {
237
237
  return { ok: true, initiative: { id: String(x.id), ws: typeof x.ws === 'string' && x.ws.length <= 40 ? x.ws : null,
238
238
  repo: typeof x.repo === 'string' && x.repo.length <= 120 ? x.repo : null, title: x.title ? cleanTitle(x.title) : null, by: ['pm', 'event', 'local'].includes(by) ? by : 'pm', ...(by === 'local' && Number.isFinite(x.shared) ? { shared: x.shared } : {}) } };
239
239
  }
240
+ /** A board link: { id, ws, title, by }, the board of /api/boards in its workspace (console3 Calendar › Notes). */
241
+ const BOARD_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
242
+ function cleanBoard(x, by) {
243
+ if (x === null) return { ok: true, board: null };
244
+ if (!x || typeof x !== 'object' || !BOARD_ID_RE.test(String(x.id || ''))) return err(400, 'that is not a board id');
245
+ return { ok: true, board: { id: String(x.id), ws: typeof x.ws === 'string' && x.ws.length <= 40 ? x.ws : null, title: x.title ? cleanTitle(x.title) : null,
246
+ by: ['pm', 'event', 'local'].includes(by) ? by : 'pm' } };
247
+ }
240
248
  /** Whether a note's meeting is this calendar event: the same id, or the same start minute and title (the brief and the
241
249
  * calendar memory give one meeting different ids). Pure. */
242
250
  function sameEvent(meeting, e) {
@@ -253,9 +261,10 @@ function findByEvent(dir, e) {
253
261
  hits.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)));
254
262
  return hits[0] || null;
255
263
  }
256
- /** Link a note to its meeting and to its initiative. A field left out is kept. The PM's initiative is never replaced by a
257
- * suggestion: `by: 'local'` is refused when the PM chose one. */
258
- function link(dir, { id, meeting, initiative, by = 'pm', now = Date.now() } = {}) {
264
+ /** Link a note to its meeting and to what it is about: an initiative, a board, or the whole Portfolio (`portfolio: true`,
265
+ * which clears the board and the initiative). A note with an initiative also keeps the initiative's board. A field left out
266
+ * is kept. The PM's initiative or board is never replaced by a suggestion: `by: 'local'` is refused when the PM chose one. */
267
+ function link(dir, { id, meeting, initiative, board, portfolio, by = 'pm', now = Date.now() } = {}) {
259
268
  const note = load(dir, id);
260
269
  if (!note) return err(404, 'no such note');
261
270
  if (meeting !== undefined) {
@@ -272,7 +281,16 @@ function link(dir, { id, meeting, initiative, by = 'pm', now = Date.now() } = {}
272
281
  const c = cleanInitiative(initiative, by);
273
282
  if (!c.ok) return c;
274
283
  note.initiative = c.initiative;
284
+ if (c.initiative) delete note.portfolio;
285
+ }
286
+ if (board !== undefined && !(by !== 'pm' && note.board && note.board.by === 'pm')) {
287
+ const c = cleanBoard(board, by);
288
+ if (!c.ok) return c;
289
+ note.board = c.board;
290
+ if (c.board) delete note.portfolio;
275
291
  }
292
+ if (portfolio === true) { note.portfolio = true; note.board = null; note.initiative = null; }
293
+ else if (portfolio === false) delete note.portfolio;
276
294
  note.updated_at = iso(now);
277
295
  store(dir, note);
278
296
  return { ok: true, note };
@@ -10,7 +10,7 @@
10
10
  * POST /api/notes/bullet { id, bullet, cat?, drop?, text?, owner?, due? } → { ok, bullet, summary } the PM's correction to one bullet (Run 8)
11
11
  * POST /api/notes/new { title?, meeting?, initiative? } → { ok, note, reused } a calendar event that already has a note
12
12
  * gets that note back (reused: true), so one meeting keeps one note (Run 4)
13
- * POST /api/notes/link { id, meeting?, initiative?, from_event? } → { ok, note, kept? } the PM's link; null unlinks (Run 4)
13
+ * POST /api/notes/link { id, meeting?, initiative?, board?, portfolio?, from_event? } → { ok, note, kept? } the PM's link; null unlinks (Run 4)
14
14
  * GET /api/notes/initiatives?id=…&ws=… → { ok, open: [{ id, title }], pick } the repository's open initiatives, and the local suggestion
15
15
  * POST /api/notes/save { id, title?, body? } → { ok, updated_at, versions, added } each new line of the notes is also
16
16
  * a transcript line, source 'typed' (`added`, Run 8)
@@ -691,7 +691,7 @@ async function handle(req, res, url, ctx) {
691
691
  return done(r);
692
692
  }
693
693
  // from_event: the initiative is the calendar event's own, and never replaces the PM's choice
694
- if (p === '/api/notes/link') return done(notes.link(dir, { id, meeting: body.meeting, initiative: body.initiative, by: body.from_event === true ? 'event' : 'pm' }));
694
+ if (p === '/api/notes/link') return done(notes.link(dir, { id, meeting: body.meeting, initiative: body.initiative, board: body.board, portfolio: typeof body.portfolio === 'boolean' ? body.portfolio : undefined, by: body.from_event === true ? 'event' : 'pm' }));
695
695
  if (p === '/api/notes/dictate') return done(notes.dictation(body.text));
696
696
  if (p === '/api/notes/save') {
697
697
  const r = notes.save(dir, { id, title: body.title, body: body.body });
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.152.0",
3
+ "version": "0.153.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.152.0",
9
+ "version": "0.153.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.152.0",
3
+ "version": "0.153.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -3,8 +3,11 @@
3
3
  * c3-calendar.js draws it when the Calendar | Notes switch is on Notes: notesView(ui) returns one persistent box, which this
4
4
  * file redraws on its own (a read, a choice, a stage, a search), so the grid's data call is not repeated.
5
5
  *
6
- * the list "By initiative" with a count and a search field, one group per initiative and "No initiative" last (dest-notes.js
7
- * groupNotes). A row: the time or day, the length, recording or paused, "no meeting", and a dot for a conflict.
6
+ * the list "Notes" with a count and a search field, grouped by what a note is about: Portfolio first, then each board in the
7
+ * portfolio's order, then "Not linked" (scopeGroups). A row: the time or day, the length, its initiative or a "board"
8
+ * tag, recording or paused, "no meeting", and a dot for a conflict.
9
+ * the head the note's meeting, then what it is about (the Portfolio, a board, or an initiative on its board) with one picker
10
+ * (aboutMenu: /api/boards and each board's Now and next initiatives), the mockup the PM approved on 2026-10-12.
8
11
  * the note the Calendar stage's layout (calendar-views.js stageNode, the mockup the PM approved on 2026-10-12): a head with
9
12
  * Before, During and After, then the stage's cards.
10
13
  * Before the meeting context the note kept (meeting.context): agenda (an item heard is ticked), what it is for, to be ready,
@@ -20,13 +23,14 @@
20
23
  * noteOfEvent(e) the note linked to a calendar event, from the shared notes read (notes-links.js), or null
21
24
  */
22
25
  import { el } from './dom.js';
23
- import { button } from './primitives.js';
26
+ import { button, menu } from './primitives.js';
27
+ import { icon } from './icons.js';
24
28
  import { c3ic } from './pm-work-view.js';
25
29
  import { pmGet, pmPost } from './http.js';
26
30
  import { toast } from './c3-toast.js';
27
31
  import { onNotes, notesFor, refreshNotes } from './notes-links.js';
28
32
  import { agendaHeard, peopleOf } from './notes-context.js';
29
- import { groupNotes, partsOf, conflictsOf, whenShort } from './dest-notes.js';
33
+ import { partsOf, conflictsOf, whenShort } from './dest-notes.js';
30
34
  import * as dock from './note-dock.js';
31
35
  import * as V from './view-notes.js';
32
36
  import { makeTask } from './notes-task.js';
@@ -35,6 +39,10 @@ import { screenStrip } from './notes-screen-view.js';
35
39
  const PHASES = [['before', 'clip', 'Before'], ['during', 'mic', 'During'], ['after', 'notes', 'After']];
36
40
  const startOf = (n) => (n.meeting && n.meeting.start) || n.recorded_from || n.created_at || n.updated_at;
37
41
  const iniName = (i) => (i ? i.title || i.id : null);
42
+ const boardName = (b) => (b ? b.title || b.id : null);
43
+ /* what a note is about, as the head's path: "Portfolio", "IDPF", or "IDPF › Partner API rate limits" */
44
+ const pathOf = (n) => (n.initiative ? [boardName(n.board), iniName(n.initiative)].filter(Boolean) : n.board ? [boardName(n.board)] : n.portfolio ? ['Portfolio'] : []);
45
+ const boardKey = (b) => (b.ws || '') + '/' + b.id;
38
46
  const mins = (ms) => (ms >= 60000 ? Math.round(ms / 60000) + ' min' : '');
39
47
  const lenOf = (n) => n.meeting_ms || Math.max(0, ...(n.transcript || []).map((x) => x.t || 0));
40
48
  const dayOf = (iso) => { const t = new Date(iso); return Number.isFinite(t.getTime()) ? t.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }) : ''; };
@@ -81,6 +89,7 @@ export function notesView(ui) {
81
89
  const s = stateOf(ui.r);
82
90
  s.ui = ui;
83
91
  if (!s.unsub) s.unsub = onNotes(() => { if (s.box.isConnected) read(s); });
92
+ if (!s.boards) loadBoards(s);
84
93
  if (!s.rows || (s.open && (!s.note || s.note.id !== s.open))) read(s);
85
94
  draw(s);
86
95
  return s.box;
@@ -96,6 +105,13 @@ async function read(s) {
96
105
  draw(s);
97
106
  }
98
107
 
108
+ /* the portfolio's boards, for the list's group order and the board names; a board the read misses keeps the note's own title */
109
+ async function loadBoards(s) {
110
+ s.boards = [];
111
+ const r = await pmGet('/api/boards');
112
+ if (r && r.ok && Array.isArray(r.boards)) { s.boards = r.boards.filter((b) => b && b.id && !b.error); if (s.box.isConnected) draw(s); }
113
+ }
114
+
99
115
  function choose(s, id) {
100
116
  if (s.open === id) return;
101
117
  s.open = id; s.note = null; s.phase = null; s.shots = []; s.rec = null;
@@ -118,7 +134,7 @@ function draw(s) {
118
134
 
119
135
  function listCard(s) {
120
136
  const qtext = s.q.trim().toLowerCase();
121
- const shown = (s.rows || []).filter((r) => !qtext || String(r.title || '').toLowerCase().includes(qtext) || String(iniName(r.initiative) || '').toLowerCase().includes(qtext)
137
+ const shown = (s.rows || []).filter((r) => !qtext || String(r.title || '').toLowerCase().includes(qtext) || pathOf(r).some((x) => String(x).toLowerCase().includes(qtext))
122
138
  || (r.summary_lines || []).some((x) => String(x).toLowerCase().includes(qtext)));
123
139
  const search = el('input', { type: 'text', class: 'cal3-nsearch', placeholder: 'Search notes', 'aria-label': 'Search notes', value: s.q, 'data-cal3-notes': 'search' });
124
140
  search.addEventListener('input', () => { s.q = search.value; draw(s); });
@@ -126,13 +142,28 @@ function listCard(s) {
126
142
  if (!s.rows) body = fold('clock', 'Reading the notes', { role: 'status' });
127
143
  else if (!s.rows.length) body = fold('mic', s.err || 'No notes yet. Press Record at the top right to start one.', { 'data-cal3-notes': 'none' });
128
144
  else if (!shown.length) body = fold('search', 'No note matches “' + s.q.trim() + '”.', { 'data-cal3-notes': 'no-match' });
129
- else body = groupNotes(shown).map((g) => el('div', { class: 'cal3-ctx', 'data-cal3-notes-group': g.label }, el('h4', null, g.label),
145
+ else body = scopeGroups(s, shown).map((g) => el('div', { class: 'cal3-ctx', 'data-cal3-notes-group': g.label }, el('h4', null, g.label),
130
146
  el('ul', { class: 'list', role: 'list' }, g.rows.map((r) => noteRow(s, r)))));
131
147
  return el('section', { class: 'card sect', 'data-cal3-notes': 'list', 'aria-label': 'Notes' },
132
- el('div', { class: 'sh2' }, el('h2', null, c3ic('notes', 'sm'), 'By initiative ', el('span', { class: 'bdg g', 'data-cal3-notes': 'count' }, String(shown.length)))),
148
+ el('div', { class: 'sh2' }, el('h2', null, c3ic('notes', 'sm'), 'Notes ', el('span', { class: 'bdg g', 'data-cal3-notes': 'count' }, String(shown.length)))),
133
149
  el('div', { class: 'cal3-nsearchw' }, search), body);
134
150
  }
135
151
 
152
+ /* the list's groups: Portfolio, each board in the portfolio's order (a board it does not list goes after them by name), an
153
+ * initiative linked before boards were (no board on the note) under "Initiatives", then "Not linked" */
154
+ function scopeGroups(s, rows) {
155
+ const order = new Map((s.boards || []).map((b, i) => [boardKey(b), i]));
156
+ const known = new Map((s.boards || []).map((b) => [boardKey(b), b.name || b.id]));
157
+ const g = new Map();
158
+ for (const r of rows) {
159
+ const k = r.portfolio ? '@portfolio' : r.board ? boardKey(r.board) : r.initiative ? '@initiative' : '';
160
+ if (!g.has(k)) g.set(k, { key: k, label: k === '@portfolio' ? 'Portfolio' : k === '@initiative' ? 'Initiatives' : !k ? 'Not linked' : known.get(k) || boardName(r.board), rows: [] });
161
+ g.get(k).rows.push(r);
162
+ }
163
+ const rank = (x) => (x.key === '@portfolio' ? [0, 0] : x.key === '@initiative' ? [3, 0] : !x.key ? [4, 0] : order.has(x.key) ? [1, order.get(x.key)] : [2, 0]);
164
+ return [...g.values()].sort((a, b) => { const p = rank(a); const q = rank(b); return p[0] - q[0] || p[1] - q[1] || a.label.localeCompare(b.label); });
165
+ }
166
+
136
167
  function noteRow(s, r) {
137
168
  const mode = held(r.id) || (r.state === 'recording' || r.state === 'paused' ? r.state : null);
138
169
  const on = s.open === r.id;
@@ -142,7 +173,9 @@ function noteRow(s, r) {
142
173
  btn('cal3-nopen', { title: 'Open ' + (r.title || 'Untitled note'), 'aria-current': on ? 'true' : 'false' }, [
143
174
  el('span', { class: 'tt' }, r.title || 'Untitled note'),
144
175
  el('span', { class: 'chips' }, el('span', { class: 'dim mono cal3-xs' }, [mode ? 'now' : whenShort(startOf(r)), len].filter(Boolean).join(' · ')),
145
- mode ? el('span', { class: 'tag', 'data-cal3-notes': 'live' }, mode === 'paused' ? 'paused' : 'recording') : null,
176
+ mode ? el('span', { class: 'tag', 'data-cal3-notes': 'live' }, mode === 'paused' ? 'paused' : 'recording')
177
+ : r.initiative ? el('span', { class: 'chip2', title: 'Initiative', 'data-cal3-notes': 'row-initiative' }, c3ic('init', 'sm'), iniName(r.initiative))
178
+ : r.board ? el('span', { class: 'tag', title: 'Linked to the board, not to one initiative', 'data-cal3-notes': 'row-board' }, 'board') : null,
146
179
  !linked(r) && !mode ? el('span', { class: 'tag', title: 'Not linked to a meeting', 'data-cal3-notes': 'no-meeting' }, 'no meeting') : null,
147
180
  r.conflicts ? el('span', { class: 'st warn', title: r.conflicts + (r.conflicts === 1 ? ' statement differs' : ' statements differ') + ' from a file' }, c3ic('alert', 'sm'), String(r.conflicts)) : null)],
148
181
  () => choose(s, r.id)),
@@ -172,7 +205,7 @@ function noteCol(s) {
172
205
  return el('div', { class: 'cal3-ncol', 'data-cal3-notes': 'note', 'data-cal3-note': n.id, 'data-cal3-stage': ph }, head, ...[].concat(body));
173
206
  }
174
207
 
175
- /* the note's meeting and initiative, each with its link: what it is linked to, then Link or Change (view-notes.js pickers) */
208
+ /* the note's meeting (view-notes.js picker) and what it is about (aboutMenu), each with what it is linked to, then Link or Change */
176
209
  function links(s, n, ev) {
177
210
  const adhoc = n.meeting && n.meeting.kind === 'adhoc';
178
211
  const when = ev ? [dayOf(ev.start), hm(ev.start) + (ev.end ? ' to ' + hm(ev.end) : '')].filter(Boolean).join(' ') : '';
@@ -183,9 +216,60 @@ function links(s, n, ev) {
183
216
  !ev && !adhoc ? el('span', { class: 'tag' }, 'no meeting') : null,
184
217
  btn('chip2 lk', { 'data-cal3-notes': 'meeting-link', title: ev || adhoc ? 'Link the note to another meeting' : 'Link the note to a meeting on the calendar' }, ev || adhoc ? 'Change' : 'Link to a meeting', () => V.linkMeeting(vctx(s), n.id)),
185
218
  ev ? btn('chip2 lk', { 'data-cal3-notes': 'show-meeting', title: 'Show the meeting in the calendar' }, [c3ic('next', 'sm'), 'Show in the calendar'], () => showMeeting(s, ev)) : null),
186
- el('span', { class: 'chips2' }, c3ic('init', 'sm'),
187
- n.initiative ? el('span', { 'data-cal3-notes': 'initiative' }, el('b', null, iniName(n.initiative))) : el('span', { class: 'dim', 'data-cal3-notes': 'initiative' }, 'No initiative'),
188
- btn('chip2 lk', { 'data-cal3-notes': 'initiative-link', title: n.initiative ? 'Link the note to another initiative' : 'Link the note to an initiative' }, n.initiative ? 'Change' : 'Link to an initiative', () => V.linkInitiative(vctx(s), n.id))));
219
+ aboutLine(s, n));
220
+ }
221
+ function aboutLine(s, n) {
222
+ const path = pathOf(n);
223
+ const b = btn('chip2 lk', { 'data-cal3-notes': 'about-link', title: 'Choose what the note is about: the Portfolio, a board or an initiative' }, path.length ? 'Change' : 'Choose what it is about', () => aboutMenu(s, n, b));
224
+ return el('span', { class: 'chips2' }, c3ic('init', 'sm'),
225
+ path.length ? el('span', { 'data-cal3-notes': 'about' }, el('b', null, path.join(' › '))) : el('span', { class: 'dim', 'data-cal3-notes': 'about' }, 'Not linked to the Portfolio, a board or an initiative'),
226
+ b);
227
+ }
228
+
229
+ /* each board with its Now and next initiatives (GET /api/boards/backlog), kept for a minute once the repository is read */
230
+ async function boardsWithInitiatives(s) {
231
+ if (s.about && Date.now() - s.about.at < 60000) return s.about.boards;
232
+ const r = await pmGet('/api/boards');
233
+ if (!r || !r.ok || !Array.isArray(r.boards)) throw new Error((r && r.error) || 'the boards could not be read');
234
+ const boards = r.boards.filter((b) => b && b.id && !b.error);
235
+ let pending = !!r.pending;
236
+ await Promise.all(boards.map(async (b) => {
237
+ const x = await pmGet('/api/boards/backlog?ws=' + encodeURIComponent(b.ws || '') + '&board=' + encodeURIComponent(b.id) + '&limit=100');
238
+ if (!x || !x.ok || x.pending || b.pending) pending = true;
239
+ b.inis = x && x.ok && Array.isArray(x.rows) ? x.rows : [];
240
+ }));
241
+ // while the repository is still being read, the initiatives are not all known: the next open reads them again
242
+ s.about = pending ? null : { at: Date.now(), boards };
243
+ return boards;
244
+ }
245
+
246
+ /* What is this note about? One menu: Portfolio (every board), each board (the whole board) with its initiatives under it, and
247
+ * Not linked when the note has a link. Choosing one saves it (POST /api/notes/link) and the note moves into its group. */
248
+ async function aboutMenu(s, n, anchor) {
249
+ let boards;
250
+ try { boards = await boardsWithInitiatives(s); } catch (e) { toast('The boards could not be read: ' + e.message); return; }
251
+ if (!anchor.isConnected) return;
252
+ const cur = n.initiative ? 'i:' + (n.initiative.ws || '') + '/' + n.initiative.id : n.board ? 'b:' + boardKey(n.board) : n.portfolio ? 'p' : '';
253
+ const save = async (body, said) => {
254
+ const r = await pmPost('/api/notes/link', { id: n.id, ...body });
255
+ if (!r || !r.ok) { toast('Not saved' + ((r && r.error) ? ': ' + r.error : '')); return; }
256
+ toast(said);
257
+ refreshNotes();
258
+ read(s);
259
+ };
260
+ const item = (k, label, detail, ic, on, sub) => ({ label, detail, role: 'menuitemradio', iconNode: sub ? el('span', { class: 'cal3-mind' }, icon(ic)) : icon(ic),
261
+ attrs: { 'data-cal3-about': k, 'aria-checked': String(cur === k) }, onSelect: on });
262
+ const items = [item('p', 'Portfolio', 'every board', 'chart-column', () => save({ portfolio: true }, 'About the Portfolio. The note moved into its group.'))];
263
+ for (const b of boards) {
264
+ const board = { id: b.id, ws: b.ws || null, title: b.name || b.id };
265
+ items.push(item('b:' + boardKey(b), board.title, 'the whole board', 'square-kanban', () => save({ board, initiative: null }, 'About ' + board.title + '. The note moved into its group.')));
266
+ for (const i of b.inis || []) {
267
+ items.push(item('i:' + (b.ws || '') + '/' + i.id, i.title || i.id, null, 'target',
268
+ () => save({ board, initiative: { id: i.id, ws: b.ws || null, title: i.title || i.id } }, 'About ' + (i.title || i.id) + ' on ' + board.title + '. The note moved into its group.'), true));
269
+ }
270
+ }
271
+ if (cur) items.push({ sep: true }, item('none', 'Not linked', 'remove the link', 'x', () => save({ portfolio: false, board: null, initiative: null }, 'Not linked to the Portfolio, a board or an initiative.')));
272
+ menu({ anchor, items, ariaLabel: 'What is this note about?' });
189
273
  }
190
274
 
191
275
  /* the replay of the note's kept audio: one <audio> per note, kept across redraws so playback goes on (as dest-notes.js does) */
@@ -207,7 +291,7 @@ function seek(s, ms) {
207
291
  const vctx = (s) => ({ state: {}, setState: () => { if (s.box.isConnected) { read(s); } } });
208
292
  const linkButtons = (s, n) => el('div', { class: 'chips2' },
209
293
  btn('chip2 lk', { 'data-cal3-notes': 'link-meeting' }, [c3ic('cal', 'sm'), 'Link to a meeting'], () => V.linkMeeting(vctx(s), n.id)),
210
- btn('chip2 lk', { 'data-cal3-notes': 'link-initiative' }, [c3ic('init', 'sm'), 'Link to an initiative'], () => V.linkInitiative(vctx(s), n.id)));
294
+ btn('chip2 lk', { 'data-cal3-notes': 'link-about' }, [c3ic('init', 'sm'), 'Choose what it is about'], (e) => aboutMenu(s, n, e.currentTarget)));
211
295
 
212
296
  /* a row of the stage's prepared list: the icon, the label, what it holds */
213
297
  const prepRow = (icon, label, detail, done) => el('div', { class: 'chips2 cal3-prep', 'data-cal3-prep': label },
@@ -509,7 +509,7 @@
509
509
  @container (min-width:1100px){.c3 .cal3-wrap{grid-template-columns:minmax(0,1fr) 340px}}
510
510
  /* Calendar › Notes (c3-notes.js): the list beside the open note; one column on a narrow page */
511
511
  .c3 .cal3-nwrap{display:grid;grid-template-columns:minmax(260px,340px) minmax(0,1fr);gap:var(--sp-3);align-items:start}
512
- .c3 .cal3-nwrap [data-cal3-notes="note"] .card{display:grid;gap:var(--sp-2)}
512
+ .c3 .cal3-nwrap [data-cal3-notes="note"] .card{display:grid;grid-template-columns:minmax(0,1fr);gap:var(--sp-2)}
513
513
  .c3 .cal3-nwrap details>summary{cursor:pointer;padding:var(--sp-1) 0}
514
514
  .c3 .cal3-ncol{display:grid;gap:var(--sp-3);align-content:start;min-width:0}
515
515
  .c3 .cal3-nhead{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap}
@@ -528,7 +528,9 @@
528
528
  .c3 .cal3-term{max-height:60vh;overflow:auto}
529
529
  .c3 .cal3-ov{margin:0}
530
530
  .c3 .cal3-tdr{text-align:right}
531
- .c3 .cal3-ncard{display:grid;gap:var(--sp-2)}
531
+ .c3 .cal3-ncard{display:grid;grid-template-columns:minmax(0,1fr);gap:var(--sp-2)}
532
+ /* the About menu (c3-notes.js aboutMenu): an initiative sits indented under its board; the menu lives in the overlay layer */
533
+ .cal3-mind{display:inline-flex;padding-left:16px}
532
534
  .c3 [data-cal3-notes-edit]{cursor:text;border-radius:6px;outline-offset:2px}
533
535
  @media (hover:hover){.c3 [data-cal3-notes-edit]:hover{background:var(--muted)}}
534
536
  .c3 .cal3-ed{display:inline-block;min-width:6ch}