create-pmos 0.129.0 → 0.131.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 (48) 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/c3-one-home-test.js +8 -3
  7. package/workspace/c3-work-test.js +200 -0
  8. package/workspace/fixtures/one-home-baseline.json +0 -91
  9. package/workspace/guide/briefing.md +60 -0
  10. package/workspace/guide/calendar-popup.md +46 -0
  11. package/workspace/guide/console-calendar.md +65 -0
  12. package/workspace/guide/index.md +4 -0
  13. package/workspace/guide/layout.md +1 -0
  14. package/workspace/guide/my-work.md +76 -1
  15. package/workspace/lib/calendar-layers.js +372 -0
  16. package/workspace/lib/pm-items.js +71 -6
  17. package/workspace/lib/report-needs.js +1 -1
  18. package/workspace/lib/route-modules.js +1 -0
  19. package/workspace/lib/routes-brief-page.js +187 -0
  20. package/workspace/lib/routes-calendar-views.js +112 -3
  21. package/workspace/lib/routes-pm-items.js +123 -13
  22. package/workspace/lib/work-focus.js +106 -0
  23. package/workspace/package-lock.json +2 -2
  24. package/workspace/package.json +1 -1
  25. package/workspace/public/pm/c3-brief-data.js +11 -0
  26. package/workspace/public/pm/c3-brief-needs.js +145 -0
  27. package/workspace/public/pm/c3-brief-pulse.js +70 -0
  28. package/workspace/public/pm/c3-brief-today.js +44 -0
  29. package/workspace/public/pm/c3-calendar.js +392 -0
  30. package/workspace/public/pm/c3-calpop.js +103 -0
  31. package/workspace/public/pm/c3-demo-brief-page.js +54 -0
  32. package/workspace/public/pm/c3-demo-cal4.js +114 -0
  33. package/workspace/public/pm/c3-demo-data.js +79 -77
  34. package/workspace/public/pm/c3-demo-misc2.js +4 -16
  35. package/workspace/public/pm/c3-demo-work-ix.js +19 -67
  36. package/workspace/public/pm/c3-demo.js +11 -4
  37. package/workspace/public/pm/c3-shell.js +27 -149
  38. package/workspace/public/pm/calendar-views.js +11 -3
  39. package/workspace/public/pm/components.css +1 -0
  40. package/workspace/public/pm/console3.css +198 -0
  41. package/workspace/public/pm/pm-work-view.js +364 -172
  42. package/workspace/public/pm/work-place.js +58 -0
  43. package/workspace/test-files.json +7 -1
  44. package/workspace/test-lists/calendar-layers.json +5 -0
  45. package/workspace/test-lists/calendar-time-page.json +5 -0
  46. package/workspace/test-lists/calpop.json +4 -0
  47. package/workspace/test-lists/console3-work-page.json +9 -0
  48. package/workspace/public/pm/c3-demo-work2.js +0 -13
@@ -5,26 +5,34 @@
5
5
  * (see pm/console3-lift.md). This file keeps the real data calls and draws what they return, nothing else.
6
6
  *
7
7
  * mountPmWork(host, ctx) draws once per host; disposePmWork(host) removes it.
8
+ * Views (initiative console3-work-page, planning/prd/console3-work-page-prd.md): Inbox, Today, Upcoming, Waiting, Someday and Review.
9
+ * The SERVER places every item and answers `work`, `review`, `focus` and `counts`; this file draws them and never copies an item from
10
+ * one view to another. Today opens with Focus (up to three jobs, each with the reason: Accept, Edit title, Not today). Upcoming groups
11
+ * the next 7 days by day, then Later. Review is a weekly pass, one item at a time (Keep, Change, Drop). An item done outside PMOS
12
+ * (`kind: offline`) shows the person and offers Done with an optional outcome, Not yet and Drop, and "Looks done?" with Confirm when
13
+ * it carries `evidence`; PMOS never closes it by itself.
8
14
  * Routes (lib/routes-pm-items.js). Every write is a page press through pmPost:
9
- * GET /api/pm-items POST /api/pm-items/{add,edit,remove,undo,done,reopen,park,unpark,move,steps/add,steps/who,steps/plan}
10
- * POST /api/pm-jobs/{add,edit,delete}
15
+ * GET /api/pm-items POST /api/pm-items/{add,edit,remove,undo,done,reopen,unpark,flag,defer,someday,waiting,notyet,keep,steps/add,steps/who,steps/plan}
16
+ * POST /api/pm-items/focus/{accept,dismiss,clear} POST /api/pm-jobs/{add,edit,delete}
11
17
  * A row opens its CASE FILE in place (c3-expand.js + c3-case.js: the job sentence, evidence, options with a recommendation, a slide to
12
- * confirm); the editable fields sit inside it behind "Edit details". A row swipes (c3-swipe.js) and takes keys: right = the right-hand
13
- * action of its list (done, in Today, ...), left = the left-hand one (later, tomorrow, ...); e and n do the same, j and k move, and the
14
- * arrows do the swipes. Every action offers Undo through the reverse route (reopen, unpark, move back, edit back, undo). Anything that
15
- * would send something to a person is an outward option: its slide is drawn disabled ("no channel connected"); nothing is sent.
16
- * In demo mode (/c3.html?demo=1, window.__c3demo) the answers of the writes are laid over the fixtures in memory (`applyOv`); the
17
- * agent run of a row with a suggested move is a mock that only walks the steps and shows a labelled draft.
18
- * Drawn from the items: the lens ring (done of total). The deck is ctx.plan, a node the page builds. The
19
- * microphone is drawn disabled.
18
+ * confirm), then the shared actions (Flag, Not before, Someday, Waiting, Drop); the editable fields sit inside it behind "Edit details".
19
+ * A row swipes (c3-swipe.js) and takes keys: right = the right-hand action of its view (done, in Today, ...), left = the left-hand one
20
+ * (tomorrow, Someday, ...); j and k move, and the arrows do the swipes. Every action offers Undo through the reverse route (reopen,
21
+ * unpark, flag back, defer back, waiting back, undo). Anything that would send something to a person is an outward option: its slide is
22
+ * drawn disabled ("no channel connected"); nothing is sent.
23
+ * In demo mode (/c3.html?demo=1, window.__c3demo) the answers of the writes are laid over the fixtures in memory (`applyOv`) and placed
24
+ * with work-place.js, the server's rule; the agent run of a row with a suggested move is a mock that only walks the steps and shows a
25
+ * labelled draft. The deck is ctx.plan, a node the page builds. The microphone is drawn disabled.
20
26
  *
21
27
  * The helpers c3ic, c3ring, ensureC3Css and c3Phone are shared with briefing-deck.js and calendar-views.js. */
22
28
  import { el } from './dom.js';
29
+ import { button } from './primitives.js';
23
30
  import { pmGet, pmPost } from './http.js';
24
31
  import { renderCase } from './c3-case.js';
25
32
  import { makeXp, attachExpand } from './c3-expand.js';
26
33
  import { attachSwipe } from './c3-swipe.js';
27
34
  import { toast } from './c3-toast.js';
35
+ import { VIEWS, REVIEW_DAYS, workPlace, arrives, buildWork, addIso, daysBetween, untouchedDays } from './work-place.js';
28
36
 
29
37
  /* ── shared with the Briefing and Calendar views: the stylesheet, the icon sprite, the ring, the phone class ───────── */
30
38
 
@@ -96,11 +104,12 @@ export function c3ib(label, icon, attrs, cls) {
96
104
 
97
105
  const API = '/api/pm-items';
98
106
  const JOBS = '/api/pm-jobs';
99
- const LENSES = [['triage', 'Triage'], ['today', 'Today'], ['week', 'Week'], ['waiting', 'Waiting'], ['later', 'Later']];
100
- const WLI = { triage: 'inbox', today: 'sun', week: 'cal', waiting: 'wait', later: 'moon' }; // console3.html line 3207
107
+ /* The six views (initiative console3-work-page, PRD R1): five places an open item can be, and the weekly Review pass. */
108
+ const LENSES = [['inbox', 'Inbox'], ['today', 'Today'], ['upcoming', 'Upcoming'], ['waiting', 'Waiting'], ['someday', 'Someday'], ['review', 'Review']];
109
+ const WLI = { inbox: 'inbox', today: 'sun', upcoming: 'cal', waiting: 'wait', someday: 'moon', review: 'eye' };
101
110
  const WHO = ['you', 'agent-drafts', 'agent-does'];
102
111
  const OUTWARD = /\b(send|sends|sending|email|emails|message|messages|tell|post|reply to)\b/i; // mirrors lib/item-steps.js
103
- const NEED = 'Needs the backend: no endpoint for this yet.';
112
+ const RESOLVES_TIP = 'Your work. The team task is separate: close it in its initiative.';
104
113
 
105
114
  export const COPY = Object.freeze({
106
115
  addPlaceholder: 'Add work: type or speak',
@@ -110,23 +119,48 @@ export const COPY = Object.freeze({
110
119
  addJob: 'Add a job', addJobPlaceholder: 'when I plan a release, I want honest dates, so Dana can trust them',
111
120
  groupJob: 'Group by job to be done', groupTime: 'Group by time', loading: 'Reading…',
112
121
  micOff: 'Speak is off on this line: on-device dictation runs in Notes, in the main app. Type the item here.',
113
- jobsNote: 'The count is every item in the job, across all lists. Tap a job to edit or delete it.',
122
+ jobsNote: 'The count is every item in the job, across all views. Tap a job to edit or delete it.',
114
123
  legend: ['open', 'done', 'migrated', 'scheduled'], readError: 'Your items could not be read.', saveError: 'Nothing was saved.',
115
- removed: 'Removed.', undo: 'Undo', restored: 'Restored.', tooLate: 'Too late to undo.', drop: 'Drop it',
124
+ removed: 'Removed.', undo: 'Undo', restored: 'Restored.', tooLate: 'Too late to undo.', drop: 'Drop',
116
125
  reassigned: (n) => (n === 1 ? '1 item now has no job.' : `${n} items now have no job.`),
117
126
  progress: (d, t) => `${d} of ${t} done`,
118
127
  jobLine: (j) => `When ${j.when}, I want ${j.want}, so ${j.so}.`,
119
128
  lens: {
120
- triage: (n) => `${n} to triage`, today: (n) => `${n} committed today`, week: (n) => `${n} land in the next 7 days`,
121
- waiting: (n) => `${n} waiting on others`, later: (n) => `${n} for later`,
129
+ inbox: (n) => `${n} to decide`, today: (n) => `${n} for today`, upcoming: (n) => `${n} in the days ahead`,
130
+ waiting: (n) => `${n} waiting on others`, someday: (n) => `${n} parked in Someday`, review: (n) => `${n} to review this week`,
122
131
  },
123
132
  lensSub: {
124
- triage: 'No date and nobody to wait on. Give each one a date, or park it.', today: 'Due today or overdue.',
125
- week: 'Due in the next 7 days.', waiting: 'Someone else must act first.', later: 'Dated further out.',
133
+ inbox: 'Captured, not decided. Give each one a day, a Not before date, a person to wait on, or park it in Someday.',
134
+ today: 'Overdue, due today and flagged. Focus says which jobs matter most today.',
135
+ upcoming: 'The next 7 days by day, then Later. A Not before date keeps an item out of Today until that day.',
136
+ waiting: 'Someone else must act first.',
137
+ someday: 'Parked on purpose. Nothing here asks for attention until the weekly Review.',
138
+ review: 'Items in Inbox, Waiting or Someday that nobody touched for 7 days, most stale first. Keep, change or drop each one.',
126
139
  },
127
- done: 'Done', park: 'Park', doneToast: 'Marked done.', parkToast: 'Parked in Later.', steps: 'Steps', noSteps: 'No steps yet.', addStep: 'Add a step',
140
+ emptyView: {
141
+ inbox: 'Inbox is empty. Capture anything that crosses your mind.', today: 'Nothing due, overdue or flagged. Pick from Upcoming when you are ready.',
142
+ upcoming: 'Nothing dated in the days ahead.', waiting: 'Nobody owes you anything right now.', someday: 'Nothing parked.',
143
+ },
144
+ done: 'Done', doneToast: 'Marked done.', steps: 'Steps', noSteps: 'No steps yet.', addStep: 'Add a step',
128
145
  who: { you: 'You', 'agent-drafts': 'Agent drafts', 'agent-does': 'Agent does' },
129
146
  whoOutward: 'Sends to a person: the agent drafts, you slide to send.', options: 'Options', plan: 'Plan the steps',
147
+ /* Focus (PRD R4) */
148
+ focus: 'Focus', focusSub: 'Up to three jobs for today, each with the reason.', focusNone: 'No focus suggested for today. Everything below still counts.',
149
+ focusPinned: 'Focus today', focusSuggested: 'Suggested', accept: 'Accept', editTitle: 'Edit title', notToday: 'Not today', unpin: 'Unpin', save: 'Save', cancel: 'Cancel',
150
+ why: (w) => 'Why: ' + w, focusAccepted: 'Pinned to Today.', focusDismissed: 'Not today. It does not come back today.', focusCleared: 'Unpinned.',
151
+ /* offline work and the shared actions (PRD R6) */
152
+ offline: 'outside PMOS', offlineNote: (p) => 'Happens outside PMOS' + (p ? ', with ' + p : '') + '. Tell PMOS how it went.',
153
+ outcome: 'What came out of it (optional)', notYet: 'Not yet:', laterToday: 'Later today', tomorrow: 'Tomorrow', nextWeek: 'Next week',
154
+ looksDone: 'Looks done?', confirm: 'Confirm done', flag: 'Flag', unflag: 'Unflag', notBefore: 'Not before', someday: 'Someday', bringBack: 'Bring back',
155
+ waitingField: 'Waiting on', waitingWho: 'Who?', stopWaiting: 'Stop waiting', flagged: 'Flagged',
156
+ notBeforeChip: (d) => 'Not before ' + d, advances: (t) => 'Advances: ' + t, moved: (n) => (n === 1 ? 'Moved once' : `Moved ${n} times`),
157
+ overdue: (n) => (n === 1 ? '1 day overdue' : `${n} days overdue`),
158
+ /* Review (PRD R7) */
159
+ reviewHead: 'Weekly review', keep: 'Keep', change: 'Change', next: 'Done changing, next', reviewDone: 'Review done',
160
+ reviewDoneText: (n) => `You looked at ${n} item${n === 1 ? '' : 's'}. Anything you changed or dropped has moved to its new view.`,
161
+ reviewNone: 'Nothing waits for review. Every item was touched in the last 7 days.', again: 'Start another pass',
162
+ untouched: (n) => `Untouched for ${n} days`, livesIn: (v) => 'Lives in ' + v, upNext: 'Up next: ',
163
+ kept: 'Kept. It comes back if nobody touches it for another week.',
130
164
  });
131
165
  const insts = new WeakMap();
132
166
 
@@ -135,37 +169,37 @@ const say = toast;
135
169
 
136
170
  /* ── the demo overlay ────────────────────────────────────────────────────────────────────────────────────────────── *
137
171
  * In /c3.html?demo=1 every write is answered { ok: true } and the fixtures never change, so the page keeps what the PM did in
138
- * memory (`inst.ov`) and lays it over each read. Real mode has no overlay: the server is the truth and every press re-reads. */
172
+ * memory (`inst.ov`) and lays it over each read, then places the items with the server's rule (work-place.js). Real mode has no
173
+ * overlay: the server places every item and every press re-reads. */
139
174
  const DEMO = () => typeof window !== 'undefined' && !!window.__c3demo;
140
- const newOv = () => ({ done: new Set(), gone: new Set(), lens: new Map(), edits: new Map(), added: [], steps: new Map(), who: new Map(), jobsAdded: [], jobEdits: new Map(), jobsGone: new Map(), seq: 0 });
141
- const LENS_IDS = LENSES.map((l) => l[0]);
175
+ const newOv = () => ({ done: new Set(), gone: new Set(), edits: new Map(), added: [], steps: new Map(), who: new Map(), focus: new Map(), jobsAdded: [], jobEdits: new Map(), jobsGone: new Map(), seq: 0 });
142
176
 
143
177
  function applyOv(base, ov) {
144
178
  const d = JSON.parse(JSON.stringify(base));
145
- const home = {};
146
- LENS_IDS.forEach((l) => (d.lists[l] || []).forEach((i) => { home[i.id] = l; }));
147
- const added = ov.added.map((a) => { home[a.item.id] = a.lens; return a.item; }).reverse();
148
179
  const jobOfItem = {};
149
- let items = [...added, ...(d.items || [])].filter((i) => !ov.gone.has(i.id));
150
180
  const wasDone = new Set((d.items || []).filter((i) => i.done).map((i) => i.id));
151
- items = items.map((i) => {
181
+ const items = [...ov.added.slice().reverse(), ...(d.items || [])].filter((i) => !ov.gone.has(i.id)).map((i) => {
152
182
  const m = { ...i, ...(ov.edits.get(i.id) || {}) };
153
183
  m.done = !!(i.done || ov.done.has(i.id));
154
184
  m.steps = [...(i.steps || []), ...(ov.steps.get(i.id) || [])].map((s) => (ov.who.has(s.id) ? { ...s, who: ov.who.get(s.id) } : s));
155
185
  jobOfItem[m.id] = m.job;
156
186
  return m;
157
187
  });
158
- const lists = {}; LENS_IDS.forEach((l) => { lists[l] = []; });
159
- items.forEach((i) => { const l = ov.lens.get(i.id) || home[i.id] || 'triage'; (lists[l] || lists.triage).push(i); });
160
- /* The mockup's Today (console3.html wItems, line 3215) is the work set for today AND the inbox items due today: an item due today shows in both lenses. */
161
- const dueToday = (i) => String(i.kind || '').toLowerCase() !== 'fyi' && ((i.due && i.due === d.today) || /^(\d\d:\d\d|today)$/.test(i.time || ''));
162
- lists.triage.filter((i) => !i.done && dueToday(i) && !lists.today.includes(i)).forEach((i) => lists.today.push(i));
163
- d.items = items; d.lists = lists;
164
- if (d.noise) d.noise = d.noise.filter((n) => !ov.done.has(n.id || 'noise-' + String(n.title).toLowerCase().replace(/[^a-z0-9]+/g, '-')));
188
+ const placed = buildWork(items, d.today, d.review_days || REVIEW_DAYS);
189
+ d.items = items; d.work = placed.work; d.review = placed.review; d.counts = placed.counts;
190
+ /* Focus: an accept pins, a dismiss hides for the day, a clear undoes either; a suggestion whose items are all closed goes */
191
+ const F = d.focus || { suggestions: [], accepted: [] };
192
+ const open = new Set(items.filter((i) => !i.done).map((i) => i.id));
193
+ const live = (f) => !(f.item_ids || []).length || f.item_ids.some((id) => open.has(id));
194
+ const st = (k) => ov.focus.get(k);
195
+ d.focus = {
196
+ accepted: [...(F.accepted || []).filter((a) => !st(a.key)), ...[...ov.focus.values()].filter((x) => x.state === 'acc').map((x) => x.f)],
197
+ suggestions: (F.suggestions || []).filter((s) => (!st(s.key) || st(s.key).state === 'clear') && live(s)).slice(0, 3),
198
+ };
165
199
  const jobs = [...(d.jobs || []), ...ov.jobsAdded].filter((j) => !ov.jobsGone.has(j.id)).map((j) => ({ ...j, ...(ov.jobEdits.get(j.id) || {}) }));
166
200
  jobs.forEach((j) => {
167
201
  j.done = (j.done || 0) + [...ov.done].filter((id) => !wasDone.has(id) && jobOfItem[id] === j.id).length;
168
- j.total = (j.total || 0) + ov.added.filter((a) => a.item.job === j.id).length;
202
+ j.total = (j.total || 0) + ov.added.filter((a) => a.job === j.id).length;
169
203
  });
170
204
  d.jobs = jobs;
171
205
  return d;
@@ -176,21 +210,30 @@ function applyLocal(inst, path, body, r) {
176
210
  const ov = inst.ov; const id = body && body.id;
177
211
  const today = (inst.base && inst.base.today) || iso(new Date());
178
212
  const cur = id && ((inst.data && inst.data.items) || []).find((i) => i.id === id);
213
+ const now = new Date().toISOString();
214
+ const edit = (f) => ov.edits.set(id, { ...(ov.edits.get(id) || {}), ...(f || {}), touched_at: now }); // the server stamps touched_at on every event
179
215
  switch (path.replace(API, '').replace(JOBS, '/job')) {
180
- case '/done': ov.done.add(id); break;
181
- case '/reopen': ov.done.delete(id); break;
182
- case '/park': ov.lens.set(id, 'later'); break;
183
- case '/unpark': ov.lens.delete(id); break;
184
- case '/move': ov.lens.set(id, body.list); break;
185
- case '/edit': {
186
- ov.edits.set(id, { ...(ov.edits.get(id) || {}), ...body.fields });
187
- if (cur && ('due' in body.fields || 'waiting_on' in body.fields)) ov.lens.set(id, lensOf({ ...cur, ...body.fields }, today));
216
+ case '/done': ov.done.add(id); edit(body.outcome ? { outcome: body.outcome } : {}); break;
217
+ case '/reopen': ov.done.delete(id); edit(); break;
218
+ case '/park': case '/someday': edit({ parked: true }); break;
219
+ case '/unpark': edit({ parked: false }); break;
220
+ case '/edit': edit(body.fields); break;
221
+ case '/flag': edit({ flagged: !!body.flagged }); break;
222
+ case '/defer': edit({ defer: body.until || null }); break;
223
+ case '/waiting': edit({ waiting_on: body.on || null }); break;
224
+ case '/notyet': {
225
+ const n = body.when === 'tomorrow' ? 1 : body.when === 'next-week' ? 7 : 0;
226
+ edit({ not_yet_count: ((cur && cur.not_yet_count) || 0) + 1, ...(n ? { defer: (r && r.defer) || addIso(today, n) } : {}) });
188
227
  break;
189
228
  }
229
+ case '/keep': edit(); break;
230
+ case '/focus/accept': ov.focus.set(body.key, { state: 'acc', f: { key: body.key, title: body.title, why: body.why, item_ids: body.item_ids || [], job: body.job || null } }); break;
231
+ case '/focus/dismiss': ov.focus.set(body.key, { state: 'no' }); break;
232
+ case '/focus/clear': ov.focus.set(body.key, { state: 'clear' }); break;
190
233
  case '/add': {
191
234
  const p = previewParse(body.line, today);
192
- const item = { ...(r.item || {}), id: (r.item && r.item.id) || 'item-demo-add' + (++ov.seq), title: p.title || String(body.line), due: p.due, priority: p.priority, waiting_on: p.waiting_on, job: null, steps: [], created_at: today + 'T05:00:00.000Z' };
193
- ov.added.push({ item, lens: lensOf(p, today) });
235
+ const item = { ...(r.item || {}), id: (r.item && r.item.id) || 'item-demo-add' + (++ov.seq), title: p.title || String(body.line), due: p.due, priority: p.priority, waiting_on: p.waiting_on, job: null, steps: [], created_at: now, touched_at: now };
236
+ ov.added.push(item);
194
237
  r.item = item;
195
238
  break;
196
239
  }
@@ -199,7 +242,6 @@ function applyLocal(inst, path, body, r) {
199
242
  case '/steps/add': { const l = ov.steps.get(id) || []; l.push({ id: 'step-demo' + (++ov.seq), text: body.text, who: 'you', done: false }); ov.steps.set(id, l); break; }
200
243
  case '/steps/who': ov.who.set(body.step_id, body.who); break;
201
244
  case '/steps/plan': {
202
- const t = (cur && cur.title) || 'the item';
203
245
  const l = ov.steps.get(id) || [];
204
246
  [['Read the item and what it links to', 'agent-does'], ['Draft the first pass or the message', 'agent-drafts'], ['Check it and decide', 'you']].forEach(([text, who]) => l.push({ id: 'step-demo' + (++ov.seq), text, who, done: false }));
205
247
  ov.steps.set(id, l);
@@ -227,8 +269,9 @@ export function mountPmWork(host, ctx) {
227
269
  if (inst) return;
228
270
  if (ctx && ctx.FIXTURE) return;
229
271
  ensureC3Css();
230
- inst = { host, ctx: ctx || {}, root: el('div', { class: 'c3 calwrap', 'data-pm-work': '' }), data: null, base: null, ov: newOv(), lens: 'triage', group: false,
231
- opens: new Set(), picks: new Map(), editing: new Set(), planning: new Set(), runs: new Map(), trays: new Set(), cases: new Set(), timers: new Set(), hidden: new Set(), jobNew: null, jobForm: false };
272
+ inst = { host, ctx: ctx || {}, root: el('div', { class: 'c3 calwrap', 'data-pm-work': '' }), data: null, base: null, ov: newOv(), lens: 'today', group: false,
273
+ opens: new Set(), picks: new Map(), editing: new Set(), planning: new Set(), runs: new Map(), trays: new Set(), cases: new Set(), timers: new Set(), hidden: new Set(), jobNew: null, jobForm: false,
274
+ focEdit: new Set(), rv: { ids: null, handled: new Set(), changing: false } };
232
275
  inst.body = el('div', { class: 'calbody pmw-body' });
233
276
  inst.bar = el('div', { class: 'calbar' });
234
277
  inst.addBox = addLine(inst);
@@ -236,6 +279,7 @@ export function mountPmWork(host, ctx) {
236
279
  inst.stopPhone = c3Phone(inst.root);
237
280
  insts.set(host, inst);
238
281
  host.appendChild(inst.root);
282
+ if (inst.ctx.onLens) inst.ctx.onLens(inst.lens); // the page's deck follows the view the page opens on
239
283
  load(inst);
240
284
  }
241
285
 
@@ -286,18 +330,34 @@ async function act(inst, rowEl, spec) {
286
330
  }
287
331
 
288
332
  const undoBy = (inst, route, body) => async () => !!(await press(inst, API + route, body, { quiet: true }));
333
+ /* Does the change move the item out of the view on screen? Then its row leaves; otherwise it stays and redraws. */
334
+ const leaves = (inst, item, patch) => inst.lens === 'review' || workPlace({ ...item, ...patch }, inst.data.today) !== inst.lens;
289
335
 
290
- const doneAct = (inst, item, row, text) => act(inst, row, { route: '/done', body: { id: item.id }, text: text || COPY.doneToast, undo: undoBy(inst, '/reopen', { id: item.id }) });
291
- const parkAct = (inst, item, row, text) => act(inst, row, { route: '/park', body: { id: item.id }, text: text || COPY.parkToast, undo: undoBy(inst, '/unpark', { id: item.id }) });
292
- const moveAct = (inst, item, row, list, text) => {
293
- const back = lensOfItem(inst, item.id);
294
- return act(inst, row, { route: '/move', body: { id: item.id, list }, text, undo: undoBy(inst, '/move', { id: item.id, list: back }) });
295
- };
296
- const dueAct = (inst, item, row, due, text) => act(inst, row, { route: '/edit', body: { id: item.id, fields: { due } }, text, undo: undoBy(inst, '/edit', { id: item.id, fields: { due: item.due || null } }) });
336
+ const doneAct = (inst, item, row, text, outcome) => act(inst, row, { route: '/done', body: outcome ? { id: item.id, outcome } : { id: item.id }, text: text || COPY.doneToast, undo: undoBy(inst, '/reopen', { id: item.id }) });
297
337
  const dropAct = (inst, item, row) => act(inst, row, { route: '/remove', body: { id: item.id }, text: COPY.removed, undo: undoBy(inst, '/undo', { id: item.id }) });
338
+ const todayAct = (inst, item, row) => act(inst, row, { route: '/edit', body: { id: item.id, fields: { due: inst.data.today, defer: null } }, text: 'In Today.',
339
+ undo: undoBy(inst, '/edit', { id: item.id, fields: { due: item.due || null, defer: item.defer || null } }), leave: leaves(inst, item, { due: inst.data.today, defer: null }) });
340
+ const flagAct = (inst, item, row) => { const on = !item.flagged; return act(inst, row, { route: '/flag', body: { id: item.id, flagged: on }, text: on ? 'Flagged for today.' : 'Unflagged.', undo: undoBy(inst, '/flag', { id: item.id, flagged: !on }), leave: leaves(inst, item, { flagged: on }) }); };
341
+ const deferAct = (inst, item, row, until) => act(inst, row, { route: '/defer', body: { id: item.id, until }, text: until ? COPY.notBeforeChip(dayLabel(until)) + '.' : 'Not before cleared.',
342
+ undo: undoBy(inst, '/defer', { id: item.id, until: item.defer || null }), leave: leaves(inst, item, { defer: until }) });
343
+ const somedayAct = (inst, item, row) => act(inst, row, { route: '/someday', body: { id: item.id }, text: 'Parked in Someday.', undo: undoBy(inst, '/unpark', { id: item.id }), leave: leaves(inst, item, { parked: true }) });
344
+ const unparkAct = (inst, item, row) => act(inst, row, { route: '/unpark', body: { id: item.id }, text: 'Back from Someday.', undo: undoBy(inst, '/someday', { id: item.id }), leave: leaves(inst, item, { parked: false }) });
345
+ const waitAct = (inst, item, row, on) => act(inst, row, { route: '/waiting', body: { id: item.id, on }, text: on ? 'Waiting on ' + on + '.' : 'No longer waiting.',
346
+ undo: undoBy(inst, '/waiting', { id: item.id, on: item.waiting_on || null }), leave: leaves(inst, item, { waiting_on: on }) });
347
+ function notYetAct(inst, item, row, when) {
348
+ const today = inst.data.today; const n = when === 'tomorrow' ? 1 : when === 'next-week' ? 7 : 0;
349
+ return act(inst, row, { route: '/notyet', body: { id: item.id, when }, text: n ? 'Not yet. Back in Today on ' + dayLabel(addIso(today, n)) + '.' : 'Not yet. It stays in Today for later.',
350
+ undo: undoBy(inst, '/defer', { id: item.id, until: item.defer || null }), leave: n ? leaves(inst, item, { defer: addIso(today, n) }) : false });
351
+ }
352
+
353
+ /* Focus (PRD R4): accept pins a job to Today, Not today hides it for the day, Unpin takes it back; each offers Undo. */
354
+ const focusBody = (f) => ({ key: f.key, title: f.title, why: f.why, item_ids: f.item_ids || [], ...(f.job ? { job: f.job } : {}) });
355
+ const focusAccept = (inst, f) => { inst.focEdit.delete(f.key); return act(inst, null, { route: '/focus/accept', body: focusBody(f), text: COPY.focusAccepted, undo: undoBy(inst, '/focus/clear', { key: f.key }), leave: false }); };
356
+ const focusDismiss = (inst, f) => act(inst, null, { route: '/focus/dismiss', body: { key: f.key }, text: COPY.focusDismissed, undo: undoBy(inst, '/focus/clear', { key: f.key }), leave: false });
357
+ const focusClear = (inst, f) => act(inst, null, { route: '/focus/clear', body: { key: f.key }, text: COPY.focusCleared, undo: undoBy(inst, '/focus/accept', focusBody(f)), leave: false });
298
358
 
299
359
  function lensOfItem(inst, id) {
300
- for (const l of LENS_IDS) if ((lists(inst)[l] || []).some((i) => i.id === id)) return l;
360
+ for (const l of VIEWS) if ((views(inst)[l] || []).some((i) => i.id === id)) return l;
301
361
  return inst.lens;
302
362
  }
303
363
 
@@ -319,9 +379,8 @@ function planSteps(inst, item) {
319
379
  const DAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
320
380
  const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
321
381
  const iso = (d) => d.toISOString().slice(0, 10);
322
- const addIso = (s, n) => { const d = new Date(s + 'T00:00:00Z'); d.setUTCDate(d.getUTCDate() + n); return iso(d); };
323
382
  const nextMonday = (s) => { const d = new Date(s + 'T00:00:00Z'); return addIso(s, ((8 - d.getUTCDay()) % 7) || 7); };
324
- const dayLabel = (s) => { const d = new Date(s + 'T00:00:00Z'); return DAYS[d.getUTCDay()].slice(0, 1).toUpperCase() + DAYS[d.getUTCDay()].slice(1, 3) + ' ' + d.getUTCDate() + ' ' + MON[d.getUTCMonth()]; };
383
+ const dayLabel = (s) => { const d = new Date(String(s).slice(0, 10) + 'T00:00:00Z'); return DAYS[d.getUTCDay()].slice(0, 1).toUpperCase() + DAYS[d.getUTCDay()].slice(1, 3) + ' ' + d.getUTCDate() + ' ' + MON[d.getUTCMonth()]; };
325
384
 
326
385
  function previewParse(text, todayIso) {
327
386
  let rest = String(text || '').trim();
@@ -346,24 +405,16 @@ function previewParse(text, todayIso) {
346
405
  return out;
347
406
  }
348
407
 
349
- function lensOf(o, todayIso) {
350
- if (o.waiting_on) return 'waiting';
351
- if (!o.due) return 'triage';
352
- if (o.due <= todayIso) return 'today';
353
- const w = new Date(todayIso + 'T00:00:00Z'); w.setUTCDate(w.getUTCDate() + 7);
354
- return o.due <= iso(w) ? 'week' : 'later';
355
- }
356
-
357
408
  const labelOf = (id) => (LENSES.find((l) => l[0] === id) || [])[1] || id;
358
409
  const small = 'font-size:var(--fs-2)';
359
410
 
360
- /* The mockup's c3prev(): "adds to", a .tag, then .chip2 / .fl / .st chips (console3.html line 4573). */
411
+ /* The mockup's c3prev(): "adds to", a .tag with the view the server will place the line in (PRD R2), then .chip2 / .fl / .st chips. */
361
412
  function previewChips(inst, text) {
362
413
  const today = (inst.data && inst.data.today) || iso(new Date());
363
414
  if (!text.trim()) return [el('span', { class: 'dim mono', style: small }, COPY.hint)];
364
415
  const o = previewParse(text, today);
365
416
  if (!o.title) return [el('span', { class: 'dim mono', style: small }, 'The line needs a title.')];
366
- const out = [el('span', { class: 'dim mono', style: small }, COPY.addsTo), el('span', { class: 'tag' }, labelOf(lensOf(o, today)))];
417
+ const out = [el('span', { class: 'dim mono wk-sm' }, COPY.addsTo), el('span', { class: 'tag', 'data-pm-lands': workPlace(o, today) }, labelOf(workPlace(o, today)))];
367
418
  if (o.due) out.push(el('span', { class: 'chip2' }, c3ic('cal', 'sm'), o.due));
368
419
  if (o.priority) out.push(el('span', { class: 'fl ' + o.priority.toLowerCase() }, o.priority));
369
420
  if (o.waiting_on) out.push(el('span', { class: 'chip2' }, c3ic('wait', 'sm'), COPY.waitingOn(o.waiting_on)));
@@ -379,12 +430,12 @@ function addLine(inst) {
379
430
  const send = async () => {
380
431
  const line = input.value.trim();
381
432
  if (!line) return;
382
- const before = inst.data && inst.data.today;
383
- const o = previewParse(line, before || iso(new Date()));
433
+ const before = (inst.data && inst.data.today) || iso(new Date());
434
+ const o = previewParse(line, before);
384
435
  const r = await press(inst, API + '/add', { line }, { quiet: true });
385
436
  if (!r) return;
386
437
  input.value = ''; chips.replaceChildren(...previewChips(inst, ''));
387
- inst.lens = lensOf(o, before || iso(new Date())); if (inst.ctx.onLens) inst.ctx.onLens(inst.lens); inst.group = false;
438
+ inst.lens = workPlace(o, before); if (inst.ctx.onLens) inst.ctx.onLens(inst.lens); inst.group = false;
388
439
  if (r.item) inst.opens.add(r.item.id);
389
440
  say({ text: 'Added to ' + labelOf(inst.lens), timeout: r.undo_ms || 6000, actionLabel: COPY.undo, onAction: async () => { if (r.item) await press(inst, API + '/remove', { id: r.item.id }, { quiet: true }); await load(inst); } });
390
441
  await load(inst);
@@ -402,11 +453,28 @@ function addLine(inst) {
402
453
  /* ── rows and the case file ──────────────────────────────────────────────────────────────────────────────────────── */
403
454
 
404
455
  const jobById = (inst, id) => (inst.data.jobs || []).find((j) => j.id === id);
405
- const lists = (inst) => inst.data.lists || {};
406
- const shown = (inst, id) => (lists(inst)[id] || []).filter((i) => !inst.hidden.has(i.id) && !i.done);
456
+ /* The five places, as the server answers them (`work`). An older server that sends only `lists` is read through the same five names. */
457
+ function views(inst) {
458
+ const d = inst.data || {};
459
+ if (d.work) return d.work;
460
+ const L = d.lists || {};
461
+ return { inbox: L.triage || [], today: L.today || [], upcoming: [...(L.week || []), ...(L.later || []).filter((i) => !i.parked)], waiting: L.waiting || [], someday: (L.later || []).filter((i) => i.parked) };
462
+ }
463
+ const shown = (inst, id) => ((id === 'review' ? (inst.data || {}).review : views(inst)[id]) || []).filter((i) => !inst.hidden.has(i.id) && !i.done);
464
+ const countOf = (inst, id) => { const c = (inst.data || {}).counts; return c && typeof c[id] === 'number' ? c[id] : shown(inst, id).length; };
465
+ const itemById = (inst, id) => ((inst.data && inst.data.items) || []).find((i) => i.id === id)
466
+ || [...VIEWS.map((v) => views(inst)[v] || []).flat(), ...((inst.data && inst.data.review) || [])].find((i) => i.id === id);
467
+ /* The title of the team task a Work item advances (PRD R8): the item's own copy, then a map the answer may carry, else the id. */
468
+ const resolvesTitle = (inst, item) => item.resolves_title || ((inst.data && inst.data.resolves_titles) || {})[item.resolves] || item.resolves;
407
469
 
408
470
  /* The mockup's .c3jf: a label (.dim.mono) and an input on one line (console3.html line 4651). */
409
471
  function field(label, input) { return el('label', null, el('span', { class: 'dim mono' }, label), input); }
472
+ /* A text button of the Work page (the mockup's .pill). */
473
+ function pill(label, icon, attrs) {
474
+ const b = button({ label, variant: 'ghost', attrs: { ...(attrs || {}), class: 'pill' } });
475
+ if (icon) b.prepend(c3ic(icon, 'sm'));
476
+ return b;
477
+ }
410
478
 
411
479
  function editField(inst, item, label, shortLabel, kind, key, value) {
412
480
  const input = el(kind === 'select' ? 'select' : 'input', { 'aria-label': label, 'data-pm-edit': item.id + ':' + key, ...(kind === 'date' ? { type: 'date' } : kind === 'text' ? { type: 'text' } : {}) });
@@ -426,6 +494,15 @@ function editField(inst, item, label, shortLabel, kind, key, value) {
426
494
  return field(shortLabel, input);
427
495
  }
428
496
 
497
+ /* The editable fields of an item (title, due, not before, priority, waiting on, job): in the case file behind "Edit details", and Review's Change. */
498
+ const editForm = (inst, item) => el('div', { class: 'c3jf', 'data-pm-edit-form': item.id },
499
+ editField(inst, item, 'Title', 'Title', 'text', 'title', item.title),
500
+ editField(inst, item, 'Due', 'Due', 'date', 'due', item.due),
501
+ editField(inst, item, COPY.notBefore, COPY.notBefore, 'date', 'defer', item.defer),
502
+ editField(inst, item, 'Priority', 'Priority', 'select', 'priority', item.priority),
503
+ editField(inst, item, 'Waiting on', 'Waiting', 'text', 'waiting_on', item.waiting_on),
504
+ editField(inst, item, 'Job', 'Job', 'select', 'job', item.job));
505
+
429
506
  /* The mockup's c3stepsHtml(): .c3steps > .c3sh + ul > li (.c3sg, .c3stt, .chips2 with the who pill) + an add input (line 4595). */
430
507
  function stepsBlock(inst, item) {
431
508
  if (inst.planning.has(item.id)) return el('div', { class: 'fold a', 'data-pm-planning': item.id, role: 'status' }, c3ic('spark', 'sm'), 'The agent is reading the item and planning the steps');
@@ -452,48 +529,78 @@ function stepsBlock(inst, item) {
452
529
  el('div', { class: 'form' }, input));
453
530
  }
454
531
 
532
+ /* ── the shared actions and offline work (PRD R6) ──────────────────────────────────────────────────────────────────── */
533
+
534
+ const rowOf = (inst, item) => inst.root.querySelector('[data-pm-item="' + item.id + '"]');
535
+
536
+ /** Flag, Not before, Someday, Waiting and Drop: every item has them in its case file. Each goes through act(), so each offers Undo. */
537
+ function quickActs(inst, item, { noDrop } = {}) {
538
+ const today = inst.data.today;
539
+ const date = el('input', { type: 'date', 'aria-label': COPY.notBefore, 'data-pm-defer': item.id, min: addIso(today, 1) });
540
+ date.value = item.defer && item.defer > today ? item.defer : '';
541
+ date.addEventListener('change', () => { if (date.value && date.value !== item.defer) deferAct(inst, item, rowOf(inst, item), date.value); });
542
+ const who = el('input', { type: 'text', placeholder: COPY.waitingWho, 'aria-label': COPY.waitingField, 'data-pm-wait-on': item.id, autocomplete: 'off' });
543
+ who.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.isComposing && who.value.trim()) waitAct(inst, item, rowOf(inst, item), who.value.trim()); });
544
+ return el('div', { class: 'wkq', 'data-pm-quick': item.id },
545
+ pill(item.flagged ? COPY.unflag : COPY.flag, 'flag', { 'data-pm-flag': item.id, 'aria-pressed': String(!!item.flagged), onclick: () => flagAct(inst, item, rowOf(inst, item)) }),
546
+ el('label', { class: 'wkq-f' }, el('span', { class: 'dim mono' }, COPY.notBefore), date),
547
+ item.parked ? pill(COPY.bringBack, 'sun', { 'data-pm-unpark': item.id, onclick: () => unparkAct(inst, item, rowOf(inst, item)) })
548
+ : pill(COPY.someday, 'moon', { 'data-pm-someday': item.id, onclick: () => somedayAct(inst, item, rowOf(inst, item)) }),
549
+ item.waiting_on ? pill(COPY.stopWaiting, 'wait', { 'data-pm-unwait': item.id, onclick: () => waitAct(inst, item, rowOf(inst, item), null) })
550
+ : el('label', { class: 'wkq-f' }, el('span', { class: 'dim mono' }, COPY.waitingField), who),
551
+ noDrop ? null : pill(COPY.drop, 'trash', { 'data-pm-drop': item.id, onclick: () => dropAct(inst, item, rowOf(inst, item)) }));
552
+ }
553
+
554
+ /** An item done outside PMOS: who it involves, Done with an optional outcome, Not yet, Drop, and "Looks done?" when there is proof.
555
+ * PMOS never closes it by itself: Confirm is the PM's press. */
556
+ function offlineBlock(inst, item) {
557
+ const out = el('input', { type: 'text', placeholder: COPY.outcome, 'aria-label': COPY.outcome, 'data-pm-outcome': item.id, autocomplete: 'off' });
558
+ const doneNow = () => { const v = out.value.trim(); return doneAct(inst, item, rowOf(inst, item), v ? 'Done. The outcome is saved.' : 'Done.', v || undefined); };
559
+ out.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.isComposing) doneNow(); });
560
+ return el('div', { class: 'wkoff', 'data-pm-offline': item.id },
561
+ el('p', { class: 'wkoff-n' }, c3ic('user', 'sm'), COPY.offlineNote(item.person)),
562
+ item.evidence ? el('div', { class: 'wkev', 'data-pm-evidence': item.id }, c3ic('eye', 'sm'), el('span', { class: 'wk-min' }, el('b', null, COPY.looksDone + ' '), item.evidence),
563
+ pill(COPY.confirm, 'check', { class: 'pill pri', 'data-pm-confirm': item.id, onclick: () => doneAct(inst, item, rowOf(inst, item), 'Done. You confirmed it.') })) : null,
564
+ el('div', { class: 'wkq' }, el('span', { class: 'form wkoff-o' }, out), pill(COPY.done, 'check', { class: 'pill pri', 'data-pm-offline-done': item.id, onclick: doneNow })),
565
+ el('div', { class: 'wkq' }, el('span', { class: 'dim mono' }, COPY.notYet),
566
+ ...[['later-today', COPY.laterToday], ['tomorrow', COPY.tomorrow], ['next-week', COPY.nextWeek]].map(([w, l]) => pill(l, null, { 'data-pm-notyet': w, onclick: () => notYetAct(inst, item, rowOf(inst, item), w) })),
567
+ pill(COPY.drop, 'trash', { 'data-pm-drop': item.id, onclick: () => dropAct(inst, item, rowOf(inst, item)) })));
568
+ }
569
+
455
570
  /* ── the case model of one item (console3.html genTask line 3216, agDecor line 5126) ─────────────────────────────────────────── */
456
571
 
457
572
  const OUT_REASON = 'No channel connected, so this stays a draft. Nothing is sent.';
458
573
  const fx = (...a) => a;
459
574
 
460
- /** The options of an item by its list. Each: { id, l, rec, fx, outward, run(inst, item, row) } where run does the work through a press route. */
575
+ /** The options of an item by its view. Each: { id, l, rec, fx, outward, run(row) } where run does the work through a press route.
576
+ * An offline item has none: it acts through its own block (Done, Not yet, Looks done). */
461
577
  function lensOptions(inst, item, lens) {
462
- const mock = item.mock_row === true; const q = mock && item.mins <= 2; const who = item.waiting_on || 'them';
463
- const today = inst.data.today; const mon = nextMonday(today);
578
+ const who = item.waiting_on || 'them';
579
+ const today = inst.data.today; const tom = addIso(today, 1); const mon = nextMonday(today);
464
580
  const o = (id, l, rec, f, run, extra) => ({ id, l, rec: !!rec, fx: f, run, ...(extra || {}) });
465
- const nowO = (rec) => o('now', q ? 'Do it now: the agent drafts, you send' : (mock ? 'Do it now' : 'Mark it done'), rec, q ? [fx('bolt', 'Under 2 minutes', 'ok')] : [fx('clock', mock && item.mins ? 'Takes ' + item.mins + ' min' : 'Closes the item', mock ? 'warn' : 'ok')], (r) => doneAct(inst, item, r, q ? 'Done. Anything outward stays a draft for you to send.' : 'Done.'));
466
- const todayO = (rec) => o('today', mock ? (q ? 'Put it in Today' : 'Put it in Today and block time') : 'Put it in Today', rec, mock && !q ? [fx('check', 'Gets a slot', 'ok')] : [fx('check', 'Moves to your Today list', 'ok')], (r) => moveAct(inst, item, r, 'today', 'In Today.'));
467
- const schedO = () => o('sched', 'Schedule for ' + dayLabel(mon), 0, [fx('cal', 'Out of today', 'ok')], (r) => dueAct(inst, item, r, mon, 'Scheduled for ' + dayLabel(mon) + '.'));
581
+ if (item.kind === 'offline') return { conf: 0, cta: '', opts: [] };
582
+ const doneO = (rec) => o('done', 'Mark it done', rec, [fx('check', 'Closes the item', 'ok')], (r) => doneAct(inst, item, r, 'Done.'));
583
+ const todayO = (rec) => o('today', 'Do it today', rec, [fx('sun', 'Moves to Today', 'ok')], (r) => todayAct(inst, item, r));
584
+ const deferO = (d, rec) => o('defer-' + d, COPY.notBeforeChip(dayLabel(d)), rec, [fx('moon', 'Out of Today until then')], (r) => deferAct(inst, item, r, d));
585
+ const somedayO = () => o('someday', 'Park it in Someday', 0, [fx('moon', 'Back in the weekly Review')], (r) => somedayAct(inst, item, r));
468
586
  const dropO = () => o('drop', 'Drop it', 0, [fx('x', 'Gone, with Undo', 'warn')], (r) => dropAct(inst, item, r));
469
587
  if (lens === 'waiting') {
470
588
  return { conf: 80, cta: 'Slide to nudge', sit: 'Waiting on ' + who + '. ' + (item.why || 'No reply yet.'), opts: [
471
589
  o('nudge', 'Nudge ' + who + ' now (drafted)', 1, [fx('out', 'Goes to Review first', 'ok')], null, { outward: true }),
472
- o('esc', 'Escalate to their lead', 0, [fx('alert', 'Stronger signal', 'warn')], null, { outward: true }),
590
+ o('unwait', 'Stop waiting: it is yours again', 0, [fx('check', 'Leaves Waiting', 'ok')], (r) => waitAct(inst, item, r, null)),
473
591
  o('wait', 'Keep waiting', 0, [fx('clock', 'Ages another day', 'warn')], () => say({ text: 'Still waiting.', timeout: 2500 })),
474
592
  ] };
475
593
  }
476
- if (lens === 'later') return { conf: 66, cta: 'Slide to promote', sit: 'Parked for later. Nothing is due, so it only matters if it has a date.', opts: [
477
- o('promo', 'Promote to this week', 1, [fx('check', 'Gets a date', 'ok')], (r) => moveAct(inst, item, r, 'week', 'Moved to Week.')), dropO()] };
478
- if (lens === 'week') return { conf: 70, cta: 'Slide to keep the date', opts: [
479
- o('today', 'Bring it into Today', 0, [fx('clock', 'Earlier than planned', 'warn')], (r) => moveAct(inst, item, r, 'today', 'In Today.')),
480
- o('keep', 'Keep the date', 1, [fx('check', 'On plan', 'ok')], () => say({ text: 'Kept.', timeout: 2500 })),
481
- o('later', 'Move to Later', 0, [fx('moon', 'No date')], (r) => moveAct(inst, item, r, 'later', 'Moved to Later.'))] };
482
- if (lens === 'today') return { conf: 82, cta: 'Slide to finish', opts: [nowO(1),
483
- o('mig', 'Migrate to tomorrow', 0, [fx('moon', 'Back tomorrow 09:00', 'warn')], (r) => dueAct(inst, item, r, addIso(today, 1), 'Migrated to tomorrow.')), schedO()] };
484
- return { conf: q ? 90 : 78, cta: q ? 'Slide to do it now' : 'Slide to commit', opts: q ? [nowO(1), todayO(0), schedO(), dropO()] : [todayO(1), nowO(0), schedO(), dropO()] };
594
+ if (lens === 'someday') return { conf: 66, cta: 'Slide to bring it back', sit: 'Parked on purpose. It only matters again when you give it a date.', opts: [
595
+ o('back', 'Bring it back', 1, [fx('check', 'Placed by its dates again', 'ok')], (r) => unparkAct(inst, item, r)), dropO()] };
596
+ if (lens === 'upcoming') return { conf: 70, cta: 'Slide to keep the date', opts: [
597
+ o('keep', 'Keep the date', 1, [fx('check', 'On plan', 'ok')], () => say({ text: 'Kept.', timeout: 2500 })), todayO(0), somedayO()] };
598
+ if (lens === 'today') return { conf: 82, cta: 'Slide to finish', opts: [doneO(1), deferO(tom, 0), deferO(mon, 0)] };
599
+ return { conf: 78, cta: 'Slide to decide', opts: [todayO(1), deferO(mon, 0), somedayO(), dropO()] };
485
600
  }
486
601
 
487
602
  /** { model, run } for an item: the model goes to renderCase(), `run(id, row)` does what the picked option says. */
488
603
  function itemCase(inst, item, lens) {
489
- if (item.noise) { // the mockup's genTask() for an FYI item (line 3232): the agent read it and found nothing for the PM
490
- const o = (id, l, rec, f, run) => ({ id, l, rec: !!rec, fx: f, run });
491
- const L = [o('arch', 'Archive it', 1, [fx('check', 'Keeps the inbox clean', 'ok')], (r) => doneAct(inst, item, r, 'Archived.')), o('keep', 'Keep it', 0, [fx('moon', 'Stays in the inbox')], () => say({ text: 'Kept.', timeout: 2500 }))];
492
- const model = { id: 'task:' + item.id, sit: 'Noise. Nothing in it needs you, and the agent has already read it.', chg: [['clock', 'About under 2 min', 'effort']], conf: 92, cta: 'Slide to archive', unsure: '', ign: '', outboxReason: OUT_REASON,
493
- opts: L.map((x) => ({ id: x.id, l: x.l, rec: x.rec, fx: x.fx, outward: false })), why: '' };
494
- const run = {}; L.forEach((x) => { run[x.id] = x.run; });
495
- return { model, run };
496
- }
497
604
  const ix = item.ix || {}; const mock = item.mock_row === true;
498
605
  const j = item.job && jobById(inst, item.job);
499
606
  const L = lensOptions(inst, item, lens);
@@ -503,14 +610,16 @@ function itemCase(inst, item, lens) {
503
610
  if (ix.why) c.push(['info', ix.why[0], ix.why[1] || '']);
504
611
  else if (item.why) c.push(['info', item.why, item.src || '']);
505
612
  if (j) c.push(['target', 'Job: ' + jm, 'JTBD']);
613
+ if (item.resolves) c.push(['init', COPY.advances(resolvesTitle(inst, item)), 'team task']);
506
614
  if (mock && item.mins) c.push(['clock', 'About ' + (item.mins <= 2 ? 'under 2' : item.mins) + ' min', 'effort']);
507
615
  if (item.due) c.push(['cal', 'Due ' + item.due, '']);
616
+ if (item.defer && item.defer > inst.data.today) c.push(['moon', COPY.notBeforeChip(dayLabel(item.defer)), '']);
508
617
  if (item.priority && !mock) c.push(['flag', 'Priority ' + item.priority, '']);
509
618
  if (item.waiting_on) c.push(['wait', 'Waiting on ' + item.waiting_on, '']);
510
619
  return c.slice(0, 3);
511
620
  })();
512
621
  const model = {
513
- id: 'task:' + item.id, sit: ix.sit || (j ? COPY.jobLine(j) : (item.why || (mock ? item.title : 'No job to be done yet. File it under one below, or leave it.'))),
622
+ id: 'task:' + item.id, sit: ix.sit || L.sit || (j ? COPY.jobLine(j) : (item.why || (mock ? item.title : 'No job to be done yet. File it under one below, or leave it.'))),
514
623
  chg: evidence, conf: ix.conf || L.conf, cta: L.cta, unsure: ix.unsure || '', ign: ix.ign || '', outboxReason: OUT_REASON,
515
624
  opts: L.opts.map((x) => ({ id: x.id, l: x.l, rec: x.rec, fx: x.fx, outward: !!x.outward })), why: '',
516
625
  };
@@ -532,7 +641,7 @@ function itemCase(inst, item, lens) {
532
641
  run.accept = (r) => doneAct(inst, item, r, 'Accepted. Recorded as your press.');
533
642
  run.keep = () => say({ text: 'Kept.', timeout: 2500 });
534
643
  } else {
535
- model.move = ix.move + ' \u00b7 swipe right, or slide below';
644
+ model.move = ix.move + ' · swipe right, or slide below';
536
645
  model.opts = [{ id: 'agmv', l: 'Let the agent act: ' + ix.move, rec: 1, fx: [fx('spark', 'The suggested move, taken by an agent', 'ok'), fx('shield', 'Inside the repo; outward only as a draft', 'ok')] }, ...model.opts.map((x) => ({ ...x, rec: 0 }))];
537
646
  model.cta = 'Slide to let the agent act';
538
647
  run.agmv = () => { say({ text: 'The agent is working on it. Anything outward stays a draft for your slide.', timeout: 4000 }); startRun(inst, item, 'res'); }; // the toast is the case's slide; a swipe starts the run without one, as the mockup does
@@ -558,16 +667,15 @@ function startRun(inst, item, intent) {
558
667
  }
559
668
 
560
669
 
561
- /** The case file element of an item: c3-case.js plus what is Work's own (the steps, the agent tray, the edit fields). */
670
+ /** The case file element of an item: c3-case.js plus what is Work's own (offline work, the shared actions, the steps, the agent tray, the edit fields). */
562
671
  function caseFile(inst, item, lens) {
563
672
  const { model, run } = itemCase(inst, item, lens);
564
673
  const root = renderCase(model, {
565
674
  picked: inst.picks.get(item.id), outbox: false,
566
675
  onPick: (id) => inst.picks.set(item.id, id),
567
676
  onConfirm: ({ option }) => {
568
- const row = inst.root.querySelector('[data-pm-item="' + item.id + '"]');
569
677
  const fn = run[option.id];
570
- if (fn) fn(row);
678
+ if (fn) fn(rowOf(inst, item));
571
679
  },
572
680
  });
573
681
  root.setAttribute('data-pm-case', item.id);
@@ -578,6 +686,7 @@ function caseFile(inst, item, lens) {
578
686
  const extras = [];
579
687
  const rs = inst.runs.get(item.id);
580
688
  const agent = item.mock_row && item.agent_move && (item.ix || {}).move;
689
+ if (item.kind === 'offline') extras.push(offlineBlock(inst, item));
581
690
  if (rs && rs.phase === 'work') {
582
691
  extras.push(el('div', { class: 'c3ag', 'data-ag': item.id }, el('div', { class: 'fold a', role: 'status' }, el('span', { class: 'c3spin' }, c3ic('spark', 'sm')), rs.steps[Math.min(rs.i, rs.steps.length - 1)] + ' · ' + (Math.min(rs.i, rs.steps.length - 1) + 1) + ' of ' + rs.steps.length,
583
692
  el('button', { type: 'button', class: 'chip2 lk', 'data-pm-agent-stop': item.id, onclick: () => { inst.runs.delete(item.id); say({ text: 'Stopped. Nothing was kept.', timeout: 2500 }); draw(inst); } }, c3ic('stop', 'sm'), 'stop'))));
@@ -592,18 +701,13 @@ function caseFile(inst, item, lens) {
592
701
  c3ib('Speak is off on this line: nothing on this page records. Type the reply here.', 'mic', { 'data-off': '', onclick: () => say({ text: 'The microphone needs the app. Nothing was recorded: type the reply here.', timeout: 4200 }) }, 'sm'),
593
702
  c3ib('Close the reply', 'x', { onClick: () => { inst.trays.delete(item.id); draw(inst); } }, 'sm')));
594
703
  }
595
- const showSteps = !(rs && rs.phase) && (inst.planning.has(item.id) || (item.steps || []).length || (!agent && (!item.mock_row || item.src === 'You')));
704
+ const showSteps = !(rs && rs.phase) && item.kind !== 'offline' && (inst.planning.has(item.id) || (item.steps || []).length || (!agent && (!item.mock_row || item.src === 'You')));
596
705
  if (showSteps) extras.push(stepsBlock(inst, item));
597
706
  const anchor = root.querySelector('.c3ag') || root.querySelector('.sit');
598
707
  if (anchor) anchor.after(...extras); else root.prepend(...extras);
599
- if (item.noise) return root; // the mockup's noise case has no edit form
600
- /* the editable fields stay in the case file behind an Edit button (title, due, priority, waiting on, job) */
601
- const form = el('div', { class: 'c3jf', 'data-pm-edit-form': item.id, hidden: !inst.editing.has(item.id) },
602
- editField(inst, item, 'Title', 'Title', 'text', 'title', item.title),
603
- editField(inst, item, 'Due', 'Due', 'date', 'due', item.due),
604
- editField(inst, item, 'Priority', 'Priority', 'select', 'priority', item.priority),
605
- editField(inst, item, 'Waiting on', 'Waiting', 'text', 'waiting_on', item.waiting_on),
606
- editField(inst, item, 'Job', 'Job', 'select', 'job', item.job));
708
+ root.append(quickActs(inst, item, { noDrop: item.kind === 'offline' }));
709
+ /* the editable fields stay in the case file behind an Edit button (title, due, not before, priority, waiting on, job) */
710
+ const form = editForm(inst, item); form.hidden = !inst.editing.has(item.id);
607
711
  const edit = el('button', { type: 'button', class: 'chip2 lk', 'aria-expanded': String(inst.editing.has(item.id)), 'data-pm-edit-toggle': item.id,
608
712
  onclick: () => { const on = !inst.editing.has(item.id); if (on) inst.editing.add(item.id); else inst.editing.delete(item.id); form.hidden = !on; edit.setAttribute('aria-expanded', String(on)); } }, c3ic('edit', 'sm'), 'Edit details');
609
713
  const ft = root.querySelector('.cft');
@@ -615,7 +719,6 @@ function caseFile(inst, item, lens) {
615
719
  /* ── swipe: the mockup's workSwipe (console3.html line 3250) and the agent's swapped glyphs (line 5136) ─────────────────────── */
616
720
 
617
721
  function swipeSpec(inst, item, lens) {
618
- if (item.noise) { const a = { label: 'Archive', icon: 'check', fn: (r) => doneAct(inst, item, r, 'Archived.') }; return { right: a, left: { ...a, icon: 'moon' } }; } // the mockup archives a noise row either way
619
722
  const mock = item.mock_row === true; const today = inst.data.today;
620
723
  if (mock && item.agent_move && (item.ix || {}).move) {
621
724
  return {
@@ -623,16 +726,15 @@ function swipeSpec(inst, item, lens) {
623
726
  left: { label: 'Reply', icon: 'reply', fn: () => { if (inst.runs.get(item.id) && inst.runs.get(item.id).phase === 'work') { say({ text: 'A session is already working on this.', timeout: 2500 }); return; } inst.trays.add(item.id); inst.opens.add(item.id); draw(inst); later(inst, () => { const i = inst.root.querySelector('[data-pm-agent-reply="' + item.id + '"]'); if (i) i.focus(); }, 30); } },
624
727
  };
625
728
  }
626
- const q = mock && item.mins <= 2;
627
- if (lens === 'triage') return {
628
- right: q ? { label: 'Done', icon: 'check', fn: (r) => doneAct(inst, item, r, 'Done.') } : { label: 'In Today', icon: 'sun', fn: (r) => moveAct(inst, item, r, 'today', 'In Today.') },
629
- left: { label: 'Later', icon: 'moon', fn: (r) => parkAct(inst, item, r, 'Moved to Later.') } };
630
- if (lens === 'today') return { right: { label: 'Done', icon: 'check', fn: (r) => doneAct(inst, item, r, 'Done.') }, left: { label: 'Tomorrow', icon: 'moon', fn: (r) => dueAct(inst, item, r, addIso(today, 1), 'Migrated to tomorrow.') } };
729
+ const someday = { label: 'Someday', icon: 'moon', fn: (r) => somedayAct(inst, item, r) };
730
+ const inToday = { label: 'In Today', icon: 'sun', fn: (r) => todayAct(inst, item, r) };
731
+ if (lens === 'inbox' || lens === 'upcoming') return { right: inToday, left: someday };
732
+ if (lens === 'today') return { right: { label: 'Done', icon: 'check', fn: (r) => doneAct(inst, item, r, 'Done.') },
733
+ left: item.kind === 'offline' ? { label: 'Not yet', icon: 'moon', fn: (r) => notYetAct(inst, item, r, 'tomorrow') } : { label: 'Tomorrow', icon: 'moon', fn: (r) => deferAct(inst, item, r, addIso(today, 1)) } };
631
734
  if (lens === 'waiting') return {
632
735
  right: { label: 'Nudge', icon: 'out', fn: () => say({ text: 'No channel connected: a nudge to ' + (item.waiting_on || 'them') + ' is not sent.', timeout: 4000 }) },
633
736
  left: { label: 'Keep waiting', icon: 'wait', fn: () => say({ text: 'Still waiting.', timeout: 2500 }) } };
634
- if (lens === 'week') return { right: { label: 'In Today', icon: 'sun', fn: (r) => moveAct(inst, item, r, 'today', 'In Today.') }, left: { label: 'Later', icon: 'moon', fn: (r) => parkAct(inst, item, r, 'Moved to Later.') } };
635
- return { right: { label: 'To Week', icon: 'cal', fn: (r) => moveAct(inst, item, r, 'week', 'Promoted to Week.') }, left: { label: 'Drop', icon: 'trash', fn: (r) => dropAct(inst, item, r) } };
737
+ return { right: { label: 'Bring back', icon: 'sun', fn: (r) => unparkAct(inst, item, r) }, left: { label: 'Drop', icon: 'trash', fn: (r) => dropAct(inst, item, r) } };
636
738
  }
637
739
 
638
740
  /* Open or close a case file in place with c3-expand.js, so the mockup's .xp height transition (grid-template-rows) plays. */
@@ -659,6 +761,19 @@ const KIND_ICON = { reply: 'reply', decide: 'scale', review: 'eye', do: 'check'
659
761
  /* The mockup's time-left bar (.fuse) with its text, for an item that carries `left` ({ text, pct, tone }). */
660
762
  const fuseBar = (l) => { const c = l.tone === 'warn' ? 'var(--warning)' : 'var(--acc)'; return el('div', { class: 'chips2', style: 'margin-top:6px;gap:8px' }, el('div', { class: 'fuse' }, el('i', { style: 'width:' + Math.round(l.pct * 100) + '%;background:' + c })), el('span', { class: 'mono', style: 'font-size:var(--fs-1);color:' + c }, l.text)); };
661
763
 
764
+ /* The chips the Work page adds to a row: outside PMOS and the person, Not before, Flagged, how often it moved, Looks done, the team task it advances. */
765
+ function workChips(inst, item) {
766
+ const today = inst.data.today; const c = [];
767
+ if (item.kind === 'offline') c.push(el('span', { class: 'tag wk-off', 'data-pm-offline-tag': item.id }, COPY.offline), el('span', { class: 'chip2' }, c3ic('user', 'sm'), item.person || 'someone'));
768
+ if (item.defer && item.defer > today) c.push(el('span', { class: 'tag wk-nb', 'data-pm-notbefore': item.id }, COPY.notBeforeChip(dayLabel(item.defer))));
769
+ if (item.flagged) c.push(el('span', { class: 'chip2 wk-fl', 'data-pm-flagged': item.id }, c3ic('flag', 'sm'), COPY.flagged));
770
+ if (item.due && String(item.due).slice(0, 10) < today) c.push(el('span', { class: 'st bad' }, c3ic('alert', 'sm'), COPY.overdue(daysBetween(item.due, today))));
771
+ if (item.not_yet_count) c.push(el('span', { class: 'tag' }, COPY.moved(item.not_yet_count)));
772
+ if (item.kind === 'offline' && item.evidence) c.push(el('span', { class: 'st warn', 'data-pm-looks-done': item.id }, c3ic('eye', 'sm'), COPY.looksDone));
773
+ if (item.resolves) c.push(el('span', { class: 'chip2', title: RESOLVES_TIP, 'data-pm-resolves': item.resolves }, c3ic('init', 'sm'), COPY.advances(resolvesTitle(inst, item))));
774
+ return c;
775
+ }
776
+
662
777
  /* The mockup's workRow(): li.nrow.wrow > .rv.l, .rv.r (made by the swipe), .ki, .nb (.tt, .chips, .xp > .xi), and the .sg glyphs (line 3258). */
663
778
  function itemRow(inst, item, lens) {
664
779
  const j = item.job && jobById(inst, item.job);
@@ -673,9 +788,10 @@ function itemRow(inst, item, lens) {
673
788
  if (mock && item.agent_move && !(rs && rs.phase === 'done')) chips.push(el('span', { class: 'chip2', style: 'gap:4px', title: 'Swipe right to let the agent act' }, c3ic('spark', 'sm'), 'agent move'));
674
789
  if (mock && item.mins) chips.push(el('span', { class: 'chip2', title: 'Effort' }, c3ic('clock', 'sm'), item.mins <= 2 ? 'under 2 min' : item.mins + ' min'));
675
790
  if (mock && item.time) chips.push(el('span', { class: 'chip2' }, c3ic('cal', 'sm'), item.time));
676
- if (item.due && !mock) chips.push(el('span', { class: 'chip2' }, c3ic('cal', 'sm'), item.due));
677
- if (item.priority && !mock) chips.push(el('span', { class: 'fl ' + item.priority.toLowerCase() }, item.priority));
791
+ if (item.due) chips.push(el('span', { class: 'chip2' }, c3ic('cal', 'sm'), item.due));
792
+ if (item.priority) chips.push(el('span', { class: 'fl ' + item.priority.toLowerCase() }, item.priority));
678
793
  if (item.waiting_on) chips.push(el('span', { class: 'chip2' }, c3ic('wait', 'sm'), COPY.waitingOn(item.waiting_on)));
794
+ chips.push(...workChips(inst, item));
679
795
  if ((item.steps || []).length) { const dn = item.steps.filter((s) => s.done).length; const ag = item.steps.filter((s) => s.who !== 'you' && !s.done).length; chips.push(el('span', { class: 'chip2', title: 'Steps' }, c3ic('tree', 'sm'), dn + ' of ' + item.steps.length + ' steps' + (ag ? ' · agent can do ' + ag : ''))); }
680
796
  if (j) chips.push(el('span', { class: 'chip2 jc', title: j.want }, c3ic('target', 'sm'), mock && j.mock_name ? j.mock_name : j.want));
681
797
  if (mock && item.src) chips.push(el('span', { class: 'tag' }, item.src));
@@ -683,10 +799,10 @@ function itemRow(inst, item, lens) {
683
799
  const title = el('span', { class: 'tt', 'data-pm-title': item.id }, item.title);
684
800
  const spec = swipeSpec(inst, item, lens);
685
801
  const recL = itemCase(inst, item, lens).model.opts.find((o) => o.rec);
686
- const recSide = agent ? 'r' : (recL && /^(drop|arch|later|mig|wait|keep)/.test(recL.id) ? 'l' : 'r');
802
+ const recSide = agent ? 'r' : (recL && /^(drop|keep|wait|defer|someday)/.test(recL.id) ? 'l' : 'r');
803
+ const icon = mock ? (KIND_ICON[item.kind] || 'check') : item.kind === 'offline' ? 'user' : item.flagged ? 'flag' : item.priority === 'P1' ? 'alert' : 'todo';
687
804
  const row = el('li', { class: 'nrow wrow', 'data-row': '', 'data-id': item.id, 'data-w': '1', 'data-pm-item': item.id, 'data-lens': lens },
688
- el('span', { class: 'ki' + (item.priority === 'P1' ? ' hot' : ''), title: item.priority || item.kind || '', style: mock && item.mins <= 2 && item.kind !== 'fyi' ? 'background:var(--accw);color:var(--acc)' : null },
689
- c3ic(mock ? (item.mins <= 2 && item.kind !== 'fyi' ? 'bolt' : (KIND_ICON[item.kind] || (item.kind === 'fyi' ? 'info' : 'check'))) : item.priority === 'P1' ? 'alert' : 'todo')),
805
+ el('span', { class: 'ki' + (item.priority === 'P1' || item.flagged ? ' hot' : ''), title: item.kind === 'offline' ? 'Happens outside PMOS' : item.priority || '' }, c3ic(icon)),
690
806
  el('div', { class: 'nb' }, title, mock && item.left ? fuseBar(item.left) : null, chips.length ? el('div', { class: 'chips' }, chips) : null),
691
807
  el('span', { class: 'sg', title: agent ? 'Drag right: the agent acts. Drag left: reply.' : 'Drag right: ' + spec.right.label + '. Drag left: ' + spec.left.label + '.' },
692
808
  el('span', { class: 'g l' + (recSide === 'l' ? ' y' : '') }, c3ic(spec.left.icon)), el('span', { class: 'g r' + (recSide === 'r' ? ' y' : '') }, c3ic(spec.right.icon))));
@@ -709,6 +825,87 @@ function itemRow(inst, item, lens) {
709
825
  return row;
710
826
  }
711
827
 
828
+ /* ── Focus: up to three jobs for today, with the reason (PRD R4) ──────────────────────────────────────────────────── */
829
+
830
+ function focusCard(inst) {
831
+ const F = inst.data.focus || { suggestions: [], accepted: [] };
832
+ const acc = F.accepted || []; const sug = (F.suggestions || []).slice(0, 3);
833
+ const one = (f, pinned) => {
834
+ const editing = inst.focEdit.has(f.key);
835
+ const input = editing ? el('input', { type: 'text', 'aria-label': COPY.editTitle, 'data-pm-focus-input': f.key, autocomplete: 'off' }) : null;
836
+ if (input) input.value = f.title;
837
+ const save = () => focusAccept(inst, { ...f, title: (input && input.value.trim()) || f.title });
838
+ const cancel = () => { inst.focEdit.delete(f.key); draw(inst); };
839
+ const edit = () => { inst.focEdit.add(f.key); draw(inst); later(inst, () => { const i = inst.root.querySelector('[data-pm-focus-input="' + f.key + '"]'); if (i) i.focus(); }, 30); };
840
+ if (input) input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.isComposing) save(); else if (e.key === 'Escape') cancel(); });
841
+ const job = f.job && jobById(inst, f.job);
842
+ const refs = (f.item_ids || []).map((id) => itemById(inst, id)).filter((i) => i && !i.done && i.title !== f.title); // a one-item job does not list itself
843
+ const btns = editing ? [pill(COPY.save, 'check', { class: 'pill pri', 'data-pm-focus-save': f.key, onclick: save }), pill(COPY.cancel, null, { 'data-pm-focus-cancel': f.key, onclick: cancel })]
844
+ : pinned ? [pill(COPY.editTitle, 'edit', { 'data-pm-focus-edit': f.key, onclick: edit }), pill(COPY.unpin, 'x', { 'data-pm-focus-clear': f.key, onclick: () => focusClear(inst, f) })]
845
+ : [pill(COPY.accept, 'check', { class: 'pill pri', 'data-pm-focus-accept': f.key, onclick: () => focusAccept(inst, f) }), pill(COPY.editTitle, 'edit', { 'data-pm-focus-edit': f.key, onclick: edit }),
846
+ pill(COPY.notToday, 'moon', { 'data-pm-focus-dismiss': f.key, onclick: () => focusDismiss(inst, f) })];
847
+ return el('div', { class: 'wkfc' + (pinned ? ' on' : ''), 'data-pm-focus-card': f.key, 'data-pm-focus-state': pinned ? 'accepted' : 'suggested' },
848
+ el('div', { class: 'chips2' }, el('span', { class: 'tag' + (pinned ? ' wk-on' : '') }, pinned ? COPY.focusPinned : COPY.focusSuggested),
849
+ job ? el('span', { class: 'chip2' }, c3ic('target', 'sm'), job.mock_name || job.want) : null),
850
+ editing ? el('span', { class: 'form' }, input) : el('b', { class: 'wkfc-t', 'data-pm-focus-title': f.key }, f.title),
851
+ f.why ? el('p', { class: 'wkfc-why' }, COPY.why(f.why)) : null,
852
+ refs.length ? el('ul', { class: 'wkfc-r', role: 'list' }, refs.map((i) => el('li', null, c3ic('next', 'sm'), el('span', null, i.title)))) : null,
853
+ el('div', { class: 'wkq' }, btns));
854
+ };
855
+ return el('section', { class: 'card sect wkfoc', 'data-pm-focus': '' },
856
+ el('div', { class: 'sh2' }, el('h2', null, c3ic('target', 'sm'), COPY.focus), el('span', { class: 'dim wk-s' }, COPY.focusSub)),
857
+ acc.length || sug.length ? el('div', { class: 'wkfg' }, ...acc.map((f) => one(f, true)), ...sug.map((f) => one(f, false))) : el('div', { class: 'dim mono wk-s', 'data-pm-focus-none': '' }, COPY.focusNone));
858
+ }
859
+
860
+ /* ── Upcoming: the next 7 days by day, then Later (PRD R5) ─────────────────────────────────────────────────────────── */
861
+
862
+ function upcomingMain(inst, list) {
863
+ const today = inst.data.today;
864
+ const block = (key, label, sub, its) => el('div', { class: 'wkday', 'data-pm-day': key },
865
+ el('h3', null, label, sub ? el('span', { class: 'dim' }, sub) : null, its.length ? el('span', { class: 'bdg g' }, String(its.length)) : null),
866
+ its.length ? el('ul', { class: 'list', role: 'list' }, its.map((i) => itemRow(inst, i, 'upcoming'))) : el('div', { class: 'wkday-none' }, 'Nothing'));
867
+ const at = (i) => { const a = arrives(i, today); return a ? Math.max(1, daysBetween(today, a)) : 99; };
868
+ const days = [];
869
+ for (let n = 1; n <= 7; n++) { const d = addIso(today, n); days.push(block(d, n === 1 ? 'Tomorrow' : dayLabel(d).slice(0, 3), n === 1 ? dayLabel(d).slice(4) : dayLabel(d).slice(4), list.filter((i) => at(i) === n))); }
870
+ days.push(block('later', 'Later', 'beyond 7 days', list.filter((i) => at(i) > 7)));
871
+ const sec = sect(el('div', { class: 'sh2' }, el('h2', null, c3ic('cal', 'sm'), labelOf('upcoming'), ' ', el('span', { class: 'bdg g' }, String(list.length)))), el('div', { class: 'wkdays' }, days));
872
+ sec.setAttribute('data-pm-list', 'upcoming');
873
+ return sec;
874
+ }
875
+
876
+ /* ── Review: a weekly pass, one item at a time (PRD R7) ────────────────────────────────────────────────────────────── */
877
+
878
+ function reviewMain(inst) {
879
+ const today = inst.data.today; const q = shown(inst, 'review'); const R = inst.rv;
880
+ const inQ = new Set(q.map((i) => i.id));
881
+ if (!R.ids) { R.ids = q.map((i) => i.id); R.handled = new Set(); R.changing = false; }
882
+ q.forEach((i) => { if (!R.ids.includes(i.id)) R.ids.push(i.id); });
883
+ const curId = R.ids.find((id) => !R.handled.has(id) && inQ.has(id)) || null;
884
+ const total = R.ids.length; const doneN = R.ids.filter((id) => R.handled.has(id) || !inQ.has(id)).length;
885
+ const restart = () => { R.ids = null; draw(inst); };
886
+ if (!curId) {
887
+ return el('section', { class: 'card sect wkrv', 'data-pm-review-done': '' }, el('p', { class: 'wkrv-t' }, total ? COPY.reviewDone : labelOf('review')),
888
+ el('p', { class: 'wk-s' }, total ? COPY.reviewDoneText(total) : COPY.reviewNone),
889
+ q.length ? el('div', { class: 'wkq' }, pill(COPY.again, 'undo', { 'data-pm-review-again': '', onclick: restart })) : null);
890
+ }
891
+ const item = q.find((i) => i.id === curId);
892
+ const next = () => { R.handled.add(curId); R.changing = false; draw(inst); };
893
+ const place = workPlace(item, today);
894
+ const upNext = R.ids.filter((id) => id !== curId && !R.handled.has(id) && inQ.has(id)).slice(0, 2).map((id) => (q.find((i) => i.id === id) || {}).title).filter(Boolean);
895
+ return el('section', { class: 'card sect wkrv', 'data-pm-review': curId },
896
+ el('div', { class: 'wkrv-p' }, el('span', { 'data-pm-review-progress': '' }, (doneN + 1) + ' of ' + total), el('span', null, COPY.reviewHead)),
897
+ el('progress', { class: 'wk-bar', value: String(doneN), max: String(Math.max(1, total)), 'aria-label': doneN + ' of ' + total + ' reviewed' }), // Review's progress bar
898
+ el('p', { class: 'wkrv-t', 'data-pm-title': curId }, item.title),
899
+ el('div', { class: 'chips' }, el('span', { class: 'tag' }, COPY.untouched(untouchedDays(item, today))), el('span', { class: 'tag' }, COPY.livesIn(labelOf(place))),
900
+ item.waiting_on ? el('span', { class: 'chip2' }, c3ic('wait', 'sm'), COPY.waitingOn(item.waiting_on)) : null, ...workChips(inst, item)),
901
+ R.changing ? el('div', { class: 'wkrv-x' }, editForm(inst, item), el('div', { class: 'wkq' }, pill(COPY.next, 'next', { class: 'pill pri', 'data-pm-review-next': curId, onclick: next })))
902
+ : el('div', { class: 'wkq' },
903
+ pill(COPY.keep, 'check', { class: 'pill pri', 'data-pm-review-keep': curId, onclick: async () => { if (await act(inst, null, { route: '/keep', body: { id: curId }, text: COPY.kept, leave: false })) { R.handled.add(curId); draw(inst); } } }),
904
+ pill(COPY.change, 'edit', { 'data-pm-review-change': curId, onclick: () => { R.changing = true; draw(inst); } }),
905
+ pill(COPY.drop, 'trash', { 'data-pm-review-drop': curId, onclick: async () => { if (await dropAct(inst, item, null)) { R.handled.add(curId); draw(inst); } } })),
906
+ upNext.length ? el('p', { class: 'dim wk-s' }, COPY.upNext + upNext.join(' · ')) : null);
907
+ }
908
+
712
909
  /* ── jobs ────────────────────────────────────────────────────────────────────────────────────────────────────────── */
713
910
 
714
911
  function jobEditField(inst, job, key) {
@@ -724,44 +921,35 @@ function jobEditField(inst, job, key) {
724
921
  return field(lab, input);
725
922
  }
726
923
 
727
- /* The mockup's c3jall(): "All N items, across every list", grouped by list (line 4657). */
924
+ /* The mockup's c3jall(): "All N items, across every view", grouped by view (line 4657). */
728
925
  function jobAll(inst, job) {
729
- const fx2 = DEMO() && window.__c3work2 && window.__c3work2.jobAll && window.__c3work2.jobAll[job.id]; // demo: the mockup's own items of the job, grouped by list
730
- if (fx2) {
731
- const n = fx2.reduce((a, g) => a + g[1].length, 0);
732
- return el('div', { class: 'c3all', 'data-pm-job-all': job.id }, el('div', { class: 'hd dim mono' }, 'All ' + n + ' items, across every list'),
733
- ...fx2.map(([tag, titles, dn]) => el('div', null, el('span', { class: 'tag' }, tag), el('span', null, ...titles.map((t) => el('span', { class: dn ? 'dn' : '' }, t))))));
734
- }
735
926
  const its = (inst.data.items || []).filter((i) => i.job === job.id);
736
927
  if (!its.length) return el('div', { class: 'dim mono', style: small }, 'No item uses this job yet.');
737
928
  const G = {};
738
929
  its.forEach((i) => { const l = i.done ? 'done' : lensOfItem(inst, i.id); (G[l] = G[l] || []).push(i); });
739
- return el('div', { class: 'c3all', 'data-pm-job-all': job.id }, el('div', { class: 'hd dim mono' }, 'All ' + its.length + ' items, across every list'),
740
- ...[...LENS_IDS, 'done'].filter((l) => G[l]).map((l) => el('div', null, el('span', { class: 'tag' }, l === 'done' ? 'Done' : labelOf(l)), el('span', null, ...G[l].map((i) => el('span', { class: i.done ? 'dn' : '' }, i.title))))));
930
+ return el('div', { class: 'c3all', 'data-pm-job-all': job.id }, el('div', { class: 'hd dim mono' }, 'All ' + its.length + ' items, across every view'),
931
+ ...[...VIEWS, 'done'].filter((l) => G[l]).map((l) => el('div', null, el('span', { class: 'tag' }, l === 'done' ? 'Done' : labelOf(l)), el('span', null, ...G[l].map((i) => el('span', { class: i.done ? 'dn' : '' }, i.title))))));
741
932
  }
742
933
 
743
934
  /* The job's case file (mockup C3.cases.job, line 4660): the words, the count, Keep / Delete and move / Delete and leave, slide to confirm. */
744
935
  function jobCase(inst, j) {
745
936
  const its = (inst.data.items || []).filter((i) => i.job === j.id);
746
937
  const done = its.filter((i) => i.done).length;
747
- const mk = !!j.mock_name; // demo: the mockup's own numbers, wording and Recommended badge
748
938
  const others = (inst.data.jobs || []).filter((q) => q.id !== j.id); const near = others[0];
749
939
  const nm = (q) => q.mock_name || q.want;
750
940
  const del = (reassign) => async () => { const r = await press(inst, JOBS + '/delete', { id: j.id, ...(reassign ? { reassign_to: reassign } : {}) }); if (r) say({ text: COPY.reassigned(r.reassigned || 0), timeout: 4000 }); };
751
- const model = { id: 'job:' + j.id, sit: COPY.jobLine(j), conf: 90, cta: mk ? 'Slide to delete' : 'Slide to keep it',
752
- chg: [['target', mk ? (j.done + ' of ' + j.total + ' done') : (done + ' of ' + its.length + ' done'), 'count across every list', 'ok'], ['info', 'Change the words below. They save at once and you can undo for 7 seconds.', '']],
941
+ const model = { id: 'job:' + j.id, sit: COPY.jobLine(j), conf: 90, cta: 'Slide to keep it',
942
+ chg: [['target', done + ' of ' + its.length + ' done', 'count across every view', 'ok'], ['info', 'Change the words below. They save at once and you can undo for 7 seconds.', '']],
753
943
  opts: [{ id: 'keep', l: 'Keep it', rec: 1, fx: [fx('check', 'Nothing changes', 'ok')] },
754
- { id: 'mv', l: near ? 'Delete it and move its ' + (mk ? j.total : its.length) + ' item' + ((mk ? j.total : its.length) === 1 ? '' : 's') + ' to "' + nm(near) + '"' : 'Delete it', rec: 0, fx: [fx('trash', 'The job is gone', 'warn'), fx('target', (mk ? j.total : its.length) + ' items change job', 'warn')] },
755
- { id: 'nj', l: 'Delete it and leave its items without a job', rec: 0, fx: [fx('trash', 'The job is gone', 'warn'), fx('alert', mk ? 'The agent will suggest a job again' : 'The items have no job', '')] }] };
944
+ { id: 'mv', l: near ? 'Delete it and move its ' + its.length + ' item' + (its.length === 1 ? '' : 's') + ' to "' + nm(near) + '"' : 'Delete it', rec: 0, fx: [fx('trash', 'The job is gone', 'warn'), fx('target', its.length + ' items change job', 'warn')] },
945
+ { id: 'nj', l: 'Delete it and leave its items without a job', rec: 0, fx: [fx('trash', 'The job is gone', 'warn'), fx('alert', 'The items have no job', '')] }] };
756
946
  const run = { keep: () => say({ text: 'Kept.', timeout: 2500 }), mv: del(near && near.id), nj: del(null) };
757
947
  const root = renderCase(model, { onConfirm: ({ option }) => { if (run[option.id]) run[option.id](); } });
758
948
  root.setAttribute('data-pm-case', j.id);
759
- if (!mk) { // a real job carries no confidence figure: never show a made-up percentage
760
- root.querySelectorAll('.opt .rk').forEach((n) => { n.replaceChildren(c3ic('spark', 'sm'), 'Suggested'); n.removeAttribute('title'); });
761
- root.querySelectorAll('.opt .conf').forEach((n) => n.remove());
762
- }
949
+ root.querySelectorAll('.opt .rk').forEach((n) => { n.replaceChildren(c3ic('spark', 'sm'), 'Suggested'); n.removeAttribute('title'); }); // a job carries no confidence figure: never show a made-up percentage
950
+ root.querySelectorAll('.opt .conf').forEach((n) => n.remove());
763
951
  const sit = root.querySelector('.sit');
764
- sit.after(el('div', { class: 'c3jf' }, mk ? jobEditField(inst, j, 'mock_name') : null, jobEditField(inst, j, 'when'), jobEditField(inst, j, 'want'), jobEditField(inst, j, 'so')), jobAll(inst, j));
952
+ sit.after(el('div', { class: 'c3jf' }, j.mock_name ? jobEditField(inst, j, 'mock_name') : null, jobEditField(inst, j, 'when'), jobEditField(inst, j, 'want'), jobEditField(inst, j, 'so')), jobAll(inst, j));
765
953
  return root;
766
954
  }
767
955
 
@@ -823,36 +1011,41 @@ function jobsCard(inst) {
823
1011
 
824
1012
  /* ── the page ────────────────────────────────────────────────────────────────────────────────────────────────────── */
825
1013
 
826
- /* The mockup's workHead() calbar: the .seg lens bar with count badges, and the group toggle as an .ib (line 3281). */
1014
+ /* The mockup's workHead() calbar: the .seg view bar with a count on every view, and the group toggle as an .ib (line 3281). */
827
1015
  function lensBar(inst) {
828
- const seg = el('div', { class: 'seg', role: 'group', 'aria-label': 'Work lens', 'data-pm-lenses': '' },
1016
+ const seg = el('div', { class: 'seg', role: 'group', 'aria-label': 'Work view', 'data-pm-lenses': '' },
829
1017
  LENSES.map(([id, label]) => {
830
- const n = inst.data ? shown(inst, id).length : 0;
1018
+ const n = inst.data ? countOf(inst, id) : 0;
831
1019
  const b = el('button', { type: 'button', 'aria-pressed': String(inst.lens === id), 'data-lens': id }, c3ic(WLI[id], 'sm'), label, n ? el('span', { class: 'bdg g' }, String(n)) : null);
832
1020
  b.addEventListener('click', () => { inst.lens = id; if (inst.ctx.onLens) inst.ctx.onLens(id); draw(inst); }); // onLens: the page's deck follows the lens (demo)
1021
+ /* every view shows its count, also 0 */
1022
+ const badge = b.querySelector('.bdg') || b.appendChild(el('span', { class: 'bdg g' }, '0'));
1023
+ badge.setAttribute('data-pm-count', id);
833
1024
  return b;
834
1025
  }));
835
- const grp = c3ib(inst.group ? COPY.groupTime : COPY.groupJob, 'target', { 'aria-pressed': String(inst.group), 'data-pm-group': '' }, 'sm');
1026
+ const grp = c3ib(inst.group ? COPY.groupTime : COPY.groupJob, 'target', { 'aria-pressed': String(inst.group), 'data-pm-group': '', disabled: inst.lens === 'review' }, 'sm');
836
1027
  grp.addEventListener('click', () => { inst.group = !inst.group; draw(inst); });
837
1028
  return [seg, el('div', { class: 'chips2' }, grp)];
838
1029
  }
839
1030
 
840
- /* The ring at the right of the lens header: done of total, over the items in the lens (the lists keep done items; the rows hide them). */
841
- function lensRing(inst) {
842
- const all = lists(inst)[inst.lens] || [];
843
- return c3ring(all.filter((i) => i.done).length, all.length, 44);
1031
+ /* The ring at the right of Today's header: done today of done today plus what is still in Today. */
1032
+ function todayRing(inst) {
1033
+ const d = inst.data;
1034
+ const doneToday = (d.items || []).filter((i) => i.done && String(i.done_at || '').slice(0, 10) === d.today).length;
1035
+ return c3ring(doneToday, doneToday + shown(inst, 'today').length, 44);
844
1036
  }
845
1037
 
846
1038
  /* A .card.sect with an .sh2 title row and a ul.list of rows (line 3262). */
847
1039
  function sect(head, rows) { return el('section', { class: 'card sect' }, head, rows); }
848
1040
 
849
- const FOCUS_ATTRS = ['data-pm-edit', 'data-pm-step-add', 'data-pm-job-edit', 'data-pm-agent-reply', 'data-pm-job-new', 'aria-label'];
1041
+ const FOCUS_ATTRS = ['data-pm-edit', 'data-pm-step-add', 'data-pm-job-edit', 'data-pm-agent-reply', 'data-pm-job-new', 'data-pm-outcome', 'data-pm-wait-on', 'data-pm-focus-input'];
850
1042
 
851
1043
  function draw(inst) {
1044
+ if (inst.lens !== inst.prevLens) { if (inst.lens === 'review') inst.rv.ids = null; inst.prevLens = inst.lens; } // a fresh Review pass each time the view is opened
852
1045
  /* keep the focus: a field the PM is typing in, or the row they were on, is found again after the redraw */
853
1046
  const a = document.activeElement; let keep = null;
854
1047
  if (a && inst.root.contains(a) && a !== document.body) {
855
- const at = FOCUS_ATTRS.slice(0, 5).find((k) => a.hasAttribute && a.hasAttribute(k));
1048
+ const at = FOCUS_ATTRS.find((k) => a.hasAttribute && a.hasAttribute(k));
856
1049
  if (at) keep = { sel: '[' + at + '="' + a.getAttribute(at) + '"]' };
857
1050
  else if (a.matches && a.matches('.nrow[data-pm-item]')) keep = { sel: '[data-pm-item="' + a.getAttribute('data-pm-item') + '"]' };
858
1051
  }
@@ -860,35 +1053,34 @@ function draw(inst) {
860
1053
  inst.bar.replaceChildren(...lensBar(inst));
861
1054
  const d = inst.data;
862
1055
  if (!d) { inst.body.replaceChildren(el('p', { class: 'dim mono', style: small }, COPY.loading)); return; }
863
- const list = shown(inst, inst.lens);
864
- const lh = el('div', { class: 'lh' },
865
- el('span', { class: 'ki' }, c3ic(WLI[inst.lens])),
866
- el('div', { style: 'min-width:0' }, el('b', null, (d.lens_text && d.lens_text[inst.lens] ? d.lens_text[inst.lens][0] : COPY.lens[inst.lens](list.length))), el('div', { class: 'dim' }, (d.lens_text && d.lens_text[inst.lens] ? d.lens_text[inst.lens][1] : COPY.lensSub[inst.lens]))),
867
- lensRing(inst));
868
- const empty = el('li', { class: 'fold' }, c3ic('check', 'sm'), COPY.empty);
1056
+ const lens = inst.lens;
1057
+ const list = lens === 'review' ? [] : shown(inst, lens);
1058
+ const lh = el('div', { class: 'lh', 'data-pm-lens-head': lens },
1059
+ el('span', { class: 'ki' }, c3ic(WLI[lens])),
1060
+ el('div', { class: 'wk-min' }, el('b', null, COPY.lens[lens](countOf(inst, lens))), el('div', { class: 'dim' }, COPY.lensSub[lens])),
1061
+ lens === 'today' ? todayRing(inst) : null);
1062
+ const empty = el('li', { class: 'fold' }, c3ic('check', 'sm'), COPY.emptyView[lens] || COPY.empty);
869
1063
  let main;
870
- if (inst.group) {
1064
+ if (lens === 'review') main = [reviewMain(inst)];
1065
+ else if (inst.group) {
871
1066
  const groups = (d.jobs || []).map((j) => [j, list.filter((i) => i.job === j.id)])
872
1067
  .concat([[null, list.filter((i) => !i.job || !jobById(inst, i.job))]]).filter((g) => g[1].length);
873
1068
  main = groups.length ? groups.map(([j, its]) => {
874
1069
  const sec = sect(el('div', { class: 'sh2' }, el('h2', null, c3ic('target', 'sm'), j ? (j.mock_name || j.want) : COPY.noJobGroup),
875
1070
  j ? el('span', { class: 'chips2 dim mono', style: small }, COPY.progress(j.done, j.total)) : null),
876
- el('ul', { class: 'list', role: 'list' }, its.map((i) => itemRow(inst, i, inst.lens))));
1071
+ el('ul', { class: 'list', role: 'list' }, its.map((i) => itemRow(inst, i, lens))));
877
1072
  sec.setAttribute('data-pm-group-card', j ? j.id : '');
878
1073
  return sec;
879
- }) : [sect(el('div', { class: 'sh2' }, el('h2', null, c3ic(WLI[inst.lens], 'sm'), labelOf(inst.lens))), el('ul', { class: 'list' }, empty))];
880
- } else {
881
- const sec = sect(el('div', { class: 'sh2' }, el('h2', null, c3ic(WLI[inst.lens], 'sm'), labelOf(inst.lens), ' ', el('span', { class: 'bdg g' }, String(list.length))),
1074
+ }) : [sect(el('div', { class: 'sh2' }, el('h2', null, c3ic(WLI[lens], 'sm'), labelOf(lens))), el('ul', { class: 'list' }, empty))];
1075
+ } else if (lens === 'upcoming') main = [upcomingMain(inst, list)];
1076
+ else {
1077
+ const sec = sect(el('div', { class: 'sh2' }, el('h2', null, c3ic(WLI[lens], 'sm'), labelOf(lens), ' ', el('span', { class: 'bdg g' }, String(list.length))),
882
1078
  el('span', { class: 'fl2', 'data-pm-legend': '' }, COPY.legend.map((w, k) => el('span', null, ['• ', '× ', '> ', '< '][k] + w)))),
883
- el('ul', { class: 'list', role: 'list' }, list.length ? list.map((i) => itemRow(inst, i, inst.lens)) : empty));
884
- sec.setAttribute('data-pm-list', inst.lens);
1079
+ el('ul', { class: 'list', role: 'list' }, list.length ? list.map((i) => itemRow(inst, i, lens)) : empty));
1080
+ sec.setAttribute('data-pm-list', lens);
885
1081
  main = [sec];
886
1082
  }
887
- if (inst.lens === 'triage' && Array.isArray(d.noise) && d.noise.length) { // optional (demo): the mockup's "Noise: kept out of your list". Each row is a work row whose case says why the agent filtered it out (console3.html line 3270)
888
- const nz = d.noise.map((n, i) => ({ id: n.id || 'noise-' + (n.title || i).toLowerCase().replace(/[^a-z0-9]+/g, '-'), title: n.title, src: n.src, kind: 'fyi', mins: 0, mock_row: true, noise: true, steps: [] }));
889
- main.push(el('section', { class: 'card sect', 'data-pm-noise': '' }, el('div', { class: 'sh2' }, el('h2', null, c3ic('info', 'sm'), 'Noise ', el('span', { class: 'bdg g' }, String(nz.length))), el('span', { class: 'dim', style: small }, 'kept out of your list')),
890
- el('ul', { class: 'list', role: 'list' }, nz.map((n) => itemRow(inst, n, 'triage')))));
891
- }
1083
+ if (lens === 'today') main.unshift(focusCard(inst));
892
1084
  /* The mockup's vWork() body: .calbody > .lh + .brief.sbb > .bcols > .bcol.l (the list) and .bcol.r (the jobs card). */
893
1085
  inst.body.replaceChildren(lh, el('div', { class: 'brief sbb' }, el('div', { class: 'bcols' }, el('div', { class: 'bcol l' }, inst.ctx.plan || null, ...main), el('div', { class: 'bcol r' }, jobsCard(inst)))));
894
1086
  if (keep) { const n = inst.root.querySelector(keep.sel); if (n && n.focus) { try { n.focus({ preventScroll: true }); } catch { /* not focusable */ } } }