create-pmos 0.525.0 → 0.527.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 (78) hide show
  1. package/package.json +51 -56
  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/briefing-premium-board-test.js +1 -1
  7. package/workspace/changelog.json +50 -0
  8. package/workspace/ev-everywhere-test.js +2 -2
  9. package/workspace/guide/agent-capabilities.md +28 -2
  10. package/workspace/guide/board.md +14 -1
  11. package/workspace/guide/claude-proposes.md +3 -1
  12. package/workspace/guide/home.md +11 -5
  13. package/workspace/guide/idle-lab.md +2 -2
  14. package/workspace/guide/lifecycle.md +16 -3
  15. package/workspace/guide/reports-set-up.md +1 -0
  16. package/workspace/guide/today.md +11 -3
  17. package/workspace/lib/bg-queue.js +7 -3
  18. package/workspace/lib/board-premium.js +4 -1
  19. package/workspace/lib/briefing-premium-agent.js +9 -2
  20. package/workspace/lib/engine/boards.js +3 -2
  21. package/workspace/lib/headless.js +19 -4
  22. package/workspace/lib/idle-lab-engine.js +531 -0
  23. package/workspace/lib/initiative-chat.js +12 -2
  24. package/workspace/lib/initiative-cli.js +1 -1
  25. package/workspace/lib/initiative-premium.js +11 -3
  26. package/workspace/lib/lab-cli.js +220 -0
  27. package/workspace/lib/lifecycle-stage.js +17 -3
  28. package/workspace/lib/one-stage.js +131 -0
  29. package/workspace/lib/pmos-verbs.js +3 -0
  30. package/workspace/lib/proposals.js +5 -2
  31. package/workspace/lib/report-chat.js +31 -4
  32. package/workspace/lib/report-draft.js +1 -1
  33. package/workspace/lib/route-modules.js +1 -0
  34. package/workspace/lib/routes-board-premium.js +22 -0
  35. package/workspace/lib/routes-boards.js +61 -12
  36. package/workspace/lib/routes-briefing-premium.js +18 -1
  37. package/workspace/lib/routes-notes.js +1 -1
  38. package/workspace/lib/routes-your-move.js +64 -0
  39. package/workspace/lib/status-signal.js +123 -0
  40. package/workspace/lib/workflow-cli.js +3 -1
  41. package/workspace/lib/your-move.js +61 -0
  42. package/workspace/package-lock.json +2 -2
  43. package/workspace/package.json +1 -1
  44. package/workspace/public/pm/bd-acts.js +2 -1
  45. package/workspace/public/pm/bd-cols.js +8 -5
  46. package/workspace/public/pm/bd-prog-panel.js +3 -2
  47. package/workspace/public/pm/bd-span.js +2 -1
  48. package/workspace/public/pm/bd-views.js +8 -6
  49. package/workspace/public/pm/cozy-home.css +3 -0
  50. package/workspace/public/pm/cozy-today.css +151 -0
  51. package/workspace/public/pm/cozy.css +9 -0
  52. package/workspace/public/pm/good-morning.js +327 -0
  53. package/workspace/public/pm/grill-words.js +16 -1
  54. package/workspace/public/pm/home-app.js +187 -18
  55. package/workspace/public/pm/home-chat.js +6 -2
  56. package/workspace/public/pm/home-data.js +45 -13
  57. package/workspace/public/pm/home-day.js +66 -14
  58. package/workspace/public/pm/home-ini-model.js +1 -1
  59. package/workspace/public/pm/home-stage-card.js +7 -0
  60. package/workspace/public/pm/ini-tasks.js +2 -2
  61. package/workspace/public/pm/ini-view-model.js +10 -3
  62. package/workspace/public/pm/ini-view.css +9 -0
  63. package/workspace/public/pm/ini-view.js +19 -5
  64. package/workspace/public/pm/move-rank.js +155 -0
  65. package/workspace/public/pm/premium-board.js +7 -4
  66. package/workspace/public/pm/premium-initiative-head.js +39 -4
  67. package/workspace/public/pm/premium-report-tab.js +15 -3
  68. package/workspace/public/pm/report-flow.js +7 -1
  69. package/workspace/public/pm/stage-chip.js +44 -0
  70. package/workspace/run-tests.js +1 -0
  71. package/workspace/server.js +43 -1
  72. package/workspace/skills/idle-lab-prototype.skill +36 -0
  73. package/workspace/skills/idle-lab-scan.skill +32 -0
  74. package/workspace/test-lists/good-morning.json +9 -0
  75. package/workspace/test-lists/idle-lab-engine.json +9 -0
  76. package/workspace/test-lists/one-stage.json +5 -0
  77. package/workspace/test-lists/status-signal.json +5 -0
  78. package/workspace/test-lists/your-move.json +9 -0
@@ -12,6 +12,7 @@ import { nameHtml } from './split-merge.js'; // Claude suggests a split or a m
12
12
  import { esc } from './premium-shell.js';
13
13
  import * as WF from './bd-wf.js';
14
14
  import { nextHtml } from './bd-next.js';
15
+ import { stageName, stageChip } from './stage-chip.js'; // W1: the one stage
15
16
 
16
17
  let A = null; // the page: { M(), statusOf(r), usual(t), trail(r), docMeta(path, ws), sel(), groups(L), folded(key), laneHead(g), subTile(name), memberIcon(id), programOf(r), programs(),
17
18
  // wf() → { list, cur, ended } the workflows on the board, the picked one (null for All) and the rows that ended (pm/bd-wf.js) }
@@ -49,7 +50,6 @@ export function outOf(r) {
49
50
  }
50
51
  /** A progress bar; a count (tickets, parts) is striped so it never reads as points (BdPanel). */
51
52
  export const barHtml = (o, cls = '') => (o ? `<span class="bd-bar${cls ? ' ' + cls : ''}" role="img" aria-label="${esc(o.label || o.pct + '%')}${o.count ? ' (by count)' : ''}"><i class="${o.count ? 'cnt' : ''}" style="width:${Math.max(0, Math.min(100, o.pct))}%"></i></span>` : '<span class="bd-bar" aria-hidden="true"><i style="width:0"></i></span>');
52
- const stepText = (r, days) => { const t = r.track || {}, w = WF.stageOfRow(r); return ((w && w.name) || t.label || r.stage || 'No stage') + (days && t.days ? ' · ' + t.days + 'd' : ''); }; // "· 0d" is silence
53
53
  const CAT_TONE = { todo: '', doing: 'y', done: 'ok', wontdo: '', archived: '' };
54
54
  /** The category pill of the List (WfAElse): To do, In progress, Done, Won't do, Archived. */
55
55
  const catPill = (r) => { const c = WF.catOfRow(r); return c ? `<span class="bd-pill${CAT_TONE[c] ? ' ' + CAT_TONE[c] : ''}">${esc(WF.catLabel(c))}</span>` : '<span class="bd-amut">—</span>'; };
@@ -83,7 +83,7 @@ function listRow(r, pg) {
83
83
  const pcells = pg ? `<span role="cell" class="bd-pt">${A.tileHtml(prod, 'bd-ti s')}<span class="bd-ell">${esc(prod)}</span></span><span role="cell" class="bd-pt">${p ? A.progTile('bd-pti s') + `<span class="bd-ell">${esc(p.title)}</span>` : ''}</span>` : '';
84
84
  return `<div class="bd-lt${sel ? ' sel' : ''}${r.acts && r.acts.life === 'hold' ? ' held' : ''}" role="row" data-row="${esc(r.key)}">`
85
85
  + `<button type="button" class="bd-ltn ini2" data-key="${esc(r.key)}" role="rowheader" aria-haspopup="dialog"><b>${nameHtml(r)}</b></button>${pcells}`
86
- + `<span role="cell" class="bd-wfn">${WF.wfIcon(WF.wfOf(r), 18)}<span class="bd-ell">${esc(WF.wfOf(r).name)}</span></span><span role="cell">${(r.track || {}).ms ? '<i class="cz-ms" aria-hidden="true"></i>' : ''}${esc(stepText(r, true))}</span><span role="cell">${catPill(r)}</span>`
86
+ + `<span role="cell" class="bd-wfn">${WF.wfIcon(WF.wfOf(r), 18)}<span class="bd-ell">${esc(WF.wfOf(r).name)}</span></span><span role="cell">${stageChip(r, { ms: (r.track || {}).ms })}${(r.track || {}).days ? ' <span class="bd-ell">' + (r.track || {}).days + 'd</span>' : ''}</span><span role="cell">${catPill(r)}</span>`
87
87
  + `<span role="cell" class="cs s-${s.tone}"><i class="cz-dot ${s.tone} ${s.mo || ''}"></i>${esc(s.text)}</span>`
88
88
  + `<span role="cell">${avatar(r.owner)}</span><span role="cell" class="bd-nxc">${nextHtml(r, { self: M().board.self, due: false })}</span>`
89
89
  + `<span role="cell"${r.over ? ' class="bd-late"' : ''}>${esc(finish)}</span>`
@@ -156,7 +156,7 @@ function card(r, drag, mixed) {
156
156
  const right = t.key === 'deliver' && r.release ? r.release.name + ' · ' + r.release.at : (t.key === 'build' || t.key === 'deliver') && t.prog && t.prog.unit !== '%' ? (mixed ? '' : t.count) : t.days != null ? t.days + 'd' + (u ? ' · usually ' + u.days : '') : '';
157
157
  const w = WF.stageOfRow(r);
158
158
  const counted = /^\d/.test(String(t.count || '')) ? out.trim() : ''; // "PRD 4 of 9", not "PRD — of 9" or "No tickets yet"
159
- const meta = mixed ? `<span class="cm">${WF.wfIcon(WF.wfOf(r), 16)}<span>${w && w.milestone ? '◆ ' : ''}${esc((w && w.name) || r.stage || 'No stage')}${counted ? ' · ' + esc(counted) : ''}</span></span>` : '';
159
+ const meta = mixed ? `<span class="cm">${WF.wfIcon(WF.wfOf(r), 16)}<span>${w && w.milestone ? '◆ ' : ''}${esc(stageName(r) || 'No stage')}${counted ? ' · ' + esc(counted) : ''}</span></span>` : '';
160
160
  const foot = left || right ? `<span class="f">${left || '<span></span>'}<span>${esc(right)}</span></span>` : '';
161
161
  return `<button type="button" class="bd-bc ini2${t.ms ? ' ms' : ''}${t.you ? ' you' : ''}${A.sel && A.sel() === r.key ? ' sel' : ''}" data-key="${esc(r.key)}"${drag ? ' draggable="true"' : ''} aria-haspopup="dialog"${r.owner ? ` title="${esc(r.title + ' · owner ' + r.owner)}"` : ''}><b class="t">${r.inProgram && A.memberIcon ? A.memberIcon(r.inProgram) : ''}${esc(r.title)}</b>${meta}<span class="s cs s-${s.tone}"><i class="cz-dot ${s.tone} ${s.mo || ''}"></i>${esc(s.text)}</span>${foot}</button>`;
162
162
  }
@@ -258,14 +258,16 @@ export function stagesHtml(L, o = {}) {
258
258
  /** A phone row: the status dot, the title, the stage and what it waits on; the workflow's icon at the end when the board has more than one. */
259
259
  function phoneRow(r) {
260
260
  const s = statusLine(r), t = r.track || {}, w = WF.stageOfRow(r);
261
- const sub = [(w && w.name) || t.label || r.stage, t.you ? t.you.replace(/^Your /, 'your ') : s.tone !== 'ok' && s.tone !== 'q' ? s.text.toLowerCase() : '', r.owner].filter(Boolean).join(' · ');
261
+ const sub = [stageName(r) || (w && w.name), t.you ? t.you.replace(/^Your /, 'your ') : s.tone !== 'ok' && s.tone !== 'q' ? s.text.toLowerCase() : '', r.owner].filter(Boolean).join(' · ');
262
262
  return `<button type="button" class="bd-lrow ini2${A.sel && A.sel() === r.key ? ' sel' : ''}" data-key="${esc(r.key)}" aria-haspopup="dialog"><i class="cz-dot ${s.tone} ${s.mo || ''}"></i><span class="tx"><span class="t">${nameHtml(r)}</span><span class="s${t.you ? '' : ' q'}">${esc(sub)}</span></span>${t.you && t.days != null ? `<span class="r">${t.days}d</span>` : ''}${many() ? WF.wfIcon(WF.wfOf(r), 18) : ''}</button>`;
263
263
  }
264
264
  export function phoneListHtml(L) {
265
- const you = L.filter((r) => r.track && r.track.you), bad = L.filter((r) => !you.includes(r) && needOf(r) <= 3), rest = L.filter((r) => !you.includes(r) && !bad.includes(r));
265
+ const you = L.filter((r) => r.track && r.track.you), bad = L.filter((r) => !you.includes(r) && needOf(r) <= 3), left = L.filter((r) => !you.includes(r) && !bad.includes(r));
266
+ // workspace grill W2: a row with nothing read is not on track; it is listed under No signal
267
+ const none = left.filter((r) => r.status === 'no signal'), rest = left.filter((r) => !none.includes(r));
266
268
  const grp = (name, rs) => (rs.length ? `<div class="bd-ghd">${esc(name)}<span class="n">${rs.length}</span></div>${rs.map(phoneRow).join('')}` : '');
267
269
  if (!L.length) return '<p class="bt-empty">No initiative matches the chips or the filter.</p>';
268
- return `<div class="bd-plist">${grp('Your turn', you)}${grp('At risk or late', bad)}${grp('On track', rest)}</div>`;
270
+ return `<div class="bd-plist">${grp('Your turn', you)}${grp('At risk or late', bad)}${grp('On track', rest)}${grp('No signal', none)}</div>`;
269
271
  }
270
272
 
271
273
  /* ── Documents (BdDocs) ───────────────────────────────────────────────────────────────────────────────────────── */
@@ -125,10 +125,13 @@ body.cz-home { min-height: 100vh; overflow: hidden; }
125
125
  .hm-it.wait { color: var(--cz-ink2); }
126
126
  .hm-mk.risk { border: 1.5px solid var(--cz-blue); color: var(--cz-blueink); }
127
127
  .hm-mk.jira { background: var(--cz-well2); color: var(--cz-ink2); }
128
+ .hm-mk.none { border: 1.5px dashed var(--cz-well2); } /* workspace grill W2: nothing read, grey; never on track */
129
+ .hm-it.none { color: var(--cz-ink2); }
128
130
  .hm-it .tag { font-size: var(--fs-badge); font-weight: var(--fw-strong); border-radius: 999px; padding: 1px 8px; line-height: var(--lh-badge); }
129
131
  .hm-it .tag.you { background: var(--cz-blue); color: var(--cz-onsolid); }
130
132
  .hm-it .tag.risk { box-shadow: inset 0 0 0 1px var(--cz-blue); color: var(--cz-blueink); }
131
133
  .hm-it .tag.jira { background: var(--cz-well2); color: var(--cz-ink3); }
134
+ .hm-it .tag.none { padding: 0; background: none; color: var(--cz-ink3); font-size: var(--fs-meta); font-weight: var(--fw-regular); line-height: var(--lh-meta); }
132
135
  .hm-it .tag.agent, .hm-it .tag.wait { padding: 0; background: none; color: var(--cz-ink3); font-size: var(--fs-meta); font-weight: var(--fw-regular); line-height: var(--lh-meta); } /* Working, Next: a plain word */
133
136
  .hm-it .tag.agent.stuck { padding: 1px 8px; box-shadow: inset 0 0 0 1.5px var(--cz-blue); color: var(--cz-blueink); font-weight: var(--fw-strong); }
134
137
  .hm-then { margin: 2px 0 0; padding: 8px 10px; border-radius: 10px; background: var(--cz-card); font-size: var(--fs-meta); line-height: 1.45; color: var(--cz-ink2); box-shadow: 0 0 0 1px var(--cz-hair); }
@@ -546,3 +546,154 @@ body.cz-home .hm-hd.hv-bar > .hv-hi { flex: 1 0 auto; max-width: 100%; }
546
546
  /* a product's own chats: the same pill as an initiative row, on its header (batch 20 item 10) */
547
547
  .hv-gh.hv-prg { position: relative; }
548
548
  .hv-gh.hv-prg > .hv-sp { flex: none; }
549
+ /* Your move is one ranked list (workspace grill W3, WgW3Move): ✦, the sentence, its age, ✓ and one more icon; Older folds after two weeks */
550
+ .yd-mvl { display: flex; flex-direction: column; background: var(--cz-card); border-radius: 18px; padding: 4px 12px 4px 14px; }
551
+ .yd-mv { display: flex; align-items: flex-start; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--cz-hair); }
552
+ .yd-mv:last-child, .yd-older .yd-mv:last-child { border-bottom: 0; }
553
+ .yd-mvs { flex: none; color: var(--cz-blueink); font-weight: var(--fw-strong); margin-top: 2px; }
554
+ .yd-mvt { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; border: 0; background: none; padding: 0; text-align: left; font: inherit; color: inherit; cursor: pointer; }
555
+ .yd-mvt b { font-size: var(--fs-row); font-weight: var(--fw-strong); line-height: var(--lh-row); }
556
+ .yd-mvt em { font-style: normal; font-size: var(--fs-label); color: var(--cz-ink3); line-height: var(--lh-label); }
557
+ .yd-mvt:focus-visible { outline: 2px solid var(--cz-blue); outline-offset: 2px; border-radius: 6px; }
558
+ .yd-age { flex: none; font-size: var(--fs-label); font-weight: var(--fw-strong); color: var(--cz-ink3); white-space: nowrap; margin-top: 2px; line-height: var(--lh-label); }
559
+ .yd-age.late { color: var(--cz-late); }
560
+ .yd-mva { flex: none; display: flex; gap: 6px; }
561
+ .yd-ok.sm { width: 44px; height: 44px; border-radius: 22px; }
562
+ .yd-alt.sm { width: 44px; height: 44px; border-radius: 22px; }
563
+ .yd-mvl > .yd-call { margin: 8px 0; padding-left: 0; }
564
+ .yd-older { padding: 4px 0 8px; }
565
+ .yd-older summary { min-height: 44px; display: flex; align-items: center; cursor: pointer; font-size: var(--fs-row); color: var(--cz-ink3); font-weight: var(--fw-strong); }
566
+ .yd-older summary:focus-visible { outline: 2px solid var(--cz-blue); outline-offset: 2px; border-radius: 6px; }
567
+ @media (max-width: 560px) { .yd-mv { flex-wrap: wrap; } .yd-mva { margin-left: auto; } }
568
+ /* ── Good morning is one page (nyt-ui GM2; frames G1 NyG1Morning, G2 NyG2Midday, G3 NyG3Evening, G4 NyG4Phone) ──
569
+ * The quarter strip first, then the reading column and the time rail. The Viewer's own width decides: 820 px or wider is two columns; narrower
570
+ * (a side Viewer, a phone) is one column in the order quarter, lead, call, Your move, the agenda, Since yesterday. Type: 13/15/18/26 only. */
571
+ .gm-wrap { container-type: inline-size; gap: 20px; }
572
+ .gm { display: flex; flex-direction: column; gap: 18px; }
573
+ .gm-k { font-size: var(--fs-label); font-weight: var(--fw-strong); color: var(--cz-ink3); letter-spacing: .04em; text-transform: uppercase; }
574
+ .gm-hd { display: flex; flex-direction: column; gap: 2px; }
575
+ .gm-hd h1 { margin: 0; font-size: var(--fs-display); font-weight: var(--fw-strong); line-height: 1.2; color: var(--cz-ink); }
576
+ .gm-sh { margin: 0 0 4px; display: flex; align-items: center; gap: 8px; font-size: var(--fs-label); font-weight: var(--fw-strong); color: var(--cz-ink3); letter-spacing: .04em; text-transform: uppercase; }
577
+ .gm-sh .gm-lnk, .gm-sh .yd-all { margin-left: auto; text-transform: none; letter-spacing: 0; }
578
+ .gm-n { color: var(--cz-ink3); font-weight: var(--fw-regular); }
579
+ .gm-lnk { border: 0; background: none; padding: 0; min-height: 32px; font: inherit; font-size: var(--fs-label); font-weight: var(--fw-strong); color: var(--cz-blueink); cursor: pointer; text-align: left; }
580
+ .gm-lnk:focus-visible, .gm button:focus-visible { outline: 2px solid var(--cz-blue); outline-offset: 2px; border-radius: 6px; }
581
+ /* the quarter strip: the verdict, one stacked bar with the PM's line as a black tick, the legend, By product */
582
+ .gm-q { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(260px, 1.3fr) auto; gap: 20px; align-items: center; background: var(--cz-card); border-radius: 18px; padding: 16px 18px; }
583
+ .gm-qt { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
584
+ .gm-qt b { font-size: var(--fs-panel); font-weight: var(--fw-strong); line-height: 1.3; color: var(--cz-ink); }
585
+ .gm-qt b .o { color: var(--cz-late); }
586
+ .gm-qt em { font-style: normal; font-size: var(--fs-body); color: var(--cz-ink2); line-height: 1.4; }
587
+ .gm-qb { position: relative; padding-top: 20px; min-width: 0; }
588
+ .gm-bar { display: flex; height: 14px; background: var(--cz-well); border-radius: 7px; }
589
+ .gm-sg { display: block; position: relative; flex: 1 1 0; height: 100%; }
590
+ .gm-sg:first-child { border-radius: 7px 0 0 7px; }
591
+ .gm-sg:last-child { border-radius: 0 7px 7px 0; }
592
+ .s-reached { background: var(--cz-blueink); }
593
+ .s-track { background: color-mix(in srgb, var(--cz-blue) 45%, var(--cz-card)); }
594
+ .s-reading { background: repeating-linear-gradient(135deg, color-mix(in srgb, var(--cz-ink) 25%, transparent) 0 2px, transparent 2px 5px); }
595
+ .s-risk { background: color-mix(in srgb, var(--cz-late) 35%, var(--cz-card)); }
596
+ .s-miss { background: var(--cz-late); }
597
+ .gm-tk { position: absolute; right: -1px; top: -6px; bottom: -6px; width: 0; border-left: 2px solid var(--cz-ink); z-index: 1; }
598
+ .gm-tk span { position: absolute; top: -18px; left: -30px; font-style: normal; width: 60px; text-align: center; font-size: var(--fs-label); font-weight: var(--fw-strong); color: var(--cz-ink); background: var(--cz-card); line-height: 16px; }
599
+ .gm-leg { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 8px; font-size: var(--fs-label); color: var(--cz-ink2); }
600
+ .gm-leg span { display: inline-flex; align-items: center; gap: 5px; }
601
+ .gm-leg i { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
602
+ /* two columns: the reading column and the time rail */
603
+ .gm-x { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 340px); grid-template-areas: "a rail" "b rail"; gap: 18px 28px; align-items: start; }
604
+ .gm-x.solo { grid-template-columns: minmax(0, 1fr); grid-template-areas: "a" "b"; }
605
+ .gm-a { grid-area: a; display: flex; flex-direction: column; gap: 18px; min-width: 0; }
606
+ .gm-b { grid-area: b; min-width: 0; }
607
+ .gm-rail { grid-area: rail; display: flex; flex-direction: column; gap: 14px; background: var(--cz-card); border-radius: 18px; padding: 14px 16px; }
608
+ /* Adler's lead: one face, a headline at 18, the "Done for you" line */
609
+ .gm-lead .yd-open { background: none; padding: 0; }
610
+ .gm-lead .yd-open.concerned { background: var(--cz-latebg); padding: 14px; }
611
+ .gm-lead .yd-ht b { font-size: var(--fs-panel); line-height: 1.3; }
612
+ .gm-did { display: flex; align-items: flex-start; gap: 6px; font-size: var(--fs-label); color: var(--cz-ink2); line-height: 1.4; }
613
+ .gm-did svg { flex: none; color: var(--cz-ok); margin-top: 1px; }
614
+ .gm-sec { display: flex; flex-direction: column; }
615
+ /* Your call, said once */
616
+ .gm-call { display: flex; align-items: center; gap: 12px; background: var(--cz-card); border-radius: 18px; padding: 14px 14px 14px 16px; border-left: 3px solid var(--cz-late); }
617
+ .gm-call > div { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
618
+ .gm-call b { font-size: var(--fs-row); font-weight: var(--fw-strong); line-height: 1.35; color: var(--cz-ink); }
619
+ .gm-call em { font-style: normal; font-size: var(--fs-body); color: var(--cz-ink2); line-height: 1.4; }
620
+ /* Your move's rows: the tick circle, the sentence with its goal tag, the due day (orange only when late) */
621
+ .gm-r { display: flex; align-items: flex-start; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--cz-hair); }
622
+ .gm-tc { flex: none; width: 24px; height: 24px; margin: 10px; border-radius: 12px; border: 2px solid var(--cz-ink3); background: none; padding: 0; cursor: pointer; position: relative; }
623
+ .gm-tc::after { content: ''; position: absolute; inset: -10px; }
624
+ .gm-tc:hover { border-color: var(--cz-blue); }
625
+ .gm-tc.no { border-style: dotted; cursor: default; }
626
+ .gm-tc.dn { display: inline-flex; align-items: center; justify-content: center; background: var(--cz-blueink); border-color: var(--cz-blueink); color: var(--cz-onsolid); cursor: default; }
627
+ .gm-rt { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; border: 0; background: none; padding: 10px 0 0; text-align: left; font: inherit; color: inherit; cursor: pointer; }
628
+ .gm-r.done .gm-rt { cursor: default; }
629
+ .gm-rt b { font-size: var(--fs-row); font-weight: var(--fw-strong); line-height: 1.35; color: var(--cz-ink); }
630
+ .gm-rt em { font-style: normal; font-size: var(--fs-label); color: var(--cz-ink3); line-height: 1.4; }
631
+ .gm-gt { display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-label); color: var(--cz-ink2); }
632
+ .gm-gt i { width: 8px; height: 8px; border-radius: 4px; flex: none; background: var(--cz-ink3); }
633
+ .gm-gt i.d-b { background: var(--cz-blueink); } .gm-gt i.d-l { background: var(--cz-blue); } .gm-gt i.d-o { background: var(--cz-late); }
634
+ .gm-gt i.d-r { background: var(--cz-red); } .gm-gt i.d-q { background: var(--cz-hair); border: 1px solid var(--cz-ink3); }
635
+ .gm-due { flex: none; padding-top: 10px; font-size: var(--fs-label); font-weight: var(--fw-strong); color: var(--cz-ink3); white-space: nowrap; }
636
+ .gm-due.late { color: var(--cz-late); }
637
+ .gm-more { display: flex; flex-wrap: wrap; gap: 4px 18px; padding-top: 8px; }
638
+ .gm-more button { display: inline-flex; align-items: center; gap: 4px; min-height: 44px; border: 0; background: none; padding: 0; font: inherit; font-size: var(--fs-body); color: var(--cz-ink2); cursor: pointer; }
639
+ .gm-more b { color: var(--cz-ink); font-weight: var(--fw-strong); }
640
+ /* Since yesterday: neutral dots; orange only for a real rise */
641
+ .gm-sl { display: flex; flex-direction: column; }
642
+ .gm-si { display: flex; align-items: baseline; gap: 10px; min-height: 44px; padding: 8px 0; border: 0; border-bottom: 1px solid var(--cz-hair); background: none; text-align: left; font: inherit; font-size: var(--fs-body); color: var(--cz-ink); cursor: pointer; line-height: 1.4; }
643
+ .gm-si i { flex: none; width: 7px; height: 7px; border-radius: 4px; background: var(--cz-ink3); transform: translateY(-2px); }
644
+ .gm-si.hot i { background: var(--cz-late); }
645
+ .gm-si span { flex: 1; min-width: 0; }
646
+ .gm-si em { flex: none; font-style: normal; font-size: var(--fs-label); color: var(--cz-ink3); }
647
+ /* the time rail: the compressed agenda (not to scale), the now line, prep notes, ✦ picks, free stretches; the notes under it */
648
+ .gm-rh { display: flex; align-items: baseline; gap: 8px; }
649
+ .gm-rh b { font-size: var(--fs-panel); font-weight: var(--fw-strong); color: var(--cz-ink); }
650
+ .gm-rh span { font-size: var(--fs-label); color: var(--cz-ink3); }
651
+ .gm-ag { display: flex; flex-direction: column; gap: 6px; }
652
+ .gm-ar { display: grid; grid-template-columns: 44px minmax(0, 1fr); gap: 8px; align-items: start; }
653
+ .gm-tm { font-size: var(--fs-label); font-weight: var(--fw-strong); color: var(--cz-ink3); padding-top: 9px; font-variant-numeric: tabular-nums; }
654
+ .gm-ab { display: flex; flex-direction: column; gap: 2px; min-height: 44px; padding: 8px 10px; border: 0; border-left: 3px solid var(--cz-hair); border-radius: 10px; background: var(--cz-well); text-align: left; font: inherit; color: inherit; cursor: pointer; }
655
+ .gm-ab b { font-size: var(--fs-body); font-weight: var(--fw-strong); line-height: 1.3; color: var(--cz-ink); }
656
+ .gm-ab em { display: inline-flex; align-items: center; gap: 4px; font-style: normal; font-size: var(--fs-label); color: var(--cz-ink3); line-height: 1.35; }
657
+ .gm-ab.mt { border-left-color: var(--cz-blue); }
658
+ .gm-ab.key { border-left-color: var(--cz-late); }
659
+ .gm-ab.sp { background: var(--cz-bluebg); border-left-color: var(--cz-blueink); }
660
+ .gm-ab.opt { background: none; border-left-style: dashed; }
661
+ .gm-ab.past { opacity: .55; }
662
+ .gm-nl { display: flex; flex-direction: column; gap: 2px; margin-top: 4px; font-size: var(--fs-label); color: var(--cz-ink2); }
663
+ .gm-nl div::before { content: '· '; }
664
+ .gm-gap { font-size: var(--fs-label); color: var(--cz-ink3); padding: 4px 10px; border-left: 3px dotted var(--cz-hair); }
665
+ .gm-now { display: grid; grid-template-columns: 44px minmax(0, 1fr); gap: 8px; align-items: center; }
666
+ .gm-now span { font-size: var(--fs-label); font-weight: var(--fw-strong); color: var(--cz-late); font-variant-numeric: tabular-nums; }
667
+ .gm-now i { height: 2px; background: var(--cz-late); border-radius: 1px; }
668
+ .gm-notes { display: flex; flex-direction: column; gap: 6px; border-top: 1px solid var(--cz-hair); padding-top: 12px; }
669
+ .gm-nt { display: flex; flex-direction: column; gap: 6px; padding: 6px 0; }
670
+ .gm-ntb { display: flex; gap: 8px; align-items: flex-start; border: 0; background: none; padding: 0; text-align: left; font: inherit; color: inherit; cursor: pointer; }
671
+ .gm-ntb svg { flex: none; color: var(--cz-ink3); margin-top: 2px; }
672
+ .gm-ntb > span { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
673
+ .gm-ntb b { font-size: var(--fs-body); font-weight: var(--fw-strong); color: var(--cz-ink); line-height: 1.3; }
674
+ .gm-ntb em { font-style: normal; font-size: var(--fs-label); color: var(--cz-ink3); }
675
+ .gm-ntl { font-size: var(--fs-label); color: var(--cz-ink2); line-height: 1.35; }
676
+ .gm-ntl::before { content: '· '; }
677
+ .gm-ntp { display: flex; align-items: center; gap: 8px; padding-left: 22px; font-size: var(--fs-label); color: var(--cz-blueink); font-weight: var(--fw-strong); }
678
+ .gm-ntp span { flex: 1; min-width: 0; }
679
+ /* the hour before a meeting (G2): the next meeting's prep leads */
680
+ .gm-prep { display: flex; flex-direction: column; gap: 6px; background: var(--cz-card); border-radius: 18px; padding: 16px 18px; border-left: 3px solid var(--cz-late); }
681
+ .gm-prep h2 { margin: 0; font-size: var(--fs-panel); font-weight: var(--fw-strong); line-height: 1.3; color: var(--cz-ink); }
682
+ .gm-prep ul { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 3px; font-size: var(--fs-body); color: var(--cz-ink2); line-height: 1.4; }
683
+ .gm-pr { display: flex; align-items: center; gap: 12px; margin-top: 4px; }
684
+ .gm-pr em { font-style: normal; font-size: var(--fs-label); color: var(--cz-ink3); }
685
+ .gm-rec { width: 44px; height: 44px; border-radius: 22px; border: 0; background: var(--cz-redbg); color: var(--cz-red); display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
686
+ /* one column: a side Viewer or a phone (G4): quarter, lead, call, Your move, then the agenda, then Since yesterday */
687
+ @container (max-width: 819px) {
688
+ .gm-x, .gm-x.solo { grid-template-columns: minmax(0, 1fr); grid-template-areas: "a" "rail" "b"; }
689
+ .gm-q { grid-template-columns: minmax(0, 1fr); gap: 10px; padding: 14px 16px; }
690
+ .gm-q .gm-qt em { display: none; }
691
+ .gm-q .gm-qt b { font-size: var(--fs-body); }
692
+ .gm-q .gm-lnk { justify-self: start; }
693
+ .gm-notes { display: none; }
694
+ }
695
+ @container (max-width: 480px) { .gm-leg { display: none; } .gm-qb { padding-top: 18px; } .gm-hd h1 { font-size: var(--fs-panel); } }
696
+ .gm-un { color: var(--cz-late) !important; }
697
+ .gm-qp { display: flex; align-items: center; gap: 8px; margin-top: 6px; padding: 8px 10px; border-radius: 14px; background: var(--cz-bluebg); }
698
+ .gm-qp > span { flex: 1; min-width: 0; font-size: var(--fs-body); font-weight: var(--fw-strong); color: var(--cz-blueink); line-height: 1.35; }
699
+ .gm-qp em { display: block; font-style: normal; font-weight: var(--fw-regular); font-size: var(--fs-label); color: var(--cz-ink2); }
@@ -393,3 +393,12 @@ body.cz { margin: 0; font-family: var(--cz-sans); color: var(--cz-ink); backgrou
393
393
  --brand-wash:var(--cz-bluebg);--success:var(--cz-ok);--good:var(--cz-ok);--warning:var(--cz-late,var(--cz-red));--destructive:var(--cz-red);
394
394
  --acc:var(--cz-blueink);--accw:var(--cz-bluebg);--hi:none;color:var(--cz-ink);font-family:var(--cz-sans)}
395
395
  .cz .c3 .brief{margin-top:0;max-width:none;font-size:inherit;line-height:inherit;font-weight:inherit;letter-spacing:normal;color:inherit;text-wrap:wrap;transform:none}
396
+ /* W1: the one stage chip (stage-chip.js), the same on every surface */
397
+ .cz-pill.cz-stg:not(.ok) { background: var(--cz-well); color: var(--cz-ink2); }
398
+ /* W1: what set the stage, and the gate line (the spec ladder) under it */
399
+ .hm-scsrc { margin: 2px 0 0; font-size: var(--fs-label); line-height: var(--lh-label); color: var(--cz-ink3); }
400
+ .hm-gate { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 6px 0 2px; font-size: var(--fs-label); line-height: var(--lh-label); color: var(--cz-ink2); }
401
+ .hm-gate .lbl { font-weight: var(--fw-strong); color: var(--cz-ink3); }
402
+ .hm-gate .g { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 999px; background: var(--cz-card); border: 1px solid var(--cz-hair); }
403
+ .hm-gate .g.no { color: var(--cz-late); border-color: var(--cz-latebg); }
404
+ .hm-gate .g .cz-ck { width: 12px; height: 12px; margin-left: 4px; stroke: var(--cz-ok); stroke-width: 3; fill: none; }
@@ -0,0 +1,327 @@
1
+ /* pm/good-morning.js — Good morning is one page (nyt-ui GM2; frames G1 NyG1Morning, G2 NyG2Midday, G3 NyG3Evening, G4 NyG4Phone on the
2
+ * main canvas, In development; brief /mnt/project-files/nyt-ui/brief-good-morning.md). Pure: Node imports it (good-morning.test.js).
3
+ *
4
+ * gmModel(Y, D, now, o) → G what the page says, from Your day's model (home-day.js dayModel) and what Home holds
5
+ * gmHtml(G, openHtml) → markup the page: header, quarter strip, the reading column and the time rail
6
+ * quarterOf(Q, state) → the quarter strip's words and parts, or null (no goals: no strip)
7
+ * agendaOf(t, now, o) → the compressed agenda of the rail
8
+ * notesOf(rows, now, o) → the notes of the rail
9
+ * stateOf(t, now) → 'morning' | 'soon' (the hour before a meeting, G2) | 'evening' (after 17:00, G3)
10
+ *
11
+ * THE PAGE (desktop): the header (date and greeting), then the quarter strip ("is the quarter OK?" first), then two columns. The reading
12
+ * column: Adler's lead (one face per screen) with the "Done for you" line, Your call (said once), Your move (the top 3 of W3's ranked list,
13
+ * pm/move-rank.js; no second ranking), then one quiet line to what was drafted and what can wait, then Since yesterday. The time rail: a
14
+ * compressed agenda (not to scale) with a now line, each meeting with its prep note, ✦ picks in free time and "3 h free" for an empty
15
+ * stretch; under it the notes since yesterday, with ✦ to link a loose recording. A narrow Viewer or a phone (G4) is one column in the same
16
+ * order: quarter, lead, call, Your move, the agenda, then Since yesterday.
17
+ *
18
+ * THROUGH THE DAY: the hour before a meeting (G2) the next meeting's prep leads and the quarter strip stays away unless it moved since the
19
+ * morning (zero is silence); past items fade and their notes fold under them. After 17:00 (G3) the strip says how the quarter moved since
20
+ * the morning, "Today made possible" lists what the day moved, "Still yours" what is left, and the rail turns to tomorrow.
21
+ *
22
+ * PROVEN ONLY. The quarter's goal states, the PM's line, the morning's snapshot, a row's goal tag and what Yes does to a goal come from the
23
+ * Good morning goal data (GM1), passed in o.quarter, o.tags and the call's effect. Each part that is missing is left out: no goals, no strip;
24
+ * no line, no tick and the verdict counts only; no morning snapshot, no "up from"; no effect, no goal clause. Nothing is guessed.
25
+ *
26
+ * The goal data is GET /api/morning-goals (lib/routes-morning-goals.js, GM1); fromMorningGoals(r) turns its answer into:
27
+ * o.quarter: { label: 'Q4', weeks: 8 | ends: ISO day, line: 18 | null, counts: { reached, on_track, reading, at_risk, miss },
28
+ * products: [{ name, counts }], morning: { counts } | morningOk: 16 | null, moved: [{ goal, from, to }],
29
+ * proposal: { line, why } | null (Claude's ✦ line, only when the PM has none), missing: [product names not read] }
30
+ * o.tags: { 'ws:ini': { name, state } } a goal tag per initiative (state one of the five above)
31
+ * o.notes: GET /api/notes rows (pm/premium-meeting.js recCore notes()); o.picks: { [noteId]: { id, title } } the ✦ link of a loose
32
+ * recording (GET /api/notes/initiatives pick: shown only when that read found one)
33
+ * A call's effect (GM1): call.effect = { meeting: { id, start, title } | null, goal, from, to } */
34
+ import { hm } from './home-data.js';
35
+ import { gapsOf } from './home-day.js';
36
+
37
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
38
+ const plural = (n, one, many) => n + ' ' + (n === 1 ? one : many);
39
+ const clip = (s, n) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
40
+ const svg = (d, w = 15, sw = 1.8) => `<svg aria-hidden="true" width="${w}" height="${w}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round"><path d="${d}"/></svg>`;
41
+ const CHECK = 'M5 12.5l4.5 4.5L19 7', CHEV = 'M10 7l5 5-5 5', NOTE = 'M6 3h9l4 4v14H6zM14 3v5h5M9 13h7M9 17h5';
42
+ const MIC = 'M12 3a3 3 0 0 1 3 3v6a3 3 0 0 1-6 0V6a3 3 0 0 1 3-3M6 11a6 6 0 0 0 12 0M12 17v4', LINK = 'M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1';
43
+ const MARK2 = 'M2 12.5l4.5 4.5L15 8M9 15l2 2L22 7', XX = 'M6 6l12 12M18 6L6 18';
44
+ const MIN = 60000, DAY = 864e5;
45
+ const MON = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
46
+ const DOW = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
47
+ const span = (m) => { const a = Date.parse(m.start); const b = m.end ? Date.parse(m.end) : a + (m.minutes || 30) * MIN; return { a, b }; };
48
+ const dur = (mins) => (mins >= 60 ? (Math.round(mins / 30) / 2).toString().replace(/\.0$/, '') + ' h' : mins + ' min');
49
+ const at = (ms) => hm(new Date(ms).toISOString());
50
+ const dayStart = (ms) => { const d = new Date(ms); d.setHours(0, 0, 0, 0); return d.getTime(); };
51
+
52
+ /* the five goal states, in the bar's order (W2 "Status says what it knows": still reading is No signal / Too early) */
53
+ export const STATES = [
54
+ { id: 'reached', word: 'reached', cls: 's-reached' },
55
+ { id: 'on_track', word: 'on track', cls: 's-track' },
56
+ { id: 'reading', word: 'still reading', cls: 's-reading' },
57
+ { id: 'at_risk', word: 'at risk', cls: 's-risk' },
58
+ { id: 'miss', word: 'will miss', cls: 's-miss' },
59
+ ];
60
+ const WORD = Object.fromEntries(STATES.map((s) => [s.id, s.word]));
61
+ const DOT = { reached: 'b', on_track: 'l', reading: 'q', at_risk: 'o', miss: 'r' };
62
+ const okOf = (c) => (c ? (c.reached || 0) + (c.on_track || 0) : 0);
63
+ const totalOf = (c) => STATES.reduce((n, s) => n + ((c && c[s.id]) || 0), 0);
64
+ const better = (from, to) => STATES.findIndex((s) => s.id === to) < STATES.findIndex((s) => s.id === from) && STATES.some((s) => s.id === from);
65
+
66
+ /** GET /api/morning-goals (GM1) → { quarter, tags } in this page's shape, or null when it did not answer. Its states are spelled
67
+ * 'on-track', 'at-risk', 'will-miss'; its strip counts with the same one function the tags use (lib/morning-goals.js stateOf). */
68
+ const GM1 = { reached: 'reached', 'on-track': 'on_track', reading: 'reading', 'at-risk': 'at_risk', 'will-miss': 'miss' };
69
+ export function fromMorningGoals(r) {
70
+ if (!r || !r.ok || !r.strip) return null;
71
+ const S = r.strip, c = {};
72
+ for (const [k, v] of Object.entries(S.counts || {})) if (GM1[k]) c[GM1[k]] = v || 0;
73
+ const qm = /-Q(\d)$/.exec(String(r.quarter || ''));
74
+ const quarter = { label: qm ? 'Q' + qm[1] : '', weeks: Number.isFinite(r.weeksLeft) ? r.weeksLeft : null, line: r.line && Number.isFinite(r.line.line) ? r.line.line : null,
75
+ counts: c, products: (S.byProduct || []).map((b) => ({ name: b.product, counts: { reached: 0, on_track: b.good || 0, reading: b.reading || 0, at_risk: b.risk || 0, miss: b.miss || 0 } })),
76
+ morningOk: r.since && Number.isFinite(r.since.from) ? r.since.from : null,
77
+ moved: ((r.since && r.since.moved) || []).filter((m) => GM1[m.from] && GM1[m.to]).map((m) => ({ goal: m.title, from: GM1[m.from], to: GM1[m.to] })),
78
+ proposal: r.proposal && Number.isFinite(r.proposal.line) ? { line: r.proposal.line, why: r.proposal.why || '' } : null, missing: Array.isArray(r.missing) ? r.missing : [], why: S.why || '' };
79
+ const tags = {};
80
+ for (const [k, t] of Object.entries(r.tags || {})) if (t && t.title && GM1[t.state]) tags[k] = { name: t.title, state: GM1[t.state] };
81
+ return { quarter, tags };
82
+ }
83
+
84
+ /** Where the day is: the hour before a meeting (G2), after 17:00 (G3), or the morning page (G1). */
85
+ export function stateOf(t, now) {
86
+ if (new Date(now).getHours() >= 17) return 'evening';
87
+ const next = (t || []).find((m) => m.state !== 'past' && m.state !== 'now' && span(m).a > now);
88
+ return next && span(next).a - now <= 60 * MIN ? 'soon' : 'morning';
89
+ }
90
+
91
+ /** The quarter strip, or null. state 'soon' shows it only when it moved since the morning (zero is silence). */
92
+ export function quarterOf(Q, state = 'morning', now = Date.now()) {
93
+ if (!Q || !Q.counts) return null;
94
+ const c = Q.counts, total = totalOf(c);
95
+ if (!total) return null;
96
+ const ok = okOf(c), line = Number.isFinite(Q.line) && Q.line > 0 && Q.line <= total ? Q.line : null;
97
+ const m = Q.morning && Q.morning.counts && totalOf(Q.morning.counts) ? okOf(Q.morning.counts) : Number.isFinite(Q.morningOk) ? Q.morningOk : null;
98
+ const moved = m != null && m !== ok;
99
+ if (state === 'soon' && !moved) return null;
100
+ const short = line ? line - ok : 0;
101
+ const verdict = plural(ok, 'goal', 'goals') + ' on track or better.';
102
+ const vline = !line ? '' : short <= 0 ? 'At or above your line of ' + line + '.' : state === 'morning' || !moved ? 'You set the line at ' + line + '.'
103
+ : (short === 1 ? 'One' : String(short)) + ' short of your line.';
104
+ // why: the morning names the product that makes most of the gap; later the day says how it moved
105
+ let why = '';
106
+ if (moved) {
107
+ const mv = (Q.moved || []).find((x) => x && x.goal && x.from && x.to);
108
+ why = (ok > m ? 'Up from ' : 'Down from ') + m + ' this morning' + (mv ? ': ' + mv.goal + ' moved from ' + WORD[mv.from] + ' to ' + WORD[mv.to] + '.' : '.');
109
+ } else if (Q.why) {
110
+ why = Q.why; // GM1's own sentence (lib/morning-goals.js stripOf): one place says why, from the same count
111
+ } else if (short > 0) {
112
+ const bad = (x) => ((x && x.miss) || 0) + ((x && x.at_risk) || 0), all = bad(c);
113
+ const P = (Q.products || []).filter((p) => p && p.name && p.counts).sort((a, b) => ((b.counts.miss || 0) - (a.counts.miss || 0)) || (bad(b.counts) - bad(a.counts)))[0];
114
+ if (P && all && bad(P.counts) * 2 >= all) {
115
+ const n = P.counts.miss || 0, of = totalOf(P.counts);
116
+ why = P.name + ' is most of the gap: ' + (n ? n + ' of its ' + of + ' goals will miss at today’s pace.' : bad(P.counts) + ' of its ' + of + ' goals are at risk.');
117
+ }
118
+ }
119
+ let weeks = '';
120
+ const end = Date.parse(Q.ends || '');
121
+ if (Number.isFinite(Q.weeks)) weeks = Q.weeks > 0 ? plural(Q.weeks, 'week', 'weeks') + ' left' : 'last week';
122
+ else if (Number.isFinite(end) && end > now) { const w = Math.max(1, Math.floor((end - now) / (7 * DAY))); weeks = plural(w, 'week', 'weeks') + ' left'; }
123
+ // Claude's ✦ line, only while the PM has none (GM1 proposeLine); the PM keeps it with ✓ or puts it off with ✕
124
+ const propose = !line && Q.proposal && Number.isFinite(Q.proposal.line) && Q.proposal.line > 0 && Q.proposal.line <= total ? Q.proposal : null;
125
+ // a product that could not be read is said, never counted as fine (the mood is never happier than the data)
126
+ const missing = (Q.missing || []).filter(Boolean);
127
+ const unread = missing.length ? 'Not counted: ' + (missing.length === 1 ? missing[0] : missing.slice(0, -1).join(', ') + ' and ' + missing[missing.length - 1]) + ' could not be read.' : '';
128
+ return { label: Q.label || '', weeks, total, ok, line, short, verdict, vline, below: short > 0, why, moved, propose, unread,
129
+ segs: STATES.map((s) => ({ ...s, n: c[s.id] || 0 })) };
130
+ }
131
+
132
+ /** A goal tag: "Search conversion · at risk", with its dot. null when GM1 gave none (no tag). */
133
+ const tagOf = (tags, ws, ini) => { const g = tags && ini ? tags[(ws || '') + ':' + ini] || tags[':' + ini] : null; return g && g.name && WORD[g.state] ? { text: g.name + ' · ' + WORD[g.state], dot: DOT[g.state] } : null; };
134
+
135
+ /** The compressed agenda: [{ kind: 'now'|'meet'|'pick'|'gap', time, title, sub, past, key, meeting, notes }]. Not to scale: the now line,
136
+ * each meeting with its prep note, ✦ picks in the free time after now, and a free stretch of 90 minutes or more as "3 h free".
137
+ * o: { key: the meeting id the call is decided in, picks: [{ title, sub, yd, key }] (one per free stretch, in order), notes: rows, nowLine } */
138
+ export function agendaOf(t, now, o = {}) {
139
+ const L = (t || []).filter((m) => m && m.start && !Number.isNaN(Date.parse(m.start))).sort((x, y) => Date.parse(x.start) - Date.parse(y.start));
140
+ const out = []; const picks = (o.picks || []).slice();
141
+ const d1 = new Date(now); d1.setHours(18, 0, 0, 0);
142
+ let cur = Math.max(now, (() => { const d = new Date(now); d.setHours(9, 0, 0, 0); return d.getTime(); })());
143
+ let nowShown = !o.nowLine;
144
+ const nowLine = () => { if (!nowShown) { out.push({ kind: 'now', time: at(now) }); nowShown = true; } };
145
+ const free = (a, b) => {
146
+ if (b - a < 30 * MIN) return;
147
+ nowLine();
148
+ const p = picks.shift();
149
+ if (p) out.push({ kind: 'pick', time: at(a), title: p.title, sub: 'Free until ' + at(b) + (p.sub ? ' · ' + p.sub : ''), yd: p.yd || '', key: p.key || '' });
150
+ else if (b - a >= 90 * MIN) out.push({ kind: 'gap', title: dur(Math.round((b - a) / MIN)) + ' free' });
151
+ };
152
+ for (const m of L) {
153
+ const s = span(m), past = m.state === 'past' || s.b <= now;
154
+ if (!past && s.a > cur) free(cur, Math.min(s.a, d1.getTime()));
155
+ if (!past && s.a > now) nowLine();
156
+ const prep = (m.prepared || []).find((x) => x && x.kind === 'prep');
157
+ const key = !!(o.key && (m.id === o.key || m.event_id === o.key));
158
+ const sub = past ? '' : [prep ? 'Prep note ready' : '', key ? 'your call is decided here' : prep ? clip(String(prep.text || '').split(/\r?\n/)[0], 48) : ''].filter(Boolean).join(' · ');
159
+ const notes = past ? (o.notes || []).filter((n) => sameMeeting(n.meeting, m)).flatMap((n) => n.summary_lines || []).slice(0, 2) : [];
160
+ out.push({ kind: 'meet', time: hm(m.start), title: m.title || 'A meeting', sub, past, key, now: m.state === 'now', meeting: m, prep: !!prep, notes, optional: !!m.optional });
161
+ if (m.state === 'now') nowShown = true;
162
+ if (!past) cur = Math.max(cur, s.b);
163
+ }
164
+ if (cur < d1.getTime()) free(cur, d1.getTime());
165
+ nowLine();
166
+ return out;
167
+ }
168
+ const minuteOf = (s) => { const x = Date.parse(s); return Number.isFinite(x) ? Math.floor(x / MIN) : null; };
169
+ /** A note's meeting is this event: the same id, or the same start minute and title (lib/notes.js sameEvent). */
170
+ export function sameMeeting(meeting, e) {
171
+ if (!meeting || meeting.kind !== 'event' || !e) return false;
172
+ if (meeting.event_id && (meeting.event_id === e.id || meeting.event_id === e.event_id)) return true;
173
+ const a = minuteOf(meeting.start);
174
+ return a !== null && a === minuteOf(e.start) && String(meeting.title || '').toLowerCase().trim() === String(e.title || '').toLowerCase().trim();
175
+ }
176
+
177
+ /** The rail's notes: meeting notes (title, when, up to two lines of the summary, where it was filed) and loose recordings (with ✦ to link,
178
+ * only when the read found an initiative). Since yesterday 00:00 (today only in the evening), newest first, at most 3. A private note never
179
+ * shows its lines; a recording still going is the live card's, not this list's. */
180
+ export function notesOf(rows, now, o = {}) {
181
+ const from = dayStart(now) - (o.today ? 0 : DAY);
182
+ return (rows || []).filter((n) => n && n.id && n.state !== 'recording' && Date.parse(n.created_at || '') >= from && (n.segments > 0 || n.words > 0 || n.summary))
183
+ .sort((a, b) => String(b.created_at).localeCompare(String(a.created_at))).slice(0, 3).map((n) => {
184
+ const t = Date.parse(n.created_at), when = (dayStart(t) === dayStart(now) ? '' : 'yesterday ') + at(t);
185
+ const loose = !n.initiative && !(n.meeting && n.meeting.kind === 'event');
186
+ const pick = loose && o.picks && o.picks[n.id] && o.picks[n.id].title ? o.picks[n.id] : null;
187
+ const mins = n.meeting_ms ? Math.max(1, Math.round(n.meeting_ms / MIN)) : 0;
188
+ return { id: n.id, title: n.title || 'Untitled note', when: [when, loose && mins ? mins + ' min' : ''].filter(Boolean).join(' · '), loose,
189
+ lines: n.private ? [] : (n.summary_lines || []).slice(0, 2), filed: n.initiative && n.initiative.title ? 'Filed to ' + n.initiative.title : '', pick };
190
+ });
191
+ }
192
+
193
+ /** The page's model. Y: dayModel's answer (Y.rank from W3); D: Home's data; o: { quarter, tags, notes, picks, today: meetings }. */
194
+ export function gmModel(Y, D, now = Date.now(), o = {}) {
195
+ D = D || {};
196
+ const t = (o.today || []).filter((m) => m && m.start && !Number.isNaN(Date.parse(m.start))).sort((x, y) => Date.parse(x.start) - Date.parse(y.start));
197
+ const state = stateOf(t, now), d = new Date(now), h = d.getHours();
198
+ const name = D.today && D.today.me ? String(D.today.me) : '';
199
+ const hello = (h < 12 ? 'Good morning' : h < 17 ? 'Good afternoon' : 'Good evening') + (name ? ', ' + name : '');
200
+ const date = DOW[d.getDay()] + ' ' + d.getDate() + ' ' + MON[d.getMonth()] + ' · ' + at(now);
201
+ const quarter = quarterOf(o.quarter, state, now);
202
+
203
+ // Your call: the first call only the PM can make, said once (it leaves Your move's rows)
204
+ const c = (Y.calls || [])[0] || null;
205
+ const fx = c && c.effect && c.effect.goal && WORD[c.effect.from] && WORD[c.effect.to] ? c.effect : null;
206
+ const meet = fx && fx.meeting && fx.meeting.start ? fx.meeting : null;
207
+ const call = c ? { id: c.id, name: c.name, blocked: c.state === 'blocked',
208
+ why: [meet ? 'Yes puts it on the ' + hm(meet.start) + ' agenda.' : '', fx ? fx.goal + ' moves from ' + WORD[fx.from] + ' to ' + WORD[fx.to] + '.' : ''].filter(Boolean).join(' ')
209
+ || (c.pick ? 'I’d ' + c.pick.charAt(0).toLowerCase() + c.pick.slice(1).replace(/\.?$/, '.') + ' It’s your call.' : c.when ? 'Goes out ' + c.when + '.' : c.sub || '') } : null;
210
+
211
+ // Your move: the top 3 of W3's ranked list, the call left out (say it once); with no ranked list, the other calls
212
+ const ranked = Y.rank ? Y.rank.rows.filter((r) => !(c && r.kind === 'call' && r.call && r.call.id === c.id)) : (Y.calls || []).slice(1).map((x) => ({ key: 'call:' + x.id, kind: 'call', call: x, title: x.name, sub: '', age: '' }));
213
+ const move = ranked.slice(0, 3).map((r) => ({ ...r, goal: tagOf(o.tags, r.ws, r.ini), late: r.rank <= 2 && r.days != null && r.days > 0 }));
214
+ const more = ranked.length - move.length + (Y.rank ? Y.rank.older.length : 0);
215
+
216
+ // the lead: honest first; a call that moves a goal ties today to the quarter; else Your day's own line
217
+ let line = Y.line, sub = Y.sub;
218
+ if (state === 'soon' && Y.mood !== 'concerned') {
219
+ const nx = t.find((m) => m.state !== 'past' && m.state !== 'now' && span(m).a > now);
220
+ const n = Math.max(1, Math.round((span(nx).a - now) / MIN));
221
+ line = (nx.title || 'Your next meeting') + ' in ' + plural(n, 'minute', 'minutes') + '.';
222
+ sub = Y.chips.length ? '' : Y.sub && !/meeting/.test(Y.sub) ? Y.sub : '';
223
+ } else if (state === 'morning' && Y.mood !== 'concerned' && quarter && quarter.below && fx && better(fx.from, fx.to) && meet) {
224
+ line = 'Today can close part of the gap: your call is decided at ' + hm(meet.start) + '.'; // the call itself is said once, below
225
+ }
226
+ // the header already greets: the lead never says "Morning, Wawan." again (say it once); a festival's wish stays
227
+ const g = /^(?:Morning|Afternoon|Evening)(?:, [^.]+)?\.\s*/;
228
+ if (g.test(line)) { line = line.replace(g, ''); if (!line) { line = sub || ''; sub = ''; } }
229
+ const lead = { ...Y, line, sub, did: Y.chips.length ? 'Done for you: ' + Y.chips.join(', ') : '' };
230
+
231
+ // the next meeting's prep card (G2): what to agree, its bullets, Record as an icon
232
+ let prep = null;
233
+ if (state === 'soon') {
234
+ const nx = t.find((m) => m.state !== 'past' && m.state !== 'now' && span(m).a > now);
235
+ const P = (nx.prepared || []).find((x) => x && x.kind === 'prep');
236
+ const lines = P ? String(P.text || '').split(/\r?\n/).map((s) => s.replace(/^\s*(?:[-*•]|\d+[.)])\s*/, '').replace(/^#+\s*/, '').trim()).filter(Boolean) : [];
237
+ prep = { meeting: nx, k: 'Next · ' + hm(nx.start) + ' · ' + (nx.title || 'A meeting'), head: lines[0] || '', bullets: lines.slice(1, 4),
238
+ files: nx.initiative && nx.initiative.title ? 'Notes file to ' + nx.initiative.title : '' };
239
+ }
240
+
241
+ // the evening: what the day moved (GET /api/phone-day recap.moved: confirmed decisions, items done, drafts), then what is still yours
242
+ const rc = D.phoneDay && D.phoneDay.recap;
243
+ const made = state === 'evening' && rc && Array.isArray(rc.moved) ? rc.moved.filter((x) => x && x.text).map((x) => ({ title: x.text, sub: x.sub || '', goal: x.goal && WORD[x.goal.from] && WORD[x.goal.to] ? x.goal.name + ' · ' + WORD[x.goal.from] + ' → ' + WORD[x.goal.to] : '' })) : [];
244
+
245
+ // the rail: today (picks in free time), or tomorrow after 17:00
246
+ const picks = [];
247
+ if (move[0]) picks.push({ title: '✦ ' + move[0].title, yd: 'mv-open', key: move[0].key });
248
+ if (Y.drafts.length) picks.push({ title: '✦ ' + (Y.drafts.length === 1 ? 'Your draft is ready' : Y.drafts.length + ' drafts are ready'), yd: 'gm-fold', key: 'drafts' });
249
+ const freeMins = gapsOf(t, now).reduce((n, g) => n + Math.round((g.b - g.a) / MIN), 0);
250
+ const tm = Y.tomorrow || (state === 'evening' && D.phoneDay && D.phoneDay.tomorrow && Array.isArray(D.phoneDay.tomorrow.meetings) ? { label: D.phoneDay.tomorrow.label || 'Tomorrow', meetings: D.phoneDay.tomorrow.meetings } : null);
251
+ const rail = state === 'evening'
252
+ ? { title: 'Tomorrow', sub: tm && tm.meetings.length ? (tm.label && tm.label !== 'Tomorrow' ? tm.label + ' · ' : '') + plural(tm.meetings.length, 'meeting', 'meetings') : '', rows: tm ? agendaOf(tm.meetings, dayStart(now) + DAY + 8 * 3600e3, {}) : [] }
253
+ : { title: 'Today', sub: state === 'soon' ? (() => { const nx = t.find((m) => m.state !== 'past' && span(m).a > now); return nx ? 'next at ' + hm(nx.start) : ''; })()
254
+ : [t.length ? plural(t.length, 'meeting', 'meetings') : '', freeMins >= 60 ? dur(freeMins) + ' free' : ''].filter(Boolean).join(' · '),
255
+ rows: agendaOf(t, now, { key: meet && meet.id, picks: state === 'morning' ? picks : picks.slice(1), notes: o.notes, nowLine: true }) };
256
+ if (!rail.rows.some((r) => r.kind === 'meet' || r.kind === 'pick')) rail.rows = [];
257
+ const notes = state === 'soon' ? [] : notesOf(o.notes, now, { today: state === 'evening', picks: o.picks });
258
+
259
+ // Since yesterday: up to 3 of New for you, neutral dots; orange only for a real rise (an item that says so)
260
+ const since = state === 'soon' ? [] : (Y.news || []).slice(0, 3); // the hour before a meeting shows no Since yesterday at all (G2)
261
+ return { state, date, hello, quarter, lead, call, move, more, made, prep, rail, notes, since, newsN: state === 'soon' ? 0 : (Y.news || []).length + (Y.digests || 0),
262
+ drafts: Y.drafts.length, wait: Y.wait.length, concerned: Y.mood === 'concerned' };
263
+ }
264
+
265
+ /* ── markup ── */
266
+ function quarterHtml(Q) {
267
+ // one cell per goal (no inline geometry): the cells grow evenly, so each part's width is its share; the tick sits on the right edge of
268
+ // the cell at the PM's line
269
+ let i = 0;
270
+ const bar = Q.segs.flatMap((s) => Array.from({ length: s.n }, () => { i++; return `<i class="gm-sg ${s.cls}">${Q.line && i === Q.line ? `<span class="gm-tk"><span>line ${Q.line}</span></span>` : ''}</i>`; })).join('');
271
+ const leg = Q.segs.filter((s) => s.n).map((s) => `<span><i class="${s.cls}"></i>${s.n} ${esc(s.word)}</span>`).join('');
272
+ const label = [Q.label, Q.weeks].filter(Boolean).join(' · ');
273
+ const prop = Q.propose ? `<div class="gm-qp"><span>✦ Set your line at ${esc(Q.propose.line)}?${Q.propose.why ? ` <em>${esc(Q.propose.why)}</em>` : ''}</span>`
274
+ + `<button type="button" class="yd-ok sm" data-yd="gm-line" data-n="${esc(Q.propose.line)}" aria-label="${esc('Set the line at ' + Q.propose.line)}" title="Set the line">${svg(CHECK, 18, 2.6)}</button>`
275
+ + `<button type="button" class="yd-alt sm" data-yd="gm-line-off" aria-label="Not this quarter" title="Not this quarter">${svg(XX, 16, 2)}</button></div>` : '';
276
+ return `<section class="gm-q" aria-label="The quarter"><div class="gm-qt">${label ? `<span class="gm-k">${esc(label)}</span>` : ''}<b>${esc(Q.verdict)}${Q.vline ? ` <span class="${Q.below ? 'o' : ''}">${esc(Q.vline)}</span>` : ''}</b>${Q.why ? `<em>${esc(Q.why)}</em>` : ''}${Q.unread ? `<em class="gm-un">${esc(Q.unread)}</em>` : ''}${prop}</div>`
277
+ + `<div class="gm-qb"><div class="gm-bar" role="img" aria-label="${esc(Q.segs.filter((s) => s.n).map((s) => s.n + ' ' + s.word).join(', ') + (Q.line ? '; your line is ' + Q.line : ''))}">${bar}</div><div class="gm-leg">${leg}</div></div>`
278
+ + `<button type="button" class="gm-lnk" data-yd="gm-intel">By product</button></section>`;
279
+ }
280
+ function rowHtml(r) {
281
+ const yes = r.yes ? `<button type="button" class="gm-tc" data-yd="mv-yes" data-key="${esc(r.key)}" aria-label="${esc(r.yes + ': ' + r.title)}" title="${esc(r.yes)}"></button>`
282
+ : r.kind === 'call' ? `<button type="button" class="gm-tc" data-yd="mv-open" data-key="${esc(r.key)}" aria-label="${esc('Open: ' + r.title)}" title="Open"></button>` : '<span class="gm-tc no" aria-hidden="true"></span>';
283
+ const tag = r.goal ? `<span class="gm-gt"><i class="d-${r.goal.dot}"></i>${esc(r.goal.text)}</span>` : r.sub ? `<em>${esc(r.sub)}</em>` : '';
284
+ return `<div class="gm-r" data-key="${esc(r.key)}"${r.kind === 'call' && r.call ? ` data-id="${esc(r.call.id)}"` : ''}>${yes}<button type="button" class="gm-rt" data-yd="mv-open" data-key="${esc(r.key)}"><b>${esc(r.title)}</b>${tag}</button>${r.age ? `<span class="gm-due${r.late ? ' late' : ''}">${esc(r.age)}</span>` : ''}</div>`;
285
+ }
286
+ const doneHtml = (r) => `<div class="gm-r done"><span class="gm-tc dn" aria-hidden="true">${svg(CHECK, 13, 2.6)}</span><span class="gm-rt"><b>${esc(r.title)}</b>${r.goal ? `<span class="gm-gt"><i class="d-o"></i>${esc(r.goal)}</span>` : r.sub ? `<em>${esc(r.sub)}</em>` : ''}</span></div>`;
287
+ function agendaHtml(rows) {
288
+ return rows.map((x) => {
289
+ if (x.kind === 'now') return `<div class="gm-now"><span>${esc(x.time)}</span><i></i></div>`;
290
+ if (x.kind === 'gap') return `<div class="gm-ar"><span></span><div class="gm-gap">${esc(x.title)}</div></div>`;
291
+ if (x.kind === 'pick') return `<div class="gm-ar"><span class="gm-tm">${esc(x.time)}</span><button type="button" class="gm-ab sp" data-yd="${esc(x.yd)}" data-key="${esc(x.key)}"${x.yd === 'gm-fold' ? ` data-fold="${esc(x.key)}"` : ''}><b>${esc(x.title)}</b><em>${esc(x.sub)}</em></button></div>`;
292
+ const notes = x.notes.length ? `<div class="gm-nl">${x.notes.map((n) => `<div>${esc(n)}</div>`).join('')}</div>` : '';
293
+ return `<div class="gm-ar"><span class="gm-tm">${esc(x.time)}</span><button type="button" class="gm-ab mt${x.key ? ' key' : ''}${x.past ? ' past' : ''}${x.optional ? ' opt' : ''}" data-yd="gm-meet" data-id="${esc(x.meeting.id || '')}"><b>${esc(x.title)}${x.optional ? ' · optional' : ''}</b>${x.sub ? `<em>${x.prep ? svg(NOTE, 13) + ' ' : ''}${esc(x.sub)}</em>` : ''}${notes}</button></div>`;
294
+ }).join('');
295
+ }
296
+ function notesHtml(N) {
297
+ return N.map((n) => `<div class="gm-nt"><button type="button" class="gm-ntb" data-yd="gm-note" data-id="${esc(n.id)}">${svg(n.loose ? MIC : NOTE, 14)}<span><b>${esc(n.title)}</b>${n.when ? `<em>${esc(n.when)}</em>` : ''}${n.lines.map((l) => `<span class="gm-ntl">${esc(l)}</span>`).join('')}${n.filed ? `<em>${esc(n.filed)}</em>` : ''}</span></button>`
298
+ + (n.pick ? `<div class="gm-ntp"><span>✦ Link to ${esc(n.pick.title)}?</span><button type="button" class="yd-ok sm" data-yd="gm-link" data-id="${esc(n.id)}" aria-label="${esc('Link it to ' + n.pick.title)}" title="Link it">${svg(LINK, 16, 2.2)}</button></div>` : '') + '</div>').join('');
299
+ }
300
+ /** The page. openHtml: home-day.js openHtml (the lead keeps Your day's opening card: face, line, misses, a failed source, an ask, the prep offer). */
301
+ export function gmHtml(G, openHtml, o = {}) {
302
+ const head = `<header class="gm-hd"><span class="gm-k">${esc(G.date)}</span><h1>${esc(G.hello)}</h1></header>`;
303
+ const q = G.quarter ? quarterHtml(G.quarter) : '';
304
+ let a = `<div class="gm-lead">${openHtml(G.lead, { ...o, gm: true })}</div>`;
305
+ if (G.prep) {
306
+ const P = G.prep;
307
+ a += `<section class="gm-prep"><span class="gm-k">${esc(P.k)}</span>${P.head ? `<h2>${esc(P.head)}</h2>` : ''}${P.bullets.length ? `<ul>${P.bullets.map((b) => `<li>${esc(b)}</li>`).join('')}</ul>` : ''}`
308
+ + `<div class="gm-pr"><button type="button" class="gm-rec" data-yd="gm-rec" data-id="${esc(P.meeting.id || '')}" aria-label="Record this meeting" title="Record">${svg(MIC, 18, 2)}</button>${P.files ? `<em>${esc(P.files)}</em>` : ''}</div></section>`;
309
+ }
310
+ if (G.call && G.state !== 'evening') {
311
+ a += `<section class="gm-sec"><h3 class="gm-sh">Your call</h3><div class="gm-call yd-call${G.call.blocked ? ' am' : ''}" data-id="${esc(G.call.id)}"><div><b>${esc(G.call.name)}</b>${G.call.why ? `<em>${esc(G.call.why)}</em>` : ''}</div>`
312
+ + `<button type="button" class="yd-alt" data-yd="sel" aria-label="${esc('Open: ' + G.call.name)}" title="Open">${svg(CHEV, 18, 2)}</button></div></section>`;
313
+ }
314
+ if (G.made.length) a += `<section class="gm-sec"><h3 class="gm-sh">Today made possible</h3>${G.made.map(doneHtml).join('')}</section>`;
315
+ const quiet = [G.more > 0 && G.state !== 'evening' ? `<button type="button" data-yd="gm-fold" data-fold="more"><b>${G.more}</b> more in Your move${svg(CHEV, 14, 2)}</button>` : '',
316
+ G.drafts ? `<button type="button" data-yd="gm-fold" data-fold="drafts"><b>${G.drafts}</b> drafted for you${svg(CHEV, 14, 2)}</button>` : '',
317
+ G.wait ? `<button type="button" data-yd="gm-fold" data-fold="wait"><b>${G.wait}</b> can wait${svg(CHEV, 14, 2)}</button>` : ''].filter(Boolean).join('');
318
+ if (G.move.length) a += `<section class="gm-sec gm-move"><h3 class="gm-sh">${G.state === 'evening' ? 'Still yours' : 'Your move'} <span class="gm-n">${G.move.length}</span></h3>${G.move.map(rowHtml).join('')}${quiet ? `<div class="gm-more">${quiet}</div>` : ''}</section>`;
319
+ else if (quiet) a += `<div class="gm-more">${quiet}</div>`;
320
+ const b = G.since.length || G.newsN ? `<section class="gm-sec gm-since"><h3 class="gm-sh">Since yesterday${G.since.length ? `<button type="button" class="yd-all" data-yd="seen" aria-label="Mark all ${G.newsN} seen" title="Mark all seen">${svg(MARK2, 18, 1.9)}</button>` : ''}</h3>`
321
+ + `<div class="gm-sl">${G.since.map((it) => `<button type="button" class="gm-si${it.rise ? ' hot' : ''}" data-yd="news" data-id="${esc(it.id)}"><i aria-hidden="true"></i><span>${esc(it.kind === 'task' ? String(it.title || '').replace(/^New task: /, '') : it.title)}</span>${it.at ? `<em>${esc(at(it.at))}</em>` : ''}</button>`).join('')}</div>`
322
+ + `<button type="button" class="gm-lnk" data-yd="news-all">New for you · ${G.newsN}</button></section>` : '';
323
+ const R = G.rail;
324
+ const rail = R.rows.length || G.notes.length ? `<aside class="gm-rail"><div class="gm-rh"><b>${esc(R.title)}</b>${R.sub ? `<span>${esc(R.sub)}</span>` : ''}</div>${R.rows.length ? `<div class="gm-ag">${agendaHtml(R.rows)}</div>` : ''}`
325
+ + (G.notes.length ? `<div class="gm-notes"><h3 class="gm-sh">${G.state === 'evening' ? 'Notes today' : 'Notes since yesterday'}<button type="button" class="gm-lnk" data-yd="gm-notes">All notes</button></h3>${notesHtml(G.notes)}</div>` : '') + '</aside>' : '';
326
+ return `<div class="gm gm-${esc(G.state)}">${head}${q}<div class="gm-x${rail ? '' : ' solo'}"><div class="gm-a">${a}</div>${rail}${b ? `<div class="gm-b">${b}</div>` : ''}</div></div>`;
327
+ }