create-pmos 0.116.0 → 0.118.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/guide/notes.md +43 -3
  7. package/workspace/guide/today.md +22 -13
  8. package/workspace/lib/brief.js +2 -2
  9. package/workspace/lib/day-marks.js +29 -9
  10. package/workspace/lib/goals.js +68 -10
  11. package/workspace/lib/notes.js +139 -17
  12. package/workspace/lib/routes-notes.js +68 -11
  13. package/workspace/package-lock.json +2 -2
  14. package/workspace/package.json +1 -1
  15. package/workspace/public/pm/components.css +289 -91
  16. package/workspace/public/pm/dest-notes.js +136 -29
  17. package/workspace/public/pm/note-dock.js +204 -164
  18. package/workspace/public/pm/notes-context.js +60 -0
  19. package/workspace/public/pm/notes-links.js +32 -6
  20. package/workspace/public/pm/primitives.js +1 -1
  21. package/workspace/public/pm/today-agenda.js +27 -53
  22. package/workspace/public/pm/today-copy.js +34 -15
  23. package/workspace/public/pm/today-day.js +98 -76
  24. package/workspace/public/pm/today-goals.js +159 -15
  25. package/workspace/public/pm/today-jobs.js +84 -0
  26. package/workspace/public/pm/today-mentions.js +50 -0
  27. package/workspace/public/pm/topbar.js +71 -32
  28. package/workspace/public/pm/view-notes.js +142 -73
  29. package/workspace/server.js +10 -8
  30. package/workspace/test-files.json +4 -0
  31. package/workspace/test-lists/today-goals-jobs-ui.json +5 -0
  32. package/workspace/test-lists/today-goals-jobs.json +5 -0
  33. package/workspace/test-lists/today-mentions-cards.json +5 -0
@@ -7,6 +7,9 @@
7
7
  * then Queue it (the shipped background prepare: one approval is one prompt hash)
8
8
  * addGoal(ctx, text, ini) → POST the add; true when the goal is on the list. While Today shows one repository, the
9
9
  * goal belongs to it (repo); the goal's detail can change that.
10
+ * goalJobsBlock(ctx, g, n) → a goal's done when and jobs, with the add and link presses: the goal's detail and a meeting's
11
+ * detail draw the same block (initiative today-goals-jobs, PRD G3, A4)
12
+ * jobsWithoutGoal(ctx) → the end of the Goals section: every job of the day with no goal, each with the link control (G5)
10
13
  * goalsSig() → a string that changes whenever what this module draws changes on its own
11
14
  *
12
15
  * `ctx` is the day section's (today-day.js): { ws, m (the day model), select(type, id), reload(), rerender(), cur(type, id) }.
@@ -17,10 +20,16 @@ import { el } from './dom.js';
17
20
  import { button, field, badge, toast, row, meter } from './primitives.js';
18
21
  import { pmPost } from './http.js';
19
22
  import { COPY } from './today-copy.js';
23
+ import { statusPill, whoActs, mark as whoMark, tagged, originOf, goalPicker, linkField } from './today-jobs.js';
20
24
 
21
25
  const G = COPY.goals;
22
26
  const D = COPY.day;
27
+ const A = COPY.act;
28
+ const MAX_JOBS = 12; // the jobs the PM types on one goal (lib/goals.js)
23
29
  let draft = '';
30
+ const ctxDraft = { done_when: '', anchor: '', due: '' }; // the "Add context" fields, kept while the section is redrawn
31
+ let ctxOpen = false;
32
+ const jobDrafts = new Map(); // goal id -> the text typed in its "Add a job" field
24
33
  let lastAdded = null; // { key, at }: the text of the last goal sent, so a double commit adds it once
25
34
  const prepared = new Map(); // step ref -> { key, branch, item_id, prompt_sha, initiative_id, ws } (a plan shown, not yet queued)
26
35
 
@@ -31,10 +40,12 @@ async function press(ctx, body) {
31
40
  return true;
32
41
  }
33
42
 
34
- /** Add a goal. `ini` ties it to an initiative, so the brief's items and meetings of that initiative join it. */
35
- export function addGoal(ctx, text, ini) {
43
+ /** Add a goal. `ini` ties it to an initiative, so the brief's items and meetings of that initiative join it. `extra` holds the
44
+ * optional context: done_when, anchor, due and event_id (the meeting it was made from). */
45
+ export function addGoal(ctx, text, ini, extra) {
36
46
  const body = { action: 'add', text: String(text || '').trim().slice(0, 140) };
37
47
  if (ini) body.initiative_id = ini;
48
+ for (const k of ['done_when', 'anchor', 'due', 'event_id']) if (extra && typeof extra[k] === 'string' && extra[k].trim()) body[k] = extra[k].trim();
38
49
  // one day for every repository: a goal added while Today shows one repository belongs to it; otherwise to none
39
50
  if (ctx.m && ctx.m.narrow && ctx.m.here) body.repo = ctx.m.here;
40
51
  return press(ctx, body);
@@ -42,21 +53,28 @@ export function addGoal(ctx, text, ini) {
42
53
 
43
54
  /** The jobs of a goal: the day model's jobs that name it. */
44
55
  const jobsOf = (m, id) => m.jobs.filter((j) => j.goal === id && !j.worth);
56
+ const typedOf = (g) => (Array.isArray(g.jobs) ? g.jobs : []);
57
+ /** "N of M jobs done": the jobs made from the brief and the ones the PM typed. Information only: it never closes a goal (G2). */
58
+ function tally(m, g) {
59
+ const js = jobsOf(m, g.id);
60
+ const typed = typedOf(g);
61
+ return { js, typed, total: js.length + typed.length, done: js.filter((j) => j.group === 'done').length + typed.filter((x) => x.done).length, need: js.filter((j) => j.group === 'needs').length };
62
+ }
45
63
 
46
64
  export function goalRows(ctx) {
47
65
  const { m } = ctx;
48
66
  if (!m.goals.length) return null;
49
67
  return el('ul', { class: 'td-day__rows', 'data-day-list': 'goals' }, ...m.goals.map((g) => {
50
- const js = jobsOf(m, g.id);
51
- const done = js.filter((j) => j.group === 'done').length;
52
- const need = js.filter((j) => j.group === 'needs').length;
68
+ const t = tally(m, g);
53
69
  const closed = g.status !== 'open';
54
- const sub = closed ? (G.coverage[g.coverage] || g.status) : js.length ? D.goalJobs(done, js.length) + (need ? ' · ' + D.goalNeed(need) : '') : (G.coverage[g.coverage] || D.goalNone);
70
+ const sub = closed ? (G.coverage[g.coverage] || g.status) : t.total ? D.goalJobs(t.done, t.total) + (t.need ? ' · ' + D.goalNeed(t.need) : '') : (G.coverage[g.coverage] || D.goalNone);
55
71
  const cur = ctx.cur('goal', g.id);
72
+ // the optional context (G1): when it is due, what it serves, and the prompt to say what done looks like
73
+ const meta = [sub, g.due ? G.dueMeta(g.due) : null, g.anchor ? G.anchorMeta(g.anchor) : null, !closed && !g.done_when ? G.addDoneWhen : null];
56
74
  return el('li', null, row({
57
- title: g.text, meta: [sub], done: closed, onOpen: () => ctx.select('goal', g.id),
75
+ title: g.text, meta, done: closed, onOpen: () => ctx.select('goal', g.id),
58
76
  lead: [el('span', { class: 'td-day__lead' }, String(m.goalNo.get(g.id)))],
59
- trail: [el('span', { class: 'td-day__chips' }, g.repo ? badge({ text: m.repoName(g.repo), tone: 'outline', attrs: { 'data-day-repo': g.repo } }) : null), meter({ done: js.length ? done : g.status === 'done' ? 1 : 0, total: js.length || 1, label: '' })],
77
+ trail: [el('span', { class: 'td-day__chips' }, g.repo ? badge({ text: m.repoName(g.repo), tone: 'outline', attrs: { 'data-day-repo': g.repo } }) : null), meter({ done: t.total ? t.done : g.status === 'done' ? 1 : 0, total: t.total || 1, label: '' })],
60
78
  attrs: { class: 'td-day__r', 'data-day-sel': 'goal:' + g.id, 'data-goal': g.id, 'data-goal-coverage': g.coverage, 'data-cur': cur },
61
79
  openAttrs: { 'aria-current': cur },
62
80
  }));
@@ -77,13 +95,32 @@ export function goalAdd(ctx) {
77
95
  lastAdded = { key, at: Date.now() };
78
96
  // clear before the press: its reload re-renders the section, and a rebuild must not bring the text back
79
97
  draft = '';
80
- if (await addGoal(ctx, v)) { if (f.control.resetCommitted) f.control.resetCommitted(''); f.control.value = ''; ctx.rerender(); }
81
- else { lastAdded = null; draft = v; ctx.rerender(); }
98
+ const extra = { ...ctxDraft };
99
+ clearContext();
100
+ if (await addGoal(ctx, v, null, extra)) { if (f.control.value.trim() === String(v).trim()) { if (f.control.resetCommitted) f.control.resetCommitted(''); f.control.value = ''; } ctx.rerender(); }
101
+ else { lastAdded = null; draft = v; Object.assign(ctxDraft, extra); ctxOpen = true; ctx.rerender(); }
82
102
  } });
83
103
  // a rebuild keeps what is typed. The field is made empty and filled after, so Enter still commits the restored text.
84
104
  f.control.value = draft;
85
105
  f.control.addEventListener('input', () => { draft = f.control.value; });
86
- return el('div', { class: 'td-day__goaladd' }, f);
106
+ return el('div', { class: 'td-day__goaladd' }, f, contextFold());
107
+ }
108
+
109
+ function clearContext() { ctxDraft.done_when = ''; ctxDraft.anchor = ''; ctxDraft.due = ''; ctxOpen = false; }
110
+
111
+ /** "Add context" (G6): done when, anchor and due. The jobs carry who does the work and what comes after, so no field asks. */
112
+ function contextFold() {
113
+ const one = (key, label, hint, max) => {
114
+ const f = field({ label, value: '', placeholder: hint, attrs: { maxlength: String(max), 'data-goals': 'ctx-' + key, 'data-goal-ctx-new': key }, onCommit: (v) => { ctxDraft[key] = v; } });
115
+ f.control.value = ctxDraft[key];
116
+ f.control.addEventListener('input', () => { ctxDraft[key] = f.control.value; });
117
+ return f;
118
+ };
119
+ const d = el('details', { class: 'td-day__fold td-goal__ctxfold', open: ctxOpen ? true : null, 'data-goals': 'context' },
120
+ el('summary', null, G.addContext),
121
+ el('div', { class: 'td-goal__ctxfields' }, one('done_when', G.doneWhen, G.doneWhenHint, 200), one('anchor', G.anchor, G.anchorHint, 80), one('due', G.due, G.dueHint, 40)));
122
+ d.addEventListener('toggle', () => { ctxOpen = d.open; });
123
+ return d;
87
124
  }
88
125
 
89
126
  async function prepareStep(ctx, st) {
@@ -117,6 +154,113 @@ function stepNode(ctx, st) {
117
154
  acts);
118
155
  }
119
156
 
157
+ const srcOf = (k) => D.srcName[k] || String(k || '');
158
+
159
+ /** A job made from the brief, as a line of a goal (G3): a check for the PM's own job that has no task yet, the title, who
160
+ * acts, where it came from (when not from the goal), and its status. The press on the title opens the job. */
161
+ function jobLine(ctx, j) {
162
+ const who = whoActs(j);
163
+ const origin = originOf(j);
164
+ const cur = ctx.cur('job', j.id);
165
+ const checkable = j.status === 'you' && !j.task;
166
+ const check = checkable ? field({ kind: 'check', label: '', value: j.group === 'done', attrs: { 'aria-label': `${A.done}: ${j.title}`, 'data-goal-job-check': j.id },
167
+ onCommit: (v) => ctx.mark(j.id, { action: v ? 'done' : 'reopen' }, v ? A.did.done(j.title) : A.did.reopen) }) : null;
168
+ return el('li', null, row({
169
+ title: j.title, meta: [who.text, origin !== 'goal' ? D.origin[origin] : null, srcOf(j.source)], done: j.group === 'done',
170
+ lead: [check, whoMark(who.who)].filter(Boolean), trail: [statusPill(j)], onOpen: () => ctx.select('job', j.id),
171
+ attrs: { class: 'td-day__r', 'data-day-sel': 'job:' + j.id, 'data-goal-job': j.id, 'data-who': who.who, 'data-cur': cur }, openAttrs: { 'aria-current': cur },
172
+ }));
173
+ }
174
+
175
+ /** A job the PM typed on the goal (G3): the PM's own, with no agent and no session. Check, uncheck and remove are the PM's presses. */
176
+ function typedLine(ctx, g, j) {
177
+ const who = whoActs({ typed: true, done: j.done });
178
+ const open = g.status === 'open';
179
+ const check = field({ kind: 'check', label: '', value: !!j.done, attrs: { 'aria-label': `${A.done}: ${j.text}`, 'data-goal-typed-check': j.id, disabled: open ? null : true },
180
+ onCommit: (v) => press(ctx, { action: v ? 'job-done' : 'job-reopen', id: g.id, job: j.id }) });
181
+ return el('li', null, row({
182
+ title: j.text, meta: [who.text, D.origin.you], done: !!j.done, lead: [check, whoMark('pm')],
183
+ trail: open ? [button({ label: tagged(G.removeJob, 'you'), size: 'sm', variant: 'ghost', attrs: { 'data-goal-typed-remove': j.id, 'aria-label': `${G.removeJob}: ${j.text}` },
184
+ onClick: () => press(ctx, { action: 'job-remove', id: g.id, job: j.id }) })] : [],
185
+ attrs: { class: 'td-day__r', 'data-goal-typed': j.id, 'data-who': 'pm' },
186
+ }));
187
+ }
188
+
189
+ /** The "Add a job" field (G3, L2): typed text becomes a job the PM owns. Enter adds it. */
190
+ function jobAdd(ctx, g) {
191
+ if (typedOf(g).length >= MAX_JOBS) return el('p', { class: 'td-day__note' }, G.jobFull(MAX_JOBS));
192
+ const f = field({ label: G.addJob, value: '', placeholder: G.addJobHint, hideLabel: true, attrs: { 'data-goals': 'job-input', 'data-goal-job-input': g.id, maxlength: '140' },
193
+ onCommit: async (v) => {
194
+ const text = String(v).trim();
195
+ if (!text) { jobDrafts.set(g.id, ''); return; }
196
+ const key = g.id + '|' + text.toLowerCase();
197
+ if (lastAdded && lastAdded.key === key && Date.now() - lastAdded.at < 10000) { jobDrafts.set(g.id, ''); f.control.value = ''; ctx.rerender(); return; }
198
+ lastAdded = { key, at: Date.now() };
199
+ jobDrafts.set(g.id, '');
200
+ if (await press(ctx, { action: 'job-add', id: g.id, text })) { if (f.control.value.trim() === text) { if (f.control.resetCommitted) f.control.resetCommitted(''); f.control.value = ''; } ctx.rerender(); }
201
+ else { lastAdded = null; jobDrafts.set(g.id, text); ctx.rerender(); }
202
+ } });
203
+ f.control.value = jobDrafts.get(g.id) || '';
204
+ f.control.addEventListener('input', () => { jobDrafts.set(g.id, f.control.value); });
205
+ return f;
206
+ }
207
+
208
+ /** Link a job of the day that has no goal to this goal (G3, G4): the picker offers only jobs with no goal. */
209
+ function jobLink(ctx, g) {
210
+ const free = ctx.m.jobs.filter((j) => !j.goal);
211
+ if (!free.length) return null;
212
+ return linkField(free, (j) => ctx.mark(j.linkId, { action: 'link', goal: g.id }, D.linked(g.text)), { label: G.linkJob, prompt: G.linkJobPick, attrs: { 'data-goal-link': g.id } });
213
+ }
214
+
215
+ /** The jobs of a goal and what finished looks like. `n` is the goal's number. The goal's detail and a meeting's detail draw it. */
216
+ export function goalJobsBlock(ctx, g, n) {
217
+ const t = tally(ctx.m, g);
218
+ const open = g.status === 'open';
219
+ return el('div', { class: 'td-day__rel td-goal__jobs', 'data-goals': 'jobs', 'data-goal-jobs': g.id },
220
+ el('h4', null, n ? G.forGoal(n, g.text) : G.jobsTitle),
221
+ g.done_when ? el('p', { class: 'td-goal__donewhen', 'data-goal-donewhen': g.id }, el('b', null, G.doneWhen + ': '), g.done_when) : null,
222
+ t.total ? el('p', { class: 'td-day__note', 'data-goal-tally': g.id }, D.goalJobs(t.done, t.total)) : null,
223
+ t.total ? el('ul', { class: 'td-day__rows', 'data-goal-job-list': g.id }, ...t.js.map((j) => jobLine(ctx, j)), ...t.typed.map((j) => typedLine(ctx, g, j)))
224
+ : el('p', { class: 'td-day__note' }, G.noJobs),
225
+ open ? jobAdd(ctx, g) : null,
226
+ open ? jobLink(ctx, g) : null);
227
+ }
228
+
229
+ /** The context of a goal (G1): done when, anchor and due. The PM edits it while the goal is open; an empty value clears it. */
230
+ function contextFields(ctx, g) {
231
+ if (g.status !== 'open') {
232
+ const bits = [[G.doneWhen, g.done_when], [G.anchor, g.anchor], [G.due, g.due]].filter(([, v]) => v);
233
+ return bits.length ? el('dl', { class: 'td-day__ctx' }, ...bits.flatMap(([k, v]) => [el('dt', null, k), el('dd', null, v)])) : null;
234
+ }
235
+ const one = (key, label, hint, max) => {
236
+ const f = field({ label, value: g[key] || '', placeholder: hint, attrs: { maxlength: String(max), 'data-goals': 'ctx-' + key, 'data-goal-ctx': key },
237
+ onCommit: (v) => press(ctx, { action: 'context', id: g.id, [key]: String(v).trim() }) });
238
+ return f;
239
+ };
240
+ return el('div', { class: 'td-goal__ctxfields', 'data-goals': 'context-fields' }, one('done_when', G.doneWhen, G.doneWhenHint, 200), one('anchor', G.anchor, G.anchorHint, 80), one('due', G.due, G.dueHint, 40));
241
+ }
242
+
243
+ /** The end of the Goals section (G5): every job of the day with no goal, each with a picker of today's open goals. */
244
+ export function jobsWithoutGoal(ctx) {
245
+ const { m } = ctx;
246
+ const free = m.jobs.filter((j) => !j.goal);
247
+ if (!m.jobs.length) return null;
248
+ const hasGoals = m.allGoals.some((g) => g.status === 'open');
249
+ return el('div', { class: 'td-day__rel td-goal__nogoal', 'data-goals': 'no-goal' },
250
+ el('h4', null, G.noGoalTitle, free.length ? el('span', null, String(free.length)) : null),
251
+ free.length ? el('ul', { class: 'td-day__rows', 'data-day-list': 'no-goal' }, ...free.map((j) => {
252
+ const who = whoActs(j);
253
+ const pick = hasGoals ? goalPicker(m, null, (gid) => {
254
+ const g = gid ? m.allGoals.find((x) => x.id === gid) : null;
255
+ if (g) ctx.mark(j.linkId, { action: 'link', goal: g.id }, D.linked(g.text));
256
+ }, { label: G.linkLabel(j.title), none: G.linkTo, attrs: { 'data-nogoal-link': j.id } }) : null;
257
+ return el('li', null, row({
258
+ title: j.title, meta: [who.text, srcOf(j.source)], lead: [whoMark(who.who)], trail: pick ? [el('span', { class: 'td-goal__pick' }, pick)] : [], onOpen: () => ctx.select('job', j.id),
259
+ attrs: { class: 'td-day__r', 'data-nogoal-job': j.id, 'data-who': who.who },
260
+ }));
261
+ })) : el('p', { class: 'td-day__note' }, G.noGoalNone));
262
+ }
263
+
120
264
  /** The detail of one goal. `row(type, id, label, lead, trail)` draws a selectable row (today-day.js). */
121
265
  export function goalDetail(ctx, id, row) {
122
266
  const { m } = ctx;
@@ -124,8 +268,7 @@ export function goalDetail(ctx, id, row) {
124
268
  if (i < 0) return null;
125
269
  const g = m.allGoals[i];
126
270
  const closed = g.status !== 'open';
127
- const js = jobsOf(m, g.id);
128
- const meets = m.events.filter((e) => e.goal === g.id);
271
+ const meets = m.events.filter((e) => e.gid === g.id);
129
272
  const ctrls = closed
130
273
  ? [button({ label: G.reopen, size: 'sm', attrs: { 'data-goal-act': 'reopen' }, onClick: () => press(ctx, { action: 'reopen', id: g.id }) })]
131
274
  : [button({ label: G.done, size: 'sm', attrs: { 'data-goal-act': 'done' }, onClick: () => press(ctx, { action: 'done', id: g.id }) }),
@@ -137,12 +280,13 @@ export function goalDetail(ctx, id, row) {
137
280
  el('div', { class: 'td-day__dhead' }, el('span', { class: 'td-day__kind' }, D.kind.goal(i + 1)), el('h3', null, g.text)),
138
281
  el('div', { class: 'td-day__acts' }, badge({ text: G.coverage[g.coverage] || g.coverage, tone: g.coverage === 'needs-plan' ? 'warning' : 'outline' }),
139
282
  g.initiative_id ? el('span', { class: 'td-day__mono' }, g.initiative_id) : null),
283
+ contextFields(ctx, g),
284
+ closed ? null : el('p', { class: 'td-day__note', 'data-goals': 'close-note' }, G.closeNote),
140
285
  ctx.repoPicker && m.repos.length ? el('div', { class: 'td-day__ch', 'data-goals': 'repo' }, el('span', null, D.repo.label),
141
286
  ctx.repoPicker(m, g.repo, (r) => press(ctx, { action: 'tag', id: g.id, repo: r }), { 'data-goal-repo': g.id })) : null,
142
287
  ctx.repoPicker && m.repos.length ? el('p', { class: 'td-day__note' }, D.repo.goalHint) : null,
143
- js.length ? el('p', null, D.goalJobs(js.filter((j) => j.group === 'done').length, js.length)) : el('p', { class: 'td-day__note' }, G.noStep),
144
288
  meets.length ? el('div', { class: 'td-day__rel' }, el('h4', null, D.meetings), el('ul', { class: 'td-day__rows' }, ...meets.map((e) => row('event', e.id, e.title, e.time)))) : null,
145
- js.length ? el('div', { class: 'td-day__rel' }, el('h4', null, D.jobs), el('ul', { class: 'td-day__rows' }, ...js.map((j) => row('job', j.id, j.title, '', j)))) : null,
289
+ goalJobsBlock(ctx, g, null),
146
290
  !closed && steps.length ? el('div', { class: 'td-day__rel', 'data-goals': 'steps' }, el('h4', null, D.stepsTitle), el('ul', { class: 'td-goal__steps' }, ...steps.map((st) => stepNode(ctx, st)))) : null,
147
291
  el('div', { class: 'td-day__acts' }, ...ctrls),
148
292
  ];
@@ -0,0 +1,84 @@
1
+ /* pm/today-jobs.js — what a job says about itself on Today (initiative today-goals-jobs, PRD J1 to J4).
2
+ *
3
+ * statusPill(j) → the status badge: To do, Needs you, Running, Done or Blocked
4
+ * whoActs(j) → { who: 'pm'|'agent', text }: who acts on the job's next press
5
+ * mark(who) → the square beside it: open for the PM, filled for the agent
6
+ * tag(who) → the small YOU or AGENT tag a press carries
7
+ * originOf(j) → 'goal'|'found'|'mention'|'you': where the job came from, derived from the data
8
+ * goalPicker(m, cur, ...) → a select: No goal, or one of today's open goals
9
+ * linkField(...) → a select of the jobs of the day that have no goal
10
+ *
11
+ * Pure drawing: no request is sent here. The presses are the day section's (today-day.js) and the goals module's.
12
+ */
13
+
14
+ import { el } from './dom.js';
15
+ import { badge, field } from './primitives.js';
16
+ import { COPY } from './today-copy.js';
17
+
18
+ const D = COPY.day;
19
+ const TONE = { '': 'muted', ok: 'outline', info: 'outline', warn: 'warning', purple: 'warning', bad: 'destructive' };
20
+
21
+ /** The status of a job as a badge. The raw status stays in data-day-status for the suites. */
22
+ export function statusPill(j) {
23
+ const s = D.status2;
24
+ const map = {
25
+ notStarted: [s.notStarted, ''], waiting: [s.waiting, ''], working: [s.working, 'info'], sending: [s.sending, 'info'],
26
+ review: [j.kind === 'auto' ? s.reviewAuto : s.review, 'purple'], sent: [s.sent, 'ok'], done: [s.done, 'ok'],
27
+ failed: [s.failed, 'bad'], you: [s.you, 'warn'],
28
+ };
29
+ const [t, tone] = map[j.status] || [j.status, ''];
30
+ return badge({ text: t, tone: TONE[tone] || 'muted', attrs: { 'data-day-status': j.status } });
31
+ }
32
+
33
+ /** Who acts on the next press. A draft to approve, a failed job and the PM's own job wait on the PM. */
34
+ export function whoActs(j) {
35
+ const W = D.who2;
36
+ if (j.typed) return j.done ? { who: 'pm', text: W.youDone } : { who: 'pm', text: W.you };
37
+ if (j.status === 'you') return j.group === 'done' ? { who: 'pm', text: W.youDone } : { who: 'pm', text: W.you };
38
+ if (j.status === 'working' || j.status === 'sending') return { who: 'agent', text: W.working };
39
+ if (j.status === 'done' || j.status === 'sent') return { who: 'agent', text: W.finished };
40
+ if (j.status === 'notStarted' || j.status === 'waiting') return { who: 'agent', text: W.queued };
41
+ return { who: 'pm', text: W.you }; // review and failed
42
+ }
43
+
44
+ /** The square: filled for the agent, open for the PM. It is a picture; the words beside it say the same. */
45
+ export function mark(who) {
46
+ return el('span', { class: 'td-day__sq', 'data-who': who, 'aria-hidden': 'true' });
47
+ }
48
+
49
+ /** The small tag on a press: YOU when the PM does it, AGENT when the press starts the agent. */
50
+ export function tag(who) {
51
+ return el('span', { class: 'td-day__tag', 'data-who': who, 'data-day': 'tag' }, who === 'agent' ? D.tag.agent : D.tag.you);
52
+ }
53
+
54
+ /** A button label with its tag: [text, tag]. */
55
+ export const tagged = (text, who) => [text, ' ', tag(who)];
56
+
57
+ const MEETING_SOURCES = ['calendar', 'wispr'];
58
+ /** Where a job came from, from the data and never from a field the PM edits. */
59
+ export function originOf(j) {
60
+ if (j.typed) return 'you';
61
+ if (j.item && j.item.mention) return 'mention';
62
+ if (j.item && (MEETING_SOURCES.includes(j.item.source) || j.item.verb === 'prepare')) return 'found';
63
+ if (!j.item) return 'you'; // a briefed task with no item: the PM gave it to the agent by hand
64
+ return j.goal ? 'goal' : 'found';
65
+ }
66
+
67
+ /** A select of today's open goals with No goal first. `onPick(goalId|null)`. */
68
+ export function goalPicker(m, cur, onPick, { label, none = D.goalCtl.none, attrs } = {}) {
69
+ const open = m.allGoals.filter((g) => g.status === 'open');
70
+ const f = field({ label: label || D.goalCtl.label, kind: 'select', value: cur || '', hideLabel: true,
71
+ options: [{ value: '', label: none }, ...open.map((g) => ({ value: g.id, label: `${m.goalNo.get(g.id)} · ${g.text}` })),
72
+ // a goal that is closed stays shown while the job is under it, so the picker never hides what is stored
73
+ ...(cur && !open.some((g) => g.id === cur) ? [{ value: cur, label: (m.allGoals.find((g) => g.id === cur) || { text: cur }).text }] : [])],
74
+ attrs, onCommit: (v) => onPick(v || null) });
75
+ return f;
76
+ }
77
+
78
+ /** A select of jobs with no goal: the first option is the prompt. `onPick(job)`. */
79
+ export function linkField(jobs, onPick, { label, prompt, attrs } = {}) {
80
+ const f = field({ label, kind: 'select', value: '', hideLabel: true, attrs,
81
+ options: [{ value: '', label: prompt }, ...jobs.map((j) => ({ value: j.linkId, label: j.title }))],
82
+ onCommit: (v) => { const j = jobs.find((x) => x.linkId === v); if (j) onPick(j); } });
83
+ return f;
84
+ }
@@ -0,0 +1,50 @@
1
+ /* pm/today-mentions.js — a mention in the look of its source (initiative today-goals-jobs, PRD M1 to M3).
2
+ *
3
+ * hasCard(source) → true for Slack, Confluence and Figma: the sources that draw as a card
4
+ * mentionCard(j) → the card of a mention job: the source line, the author, the time, the text, and, only when the
5
+ * brief item carries them, the quoted passage (Confluence) or the frame (Figma)
6
+ *
7
+ * Pure drawing from the brief item's own fields (who, about, ask, where, quote, thread). A field the item lacks is left
8
+ * out, never made up. No press lives here: the card sits inside the row's open button or in the detail, and the card
9
+ * links to the conversation. Nothing here posts to, resolves in or reacts in a service.
10
+ */
11
+
12
+ import { el } from './dom.js';
13
+ import { icon } from './icons.js';
14
+ import { COPY } from './today-copy.js';
15
+
16
+ const D = COPY.day;
17
+ const ICON = { slack: 'message-square', confluence: 'file-text', figma: 'diamond' };
18
+
19
+ export const hasCard = (source) => Object.prototype.hasOwnProperty.call(ICON, source);
20
+
21
+ const initials = (who) => String(who || '').replace(/[^\p{L}\p{N}\s]/gu, ' ').split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0].toUpperCase()).join('');
22
+
23
+ /** The time of the author's last message in the thread the item carries; none when it carries none. */
24
+ function timeOf(it) {
25
+ const mine = (Array.isArray(it.thread) ? it.thread : []).filter((m) => m && m.at && (!it.who || m.who === it.who));
26
+ return mine.length ? mine[mine.length - 1].at : null;
27
+ }
28
+
29
+ /** The card of one mention. `name` is the display name of the source (Slack, Confluence, Figma). */
30
+ export function mentionCard(j, name) {
31
+ const it = j.item || {};
32
+ const svc = j.source;
33
+ const ini = initials(it.who);
34
+ const words = [it.about, it.ask].filter(Boolean);
35
+ const when = timeOf(it);
36
+ const quote = svc === 'confluence' && typeof it.quote === 'string' && it.quote.trim() ? it.quote.trim() : null;
37
+ const frame = svc === 'figma'
38
+ ? el('span', { class: 'td-day__mc-frame', 'data-mc': 'frame', 'aria-hidden': 'true' }, icon('circle-dot')) : null;
39
+ const avatar = svc !== 'figma' && ini ? el('span', { class: 'td-day__av', 'data-mc': 'avatar', 'aria-hidden': 'true' }, ini) : null;
40
+ return el('span', { class: 'td-day__mc', 'data-mention-card': svc },
41
+ el('span', { class: 'td-day__mc-head', 'data-mc': 'source' }, icon(ICON[svc]), D.mc.via(name, it.where)),
42
+ quote ? el('span', { class: 'td-day__mc-quote', 'data-mc': 'quote' }, quote) : null,
43
+ el('span', { class: 'td-day__mc-main' },
44
+ frame || avatar,
45
+ el('span', { class: 'td-day__mc-col' },
46
+ it.who || when ? el('span', { class: 'td-day__mc-line' },
47
+ it.who ? el('strong', { class: 'td-day__mc-name', 'data-mc': 'who' }, it.who) : null,
48
+ when ? el('span', { class: 'td-day__mc-time', 'data-mc': 'time' }, when) : null) : null,
49
+ ...words.map((w) => el('span', { class: 'td-day__mc-text', 'data-mc': 'text' }, w)))));
50
+ }
@@ -19,11 +19,12 @@
19
19
  */
20
20
 
21
21
  import { el, setChildren } from './dom.js';
22
- import { button, needsDot, needsCount } from './primitives.js';
22
+ import { button, iconButton, menu, needsDot, needsCount } from './primitives.js';
23
23
  import { COPY } from './frame-copy.js';
24
24
  import * as dock from './note-dock.js';
25
- import { iconButton } from './primitives.js';
26
25
  import { el as h } from './dom.js';
26
+ import { upcomingToday, upcomingWithoutNote } from './notes-links.js';
27
+ import { contextOf, peopleOf } from './notes-context.js';
27
28
 
28
29
  /** The destinations the bar names, in order. Settings sits at the end of the bar, after Sync. Docs joined the bar at the
29
30
  * PM's word (2026-10-06, docs-three-way-sync: "I thought Docs is one of the main menu"). People joined after Docs when the
@@ -57,8 +58,9 @@ export function createTopbar({ go, openPalette, openView, repoMenu, sync } = {})
57
58
  const dot = needsDot();
58
59
  const links = {};
59
60
  for (const id of [...BAR_DESTS, 'settings']) {
60
- links[id] = button({
61
- label: COPY.nav[id], variant: 'ghost', icon: NAV_ICON[id],
61
+ // icon only (meeting-notes Run 13): the place's name is its tooltip and its aria-label
62
+ links[id] = iconButton({
63
+ icon: NAV_ICON[id], ariaLabel: COPY.nav[id],
62
64
  attrs: { class: 'fr-link', 'data-fr-dest': id, 'aria-current': 'false' },
63
65
  onClick: () => go && go(id),
64
66
  });
@@ -80,42 +82,80 @@ export function createTopbar({ go, openPalette, openView, repoMenu, sync } = {})
80
82
  }
81
83
  const nav = el('nav', { class: 'fr-nav', 'aria-label': COPY.nav.label }, BAR_DESTS.map((id) => links[id]));
82
84
 
83
- const search = button({
84
- label: COPY.search, variant: 'outline', icon: 'search',
85
+ const search = iconButton({
86
+ icon: 'search', ariaLabel: COPY.search + ' (' + COPY.searchKey + ')',
85
87
  attrs: { class: 'fr-search', 'data-fr': 'search', 'aria-keyshortcuts': 'Meta+K Control+K' },
86
88
  onClick: () => openPalette && openPalette(),
87
89
  });
88
- search.appendChild(el('kbd', { class: 'fr-kbd' }, COPY.searchKey));
89
90
 
90
- /* The Record control (meeting-notes Run 12, mockup board 2), right of the bar before Settings. Idle: Record. Recording: the
91
- * red dot and the clock (a press opens the note), the highlight and Pause. Paused: the clock and Resume. Stop is in the note. */
91
+ /* The Record control (meeting-notes Run 13, prototype/meeting-notes/record-control-mockup.html). Two groups, always in the same
92
+ * place. Left, only while a meeting is recording or paused: the chip that names it, then Highlight, Pause and Stop (recording)
93
+ * or Resume and Stop (paused). Nothing to resume: no left group. Right, always: New (an untitled note) and an arrow that lists
94
+ * today's meetings that have not started; both are unavailable while a meeting is recording. The highlight's pop-up hangs
95
+ * under the control. */
92
96
  const recSlot = el('div', { class: 'fr-rec', 'data-fr': 'record' });
93
97
  let recKey = '';
94
98
  let recTick = null;
95
- const recClock = () => { const st = dock.recordState(); return st.started ? fmtClock(Date.now() - st.started) : ''; };
99
+ const left = (until) => Math.max(1, Math.ceil((until - Date.now()) / 60000));
100
+ const chipText = (st) => (st.mode === 'recording' ? (st.started ? fmtClock(Date.now() - st.started) : '') : st.until ? 'paused · ' + left(st.until) + ' min left' : 'paused');
101
+ const tip = (b, text) => { b.title = text; b.setAttribute('aria-label', text); return b; };
102
+ const off = (b, why) => { b.setAttribute('aria-disabled', 'true'); b.classList.add('fr-rec__off'); tip(b, why); return b; };
103
+ const chipOf = (st) => {
104
+ const clock = h('span', { class: 'fr-rec__clock', 'data-fr-rec-clock': '' }, chipText(st));
105
+ const dotEl = h('span', { class: 'fr-rec-dot' + (st.mode === 'paused' ? ' fr-rec-dot--paused' : ''), 'aria-hidden': 'true' });
106
+ const b = button({ label: h('span', { class: 'fr-rec__txt' }, dotEl, h('b', { class: 'fr-rec__title' }, st.title), clock), variant: 'ghost',
107
+ attrs: { class: 'fr-rec__seg fr-rec__chip', 'data-fr-rec': st.mode, 'aria-label': (st.mode === 'recording' ? 'Recording ' : 'Paused: ') + st.title + '. Open the note.' }, onClick: () => dock.openDock(st.id) });
108
+ return b;
109
+ };
96
110
  function drawRecord() {
97
111
  const st = dock.recordState();
98
- const busy = st.mode === 'recording' && dockHighlight();
99
- const key = st.mode + ':' + st.id + ':' + (busy ? busy.state + busy.open : '');
112
+ const hl = st.mode === 'recording' ? dock.highlightBusy() : null;
113
+ const key = [st.mode, st.id, st.title, hl ? hl.state + hl.open : ''].join(':');
100
114
  if (key === recKey) return;
101
115
  recKey = key;
102
116
  clearInterval(recTick); recTick = null;
103
- if (st.mode === 'idle') {
104
- setChildren(recSlot, [button({ label: 'Record', variant: 'outline', icon: 'mic', attrs: { class: 'fr-rec__start', 'data-fr-rec': 'idle', title: 'Start a note and record the meeting' }, onClick: (e) => dock.recordNew(e && e.currentTarget) })]);
105
- return;
106
- }
107
- if (st.mode === 'paused') {
108
- setChildren(recSlot, [button({ label: 'Resume', variant: 'outline', icon: 'mic', attrs: { class: 'fr-rec__start', 'data-fr-rec': 'paused', title: 'Resume recording ' + (st.title || 'the note') }, onClick: () => dock.resumeNow() })]);
109
- return;
117
+ const kids = [];
118
+ const recording = st.mode === 'recording';
119
+ if (st.mode !== 'idle') {
120
+ const chip = chipOf(st);
121
+ const group = [chip];
122
+ if (recording) {
123
+ const hlb = iconButton({ icon: 'highlighter', ariaLabel: hl ? 'Highlight in progress: show it' : 'Highlight the last 30 seconds and what is said next',
124
+ attrs: { class: 'fr-rec__seg' + (hl ? ' fr-rec__seg--busy' : ''), 'data-fr-rec-highlight': hl ? hl.state : '' }, onClick: () => dock.highlightNow() });
125
+ group.push(hlb, iconButton({ icon: 'pause', ariaLabel: 'Pause (you can resume within 1 hour)', attrs: { class: 'fr-rec__seg', 'data-fr-rec-pause': '' }, onClick: () => dock.pauseNow() }));
126
+ } else {
127
+ group.push(iconButton({ icon: 'play', ariaLabel: 'Resume ' + st.title, attrs: { class: 'fr-rec__seg fr-rec__resume', 'data-fr-rec-resume': '' }, onClick: () => dock.resumeNow() }));
128
+ }
129
+ group.push(iconButton({ icon: 'square', ariaLabel: 'Stop for good (cannot be resumed)', attrs: { class: 'fr-rec__seg fr-rec__stop', 'data-fr-rec-stop': '' }, onClick: () => dock.stopNow() }));
130
+ kids.push(h('div', { class: 'fr-rec__group', role: 'group', 'aria-label': (recording ? 'Recording ' : 'Paused: ') + st.title }, group));
131
+ const clockNode = chip.querySelector('[data-fr-rec-clock]');
132
+ recTick = setInterval(() => { const s2 = dock.recordState(); if (clockNode) clockNode.textContent = chipText(s2); }, 1000);
110
133
  }
111
- const clock = h('span', { class: 'fr-rec__clock', 'data-fr-rec-clock': '' }, recClock());
112
- recTick = setInterval(() => { clock.textContent = recClock(); }, 1000);
113
- const open = button({ label: h('span', { class: 'fr-rec__txt' }, h('span', { class: 'fr-rec-dot', 'aria-hidden': 'true' }), clock), variant: 'ghost',
114
- attrs: { class: 'fr-rec__seg fr-rec__open', 'data-fr-rec': 'recording', 'aria-label': 'Recording ' + (st.title || 'a note') + '. Open the note.' }, onClick: () => dock.openDock(st.id) });
115
- const hl = iconButton({ icon: 'highlighter', ariaLabel: busy ? 'Highlight in progress: show it' : 'Highlight the last 30 seconds and what is said next',
116
- attrs: { class: 'fr-rec__seg' + (busy ? ' fr-rec__seg--busy' : ''), 'data-fr-rec-highlight': busy ? busy.state : '' }, onClick: () => dock.highlightNow() });
117
- const pause = iconButton({ icon: 'pause', ariaLabel: 'Pause recording', attrs: { class: 'fr-rec__seg', 'data-fr-rec-pause': '' }, onClick: () => dock.pauseNow() });
118
- setChildren(recSlot, [h('div', { class: 'fr-rec__group', role: 'group', 'aria-label': 'Recording' }, open, hl, pause)]);
134
+ const why = 'Pause or stop the meeting being recorded to start a new one';
135
+ const mic = iconButton({ icon: 'mic', ariaLabel: 'Record an untitled note', attrs: { class: 'fr-rec__seg fr-rec__new', 'data-fr-rec-new': '' }, onClick: (e) => { if (recording) return; dock.recordNew(e && e.currentTarget); } });
136
+ const arrow = iconButton({ icon: 'chevron-down', ariaLabel: 'Record for an upcoming meeting', size: 'sm', attrs: { class: 'fr-rec__seg fr-rec__upcoming', 'data-fr-rec-upcoming': '' },
137
+ onClick: (e) => { if (!recording) openUpcoming(e && e.currentTarget); } });
138
+ if (recording) { off(mic, why); off(arrow, why); }
139
+ kids.push(h('div', { class: 'fr-rec__group fr-rec__group--new', role: 'group', 'aria-label': 'New recording' }, mic, arrow));
140
+ const panel = recording ? dock.highlightPanel() : null;
141
+ if (panel) kids.push(panel);
142
+ setChildren(recSlot, kids);
143
+ }
144
+ /** The arrow beside New: Untitled note, then today's meetings that have not started and have no note, the next one marked. */
145
+ async function openUpcoming(anchor) {
146
+ const evs = upcomingWithoutNote(await upcomingToday());
147
+ const fmt = (e, i) => {
148
+ const c = contextOf(e) || {};
149
+ const mins = Math.max(1, Math.round((Date.parse(e.start) - Date.now()) / 60000));
150
+ const inText = mins < 60 ? 'in ' + mins + ' min' : 'in ' + Math.floor(mins / 60) + ' h' + (mins % 60 ? ' ' + (mins % 60) + ' min' : '');
151
+ const who = peopleOf(c); const extra = c.attendees ? ' (' + c.attendees + ' attendees)' : '';
152
+ const bits = [i === 0 ? 'Next' : null, inText, who.length ? who.slice(0, 2).join(', ') + (who.length > 2 ? ' +' + (who.length - 2) : '') : c.attendees ? c.attendees + ' attendees' : null, c.agenda && c.agenda.length ? c.agenda.length + ' agenda item' + (c.agenda.length === 1 ? '' : 's') : null].filter(Boolean);
153
+ void extra;
154
+ return { label: e.title, hint: e.time || '', detail: bits.join(' · '), attrs: { 'data-fr-rec-event': e.id || '' }, onSelect: () => dock.recordEvent(e) };
155
+ };
156
+ const items = [{ label: 'Untitled note', icon: 'plus', detail: 'not linked to a meeting', attrs: { 'data-fr-rec-untitled': '' }, onSelect: () => dock.recordNew() }, { sep: true },
157
+ ...(evs.length ? evs.map(fmt) : [{ label: 'No more meetings today', attrs: { 'data-fr-rec-none': '' }, onSelect: () => {} }])];
158
+ menu({ anchor, items, ariaLabel: 'Record a new meeting' });
119
159
  }
120
160
 
121
161
  let syncBtn = null;
@@ -130,11 +170,10 @@ export function createTopbar({ go, openPalette, openView, repoMenu, sync } = {})
130
170
  return b;
131
171
  }
132
172
  function syncButton(label, busy) {
133
- return button({
134
- label, variant: 'outline', size: 'sm', disabled: busy,
135
- attrs: { class: 'fr-sync', 'data-fr': 'sync', title: COPY.sync.title },
136
- onClick: () => sync && sync(),
137
- });
173
+ const b = iconButton({ icon: 'refresh-cw', ariaLabel: busy ? label : COPY.sync.title || label,
174
+ attrs: { class: 'fr-sync', 'data-fr': 'sync', 'data-fr-sync-label': label, 'aria-busy': busy ? 'true' : 'false' }, onClick: () => { if (!busy && sync) sync(); } });
175
+ if (busy) { b.setAttribute('aria-disabled', 'true'); b.classList.add('fr-sync--busy'); }
176
+ return b;
138
177
  }
139
178
 
140
179
  function update(m) {