create-pmos 0.107.0 → 0.109.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.
@@ -0,0 +1,36 @@
1
+ /* pm/notes-task.js — Make task: a meeting note's next step as a plan item of the note's initiative (meeting-notes Run 12,
2
+ * mockup board 3 "Next step to task in one click").
3
+ *
4
+ * makeTask(note, { text, owner?, due? }, { repo?, btn? }) → Promise<item id | null>
5
+ *
6
+ * One press, no form: the item goes into the initiative's first stage through the Breakdown's own writer (POST
7
+ * /api/plan-items, as the PM), with the owner when the line names one and the due day when it is a date. A toast says
8
+ * where it went and offers Undo (the item is withdrawn). A note with no initiative gets a toast that says to link one.
9
+ */
10
+ import { toast, SEL } from './primitives.js';
11
+ import { pmGet, pmPost } from './http.js';
12
+ import { wsOf } from './data.js';
13
+
14
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
15
+ const iniName = (i) => (i ? i.title || i.id : null);
16
+ const label = (btn, text, done) => { if (!btn) return; const l = btn.querySelector(SEL.btnLabel); if (l) l.textContent = text; btn.disabled = !!done; };
17
+
18
+ export async function makeTask(n, x, { repo, btn } = {}) {
19
+ const ini = n.initiative;
20
+ const ws = ini && (ini.ws || n.ws || (repo ? wsOf(repo) : null));
21
+ if (!ini || !ini.id || !ws) { toast({ text: 'Link the note to an initiative first. Make task adds the step to its breakdown.' }); return null; }
22
+ if (btn) btn.disabled = true;
23
+ const lay = await pmGet('/api/plan-items?ladder=0&ws=' + encodeURIComponent(ws) + '&id=' + encodeURIComponent(ini.id));
24
+ const stage = lay && lay.ok && (lay.stages || [])[0];
25
+ if (!stage) { if (btn) btn.disabled = false; toast({ text: 'The task was not made: ' + ((lay && lay.error) || 'the initiative has no stage'), tone: 'destructive' }); return null; }
26
+ const ev = { ev: 'planned', initiative: ini.id, title: String(x.text || '').slice(0, 200), stage: stage.id, note: 'From the meeting note "' + (n.title || 'Untitled note') + '"' };
27
+ if (x.owner) ev.owner = /^(you|me)$/i.test(x.owner) ? 'you' : 'person:' + String(x.owner).slice(0, 60);
28
+ if (x.due && DATE_RE.test(x.due)) ev.due = x.due;
29
+ const r = await pmPost('/api/plan-items', { ws, events: [ev] });
30
+ if (!r || !r.ok) { if (btn) btn.disabled = false; toast({ text: 'The task was not made' + ((r && r.error) ? ': ' + r.error : ''), tone: 'destructive' }); return null; }
31
+ const item = (r.written && r.written[0] && r.written[0].item) || null;
32
+ label(btn, 'Task made', true);
33
+ toast({ text: 'Added to ' + iniName(ini) + ', under ' + (stage.label || stage.id), actionLabel: item ? 'Undo' : undefined,
34
+ onAction: item ? async () => { const u = await pmPost('/api/plan-items', { ws, events: [{ ev: 'withdrawn', item, initiative: ini.id }] }); if (u && u.ok) label(btn, 'Make task', false); } : undefined });
35
+ return item;
36
+ }
@@ -47,7 +47,6 @@ export const VIEWS = {
47
47
  // meeting-notes Run 1: typed notes, dictation, meeting transcripts and summaries; the PM's own, the same in every repository
48
48
  notes: { label: 'notes', mode: null, scoped: false, palette: true, hint: 'type, dictate or record a meeting' },
49
49
  // meeting-notes Run 11: notes menu with two-column view grouped by initiative (R47)
50
- notesmenu: { label: 'notes menu', mode: null, scoped: false, hint: 'view and organize all notes by initiative' },
51
50
  };
52
51
 
53
52
  /** The view a list mode opens as, or null when the mode is not a view. `product` is the list itself. */
@@ -97,7 +96,7 @@ function modes() {
97
96
  * every `data-*` hook the suites use, and only adds the board's frame around it. A view with no board module draws as before. */
98
97
  export const BOARD_MODULES = {
99
98
  today: './view-today.js', validate: './view-validate.js', addrepo: './view-addrepo.js', livenotes: './view-livenotes.js',
100
- notes: './view-notes.js', notesmenu: './view-notes-menu.js',
99
+ notes: './view-notes.js',
101
100
  };
102
101
  const boardMods = {}; // view -> undefined (not tried) · null (loading or absent) · module
103
102
  function boardOf(view) {
@@ -34,7 +34,7 @@ import { state, setState, FIXTURE } from './state.js';
34
34
  import * as FX from './fixture.js';
35
35
  import { openTerm as openTermTab } from './panel-initiatives.js';
36
36
  import { startHere } from './panel-trees.js';
37
- import { sessionDefaults } from './dock.js';
37
+ import { sessionDefaults, setSessionDefaults, adoptProfileDefault } from './dock.js';
38
38
  import { button } from './primitives.js';
39
39
  import { flagPicker, flagsLine, flagsMissingValue, flagsWire } from './flag-picker.js';
40
40
 
@@ -184,6 +184,8 @@ function refreshReads() {
184
184
  }
185
185
  if (typeof dataMod.loadProfiles === 'function') {
186
186
  dataMod.loadProfiles().then((r) => {
187
+ // the server's Claude default wins over this browser's copy (dock.js adoptProfileDefault)
188
+ adoptProfileDefault(r);
187
189
  setState({ profiles: r || null });
188
190
  // Preselect from the server's remembered preference — never from a hardcoded name. A
189
191
  // preference naming a descriptor that no longer reads preselects NOTHING rather than
@@ -414,6 +416,8 @@ async function setDefault() {
414
416
  if (!dataMod || typeof dataMod.preferProfile !== 'function') return;
415
417
  const r = await dataMod.preferProfile(state.newSessAgent || 'shell', state.preflightProfile === undefined ? null : state.preflightProfile);
416
418
  if (!r || !r.ok) { setState({ newSessError: (r && r.error) || 'the default could not be saved' }); return; }
419
+ // the Claude default is one default: Settings and a dock start read it too, so they follow this choice
420
+ if ((state.newSessAgent || 'shell') === 'claude') setSessionDefaults({ profile: state.preflightProfile || null });
417
421
  setState((s) => ({ newSessError: null, profiles: s.profiles ? { ...s.profiles, prefs: r.prefs || {} } : s.profiles }));
418
422
  }
419
423
 
@@ -53,6 +53,8 @@ export const DESTINATIONS = Object.freeze([
53
53
  // Not a pm-os-absorb run (run: null): docs-three-way-sync added it — is each linked document the same as its copies.
54
54
  Object.freeze({ id: 'docs', label: 'Docs', key: 'g d', module: 'dest-docs.js', run: null }),
55
55
  // Not a pm-os-absorb run (run: null): people-directory added it — the people the PM works with in a repository.
56
+ // Not a pm-os-absorb run (run: null): meeting-notes Run 12 added it — every meeting note by initiative, and the note.
57
+ Object.freeze({ id: 'notes', label: 'Notes', key: 'g n', module: 'dest-notes.js', run: null }),
56
58
  Object.freeze({ id: 'people', label: 'People', key: 'g p', module: 'dest-people.js', run: null }),
57
59
  Object.freeze({ id: 'settings', label: 'Settings', key: null, module: 'dest-settings.js', run: 3 }),
58
60
  ]);
@@ -21,13 +21,16 @@
21
21
  import { el, setChildren } from './dom.js';
22
22
  import { button, needsDot, needsCount } from './primitives.js';
23
23
  import { COPY } from './frame-copy.js';
24
- import { isRecording } from './note-dock.js';
24
+ import * as dock from './note-dock.js';
25
+ import { iconButton } from './primitives.js';
26
+ import { el as h } from './dom.js';
25
27
 
26
28
  /** The destinations the bar names, in order. Settings sits at the end of the bar, after Sync. Docs joined the bar at the
27
29
  * PM's word (2026-10-06, docs-three-way-sync: "I thought Docs is one of the main menu"). People joined after Docs when the
28
30
  * PM approved the People mockup and said build it (2026-10-06, people-directory). My work joined beside Agenda when the PM
29
- * approved the mockup that puts it there and said build it (2026-10-07, my-work), so the bar holds 10 controls. */
30
- export const BAR_DESTS = Object.freeze(['today', 'mywork', 'initiatives', 'board', 'reports', 'docs', 'people']);
31
+ * approved the mockup that puts it there and said build it (2026-10-07, my-work). Notes joined between Docs and People with the
32
+ * Record control before Settings, as the "Notes: next features" mockup the PM approved (2026-10-07, meeting-notes Run 12). */
33
+ export const BAR_DESTS = Object.freeze(['today', 'mywork', 'initiatives', 'board', 'reports', 'docs', 'notes', 'people']);
31
34
 
32
35
  /** Is this sync reading "not in sync"? Behind, ahead, records not yet recorded, or `remote_stale`: no fetch in the last
33
36
  * few minutes, so another machine's push would be invisible here and "in sync" cannot be claimed. An unreadable status
@@ -38,7 +41,10 @@ export function unsyncedOf(st) {
38
41
  }
39
42
 
40
43
  /** The lucide symbol each place wears in the bar (UI comfort, 2026-10-07: a word alone is slow to find; the phone's tab bar already wears these). */
41
- const NAV_ICON = Object.freeze({ today: 'house', mywork: 'list-checks', initiatives: 'target', board: 'square-kanban', reports: 'chart-column', docs: 'file-text', people: 'users', settings: 'settings' });
44
+ const NAV_ICON = Object.freeze({ today: 'house', mywork: 'list-checks', initiatives: 'target', board: 'square-kanban', reports: 'chart-column', docs: 'file-text', notes: 'notebook-pen', people: 'users', settings: 'settings' });
45
+
46
+ const fmtClock = (ms) => { const t = Math.max(0, Math.floor(ms / 1000)); const m = Math.floor(t / 60); return (m >= 60 ? Math.floor(m / 60) + ':' + String(m % 60).padStart(2, '0') : String(m)) + ':' + String(t % 60).padStart(2, '0'); };
47
+ const dockHighlight = () => (dock.highlightBusy ? dock.highlightBusy() : null);
42
48
 
43
49
  export function createTopbar({ go, openPalette, openView, repoMenu, sync } = {}) {
44
50
  const node = el('header', { class: 'fr-bar', 'data-shell': 'top', 'data-fr': 'bar' });
@@ -47,12 +53,6 @@ export function createTopbar({ go, openPalette, openView, repoMenu, sync } = {})
47
53
  let repoLabel = null;
48
54
  const repoSlot = el('div', { class: 'fr-bar__repo' });
49
55
 
50
- const recordingIndicator = el('span', {
51
- class: 'fr-recording-dot', 'data-fr-recording': '',
52
- title: 'Notes are being recorded',
53
- 'aria-hidden': 'true',
54
- });
55
-
56
56
  const count = needsCount('0', { 'data-today-badge': '' });
57
57
  const dot = needsDot();
58
58
  const links = {};
@@ -87,11 +87,36 @@ export function createTopbar({ go, openPalette, openView, repoMenu, sync } = {})
87
87
  });
88
88
  search.appendChild(el('kbd', { class: 'fr-kbd' }, COPY.searchKey));
89
89
 
90
- const notes = button({
91
- label: 'Notes', variant: 'outline', icon: 'notebook-pen',
92
- attrs: { class: 'fr-notes', 'data-fr': 'notes', 'data-fr-view': 'notes', title: 'Open notes menu (all notes by initiative)' },
93
- onClick: () => openView && openView('notesmenu'),
94
- });
90
+ /* The Record control (meeting-notes Run 12, mockup board 2), right of the bar before Settings. Idle: Record. Recording: the
91
+ * red dot and the clock (a press opens the note), the highlight and Pause. Paused: the clock and Resume. Stop is in the note. */
92
+ const recSlot = el('div', { class: 'fr-rec', 'data-fr': 'record' });
93
+ let recKey = '';
94
+ let recTick = null;
95
+ const recClock = () => { const st = dock.recordState(); return st.started ? fmtClock(Date.now() - st.started) : ''; };
96
+ function drawRecord() {
97
+ const st = dock.recordState();
98
+ const busy = st.mode === 'recording' && dockHighlight();
99
+ const key = st.mode + ':' + st.id + ':' + (busy ? busy.state + busy.open : '');
100
+ if (key === recKey) return;
101
+ recKey = key;
102
+ clearInterval(recTick); recTick = null;
103
+ if (st.mode === 'idle') {
104
+ setChildren(recSlot, [button({ label: 'Record', variant: 'outline', icon: 'mic', attrs: { class: 'fr-rec__start', 'data-fr-rec': 'idle', title: 'Start a note and record the meeting' }, onClick: (e) => dock.recordNew(e && e.currentTarget) })]);
105
+ return;
106
+ }
107
+ if (st.mode === 'paused') {
108
+ setChildren(recSlot, [button({ label: 'Resume', variant: 'outline', icon: 'mic', attrs: { class: 'fr-rec__start', 'data-fr-rec': 'paused', title: 'Resume recording ' + (st.title || 'the note') }, onClick: () => dock.resumeNow() })]);
109
+ return;
110
+ }
111
+ const clock = h('span', { class: 'fr-rec__clock', 'data-fr-rec-clock': '' }, recClock());
112
+ recTick = setInterval(() => { clock.textContent = recClock(); }, 1000);
113
+ const open = button({ label: h('span', { class: 'fr-rec__txt' }, h('span', { class: 'fr-rec-dot', 'aria-hidden': 'true' }), clock), variant: 'ghost',
114
+ attrs: { class: 'fr-rec__seg fr-rec__open', 'data-fr-rec': 'recording', 'aria-label': 'Recording ' + (st.title || 'a note') + '. Open the note.' }, onClick: () => dock.openDock(st.id) });
115
+ const hl = iconButton({ icon: 'highlighter', ariaLabel: busy ? 'Highlight in progress: show it' : 'Highlight the last 30 seconds and what is said next',
116
+ attrs: { class: 'fr-rec__seg' + (busy ? ' fr-rec__seg--busy' : ''), 'data-fr-rec-highlight': busy ? busy.state : '' }, onClick: () => dock.highlightNow() });
117
+ const pause = iconButton({ icon: 'pause', ariaLabel: 'Pause recording', attrs: { class: 'fr-rec__seg', 'data-fr-rec-pause': '' }, onClick: () => dock.pauseNow() });
118
+ setChildren(recSlot, [h('div', { class: 'fr-rec__group', role: 'group', 'aria-label': 'Recording' }, open, hl, pause)]);
119
+ }
95
120
 
96
121
  let syncBtn = null;
97
122
  let syncLabel = null;
@@ -129,8 +154,8 @@ export function createTopbar({ go, openPalette, openView, repoMenu, sync } = {})
129
154
  syncLabel = want;
130
155
  syncBtn = want ? syncButton(want, !!model.syncing) : null;
131
156
  }
132
- recordingIndicator.classList.toggle('fr-recording-dot--active', isRecording());
133
- setChildren(node, [repoSlot, recordingIndicator, nav, search, notes, syncBtn, links.settings]);
157
+ drawRecord();
158
+ setChildren(node, [repoSlot, nav, search, syncBtn, recSlot, links.settings]);
134
159
  }
135
160
 
136
161
  update({});
@@ -45,8 +45,6 @@ import { pmGet, pmPost } from './http.js';
45
45
  import { mdRender } from './markdown.js';
46
46
  import { icon } from './icons.js';
47
47
  import { button, iconButton, field, row, sheet, empty, menu, toast, ensureComponentsCss } from './primitives.js';
48
- import { startHighlight, updateListenProgress, formatListenRemaining, addHighlightToNote } from '/lib/notes-highlight.js';
49
- import { checkConflict, collectRepoPassages, formatConflict } from '/lib/notes-conflicts.js';
50
48
 
51
49
  const SAVE_MS = 700; // a pause this long in typing saves the note
52
50
  const POLL_MS = 1500; // while listening, and until the last line is labelled, the note is read this often
@@ -739,161 +737,119 @@ async function startSpeech(ctx, n, mode, install = false) {
739
737
 
740
738
  /* ── highlighting during recording (Run 11) ─────────────────────────────────────── */
741
739
  /** Start a highlight: capture last 30s, listen forward for the chosen duration. */
742
- async function startHighlighting(ctx, n) {
743
- try {
744
- highlight = await startHighlight(n.id, { buffer: true }); // placeholder for actual audio buffer
745
- highlight.state = 'capturing';
746
- ctx.setState({});
747
-
748
- // Simulate capture completion after 100ms
749
- setTimeout(() => {
750
- if (highlight && highlight.id) {
751
- highlight.state = 'listening';
752
- let listenedMs = 0;
753
- const duration = { '30s': 30 * 1000, '1min': 60 * 1000, '2min': 120 * 1000 }[highlightListenDuration] || 30 * 1000;
754
-
755
- highlightTimer = setInterval(() => {
756
- listenedMs += 100;
757
- if (highlight) highlight.listenedMs = listenedMs;
758
- ctx.setState({});
759
- if (listenedMs >= duration) {
760
- clearInterval(highlightTimer);
761
- highlightTimer = null;
762
- if (highlight) {
763
- highlight.state = 'transcribing';
764
- ctx.setState({});
765
- // Simulate transcription
766
- setTimeout(() => {
767
- if (highlight) {
768
- highlight.text = 'Transcribed highlight text here.';
769
- highlight.state = 'labeling';
770
- ctx.setState({});
771
- // Simulate labeling
772
- setTimeout(() => {
773
- if (highlight) {
774
- highlight.label = 'Statement';
775
- highlight.state = 'checking';
776
- ctx.setState({});
777
- // Simulate checking
778
- setTimeout(() => {
779
- if (highlight) {
780
- highlight.state = 'done';
781
- ctx.setState({});
782
- }
783
- }, 500);
784
- }
785
- }, 300);
786
- }
787
- }, 500);
788
- }
789
- }
790
- }, 100);
791
- }
792
- }, 100);
793
- } catch (e) {
794
- banner = { tone: 'bad', text: 'Highlight failed: ' + e.message };
795
- ctx.setState({});
796
- }
797
- }
798
-
799
- /** Close the highlight pop-up (work continues in background). */
800
- function closeHighlight(ctx) {
801
- highlight = null;
802
- if (highlightTimer) clearInterval(highlightTimer);
803
- highlightTimer = null;
740
+ /* ── highlights (meeting-notes Run 12, mockup board 0) ──────────────────────────────────────────────────────────────
741
+ * A press keeps the last 30 seconds and listens for 30 s, 1 min or 2 min more (the PM can change it while it listens).
742
+ * When the window closes and its audio is transcribed, POST /api/notes/highlight makes the highlight from the transcript
743
+ * lines said in the window: their words, the strongest label among them (Jev's when it is on), and the closest file in
744
+ * the repository with Jev's comparison when the check is on. The pop-up shows each step as it happens; closing it does not
745
+ * stop the work, and the highlight is kept in the note. Nothing here is made up: a window with no transcribed line says so. */
746
+ const HL_BEFORE_MS = 30 * 1000;
747
+ const HL_AFTER = [['30s', 30 * 1000, '30 s'], ['1min', 60 * 1000, '1 min'], ['2min', 120 * 1000, '2 min']];
748
+ const hlAfterMs = () => (HL_AFTER.find((x) => x[0] === highlightListenDuration) || HL_AFTER[0])[1];
749
+ let hlOpen = false;
750
+ /** Highlight on the note that is listening: from the Record control, the note's own button, or H. */
751
+ export function highlightNow(ctx, id) {
752
+ const n = cache.get(id);
753
+ const at = liveMs(n);
754
+ if (at === null) return false;
755
+ if (highlight && highlight.state === 'listening') { hlOpen = true; ctx.setState({}); return true; }
756
+ highlight = { id: n.id, title: n.title || '', at, from: Math.max(0, at - HL_BEFORE_MS), state: 'listening', pressed: Date.now(), result: null, error: null };
757
+ hlOpen = true;
758
+ clearInterval(highlightTimer);
759
+ highlightTimer = setInterval(() => hlTick(ctx), 500);
804
760
  ctx.setState({});
761
+ return true;
805
762
  }
806
-
807
- /** Accept the highlight and add it to the note. */
808
- async function acceptHighlight(ctx, n) {
809
- if (highlight && highlight.text) {
810
- addHighlightToNote(n, highlight);
811
- closeHighlight(ctx);
812
- await pmPost('/api/notes/save', { id: n.id, notes: n });
813
- ctx.setState({});
763
+ function hlTick(ctx) {
764
+ const h = highlight;
765
+ if (!h) { clearInterval(highlightTimer); highlightTimer = null; return; }
766
+ const live = cache.get(h.id) && listening(cache.get(h.id));
767
+ // the window closes when its time is up, or when listening stops first
768
+ if (h.state === 'listening' && (Date.now() - h.pressed >= hlAfterMs() || !live)) {
769
+ clearInterval(highlightTimer); highlightTimer = null;
770
+ h.to = h.at + Math.min(hlAfterMs(), Date.now() - h.pressed);
771
+ finishHighlight(ctx, h);
814
772
  }
773
+ const t = typeof document !== 'undefined' && document.querySelector('[data-nt-hl-left]');
774
+ if (t) t.textContent = hlLeft(h); else ctx.setState({});
815
775
  }
816
-
817
- /** Create a task from a note next step (Run 11). */
818
- async function makeTask(ctx, n, text) {
819
- try {
820
- const title = text.split('\n')[0].slice(0, 100); // First line, max 100 chars
821
- const result = await pmPost('/api/notes/make-task', {
822
- noteId: n.id,
823
- title: title,
824
- description: text,
825
- relatedNoteId: n.id
826
- });
827
- banner = { tone: 'good', text: 'Task created: ' + title };
828
- ctx.setState({});
829
- setTimeout(() => { banner = null; ctx.setState({}); }, 2000);
830
- } catch (e) {
831
- banner = { tone: 'bad', text: 'Failed to create task: ' + e.message };
832
- ctx.setState({});
776
+ const hlLeft = (h) => fmtTime(Math.min(Date.now() - h.pressed, hlAfterMs())) + ' of ' + (HL_AFTER.find((x) => x[0] === highlightListenDuration) || HL_AFTER[0])[2];
777
+ async function finishHighlight(ctx, h) {
778
+ h.state = 'transcribing'; ctx.setState({});
779
+ // the window's last audio is still being transcribed: wait for it (at most 30 s)
780
+ const t0 = Date.now();
781
+ for (;;) {
782
+ const r = await loadNote(ctx, h.id);
783
+ const done = r && r.ok && !(r.audio && r.audio.pending) && (r.note.transcript || []).every((x) => !(typeof x.t === 'number' && x.t <= h.to && x.open));
784
+ if (done || Date.now() - t0 > 30000 || highlight !== h) break;
785
+ await new Promise((res) => setTimeout(res, 1000));
833
786
  }
787
+ if (highlight !== h) return;
788
+ h.state = 'checking'; ctx.setState({});
789
+ const r = await pmPost('/api/notes/highlight', { id: h.id, from: h.from, to: h.to });
790
+ if (highlight !== h) return;
791
+ if (r && r.ok) { h.result = r.highlight; h.checked = r.checked; h.jev = r.jev; h.state = 'done'; await loadNote(ctx, h.id); }
792
+ else { h.state = 'failed'; h.error = (r && r.error) || 'the highlight was not made'; }
793
+ hlOpen = true;
794
+ ctx.setState({});
834
795
  }
796
+ function closeHighlight(ctx) {
797
+ // closing while it still works only hides the pop-up; a finished one is let go
798
+ if (highlight && (highlight.state === 'done' || highlight.state === 'failed')) highlight = null;
799
+ hlOpen = false;
800
+ ctx.setState({});
801
+ }
802
+ async function removeHighlight(ctx, h) {
803
+ if (h.result) await pmPost('/api/notes/highlight-remove', { id: h.id, hid: h.result.id });
804
+ highlight = null; hlOpen = false;
805
+ await loadNote(ctx, h.id);
806
+ ctx.setState({});
807
+ }
808
+ /** Is a highlight being made, and is its result waiting to be seen? (the Record control's dot) */
809
+ export const highlightBusy = () => (highlight ? { state: highlight.state, open: hlOpen } : null);
810
+ export function openHighlight(ctx) { if (highlight) { hlOpen = true; ctx.setState({}); } }
835
811
 
836
- /** Render the highlight pop-up when a highlight is active. */
837
- function highlightPopup(ctx, n) {
838
- if (!highlight) return null;
839
-
840
- const steps = [
841
- { id: 'captured', label: 'Captured', done: highlight.state !== null && highlight.state !== 'capturing' },
842
- { id: 'listening', label: 'Listening', done: highlight.state !== 'listening' && highlight.state !== 'capturing' },
843
- { id: 'transcribed', label: 'Transcribed', done: highlight.state === 'labeling' || highlight.state === 'checking' || highlight.state === 'done' },
844
- { id: 'labeled', label: 'Labeled', done: highlight.state === 'checking' || highlight.state === 'done', dim: !highlight.label },
845
- { id: 'checked', label: 'Checked', done: highlight.state === 'done', dim: true },
846
- { id: 'done', label: 'Done', done: highlight.state === 'done' },
812
+ /** The highlight's pop-up (pm/note-dock.js draws it on every place), or null when there is none or it is closed. */
813
+ export function highlightPanel(ctx) {
814
+ const h = highlight;
815
+ if (!h || !hlOpen) return null;
816
+ const order = ['listening', 'transcribing', 'checking', 'done'];
817
+ const at = order.indexOf(h.state);
818
+ const step = (k, label, extra) => {
819
+ const i = order.indexOf(k);
820
+ const done = h.state === 'done' || (at > i && h.state !== 'failed');
821
+ const now = h.state === k;
822
+ return el('li', { class: 'nt-hl-step' + (done ? ' nt-hl-step--done' : now ? ' nt-hl-step--now' : ''), 'data-nt-hl-step': k },
823
+ el('span', { class: 'nt-hl-step__mk' }, icon(done ? 'check' : 'circle-dot')), el('span', null, label), extra || null);
824
+ };
825
+ const durs = el('div', { class: 'nt-hl-durs', role: 'group', 'aria-label': 'Listen after' }, HL_AFTER.map(([k, , label]) => button({ label, variant: highlightListenDuration === k ? 'secondary' : 'ghost', size: 'sm',
826
+ onClick: () => { highlightListenDuration = k; ctx.setState({}); }, attrs: { 'data-nt-duration': k, 'aria-pressed': highlightListenDuration === k ? 'true' : 'false' } })));
827
+ const body = [
828
+ el('div', { class: 'nt-hl-head' }, el('h3', { class: 'nt-hl-title' }, 'Highlight'), el('span', { class: 'nt-hl-at' }, fmtTime(h.from) + (h.to ? '–' + fmtTime(h.to) : ' →')),
829
+ h.result ? el('span', { class: 'nt-hl-tag' }, h.result.label) : null),
830
+ el('ol', { class: 'nt-hl-steps' },
831
+ el('li', { class: 'nt-hl-step nt-hl-step--done' }, el('span', { class: 'nt-hl-step__mk' }, icon('check')), el('span', null, 'Kept the last 30 seconds')),
832
+ step('listening', 'Listening', h.state === 'listening' ? el('span', { class: 'nt-hl-left', 'data-nt-hl-left': '' }, hlLeft(h)) : null),
833
+ step('transcribing', 'Transcribe the window'),
834
+ step('checking', 'Label it and check the repository'),
835
+ ),
836
+ h.state === 'listening' ? durs : null,
847
837
  ];
848
-
849
- const progress = el('div', { class: 'nt-highlight__progress' });
850
- steps.forEach((step) => {
851
- const stepEl = el('div', { class: 'nt-highlight__step' + (step.done ? ' nt-highlight__step--done' : '') + (step.dim ? ' nt-highlight__step--dim' : '') });
852
- const checkSpan = el('span', { class: 'nt-highlight__check' });
853
- const checkIcon = step.done ? icon('check', { 'aria-label': 'Done' }) : icon('circle-dot', { 'aria-label': 'Not done' });
854
- checkSpan.appendChild(checkIcon);
855
- stepEl.appendChild(checkSpan);
856
- stepEl.appendChild(el('span', { class: 'nt-highlight__label' }, step.label));
857
- if (step.id === 'listening' && highlight.state === 'listening') {
858
- const remaining = formatListenRemaining(highlight);
859
- stepEl.appendChild(el('span', { class: 'nt-highlight__time' }, remaining));
860
- }
861
- progress.appendChild(stepEl);
862
- });
863
-
864
- const durationOptions = el('div', { class: 'nt-highlight__durations' });
865
- ['30s', '1min', '2min'].forEach((dur) => {
866
- const opt = button({
867
- label: dur,
868
- variant: highlightListenDuration === dur ? 'primary' : 'secondary',
869
- onClick: () => {
870
- highlightListenDuration = dur;
871
- ctx.setState({});
872
- },
873
- attrs: { class: 'nt-highlight__duration-btn', 'data-nt-duration': dur }
874
- });
875
- durationOptions.appendChild(opt);
876
- });
877
-
878
- const actions = el('div', { class: 'nt-highlight__actions' });
879
- actions.appendChild(button({
880
- label: highlight.state === 'done' ? 'Accept' : 'Cancel',
881
- variant: 'primary',
882
- onClick: () => {
883
- if (highlight.state === 'done') acceptHighlight(ctx, n);
884
- else closeHighlight(ctx);
885
- },
886
- attrs: { 'data-nt-highlight-accept': '' }
887
- }));
888
-
889
- const popup = el('div', { class: 'nt-highlight__popup', 'data-nt-part': 'highlight-popup' },
890
- el('div', { class: 'nt-highlight__header' }, el('h3', {}, 'Recording Highlight')),
891
- progress,
892
- highlight.state === 'listening' ? durationOptions : null,
893
- actions
894
- );
895
-
896
- return popup;
838
+ if (h.state === 'done' && h.result) {
839
+ const c = h.result.check;
840
+ body.push(el('p', { class: 'nt-hl-text', 'data-nt-hl-text': '' }, h.result.text));
841
+ body.push(el('p', { class: 'nt-hint', 'data-nt-hl-check': c ? c.verdict : 'none' }, c && c.doc
842
+ ? (c.verdict === 'differs' ? 'Differs from ' : c.verdict === 'agrees' ? 'Agrees with ' : 'Closest file: ') + c.doc + (c.line ? ':' + c.line : '') + (c.by === 'jev' ? '' : ' (found on this machine; turn on the Jev check in Notes settings to compare)')
843
+ : h.checked ? 'No file in the repository talks about this.' : 'Not checked: the note is not linked to a repository.'));
844
+ }
845
+ if (h.state === 'failed') body.push(el('p', { class: 'nt-hint nt-hint--bad', 'data-nt-hl-failed': '' }, 'No highlight: ' + h.error + '.'));
846
+ const acts = [];
847
+ if (h.state === 'listening') acts.push(button({ label: 'Stop listening now', variant: 'outline', size: 'sm', onClick: () => { h.pressed = Date.now() - hlAfterMs(); hlTick(ctx); }, attrs: { 'data-nt-hl-stop': '' } }));
848
+ if (h.state === 'done') acts.push(button({ label: 'Done', variant: 'primary', size: 'sm', onClick: () => closeHighlight(ctx), attrs: { 'data-nt-hl-done': '' } }),
849
+ button({ label: 'Remove', variant: 'ghost', size: 'sm', onClick: () => removeHighlight(ctx, h), attrs: { 'data-nt-hl-remove': '' } }));
850
+ else acts.push(button({ label: 'Close', variant: 'ghost', size: 'sm', onClick: () => closeHighlight(ctx), attrs: { 'data-nt-hl-close': '' } }));
851
+ body.push(el('div', { class: 'nt-hl-acts' }, acts));
852
+ return el('section', { class: 'nt-hl', role: 'dialog', 'aria-label': 'Highlight', 'data-nt-part': 'highlight-popup', 'data-nt-hl': h.state }, body);
897
853
  }
898
854
 
899
855
  async function stopRecording(ctx) {
@@ -909,6 +865,17 @@ async function stopRecording(ctx) {
909
865
  ctx.setState({});
910
866
  }
911
867
 
868
+ /** Stop in the note: listening stops and the note leaves the Record control at the top right (pm/note-dock.js). */
869
+ async function stopAndFinish(ctx) {
870
+ const d = await import('./note-dock.js');
871
+ if (d.recordState().mode !== 'idle') await d.finish(); else await stopRecording(ctx);
872
+ }
873
+ /** Resume in the note: the Record control holds it again and it listens. */
874
+ async function resumeOn(n) {
875
+ const d = await import('./note-dock.js');
876
+ await d.recordNote(n.id, n.title);
877
+ }
878
+
912
879
  /* ── the two sheets: what to listen to, and settings ─────────────────────────────────────────── */
913
880
  async function chooseSources(ctx, n) {
914
881
  const eng = engine && engine.ws === wsOf(ctx) && engine.whisper ? engine : await loadEngine(ctx);
@@ -1197,13 +1164,13 @@ function header(ctx, n, ed, opts = {}) {
1197
1164
  el('span', { 'data-nt-sources': '' }, ' · ' + (live.state === 'starting' || live.state === 'checking' ? 'starting' : ['mic', 'meeting'].filter((s) => live.sources.includes(s)).join(' + '))),
1198
1165
  el('span', { class: 'nt-sr' }, ' Notes are being taken.'));
1199
1166
  // Highlight button (Run 11)
1200
- acts.push(iconButton({ icon: 'zap', ariaLabel: 'Highlight (H)', title: 'Highlight the last 30 seconds and listen forward', onClick: () => startHighlighting(ctx, n), attrs: { 'data-nt-highlight': '' } }));
1201
- acts.push(button({ label: 'Stop', variant: 'outline', size: 'sm', onClick: () => stopRecording(ctx), attrs: { 'data-nt-stop': '' } }));
1167
+ acts.push(iconButton({ icon: 'highlighter', ariaLabel: 'Highlight the last 30 seconds and what is said next', onClick: () => highlightNow(ctx, n.id), attrs: { 'data-nt-highlight': '' } }));
1168
+ acts.push(button({ label: 'Stop', variant: 'outline', size: 'sm', onClick: () => stopAndFinish(ctx), attrs: { 'data-nt-stop': '' } }));
1202
1169
  } else {
1203
1170
  const len = (n.transcript || []).length ? fmtTime(Math.max(...(n.transcript || []).map((x) => x.t || 0))) : '';
1204
1171
  status = el('span', { class: 'nt-live' }, el('b', null, 'Meeting note'), len ? el('span', null, len) : null);
1205
1172
  if (!n.private) acts.push((n.transcript || []).length
1206
- ? button({ label: 'Listen again', variant: 'ghost', size: 'sm', onClick: () => chooseSources(ctx, n), attrs: { 'data-nt-listen': 'again' } })
1173
+ ? button({ label: 'Resume', variant: 'primary', size: 'sm', onClick: () => resumeOn(n), attrs: { 'data-nt-listen': 'again' } })
1207
1174
  : button({ label: 'Listen', variant: 'primary', size: 'sm', onClick: () => chooseSources(ctx, n), attrs: { 'data-nt-listen': '' } }));
1208
1175
  }
1209
1176
  if (opts.onMinimize) acts.push(button({ label: 'Minimize', variant: 'ghost', size: 'sm', onClick: opts.onMinimize, attrs: { 'data-nt-minimize': '' } }));
@@ -1244,7 +1211,7 @@ function itemRow(ctx, n, key, title, x) {
1244
1211
  const actions = [];
1245
1212
  // Add "Make task" button for action items (Run 11)
1246
1213
  if (key === 'action') {
1247
- actions.push(iconButton({ icon: 'plus', ariaLabel: 'Make a task from this', onClick: () => makeTask(ctx, n, x.text), size: 'sm', attrs: { 'data-nt-make-task': '' } }));
1214
+ actions.push(iconButton({ icon: 'plus', ariaLabel: 'Make a task from this', onClick: (e) => import('./notes-task.js').then((m) => m.makeTask(n, x, { repo: ctx.state && ctx.state.product, btn: e && e.currentTarget })), size: 'sm', attrs: { 'data-nt-make-task': '' } }));
1248
1215
  }
1249
1216
  // Add remove buttons for unpinned items
1250
1217
  if (bullet) {
@@ -1557,11 +1524,6 @@ export function renderBoard(inner, ctx) {
1557
1524
  wrap.appendChild(el('div', { class: 'nt-body2' }, noteColumn(ctx, n, ed), transcriptColumn(ctx, n)));
1558
1525
  if (pl) queueMicrotask(paintNow);
1559
1526
  if (((n.agent && n.agent.started && !n.agent.markdown) || finalPending(n)) && !agentTimer) pollAgent(ctx, n.id);
1560
- // Add highlight pop-up if active
1561
- if (highlight && n.id === currentId) {
1562
- const hlPopup = highlightPopup(ctx, n);
1563
- if (hlPopup) wrap.appendChild(hlPopup);
1564
- }
1565
1527
  }
1566
1528
  inner.appendChild(wrap);
1567
1529
  keep();
@@ -1,5 +1,5 @@
1
1
  /* lucide-sprite.js — GENERATED by workspace/third-party/build-lucide-sprite.js from lucide-static 1.52.0.
2
- * Do not edit by hand. The icons: grip-vertical, plus, check, square, square-check-big, x, undo-2, flag, bot, chevron-right, chevron-down, ellipsis, circle-alert, history, arrow-up, arrow-down, diamond, settings, search, refresh-cw, chevron-left, sun, moon, arrow-left, arrow-right, globe, columns-2, arrow-up-down, arrow-left-right, panel-top, panel-left, maximize-2, minimize-2, git-branch, clock, bold, italic, code, link, type, heading, list, list-checks, text-quote, file-code, git-compare, cloud, share-2, house, target, square-kanban, file-text, square-terminal, calendar, chart-column, users, git-merge, mail, message-square, ticket, ban.
2
+ * Do not edit by hand. The icons: grip-vertical, plus, check, square, square-check-big, x, undo-2, flag, bot, chevron-right, chevron-down, ellipsis, circle-alert, history, arrow-up, arrow-down, diamond, settings, search, refresh-cw, chevron-left, sun, moon, arrow-left, arrow-right, globe, columns-2, arrow-up-down, arrow-left-right, panel-top, panel-left, maximize-2, minimize-2, git-branch, clock, bold, italic, code, link, type, heading, list, list-checks, text-quote, file-code, git-compare, cloud, share-2, house, target, square-kanban, file-text, square-terminal, calendar, chart-column, users, git-merge, mail, message-square, ticket, ban, notebook-pen, mic, pause, play, highlighter, circle-dot.
3
3
  *
4
4
  * ISC License
5
5
  *
@@ -107,5 +107,11 @@ export const SPRITE = '<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true
107
107
  + '<symbol id="lucide-message-square" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z" /></symbol>'
108
108
  + '<symbol id="lucide-ticket" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z" /><path d="M13 5v2" /><path d="M13 17v2" /><path d="M13 11v2" /></symbol>'
109
109
  + '<symbol id="lucide-ban" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><path d="M4.929 4.929 19.07 19.071" /></symbol>'
110
+ + '<symbol id="lucide-notebook-pen" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13.4 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7.4" /><path d="M2 6h4" /><path d="M2 10h4" /><path d="M2 14h4" /><path d="M2 18h4" /><path d="M21.378 5.626a1 1 0 1 0-3.004-3.004l-5.01 5.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z" /></symbol>'
111
+ + '<symbol id="lucide-mic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19v3" /><path d="M19 10v2a7 7 0 0 1-14 0v-2" /><rect x="9" y="2" width="6" height="13" rx="3" /></symbol>'
112
+ + '<symbol id="lucide-pause" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="14" y="3" width="5" height="18" rx="1" /><rect x="5" y="3" width="5" height="18" rx="1" /></symbol>'
113
+ + '<symbol id="lucide-play" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" /></symbol>'
114
+ + '<symbol id="lucide-highlighter" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 11-6 6v3h9l3-3" /><path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4" /></symbol>'
115
+ + '<symbol id="lucide-circle-dot" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1" /><circle cx="12" cy="12" r="10" /></symbol>'
110
116
  + '</svg>';
111
- export const ICONS = ["grip-vertical","plus","check","square","square-check-big","x","undo-2","flag","bot","chevron-right","chevron-down","ellipsis","circle-alert","history","arrow-up","arrow-down","diamond","settings","search","refresh-cw","chevron-left","sun","moon","arrow-left","arrow-right","globe","columns-2","arrow-up-down","arrow-left-right","panel-top","panel-left","maximize-2","minimize-2","git-branch","clock","bold","italic","code","link","type","heading","list","list-checks","text-quote","file-code","git-compare","cloud","share-2","house","target","square-kanban","file-text","square-terminal","calendar","chart-column","users","git-merge","mail","message-square","ticket","ban"];
117
+ export const ICONS = ["grip-vertical","plus","check","square","square-check-big","x","undo-2","flag","bot","chevron-right","chevron-down","ellipsis","circle-alert","history","arrow-up","arrow-down","diamond","settings","search","refresh-cw","chevron-left","sun","moon","arrow-left","arrow-right","globe","columns-2","arrow-up-down","arrow-left-right","panel-top","panel-left","maximize-2","minimize-2","git-branch","clock","bold","italic","code","link","type","heading","list","list-checks","text-quote","file-code","git-compare","cloud","share-2","house","target","square-kanban","file-text","square-terminal","calendar","chart-column","users","git-merge","mail","message-square","ticket","ban","notebook-pen","mic","pause","play","highlighter","circle-dot"];
@@ -42,6 +42,7 @@
42
42
  "doc-category.test.js",
43
43
  "doc-category-corrections.test.js",
44
44
  "notes-highlight.test.js",
45
+ "notes-page.test.js",
45
46
  "notes-conflicts.test.js",
46
47
  "proactive-prepare.test.js",
47
48
  "learn.test.js",
@@ -149,6 +150,7 @@
149
150
  "doc-category.test.js": 1,
150
151
  "doc-category-corrections.test.js": 1,
151
152
  "notes-highlight.test.js": 1,
153
+ "notes-page.test.js": 1,
152
154
  "notes-conflicts.test.js": 1,
153
155
  "proactive-prepare.test.js": 9,
154
156
  "learn.test.js": 3,
@@ -206,7 +208,7 @@
206
208
  "pm-r29-filespad-test.js": 5,
207
209
  "pm-r29-newsession-alignment-test.js": 8,
208
210
  "pm-live-notes-test.js": 24,
209
- "pm-notes-menu-test.js": 8,
211
+ "pm-notes-menu-test.js": 10,
210
212
  "pm-r30-test.js": 4,
211
213
  "ui-test.js": 30,
212
214
  "plan-items.test.js": 1,
@@ -12,7 +12,7 @@ The workspace runs with no network (D62). Two third-party browser files are comm
12
12
  | Package | Version | Licence | Tarball | Tarball sha256 | Shipped file |
13
13
  |---|---|---|---|---|---|
14
14
  | sortablejs | 1.15.7 | MIT | https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz | `66cbfc8fa18860783584fe0c346baf6cbf3399da72d23d21053425ba232abed4` | `public/vendor/Sortable.min.js`: an unmodified copy of `package/Sortable.min.js` (45 478 bytes, sha256 `bf4241bc73fef7f11c59a283a69fe8051cdd31c6d8ff5a2b9ba219e7831fcf76`; npm integrity `sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==`) |
15
- | lucide-static | 1.52.0 | ISC, plus the Feather MIT notice | https://registry.npmjs.org/lucide-static/-/lucide-static-1.52.0.tgz | `4d2aa079171e4eba4c704844b8b0c9edb6e4cfb2fb8bd86f7b14353994f04a87` | `public/vendor/lucide-sprite.js`: generated by `build-lucide-sprite.js` (21 404 bytes, sha256 `b97ca7eba4a6f6d29614c41d3b050c96df67d77b36a5db5ee175b830d003edfc`). It holds 61 icons: grip-vertical, plus, check, square, square-check-big, x, undo-2, flag, bot, chevron-right, chevron-down, ellipsis, circle-alert, history, arrow-up, arrow-down, diamond, then the eight the frame's chrome draws (Run 3): settings, search, refresh-cw, chevron-left, sun, moon, arrow-left, arrow-right, then globe, columns-2, arrow-up-down and arrow-left-right for the dock strip's web, split and orientation chips (Run 3, C6), then panel-top, panel-left, maximize-2, minimize-2, git-branch and clock for the dock's icon controls and session rows (0.54.2), then bold, italic, code, link, type, heading, list, list-checks, text-quote, file-code, git-compare, cloud and share-2 for the document tab's in-place editor, its Confluence copy and its connections, then house, target, square-kanban, file-text, square-terminal, calendar and chart-column for the phone's tab bar and view switches, then users, git-merge, mail, message-square, ticket and ban for the People place's nav icon, merge button and interaction sources |
15
+ | lucide-static | 1.52.0 | ISC, plus the Feather MIT notice | https://registry.npmjs.org/lucide-static/-/lucide-static-1.52.0.tgz | `4d2aa079171e4eba4c704844b8b0c9edb6e4cfb2fb8bd86f7b14353994f04a87` | `public/vendor/lucide-sprite.js`: generated by `build-lucide-sprite.js` (23 263 bytes, sha256 `6866d7d284f4f09139daa9f31cff28c4cc56b6c008aa0b58e6ee0e61907aac2f`). It holds 67 icons: grip-vertical, plus, check, square, square-check-big, x, undo-2, flag, bot, chevron-right, chevron-down, ellipsis, circle-alert, history, arrow-up, arrow-down, diamond, then the eight the frame's chrome draws (Run 3): settings, search, refresh-cw, chevron-left, sun, moon, arrow-left, arrow-right, then globe, columns-2, arrow-up-down and arrow-left-right for the dock strip's web, split and orientation chips (Run 3, C6), then panel-top, panel-left, maximize-2, minimize-2, git-branch and clock for the dock's icon controls and session rows (0.54.2), then bold, italic, code, link, type, heading, list, list-checks, text-quote, file-code, git-compare, cloud and share-2 for the document tab's in-place editor, its Confluence copy and its connections, then house, target, square-kanban, file-text, square-terminal, calendar and chart-column for the phone's tab bar and view switches, then users, git-merge, mail, message-square, ticket and ban for the People place's nav icon, merge button and interaction sources, then notebook-pen, mic, pause, play, highlighter and circle-dot for the Notes place, the Record button and the highlight |
16
16
 
17
17
  The licences are in this folder: [sortablejs-1.15.7.LICENSE](sortablejs-1.15.7.LICENSE) and [lucide-static-1.52.0.LICENSE](lucide-static-1.52.0.LICENSE). The sprite also carries the lucide licence text as its first comment.
18
18