create-pmos 0.132.0 → 0.134.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 (64) 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/bin/pmos.js +18 -2
  7. package/workspace/guide/index.md +1 -0
  8. package/workspace/guide/notes.md +19 -1
  9. package/workspace/guide/welcome.md +38 -0
  10. package/workspace/lib/import-overlay.js +32 -0
  11. package/workspace/lib/notes-audio.js +28 -9
  12. package/workspace/lib/notes-capture.js +128 -0
  13. package/workspace/lib/notes-live-guard.js +117 -0
  14. package/workspace/lib/notes-whisper.js +132 -16
  15. package/workspace/lib/notes.js +4 -2
  16. package/workspace/lib/route-modules.js +1 -0
  17. package/workspace/lib/routes-notes.js +60 -8
  18. package/workspace/lib/routes-onboarding.js +215 -0
  19. package/workspace/lib/vendor/scripts/anchor-resolve.js +351 -0
  20. package/workspace/lib/vendor/scripts/delivery-index.js +356 -0
  21. package/workspace/lib/vendor/scripts/intake.js +78 -0
  22. package/workspace/lib/vendor/scripts/lane-defs.js +226 -0
  23. package/workspace/lib/vendor/scripts/migrate-layout.js +775 -0
  24. package/workspace/lib/vendor/scripts/nine-stages.js +81 -0
  25. package/workspace/lib/vendor/scripts/onboarding/apply.js +110 -0
  26. package/workspace/lib/vendor/scripts/onboarding/brief.js +154 -0
  27. package/workspace/lib/vendor/scripts/onboarding/first-briefing.js +41 -0
  28. package/workspace/lib/vendor/scripts/onboarding/first-look.js +84 -0
  29. package/workspace/lib/vendor/scripts/onboarding/infer-brief.js +70 -0
  30. package/workspace/lib/vendor/scripts/onboarding/proposals.js +98 -0
  31. package/workspace/lib/vendor/scripts/onboarding/scan-worker.js +27 -0
  32. package/workspace/lib/vendor/scripts/onboarding/scan.js +859 -0
  33. package/workspace/lib/vendor/scripts/onboarding/structure.js +217 -0
  34. package/workspace/lib/vendor/scripts/plan-items-stage.js +271 -0
  35. package/workspace/lib/vendor/scripts/read-jsonl.js +53 -0
  36. package/workspace/lib/vendor/scripts/signals/layout-paths.js +43 -0
  37. package/workspace/lib/vendor/scripts/status-facts.js +214 -0
  38. package/workspace/lib/vendor/scripts/trust/import-guard.js +36 -0
  39. package/workspace/lib/vendor/scripts/trust/ledger.js +53 -0
  40. package/workspace/lib/vendor/scripts/workflow-state.js +1888 -0
  41. package/workspace/lib/vendor/workspace/lib/actor.js +91 -0
  42. package/workspace/lib/vendor/workspace/lib/board-cache.js +156 -0
  43. package/workspace/lib/vendor/workspace/lib/console-state.js +295 -0
  44. package/workspace/lib/vendor/workspace/lib/doc-category.js +227 -0
  45. package/workspace/lib/vendor/workspace/lib/factory-join.js +158 -0
  46. package/workspace/lib/vendor/workspace/lib/git-info.js +306 -0
  47. package/workspace/lib/vendor/workspace/lib/plan-items.js +922 -0
  48. package/workspace/lib/vendor/workspace/lib/plan-tree.js +419 -0
  49. package/workspace/lib/vendor/workspace/lib/review-gate.js +39 -0
  50. package/workspace/lib/vendor/workspace/lib/sink.js +321 -0
  51. package/workspace/lib/vendor/workspace/lib/state-root.js +587 -0
  52. package/workspace/package-lock.json +2 -2
  53. package/workspace/package.json +1 -1
  54. package/workspace/public/pm/calendar-views.js +25 -12
  55. package/workspace/public/pm/components.css +280 -0
  56. package/workspace/public/pm/console3.css +19 -0
  57. package/workspace/public/pm/shell-recording.js +122 -12
  58. package/workspace/public/pm/view-notes.js +7 -2
  59. package/workspace/public/pm/welcome.js +283 -0
  60. package/workspace/public/welcome.html +20 -0
  61. package/workspace/server.js +2 -2
  62. package/workspace/test-files.json +2 -0
  63. package/workspace/test-lists/meeting-notes.json +5 -3
  64. package/workspace/test-lists/onboarding-firstrun.json +15 -0
@@ -3,7 +3,7 @@
3
3
  * (.calbar, .lens, .seg, .ib), the Today chip (.tdy), the lens header (.lh), the day timeline (.vday, .vb), the month grid
4
4
  * (.cal, .cd), the progress chart (svg.pg: progSVG, line 2712), the direction card (.fc.dir with .dial and .sevs), the key
5
5
  * result rows (.krg, .krt) and the swipe and key gestures. The styles are pm/console3.css (see pm/console3-lift.md).
6
- * This file keeps the data call and draws what the server sent. It reads GET /api/calendar-views only and writes nothing.
6
+ * This file keeps the data call and draws what the server sent. It reads GET /api/calendar-views only; its Record button starts, resumes or stops a recording through pm/note-dock.js.
7
7
  * A KR without a metric reads "no metric" and is not scored; an empty lens says why; nothing is invented.
8
8
  *
9
9
  * mountCalendarViews(host, ctx) is safe to call on every render. disposeCalendarViews(host) drops the host's state. */
@@ -18,6 +18,7 @@ import { renderCase } from './c3-case.js';
18
18
  import { makeXp, attachExpand } from './c3-expand.js';
19
19
  import { mountQuarter, mountYear, dispose as disposeStrategy } from './c3-cal-strategy.js'; // the Quarter and Year panes
20
20
  import { simDock, simStart, simState, isDemo } from './shell-recording.js';
21
+ import * as dock from './note-dock.js';
21
22
  import { drawCalendarPage } from './c3-calendar.js'; // the Calendar tab as a page about time (ctx.page)
22
23
 
23
24
  /* ── shared state of the /c3.html page (console3 wave C3) ──────────────────────────────────────────────────────────
@@ -597,18 +598,30 @@ function scrubWire(v, timed, end, t0, pph) {
597
598
  /* ── the stage under a day's cards on the Calendar tab: the mockup's Before, During and After (stageHtml line 2598) ── */
598
599
 
599
600
  const autoPhase = (e) => (endH(e) <= nowH() ? 'after' : timeOf(e.at) <= nowH() ? 'during' : 'before');
600
- const NOREC = 'Nothing on a page can record. Recording happens in the main app\'s note dock.';
601
601
  const IX2 = () => (typeof window !== 'undefined' && window.__c3cal2) || null; // c3-demo-cal2.js (demo only): the After notes, the Week grid, the progress rows
602
602
  const simOn = () => simState().mode !== 'idle';
603
- const noRecNode = () => el('div', { class: 'fold', 'data-cv': 'norec' }, c3ic('mic', 'sm'), NOREC + ' ', el('a', { href: '/', target: '_blank', rel: 'noopener', 'data-cv': 'main-app' }, 'Open the main app'));
603
+ const realMode = () => { try { return dock.recordState().mode; } catch { return 'idle'; } };
604
604
 
605
605
  /* the Record button of the During and After stages (the mockup's data-a="nlive"). In demo it drives the simulated recorder, which the top bar's
606
- * recording group shows (shell-recording.js). In real mode it is disabled: a page records nothing, so it says where recording happens. */
607
- function recordBtn(m) {
606
+ * recording group shows (shell-recording.js). In real mode it records through the note dock, the same recorder as the top bar's Record control:
607
+ * Record starts a note linked to this calendar event, Resume continues a paused meeting, Stop asks first. `ev` is the calendar cell's event. */
608
+ const asEvent = (ev) => ({ ...ev, id: baseId(ev), start: ev.start || ev.at, end: ev.end || null });
609
+ function recordBtn(m, ev, redraw) {
608
610
  const demo = isDemo(); const on = demo && simOn();
609
- return el('button', { type: 'button', class: 'ib pri', style: 'width:auto;padding:0 14px;gap:6px;display:inline-flex;align-items:center', 'data-cv': 'record', disabled: !demo,
610
- title: demo ? (on ? 'Stop and write final notes' : 'Record (a simulation: the demo records nothing)') : NOREC + ' Use the link below.',
611
- onClick: () => { if (simOn()) simDock.stopNow(); else simStart(m.title); } }, c3ic(on ? 'stop' : 'mic'), on ? 'Stop' : 'Record');
611
+ const mode = demo ? 'idle' : realMode();
612
+ const label = demo ? (on ? 'Stop' : 'Record') : mode === 'recording' ? 'Stop' : mode === 'paused' ? 'Resume' : 'Record';
613
+ const done = () => { if (redraw) setTimeout(redraw, 300); };
614
+ const real = async () => {
615
+ if (mode === 'recording') await dock.stopNow();
616
+ else if (mode === 'paused') await dock.resumeNow();
617
+ else if (ev) await dock.recordEvent(asEvent(ev));
618
+ done();
619
+ };
620
+ const title = demo ? (on ? 'Stop and write final notes' : 'Record (a simulation: the demo records nothing)') : mode === 'recording' ? 'Stop and write final notes' : mode === 'paused' ? 'Resume the paused meeting' : 'Record this meeting. Notes stay on this machine.';
621
+ const b = c3ib(title, label === 'Stop' ? 'stop' : 'mic', { 'data-cv': 'record', 'aria-label': label,
622
+ onClick: () => { if (!demo) { real(); return; } if (simOn()) simDock.stopNow(); else simStart(m.title); } }, 'pri c3cv-rec');
623
+ b.append(label);
624
+ return b;
612
625
  }
613
626
  const liveBody = () => [el('div', { class: 'wave', 'aria-hidden': 'true' }, ...new Array(28).fill(0).map(() => el('i'))),
614
627
  el('div', { class: 'term', 'data-cv': 'live-text' }, (IX2() && IX2().NOTES.live) || 'Dara: the keys are the real blocker, not the scope.\nYou: then we cut to three flows and keep 24 Oct.\n[highlight] decision: cut to three flows\n[caught] action: Dara confirms sandbox keys'),
@@ -618,9 +631,9 @@ const avN = (n) => el('span', { class: 'av', title: n }, String(n)[0]);
618
631
  /* the After stage's notes card: the mockup's notesView (line 1494). Decisions, actions with owner and due, routing, and who was told what. */
619
632
  function notesCard(host, r, m) {
620
633
  const N = isDemo() && IX2() && IX2().NOTES; const redraw = () => refreshSel(host, r);
621
- const head = el('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:10px' }, el('h3', null, c3ic('notes'), 'Notes'), recordBtn(m));
634
+ const head = el('div', { class: 'c3cv-head' }, el('h3', null, c3ic('notes'), 'Notes'), recordBtn(m, selEvent(r), redraw));
622
635
  if (isDemo() && simOn()) return el('section', { class: 'card', 'data-cv': 'notes' }, head, ...liveBody());
623
- if (!N) return el('section', { class: 'card', 'data-cv': 'notes' }, head, el('div', { class: 'fold', 'data-cv': 'no-notes' }, c3ic('moon', 'sm'), 'No notes, decisions or actions are held for this meeting. This page reads the calendar and records nothing.'), isDemo() ? null : noRecNode());
636
+ if (!N) return el('section', { class: 'card', 'data-cv': 'notes' }, head, el('div', { class: 'fold', 'data-cv': 'no-notes' }, c3ic('moon', 'sm'), 'No notes, decisions or actions are held for this meeting. Press Record to take them. Notes stay on this machine.'));
624
637
  const nt = r.nt || (r.nt = { owners: {}, due: {}, logged: {}, diff: false, sent: false });
625
638
  const sel = (label, opts, val, on) => el('select', { 'aria-label': label, onChange: (e) => on(e.target.value) }, ...opts.map((x) => el('option', { value: x, selected: x === val }, x || 'No owner')));
626
639
  const small2 = 'font-size:var(--fs-2)';
@@ -681,8 +694,8 @@ function stageNode(host, r) {
681
694
  } else if (ph === 'during') {
682
695
  const sim = isDemo() && simOn();
683
696
  body = el('section', { class: 'card', 'data-cv': 'stage-during' },
684
- el('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:10px' }, el('h3', null, c3ic('mic'), 'Live notes'), recordBtn(m)),
685
- sim ? liveBody() : isDemo() ? el('div', { class: 'fold' }, c3ic('mic', 'sm'), 'press Record. Notes stay on this machine.') : noRecNode());
697
+ el('div', { class: 'c3cv-head' }, el('h3', null, c3ic('mic'), 'Live notes'), recordBtn(m, e, () => refreshSel(host, r))),
698
+ sim ? liveBody() : el('div', { class: 'fold', 'data-cv': 'live-idle' }, c3ic('mic', 'sm'), realMode() === 'idle' ? 'Press Record. Notes stay on this machine.' : 'A meeting is ' + (realMode() === 'recording' ? 'recording' : 'paused') + '. Use the Record control in the top bar to mark a moment, pause or stop.'));
686
699
  } else {
687
700
  const D = m.demo ? [['Note to Priya: 24 Oct is conditional', 'out'], ['Note to design: scope decision', 'out']] : [];
688
701
  const drafts = el('section', { class: 'card', 'data-cv': 'stage-after' }, el('h3', null, c3ic('spark'), 'Drafted after the meeting'),
@@ -7965,5 +7965,285 @@
7965
7965
  containers). The tab strip and the scratchpad are drawn with inline radii (workspace-cockpit-nav); the inline radii
7966
7966
  (8px tab strip, 6px controls) stay. A tab's colour dot is a dot and keeps its circle. */
7967
7967
 
7968
+ /* -- layouts · .wl- Welcome: the first-run page (onboarding-scan-and-map slice 2) -- */
7969
+ .wl-page {
7970
+ background: var(--background);
7971
+ }
7972
+ .wl-body {
7973
+ margin: 0;
7974
+ background: var(--background);
7975
+ color: var(--foreground);
7976
+ font-family: var(--sans);
7977
+ font-size: 16px;
7978
+ line-height: 1.5;
7979
+ }
7980
+ .wl-wrap {
7981
+ max-width: 720px;
7982
+ margin: 0 auto;
7983
+ padding: 24px 16px 64px;
7984
+ box-sizing: border-box;
7985
+ }
7986
+ .wl-wrap * {
7987
+ box-sizing: border-box;
7988
+ }
7989
+ .wl-steps {
7990
+ display: flex;
7991
+ flex-wrap: wrap;
7992
+ gap: 6px;
7993
+ margin: 0 0 20px;
7994
+ padding: 0;
7995
+ list-style: none;
7996
+ font-family: var(--mono);
7997
+ font-size: 12px;
7998
+ color: var(--muted-foreground);
7999
+ }
8000
+ .wl-steps li {
8001
+ padding: 2px 8px;
8002
+ border: 1px solid var(--border);
8003
+ border-radius: 6px;
8004
+ }
8005
+ .wl-steps li[aria-current="step"] {
8006
+ color: var(--foreground);
8007
+ border-color: var(--foreground);
8008
+ }
8009
+ .wl-h1 {
8010
+ font-size: 20px;
8011
+ line-height: 1.2;
8012
+ margin: 0 0 8px;
8013
+ font-weight: 600;
8014
+ }
8015
+ .wl-h2 {
8016
+ font-size: 16px;
8017
+ margin: 0;
8018
+ font-weight: 600;
8019
+ }
8020
+ .wl-p {
8021
+ margin: 0 0 12px;
8022
+ }
8023
+ .wl-muted {
8024
+ color: var(--muted-foreground);
8025
+ }
8026
+ .wl-card {
8027
+ background: var(--card);
8028
+ border: 1px solid var(--border);
8029
+ border-radius: 8px;
8030
+ padding: 14px 16px;
8031
+ margin: 0 0 14px;
8032
+ }
8033
+ .wl-path {
8034
+ font-family: var(--mono);
8035
+ font-size: 12px;
8036
+ color: var(--muted-foreground);
8037
+ word-break: break-all;
8038
+ }
8039
+ .wl-promise {
8040
+ font-family: var(--mono);
8041
+ font-size: 12px;
8042
+ margin: 16px 0;
8043
+ padding: 10px 12px;
8044
+ border-left: 3px solid var(--foreground);
8045
+ background: var(--muted);
8046
+ }
8047
+ .wl-actions {
8048
+ display: flex;
8049
+ flex-wrap: wrap;
8050
+ align-items: center;
8051
+ gap: 12px;
8052
+ margin-top: 18px;
8053
+ }
8054
+ .wl-btn {
8055
+ font: inherit;
8056
+ cursor: pointer;
8057
+ border-radius: 8px;
8058
+ padding: 10px 16px;
8059
+ min-height: 44px;
8060
+ border: 1px solid var(--border);
8061
+ background: var(--secondary);
8062
+ color: var(--foreground);
8063
+ text-decoration: none;
8064
+ display: inline-flex;
8065
+ align-items: center;
8066
+ justify-content: center;
8067
+ }
8068
+ .wl-btn.wl-primary {
8069
+ background: var(--primary);
8070
+ color: var(--primary-foreground);
8071
+ border-color: var(--primary);
8072
+ font-weight: 600;
8073
+ }
8074
+ .wl-btn:disabled {
8075
+ opacity: 0.55;
8076
+ cursor: default;
8077
+ }
8078
+ .wl-btn.wl-quiet {
8079
+ background: none;
8080
+ border: none;
8081
+ color: var(--muted-foreground);
8082
+ text-decoration: underline;
8083
+ padding: 8px 4px;
8084
+ min-height: 36px;
8085
+ }
8086
+ .wl-btn.wl-small {
8087
+ min-height: 32px;
8088
+ padding: 4px 10px;
8089
+ font-size: 14px;
8090
+ }
8091
+ .wl-btn:focus-visible,
8092
+ .wl-input:focus-visible,
8093
+ .wl-check:focus-visible {
8094
+ outline: 2px solid color-mix(in oklab, var(--ring) 50%, var(--foreground));
8095
+ outline-offset: 2px;
8096
+ }
8097
+ .wl-spin {
8098
+ display: inline-block;
8099
+ width: 14px;
8100
+ height: 14px;
8101
+ border: 2px solid var(--border);
8102
+ border-top-color: var(--foreground);
8103
+ border-radius: 6px;
8104
+ animation: wlspin 0.8s linear infinite;
8105
+ margin-right: 8px;
8106
+ }
8107
+ @keyframes wlspin {
8108
+ to {
8109
+ transform: rotate(360deg);
8110
+ }
8111
+ }
8112
+ .wl-error {
8113
+ border: 1px solid var(--destructive);
8114
+ color: var(--destructive);
8115
+ border-radius: 8px;
8116
+ padding: 10px 12px;
8117
+ margin: 0 0 14px;
8118
+ }
8119
+ .wl-findings {
8120
+ margin: 0;
8121
+ padding-left: 22px;
8122
+ }
8123
+ .wl-findings li {
8124
+ margin: 0 0 12px;
8125
+ }
8126
+ .wl-ref {
8127
+ font-family: var(--mono);
8128
+ font-size: 12px;
8129
+ color: var(--muted-foreground);
8130
+ word-break: break-all;
8131
+ }
8132
+ .wl-board > header {
8133
+ display: flex;
8134
+ flex-wrap: wrap;
8135
+ justify-content: space-between;
8136
+ gap: 8px;
8137
+ align-items: baseline;
8138
+ margin-bottom: 8px;
8139
+ }
8140
+ .wl-row {
8141
+ display: grid;
8142
+ grid-template-columns: auto 1fr;
8143
+ gap: 6px 10px;
8144
+ padding: 10px 0;
8145
+ border-top: 1px solid var(--border);
8146
+ align-items: start;
8147
+ }
8148
+ .wl-check {
8149
+ width: 20px;
8150
+ height: 20px;
8151
+ margin: 10px 0 0;
8152
+ }
8153
+ .wl-input {
8154
+ font: inherit;
8155
+ color: var(--foreground);
8156
+ background: var(--background);
8157
+ border: 1px solid var(--input);
8158
+ border-radius: 6px;
8159
+ padding: 6px 8px;
8160
+ min-height: 36px;
8161
+ max-width: 100%;
8162
+ }
8163
+ .wl-row .wl-input[type="text"] {
8164
+ width: 100%;
8165
+ }
8166
+ .wl-meta {
8167
+ display: flex;
8168
+ flex-wrap: wrap;
8169
+ gap: 6px 10px;
8170
+ align-items: center;
8171
+ font-family: var(--mono);
8172
+ font-size: 12px;
8173
+ color: var(--muted-foreground);
8174
+ }
8175
+ .wl-chip {
8176
+ border: 1px solid var(--border);
8177
+ border-radius: 6px;
8178
+ padding: 1px 8px;
8179
+ }
8180
+ .wl-docs {
8181
+ margin: 4px 0 0;
8182
+ padding-left: 18px;
8183
+ font-family: var(--mono);
8184
+ font-size: 12px;
8185
+ color: var(--muted-foreground);
8186
+ word-break: break-all;
8187
+ }
8188
+ .wl-q {
8189
+ margin: 0 0 14px;
8190
+ }
8191
+ .wl-label {
8192
+ display: block;
8193
+ margin: 4px 0;
8194
+ }
8195
+ .wl-q .wl-free {
8196
+ margin-top: 10px;
8197
+ color: var(--muted-foreground);
8198
+ }
8199
+ .wl-q .wl-free .wl-input {
8200
+ display: block;
8201
+ margin-top: 4px;
8202
+ }
8203
+ .wl-added {
8204
+ margin: 8px 0 0;
8205
+ padding-left: 18px;
8206
+ font-size: 14px;
8207
+ }
8208
+ .wl-sources {
8209
+ list-style: none;
8210
+ margin: 0 0 12px;
8211
+ padding: 0;
8212
+ display: grid;
8213
+ gap: 12px;
8214
+ }
8215
+ .wl-source {
8216
+ display: flex;
8217
+ flex-wrap: wrap;
8218
+ justify-content: space-between;
8219
+ align-items: center;
8220
+ gap: 12px;
8221
+ padding-bottom: 12px;
8222
+ border-bottom: 1px solid var(--border);
8223
+ }
8224
+ .wl-source-text {
8225
+ flex: 1 1 260px;
8226
+ min-width: 0;
8227
+ }
8228
+ .wl-source-actions {
8229
+ display: flex;
8230
+ flex-wrap: wrap;
8231
+ align-items: center;
8232
+ gap: 8px;
8233
+ }
8234
+ @media (max-width: 520px) {
8235
+ .wl-actions {
8236
+ flex-direction: column;
8237
+ align-items: stretch;
8238
+ }
8239
+ .wl-actions .wl-btn {
8240
+ width: 100%;
8241
+ }
8242
+ .wl-actions .wl-btn.wl-quiet {
8243
+ width: auto;
8244
+ align-self: center;
8245
+ }
8246
+ }
8247
+
7968
8248
  /* -- layouts · .cal3- the console3 Calendar page (calendar-time-page): its rules sit in console3.css beside the .vday rules they extend -- */
7969
8249
  /* -- end of the layout sections: add nothing below this line -- */
@@ -1699,6 +1699,25 @@
1699
1699
  .c3 .c3hold .hb.off{opacity:.55;cursor:not-allowed}
1700
1700
  .c3 .c3hold .hint{font:var(--fs-1) var(--mono);color:var(--muted-foreground)}
1701
1701
  .c3 .c3hold .cq{display:flex;gap:var(--sp-2);align-items:center;flex-wrap:wrap;font:var(--fs-2) var(--mono)}
1702
+ /* The Record control, always in the top bar (shell-recording.js, real mode). Left, while a meeting records or is paused: the red .recg group
1703
+ * with the chip, Highlight, Pause or Resume, and Stop. Right, always: the neutral .recg.idle group with New and the arrow that lists today's meetings. */
1704
+ .c3 .c3rec{position:relative;display:inline-flex;align-items:stretch;gap:6px;z-index:31}
1705
+ .c3 .c3rec .recg.idle{color:var(--foreground);border-color:var(--border);background:var(--card)}
1706
+ .c3 .c3rec .recg.idle>*+*{border-left-color:var(--border)}
1707
+ .c3 .c3rec .recg.idle .ic{color:var(--destructive)}
1708
+ .c3 .c3rec .recg .ic{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
1709
+ .c3 .c3rec .recg .c3rec__chip{max-width:44ch}
1710
+ .c3 .c3rec .c3rec__eng{margin-left:6px;font-size:11px;color:var(--muted-foreground);white-space:nowrap}
1711
+ .c3.ph .c3rec .c3rec__eng{display:none}
1712
+ .c3 .c3rec .c3rec__off{opacity:.45;cursor:not-allowed}
1713
+ @media (hover:hover){.c3 .c3rec .recg.idle button:hover{background:var(--muted)}.c3 .c3rec .c3rec__off:hover{background:none}}
1714
+ .c3 .c3rec .recg button>span{display:inline-flex;align-items:center;gap:6px;min-width:0}
1715
+ /* the calendar's Record button (calendar-views.js recordBtn) and the card heads it sits in */
1716
+ .c3 .ib.c3cv-rec{width:auto;padding:0 14px;gap:6px;display:inline-flex;align-items:center}
1717
+ .c3 .c3cv-head{display:flex;justify-content:space-between;align-items:center;gap:10px}
1718
+ .c3 .c3rec .c3rec__pop{position:absolute;top:calc(100% + 6px);right:0;z-index:40;min-width:280px}
1719
+ .c3.ph .c3rec .c3rec__lbl{display:none}
1720
+ .c3.ph .c3rec .c3rec__live{order:-1}
1702
1721
  /* ===== the Work page (pm/pm-work-view.js, initiative console3-work-page): Focus, Upcoming by day, the weekly Review, offline work and the
1703
1722
  * shared actions. Tokens only; corners 6px on a control and 8px on a container. */
1704
1723
  .c3 .wk-min{min-width:0}
@@ -1,14 +1,18 @@
1
- /* pm/shell-recording.js — the recording indicator beside the bell (console3 wave E2b).
1
+ /* pm/shell-recording.js — the Record control in console3's top bar (console3 wave E2b; the Record control port).
2
2
  *
3
3
  * mountRecording(host) / disposeRecording(host)
4
4
  *
5
- * It starts nothing. It reads the real recorder through note-dock.js (recordState) and offers only the controls the Record control in
6
- * the top bar already has: open the note, mark a moment (highlightNow), pause, resume and stop (pauseNow, resumeNow, stopNow). When
7
- * nothing is recording or paused the group is hidden. While one is, a red dot sits on the Notes tab (`[data-fr-dest="notes"]` in the
8
- * frame's bar; a phone tab `[data-dest="notes"]` if one is drawn). Lifted: console3.html `.recg` and `recCtl` (lines 916 to 924, 4385
9
- * to 4400). The group shows the recorder's state under reduced motion as a static dot. The clock is the recorder's own start time.
5
+ * The control is always there, idle included, with the same capabilities as the main app's Record control (pm/topbar.js, pm/note-dock.js):
6
+ * New (an untitled note that records at once), an arrow that lists today's meetings that have not started (a pick makes a note linked to that
7
+ * calendar event and records it), and, while a meeting records or is paused, a chip that names it with Highlight, Pause or Resume, and Stop
8
+ * (Stop asks first and offers "Pause instead"). All of it goes through pm/note-dock.js, which owns the real recorder, so the audio capture of
9
+ * pm/view-notes.js runs here without the main app's Notes place on screen. The chip opens the note in the main app, because console3 has no
10
+ * Notes place. While one meeting records, a red dot sits on the Calendar tab (`[data-c3-tab="cal"]`) and on a Notes tab if a frame draws one.
11
+ * On /c3.html?demo=1 the control drives a SIMULATED recorder (see below): it opens no microphone and saves nothing, and it shows only while
12
+ * the simulation runs. Lifted: console3.html `.recg` and `recCtl` (lines 916 to 924, 4385 to 4400). The clock is the recorder's own start time.
10
13
  */
11
14
  import { el } from './dom.js';
15
+ import { button } from './primitives.js';
12
16
  import { ensureC3Css, c3ic } from './pm-work-view.js';
13
17
  import * as dock from './note-dock.js';
14
18
  import { recView, clockOf } from './shell-model.js';
@@ -20,10 +24,10 @@ import { toast } from './c3-toast.js';
20
24
  const TAB = '[data-fr-dest="notes"], #mobNav [data-dest="notes"], [data-c3-tab="cal"]';
21
25
 
22
26
  /* ── demo mode: a SIMULATED recorder ──────────────────────────────────────────────────────────────────────────────────────────────
23
- * A page cannot record (nothing on a page may record). On /c3.html?demo=1 the Record button of the Calendar's During and After stages drives
24
- * this stand-in, so the top bar shows the mockup's recording group: a pulsing dot, the clock, mark moment, pause or resume, and stop.
25
- * It keeps a clock (performance.now: the demo freezes Date) and nothing else. No microphone is opened and nothing is saved.
26
- * Real mode never reaches it: `isDemo()` reads the page's data-c3-demo mark, which only the demo sets. */
27
+ * On /c3.html?demo=1 the Record button of the Calendar's During and After stages drives this stand-in, so the top bar shows the mockup's
28
+ * recording group: a pulsing dot, the clock, mark moment, pause or resume, and stop. It keeps a clock (performance.now: the demo freezes
29
+ * Date) and nothing else. No microphone is opened and nothing is saved. Real mode never reaches it: `isDemo()` reads the page's
30
+ * data-c3-demo mark, which only the demo sets. */
27
31
  export const isDemo = () => typeof document !== 'undefined' && !!document.querySelector('[data-c3-demo]');
28
32
  const SIM = { mode: 'idle', id: 'sim', title: '', started: 0, pausedAt: 0 };
29
33
  const tick = () => performance.now();
@@ -50,7 +54,8 @@ export function mountRecording(host, opts) {
50
54
  if (!host) return;
51
55
  disposeRecording(host);
52
56
  ensureC3Css();
53
- const d = (opts && opts.dock) || (isDemo() ? simDock : dock);
57
+ if (!(opts && opts.dock) && !isDemo()) return mountReal(host);
58
+ const d = (opts && opts.dock) || simDock;
54
59
  const nowMs = () => (d.now ? d.now() : Date.now());
55
60
  const grp = el('span', { class: 'recg', role: 'group', 'aria-label': 'Recording', hidden: true, 'data-shell': 'recording' });
56
61
  host.insertBefore(grp, host.firstChild);
@@ -80,7 +85,8 @@ export function mountRecording(host, opts) {
80
85
  else {
81
86
  rec.clock = el('span', { class: 'recclk', 'data-shell-rec-clock': '' });
82
87
  const open = el('button', { type: 'button', title: (v.mode === 'recording' ? 'Recording: ' : 'Paused: ') + v.title + '. Open the live note', 'aria-label': 'Open the note: ' + v.title, 'data-shell-rec-open': '', onclick: () => d.openDock(st.id) },
83
- el('span', { class: 'recdot' + (v.dot === 'static' ? ' static' : ''), 'data-dot': v.dot }), rec.clock, el('span', { class: 'rt' }, v.title));
88
+ el('span', { class: 'recdot' + (v.dot === 'static' ? ' static' : ''), 'data-dot': v.dot }), rec.clock, el('span', { class: 'rt' }, v.title),
89
+ ...(eng.text ? [el('span', { class: 'c3rec__eng', 'data-fr-rec-engine': '', title: 'The live transcript: engine and language (Notes settings)' }, eng.text)] : []));
84
90
  const kids = [open];
85
91
  if (v.canMark) kids.push(btn('Mark this moment', 'highlighter', () => d.highlightNow(), 'data-shell-rec-mark'));
86
92
  if (v.canPause) kids.push(btn('Pause', 'pause', () => d.pauseNow(), 'data-shell-rec-pause'));
@@ -103,8 +109,112 @@ export function disposeRecording(host) {
103
109
  const rec = host && live.get(host);
104
110
  if (!rec) return;
105
111
  clearInterval(rec.timer);
112
+ if (rec.off) rec.off();
106
113
  if (rec.onSim) window.removeEventListener('c3-sim-rec', rec.onSim);
107
114
  rec.grp.remove();
108
115
  for (const t of document.querySelectorAll(TAB)) { const dot = t.querySelector('.shell-recdot'); if (dot) dot.remove(); }
109
116
  live.delete(host);
110
117
  }
118
+
119
+ /* ── real mode: the always-visible Record control ─────────────────────────────────────────────────────────────────────────────────── */
120
+
121
+ const NS_SVG = 'http://www.w3.org/2000/svg';
122
+ const glyph = (d) => { const s = document.createElementNS(NS_SVG, 'svg'); s.setAttribute('class', 'ic sm'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true'); const p = document.createElementNS(NS_SVG, 'path'); p.setAttribute('d', d); s.appendChild(p); return s; };
123
+ const RGL = { highlighter: () => c3ic('flag', 'sm'), pause: () => glyph('M8 5v14M16 5v14'), play: () => c3ic('play', 'sm'), square: () => c3ic('stop', 'sm'), mic: () => c3ic('mic', 'sm'), down: () => glyph('M6 9l6 6 6-6') };
124
+
125
+ /** The chip opens the note. console3 has no Notes place, so it opens the main app, where the full note is. */
126
+ const openNote = () => { try { window.open('/', '_blank', 'noopener'); } catch { /* a blocked pop-up */ } };
127
+
128
+ /* The engine and language the live transcript uses, on the chip (notes-recording-reliability): a meeting transcribed in the wrong
129
+ * language because of a setting is visible while it records. Read once per recording from GET /api/notes/engine. */
130
+ const ENGINE = { Whisper: 'whisper', Qwen: 'qwen' };
131
+ async function engineLabel() {
132
+ try {
133
+ const { pmGet } = await import('./http.js');
134
+ const r = await pmGet('/api/notes/engine');
135
+ const w = r && r.ok ? r.whisper : null;
136
+ if (!w || !w.live) return '';
137
+ const name = Object.keys(ENGINE).find((k) => ENGINE[k] === w.live) || w.live;
138
+ const lang = ((w.languages || []).find((x) => x.value === w.language) || {}).label || w.language || '';
139
+ return name + (lang ? ' · ' + lang : '');
140
+ } catch { return ''; }
141
+ }
142
+
143
+ function mountReal(host) {
144
+ ensureC3Css();
145
+ let ready = false;
146
+ const wrap = el('span', { class: 'c3rec', 'data-fr': 'record', 'data-shell': 'recording' });
147
+ host.insertBefore(wrap, host.firstChild);
148
+ const rec = { grp: wrap, key: '', clock: null };
149
+ const tabDot = (on) => {
150
+ for (const t of document.querySelectorAll(TAB)) {
151
+ let dot = t.querySelector('.shell-recdot');
152
+ if (!dot) { dot = el('span', { class: 'shell-recdot', 'aria-hidden': 'true' }); t.appendChild(dot); }
153
+ dot.hidden = !on;
154
+ }
155
+ };
156
+ const btn = (label, name, fn, attrs) => button({ label: RGL[name](), variant: 'ghost', onClick: fn, attrs: { title: label, 'aria-label': label, ...attrs } });
157
+ const off = (b, why) => { b.setAttribute('aria-disabled', 'true'); b.classList.add('c3rec__off'); b.title = why; b.setAttribute('aria-label', why); return b; };
158
+ const chipText = (st) => (st.mode === 'recording' ? (st.started ? clockOf(Date.now() - st.started) : '') : st.until ? 'paused · ' + Math.max(1, Math.ceil((st.until - Date.now()) / 60000)) + ' min left' : 'paused');
159
+
160
+ /** The arrow beside New: Untitled note, then today's meetings that have not started and have no note, the next one marked. */
161
+ async function openUpcoming(anchor) {
162
+ const { menu } = await import('./primitives.js');
163
+ const links = await import('./notes-links.js');
164
+ const evs = links.upcomingWithoutNote(await links.upcomingToday());
165
+ const fmt = (e, i) => {
166
+ const mins = Math.max(1, Math.round((Date.parse(e.start) - Date.now()) / 60000));
167
+ const inText = mins < 60 ? 'in ' + mins + ' min' : 'in ' + Math.floor(mins / 60) + ' h' + (mins % 60 ? ' ' + (mins % 60) + ' min' : '');
168
+ return { label: e.title, hint: e.time || '', detail: [i === 0 ? 'Next' : null, inText, e.with || null].filter(Boolean).join(' · '), attrs: { 'data-fr-rec-event': e.id || '' }, onSelect: () => dock.recordEvent(e) };
169
+ };
170
+ menu({ anchor, ariaLabel: 'Record a new meeting', items: [{ label: 'Untitled note', icon: 'plus', detail: 'not linked to a meeting', attrs: { 'data-fr-rec-untitled': '' }, onSelect: () => dock.recordNew() }, { sep: true },
171
+ ...(evs.length ? evs.map(fmt) : [{ label: 'No more meetings today', attrs: { 'data-fr-rec-none': '' }, onSelect: () => {} }])] });
172
+ }
173
+
174
+ let eng = { id: null, text: '' };
175
+ const sync = () => {
176
+ let st; try { st = dock.recordState(); } catch { st = { mode: 'idle' }; }
177
+ if (st.mode !== 'idle' && eng.id !== st.id) { eng = { id: st.id, text: '' }; const want = st.id; engineLabel().then((t) => { if (eng.id === want && t) { eng.text = t; rec.key = ''; sync(); } }); }
178
+ const hl = st.mode === 'recording' && dock.highlightBusy ? dock.highlightBusy() : null;
179
+ const key = [st.mode, st.id, st.title, hl ? hl.state + hl.open : '', eng.text].join(':');
180
+ if (key !== rec.key) {
181
+ rec.key = key; rec.clock = null;
182
+ const recording = st.mode === 'recording';
183
+ const kids = [];
184
+ if (st.mode !== 'idle') {
185
+ const v = recView(st, reducedMotion());
186
+ rec.clock = el('span', { class: 'recclk', 'data-fr-rec-clock': '' }, chipText(st));
187
+ const chip = button({ variant: 'ghost', onClick: () => openNote(st.id),
188
+ attrs: { class: 'c3rec__chip', title: (recording ? 'Recording: ' : 'Paused: ') + v.title + '. Open the note in the main app', 'aria-label': (recording ? 'Recording ' : 'Paused: ') + v.title + '. Open the note.', 'data-fr-rec': st.mode, 'data-shell-rec-open': '' },
189
+ label: [el('span', { class: 'recdot' + (v.dot === 'static' ? ' static' : ''), 'data-dot': v.dot }), rec.clock, el('span', { class: 'rt' }, v.title),
190
+ ...(eng.text ? [el('span', { class: 'c3rec__eng', 'data-fr-rec-engine': '', title: 'The live transcript: engine and language (Notes settings)' }, eng.text)] : [])] });
191
+ const group = [chip];
192
+ if (recording) {
193
+ group.push(btn(hl ? 'Highlight in progress: show it' : 'Highlight the last 30 seconds and what is said next', 'highlighter', () => dock.highlightNow(), { 'data-fr-rec-highlight': hl ? hl.state : '', 'data-shell-rec-mark': '' }),
194
+ btn('Pause (you can resume within 1 hour)', 'pause', () => dock.pauseNow(), { 'data-fr-rec-pause': '', 'data-shell-rec-pause': '' }));
195
+ } else group.push(btn('Resume ' + st.title, 'play', () => dock.resumeNow(), { 'data-fr-rec-resume': '', 'data-shell-rec-resume': '' }));
196
+ group.push(btn('Stop for good (cannot be resumed)', 'square', () => dock.stopNow(), { 'data-fr-rec-stop': '', 'data-shell-rec-stop': '' }));
197
+ kids.push(el('span', { class: 'recg c3rec__live' + (recording ? '' : ' paused'), role: 'group', 'aria-label': (recording ? 'Recording ' : 'Paused: ') + st.title }, group));
198
+ }
199
+ const why = 'Pause or stop the meeting being recorded to start a new one';
200
+ const mic = button({ variant: 'ghost', label: [RGL.mic(), el('span', { class: 'c3rec__lbl' }, 'Record')], onClick: (e) => { if (!recording) dock.recordNew(e.currentTarget); },
201
+ attrs: { title: 'Record an untitled note', 'aria-label': 'Record an untitled note', 'data-fr-rec-new': '' } });
202
+ const arrow = btn('Record for an upcoming meeting', 'down', (e) => { if (!recording) openUpcoming(e.currentTarget); }, { 'data-fr-rec-upcoming': '', 'aria-haspopup': 'menu' });
203
+ if (recording) { off(mic, why); off(arrow, why); }
204
+ kids.push(el('span', { class: 'recg idle', role: 'group', 'aria-label': 'New recording' }, mic, arrow));
205
+ const panel = recording && dock.highlightPanel ? dock.highlightPanel() : null;
206
+ if (panel) kids.push(el('div', { class: 'c3rec__pop' }, panel));
207
+ wrap.replaceChildren(...kids);
208
+ tabDot(st.mode !== 'idle');
209
+ }
210
+ if (rec.clock) rec.clock.textContent = chipText(st);
211
+ };
212
+ /* note-dock reads the notes (a meeting the server still holds open is found again) and re-draws on every state change */
213
+ try { dock.mountDock(); } catch { /* the dock is not available on this page */ }
214
+ let unsub = null;
215
+ import('./state.js').then((m) => { unsub = m.subscribe(sync); }).catch(() => {});
216
+ rec.timer = setInterval(sync, 1000);
217
+ rec.off = () => { if (unsub) unsub(); };
218
+ sync();
219
+ live.set(host, rec);
220
+ }
@@ -647,7 +647,7 @@ function checkSilence(ctx, a) {
647
647
  ctx.setState({});
648
648
  }
649
649
  async function startAudio(ctx, n, want) {
650
- const a = { id: n.id, state: 'starting', sources: [], streams: [], ctx: null, started: Date.now(), clock: null, msg: null, queue: {}, buf: {}, rs: {}, rs24: {}, buf24: {}, peak: {}, began: Date.now(), lost: 0, ended: 0 };
650
+ const a = { id: n.id, state: 'starting', sources: [], streams: [], ctx: null, started: Date.now(), clock: null, msg: null, queue: {}, buf: {}, rs: {}, rs24: {}, buf24: {}, peak: {}, began: Date.now(), lost: 0, ended: 0, cap: null, seq: {}, off: {} };
651
651
  const opened = [];
652
652
  // the meeting first, straight from the press: the browser lets a page share a screen only right after a click
653
653
  for (const kind of want.slice().sort((x, y) => (x === 'meeting' ? -1 : y === 'meeting' ? 1 : 0))) {
@@ -662,6 +662,8 @@ async function startAudio(ctx, n, want) {
662
662
  n.recorded_from = r.recorded_from;
663
663
  // the clock counts the meeting time, as the line times do: Listen again carries on from where it stopped
664
664
  a.started = Date.parse(r.clock_from || r.recorded_from) || a.started;
665
+ // each chunk names its capture, its sequence number and its sample offset, so the server writes it once and in its place
666
+ a.cap = r.capture && r.capture.token ? r.capture.token : null;
665
667
  let ac;
666
668
  // the device's own rate: the 16 kHz audio for transcription and the 24 kHz audio for the replay are both made from the same blocks (D92 as amended)
667
669
  try { ac = new AudioContext(); } catch { ac = new AudioContext({ sampleRate: RATE }); }
@@ -694,13 +696,16 @@ async function startAudio(ctx, n, want) {
694
696
  function sendChunk(ctx, a, kind, final) {
695
697
  const b = a.buf[kind] || []; a.buf[kind] = [];
696
698
  const pcm = b64(Int16Array.from(b));
699
+ const seq = a.seq[kind] = a.seq[kind] === undefined ? 0 : a.seq[kind] + 1;
700
+ const off = a.off[kind] || 0; a.off[kind] = off + b.length;
701
+ const where = a.cap ? { cap: a.cap, seq, off } : {};
697
702
  const b24 = a.buf24[kind] || []; a.buf24[kind] = [];
698
703
  const pcm24 = b24.length ? b64(Int16Array.from(b24)) : undefined;
699
704
  const seconds = b.length / RATE;
700
705
  a.queue[kind] = a.queue[kind].then(async () => {
701
706
  let r = null;
702
707
  for (let tries = 0; tries < SEND_TRIES; tries++) {
703
- r = await pmPost('/api/notes/audio', { id: a.id, source: kind, pcm, pcm24, final });
708
+ r = await pmPost('/api/notes/audio', { id: a.id, source: kind, pcm, pcm24, final, ...where });
704
709
  if (r && r.ok) return;
705
710
  if (r && (r.status === 409 || r.status === 404)) break; // the server refused this note's audio: sending again changes nothing
706
711
  if (tries + 1 < SEND_TRIES) await new Promise((res) => setTimeout(res, SEND_BACKOFF_MS * 3 ** tries));