create-pmos 0.238.0 → 0.240.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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/briefing.md +31 -3
- package/workspace/guide/document-sheet.md +3 -1
- package/workspace/lib/route-modules.js +2 -1
- package/workspace/lib/routes-brief-page.js +56 -5
- package/workspace/lib/routes-mentions.js +37 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/briefing-deck.js +1 -0
- package/workspace/public/pm/c3-brief-mentions.js +136 -0
- package/workspace/public/pm/c3-brief-needs.js +23 -0
- package/workspace/public/pm/c3-brief-pulse.js +1 -0
- package/workspace/public/pm/c3-brief-summary.js +139 -0
- package/workspace/public/pm/c3-demo-brief-page.js +20 -4
- package/workspace/public/pm/c3-doc-sheet.js +17 -1
- package/workspace/public/pm/c3-shell.js +32 -2
- package/workspace/public/pm/console3.css +20 -0
- package/workspace/test-files.json +2 -0
- package/workspace/test-lists/c3-doc-sheet-size.json +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.240.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-14T12:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-14T12:42:25Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-14T12:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-14T12:42:25Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-14T12:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-14T12:42:25Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "The Briefing: today's brief"
|
|
4
|
-
description: "The Briefing page of the console (/): the headline cards, Today, Needs you, Portfolio pulse, and what Done, Not yet and Drop do to an item."
|
|
4
|
+
description: "The Briefing page of the console (/): The brief, the headline cards, Today, Needs you, Mentions, Portfolio pulse, and what Done, Not yet and Drop do to an item."
|
|
5
5
|
timestamp: 2026-10-14
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# The Briefing: today's brief
|
|
9
9
|
|
|
10
|
-
The Briefing is the first page of the console at `/`. It has two columns. The left column holds the greeting, the headline cards
|
|
10
|
+
The Briefing is the first page of the console at `/`. It has two columns. The left column holds the greeting, The brief, the headline cards, Needs you and Mentions. The right column holds Today and Portfolio pulse. It holds no calendar. The full calendar is the Calendar tab.
|
|
11
11
|
|
|
12
12
|
Review is in the Work page, in its Review lens. Handled, What I could stop and Data you can lean on are in Reports, under Drafts.
|
|
13
13
|
|
|
@@ -35,6 +35,20 @@ Overdue cards come first, the most late first. The signal engine's cards come ne
|
|
|
35
35
|
|
|
36
36
|
Above the cards, the greeting names the part of the day, for example "Good morning, Wawan". The name is the first word of the git user name on the computer where PMOS runs. When git holds no name, the greeting has none. The part of the day follows the clock. There is no button to change it. When the clock moves to a new part, the line under the greeting and the order of the cards change after you stop using the page for a minute.
|
|
37
37
|
|
|
38
|
+
## The brief
|
|
39
|
+
|
|
40
|
+
The brief is one paragraph under the greeting, above the headline cards. Read it to know the day in a few seconds. It says, in this order:
|
|
41
|
+
|
|
42
|
+
1. The one decision that the day turns on. When no row is a decision that only you can make, it names the first row of Needs you.
|
|
43
|
+
2. How many things need you, how many are overdue, and the oldest overdue item.
|
|
44
|
+
3. Who waits for your reply to a mention.
|
|
45
|
+
4. How many meetings are left today, and the next meeting that still has a to-do before it.
|
|
46
|
+
5. Which initiatives are off track or at risk, and how many are on track.
|
|
47
|
+
|
|
48
|
+
Each number and each title comes from a row on the page. The count of things that need you is the same as the count of Needs you. Press a title to go to its row. The line at the right of the title says when PMOS wrote the paragraph and which sections it read. When a section changes, for example when you mark an item done, the paragraph changes too. In the evening the paragraph says what is still open and how many meetings you held.
|
|
49
|
+
|
|
50
|
+
PMOS writes the paragraph from the rows on the page. It does not call a model and it does not write a record.
|
|
51
|
+
|
|
38
52
|
## Today
|
|
39
53
|
|
|
40
54
|
Today starts with one line: the meeting that is on now or next, how many meetings are left, and how many of them are prepped, with a small ring. **Open calendar** opens the calendar. Under it, Today lists the meetings of the day, in time order. Each row shows:
|
|
@@ -73,6 +87,20 @@ Press a row to open it in place. A row of your own item has three actions. Each
|
|
|
73
87
|
|
|
74
88
|
PMOS can see evidence that an outside item is done. For example, a meeting with the person is on the calendar. Then the row says "Looks done: a meeting with Ana is booked Tue 10:00. Confirm?" PMOS never marks the item done itself. The item changes only when you press **Done** or **Yes, it is done**.
|
|
75
89
|
|
|
90
|
+
## Mentions
|
|
91
|
+
|
|
92
|
+
Mentions lists the places where someone mentioned you today: Slack, Jira, Confluence and mail. The list comes from today's morning brief. When there is no morning brief for today, Mentions says so.
|
|
93
|
+
|
|
94
|
+
The section is quiet. It shows one line that names who waits for your reply, for example "Priya in Slack and Dara in Jira are waiting on your reply." The header says how many need a reply. Press **Show all** to open the list. A tab for each source filters the list. Each row shows the person, the text they wrote, the source and the place, the time, and **needs your reply** or **for your information**. Each row has these actions:
|
|
95
|
+
|
|
96
|
+
- **Open in Slack** (or Jira, Confluence, Mail) opens the mention in a new tab. A mention with no link has no such button.
|
|
97
|
+
- **Add to Work** adds an item to your Work list, for example "Reply to Dara (Jira) by today".
|
|
98
|
+
- **Mark read** removes the row from the list. Press **Undo** in the notice to bring it back.
|
|
99
|
+
|
|
100
|
+
A mention that is already a row of Needs you does not show again in Mentions. That row in Needs you has a **mentioned you** tag.
|
|
101
|
+
|
|
102
|
+
PMOS does not reply, react or post in Slack, Jira, Confluence or mail.
|
|
103
|
+
|
|
76
104
|
## Portfolio pulse
|
|
77
105
|
|
|
78
106
|
Portfolio pulse says how many initiatives need attention, then shows one bar for the status of the initiatives and one row for each initiative. An initiative shows when it has a promised date, or when PMOS holds a read of its Jira epic. A row from a Jira epic shows "3 of 8 done" and a bar. "Done" counts the tickets under the epic that are done in Jira. A row with blocked tickets says **Blocked**. The Jira read is the last Jira status check; the opened row says its date. When no initiative has a promised date or a Jira read, the pulse says so. **All initiatives** at the bottom opens the Initiatives page. Press a row to open it in place. It shows why the initiative has that status, the promised date against the forecast, and the Jira progress when PMOS holds it. The page does not change.
|
|
@@ -85,6 +113,6 @@ An item has its actions in one place only. On the Briefing, Today and Portfolio
|
|
|
85
113
|
|
|
86
114
|
## On a phone
|
|
87
115
|
|
|
88
|
-
At 400 pixels wide the Briefing is one column (greeting, cards, Needs you, Today, Portfolio pulse) with no sideways scroll. Each button in an opened row is 44 pixels high.
|
|
116
|
+
At 400 pixels wide the Briefing is one column (greeting, The brief, cards, Needs you, Mentions, Today, Portfolio pulse) with no sideways scroll. Each button in an opened row is 44 pixels high.
|
|
89
117
|
|
|
90
118
|
On a phone, every console page has the same top. The page title has Record at its right. The sunrise line is under it, then the tool buttons in one row. The five tabs share the width, and each tab shows an icon over its name, so Initiatives and Reports are always on screen. A count shows at the top right of its tab's icon. A person in a row shows as a small round letter, and you can tap anywhere in the 44-pixel square around it. A line that has a sentence and a due date wraps like normal text.
|
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "The document sheet"
|
|
4
4
|
description: "How to read a document as rich text with its outline, edit it, review the agent's proposals section by section, read the edit log, keep its Confluence and Google Doc copies in step, see the documents around it, and clear a stale document."
|
|
5
|
-
timestamp: 2026-10-
|
|
5
|
+
timestamp: 2026-10-14
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# The document sheet
|
|
9
9
|
|
|
10
10
|
Open a document in Initiatives › Documents, in an initiative's Docs, or from Reports. The document opens in a sheet on the right. On a phone, it opens from the bottom. The segment under the title has six modes.
|
|
11
11
|
|
|
12
|
+
To make the sheet wider, select **Half**, **3/4** or **Whole** beside Close; **Normal** goes back to the standard width. The sheet keeps your choice in this browser and opens at that size next time. On a phone the sheet always fills the screen, so the sizes do not show.
|
|
13
|
+
|
|
12
14
|
## Read
|
|
13
15
|
|
|
14
16
|
Read shows the document as formatted text: headings, bold, links, tables, checklists and callouts. The front matter shows as properties at the top.
|
|
@@ -49,7 +49,8 @@ const MODULES = Object.freeze([
|
|
|
49
49
|
'routes-forecast', // console3 B5: forecast, critical path, time machine and capacity (read-only)
|
|
50
50
|
'routes-calendar-views', // console3 C2: Day/Week/Month/Quarter/Year cells, Gantt and the strategy lens (read-only)
|
|
51
51
|
'routes-briefing', // console3 C1: the day-part Briefing cards from the signal engine (read-only)
|
|
52
|
-
'routes-brief-page', // console3 Briefing as the brief: Today agenda
|
|
52
|
+
'routes-brief-page', // console3 Briefing as the brief: Today agenda, Needs you and Mentions (read-only; writes go through routes-pm-items)
|
|
53
|
+
'routes-mentions', // console3 Briefing Mentions: the PM's read marks (page press only)
|
|
53
54
|
'routes-phone-day', // phone companion: the agent's finished drafts, the evening recap and tomorrow at a glance (read-only)
|
|
54
55
|
'routes-report-facts', // console3 D: GET /api/report-families/facts (read-only, real data only)
|
|
55
56
|
'routes-records', // console3 E1a: ideas, hypotheses, decisions, KR metrics, bets (GET list; page-press add/edit/reading)
|
|
@@ -17,8 +17,15 @@
|
|
|
17
17
|
* evidence is { text, event_id } when the calendar holds a meeting with the person, else null: the page asks the PM to
|
|
18
18
|
* confirm it and never marks the item done itself.
|
|
19
19
|
* A decision row: { key, kind: 'decision', headline, tag, card }.
|
|
20
|
+
* mentions the items of today's morning brief (<runtime>/briefs/<date>.json) that say `mention: true`: someone named the PM in Slack, Jira,
|
|
21
|
+
* Confluence or mail (initiative c3-brief-summary-mentions). Newest first: { id, source, who, where, text, at, reply, link, initiative,
|
|
22
|
+
* in_needs, need_key, read }. text is the item's quote, else its ask, else its title. at is the time of the author's last message in the item's thread.
|
|
23
|
+
* reply is the item's reply_needed. in_needs: a worth-asking item, which Needs you shows as its row need_key ("ask:<id>"). A mention whose ledger copy
|
|
24
|
+
* (brief:<id>) is done or dropped is left out. read: the PM marked it read (<runtime>/mentions/read.jsonl). A field the item lacks is absent.
|
|
25
|
+
* mentions is null when there is no brief for today.
|
|
20
26
|
* Item fields read defensively (a missing field is absent): kind, defer, person, evidence, workPlacement(item, today) when lib/pm-items.js
|
|
21
|
-
* has it. A deferred item ('Not yet') is left out until its defer date or time. Writes go through the existing pm-items routes
|
|
27
|
+
* has it. A deferred item ('Not yet') is left out until its defer date or time. Writes go through the existing pm-items routes; a mention's
|
|
28
|
+
* read mark goes through POST /api/mentions/read (routes-mentions.js). */
|
|
22
29
|
const path = require('path');
|
|
23
30
|
const pmItems = require('./pm-items.js');
|
|
24
31
|
const { repoScript } = require('./repo-script.js');
|
|
@@ -129,13 +136,53 @@ function needsOf(items, events, cards, now, deps = {}) {
|
|
|
129
136
|
return [...late, ...dueToday, ...offline, ...decisions];
|
|
130
137
|
}
|
|
131
138
|
|
|
139
|
+
const MENTION_ID = /^[0-9a-f]{12}$/;
|
|
140
|
+
const httpsUrl = (v) => { const t = String(v || ''); try { return new URL(t).protocol === 'https:' && t.length <= 500 ? t : null; } catch { return null; } };
|
|
141
|
+
|
|
142
|
+
/** The read marks: the last line for each id wins. */
|
|
143
|
+
function readMarks(lines) {
|
|
144
|
+
const m = new Map();
|
|
145
|
+
for (const l of lines || []) if (l && MENTION_ID.test(String(l.id || '')) && typeof l.read === 'boolean') m.set(l.id, l.read);
|
|
146
|
+
return m;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The mentions of today's brief. itemLines: the PM's ledger (pm-items items.jsonl lines); reads: readMarks(). Pure. */
|
|
150
|
+
function mentionsOf(brief, itemLines, reads, todayIso) {
|
|
151
|
+
if (!brief || brief.date !== todayIso) return null;
|
|
152
|
+
const folded = pmItems.fold(itemLines || []);
|
|
153
|
+
const done = new Set(folded.filter((i) => i.done).map((i) => i.id));
|
|
154
|
+
const live = new Set(folded.map((i) => i.id));
|
|
155
|
+
const dropped = new Set((itemLines || []).filter((l) => l && l.id && !live.has(l.id)).map((l) => l.id));
|
|
156
|
+
const seen = new Set(); const out = [];
|
|
157
|
+
for (const [list, ask] of [[brief.priorities, false], [brief.worth_asking, true]]) {
|
|
158
|
+
for (const x of Array.isArray(list) ? list : []) {
|
|
159
|
+
if (!x || !x.mention || !MENTION_ID.test(String(x.id || '')) || seen.has(x.id)) continue;
|
|
160
|
+
seen.add(x.id);
|
|
161
|
+
const key = 'brief:' + x.id;
|
|
162
|
+
if (done.has(key) || dropped.has(key)) continue;
|
|
163
|
+
const thread = Array.isArray(x.thread) ? x.thread : [];
|
|
164
|
+
const mine = thread.filter((m) => m && m.at && !Number.isNaN(Date.parse(m.at)) && (!x.who || m.who === x.who));
|
|
165
|
+
const row = { id: x.id, source: x.source, text: clip(x.quote || x.ask || x.title, 280), reply: x.reply_needed === true, in_needs: ask || !!x.worth_asking, read: reads && reads.get(x.id) === true };
|
|
166
|
+
if (x.who) row.who = clip(x.who, 120);
|
|
167
|
+
if (x.where) row.where = clip(x.where, 120);
|
|
168
|
+
row.at = mine.length ? new Date(mine[mine.length - 1].at).toISOString() : null;
|
|
169
|
+
const link = httpsUrl(x.link); if (link) row.link = link;
|
|
170
|
+
if (x.initiative_id) row.initiative = x.initiative_id;
|
|
171
|
+
if (row.in_needs) row.need_key = 'ask:' + x.id; // the Needs you row it is (briefing-real.js askNeed)
|
|
172
|
+
out.push(row);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return out.sort((a, b) => (b.at ? Date.parse(b.at) : 0) - (a.at ? Date.parse(a.at) : 0));
|
|
176
|
+
}
|
|
177
|
+
|
|
132
178
|
/** The pure builder. deps: { events, items (folded), cards, notes } so a test needs no repository. */
|
|
133
179
|
function build(query, deps = {}) {
|
|
134
180
|
const q = query || {};
|
|
135
181
|
let now = q.now ? new Date(q.now) : (deps.now ? new Date(deps.now) : new Date());
|
|
136
182
|
if (Number.isNaN(now.getTime())) now = new Date();
|
|
137
183
|
const events = deps.events || []; const items = deps.items || [];
|
|
138
|
-
const body = { ok: true, date: localDate(now), today: todayAgenda(events, items, now, deps.notes || {}), needs: needsOf(items, events, deps.cards || [], now, deps)
|
|
184
|
+
const body = { ok: true, date: localDate(now), today: todayAgenda(events, items, now, deps.notes || {}), needs: needsOf(items, events, deps.cards || [], now, deps),
|
|
185
|
+
mentions: mentionsOf(deps.brief || null, deps.itemLines || [], readMarks(deps.readLines), localDate(now)) };
|
|
139
186
|
if (deps.decisionsError) body.decisions_error = deps.decisionsError;
|
|
140
187
|
return { status: 200, body };
|
|
141
188
|
}
|
|
@@ -164,7 +211,8 @@ function firstName(cwd) {
|
|
|
164
211
|
|
|
165
212
|
module.exports = {
|
|
166
213
|
prefixes: ['/api/brief-page'],
|
|
167
|
-
|
|
214
|
+
MENTION_ID,
|
|
215
|
+
build, todayAgenda, needsOf, evidenceFor, deferred, firstName, notesAdapter, mentionsOf, readMarks,
|
|
168
216
|
handle: async (req, res, url, ctx) => {
|
|
169
217
|
if (url.pathname !== '/api/brief-page') return false;
|
|
170
218
|
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
@@ -174,7 +222,10 @@ module.exports = {
|
|
|
174
222
|
try {
|
|
175
223
|
const dir = ctx.runtimeDir;
|
|
176
224
|
const events = dir ? require('./calendar-events.js').readAll(dir, { now: now.getTime() }) : [];
|
|
177
|
-
const
|
|
225
|
+
const itemLines = dir ? pmItems.readLines(path.join(dir, 'pm-items', 'items.jsonl')) : [];
|
|
226
|
+
const items = pmItems.fold(itemLines);
|
|
227
|
+
let brief = null; try { brief = dir ? JSON.parse(require('fs').readFileSync(path.join(dir, 'briefs', localDate(now) + '.json'), 'utf8')) : null; } catch { brief = null; }
|
|
228
|
+
const readLines = dir ? pmItems.readLines(path.join(dir, 'mentions', 'read.jsonl')) : [];
|
|
178
229
|
// The decisions come from the signal engine, whose repository read is slow. The page already holds them (GET /api/briefing feeds its deck), so the
|
|
179
230
|
// read is made only when the caller asks (?decisions=1); the items and the agenda never wait for it.
|
|
180
231
|
let cards = []; let decisionsError = null;
|
|
@@ -185,7 +236,7 @@ module.exports = {
|
|
|
185
236
|
const facts = await require('./facts-async.js').readFactsAsync(root, { today: iso });
|
|
186
237
|
cards = require(repoScript('signals/index.js')).present(facts, { lens: 'Unblock', zoom: 'week', today: iso }).cards || [];
|
|
187
238
|
} catch (e) { decisionsError = 'the decisions could not be read: ' + (e && e.message ? e.message : String(e)); }
|
|
188
|
-
const out = build({ now: now.toISOString() }, { events, items, cards, decisionsError, notes: dir ? notesAdapter(dir) : {} });
|
|
239
|
+
const out = build({ now: now.toISOString() }, { events, items, itemLines, brief, readLines, cards, decisionsError, notes: dir ? notesAdapter(dir) : {} });
|
|
189
240
|
if (out.body && out.body.ok) out.body.me = firstName(path.resolve(__dirname, '..', '..'));
|
|
190
241
|
ctx.json(res, out.body, out.status);
|
|
191
242
|
} catch (e) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-mentions.js — POST /api/mentions/read { id, read } → { ok } (initiative c3-brief-summary-mentions)
|
|
3
|
+
*
|
|
4
|
+
* The PM marked a mention of today's brief read on the Briefing (read: false undoes it). One appended line in <runtime>/mentions/read.jsonl:
|
|
5
|
+
* { id, read, at }. GET /api/brief-page reads the file back (routes-brief-page.js readMarks: the last line for an id wins).
|
|
6
|
+
* The page press only (pm-guard requirePagePress). Nothing is sent to Slack, Jira, Confluence or mail. */
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const guard = require('./pm-guard.js');
|
|
9
|
+
const pmItems = require('./pm-items.js');
|
|
10
|
+
const { MENTION_ID } = require('./routes-brief-page.js');
|
|
11
|
+
|
|
12
|
+
/** The check of a body: null when it is good, else the reason. Pure. */
|
|
13
|
+
function badBody(b) {
|
|
14
|
+
if (!b || !MENTION_ID.test(String(b.id || ''))) return 'id must be a mention id (12 hex characters)';
|
|
15
|
+
if (typeof b.read !== 'boolean') return 'read must be true or false';
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = {
|
|
20
|
+
prefixes: ['/api/mentions'],
|
|
21
|
+
badBody,
|
|
22
|
+
handle: async (req, res, url, ctx) => {
|
|
23
|
+
if (url.pathname !== '/api/mentions/read') return false;
|
|
24
|
+
if (req.method !== 'POST') { ctx.json(res, { ok: false, error: 'POST only' }, 405); return true; }
|
|
25
|
+
const got = await ctx.readJson(req);
|
|
26
|
+
if (!got.ok) { ctx.json(res, { ok: false, error: 'invalid JSON body' }, 400); return true; }
|
|
27
|
+
const b = got.body || {};
|
|
28
|
+
const g = guard.requirePagePress(req, b);
|
|
29
|
+
if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
|
|
30
|
+
const err = badBody(b);
|
|
31
|
+
if (err) { ctx.json(res, { ok: false, error: err }, 400); return true; }
|
|
32
|
+
if (!ctx.runtimeDir) { ctx.json(res, { ok: false, error: 'no runtime folder' }, 500); return true; }
|
|
33
|
+
pmItems.appendEvent(path.join(ctx.runtimeDir, 'mentions', 'read.jsonl'), { id: b.id, read: b.read }, new Date());
|
|
34
|
+
ctx.json(res, { ok: true });
|
|
35
|
+
return true;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.240.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.240.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -364,6 +364,7 @@ function draw(S) {
|
|
|
364
364
|
if (bare && (!d || !d.ok || d.scope !== S.ctx.scope || !(d.cards || []).length)) { S.root.replaceChildren(); S.root.hidden = true; S.flip = null; return; }
|
|
365
365
|
if (bare) S.root.hidden = false;
|
|
366
366
|
const kids = bare ? [] : [stance(S)];
|
|
367
|
+
if (!bare && S.ctx && S.ctx.afterStance) kids.push(S.ctx.afterStance); // a node the page keeps under the greeting (console3: The brief)
|
|
367
368
|
if (!d) kids.push(el('p', { class: 'dim mono', style: small, role: 'status' }, 'Reading the repository'));
|
|
368
369
|
else if (!d.ok) kids.push(el('p', { class: 'dim mono', style: small, role: 'status', 'data-bf': 'error' }, 'The briefing could not be read: ' + d.error));
|
|
369
370
|
else {
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/* pm/c3-brief-mentions.js — the Briefing's "Mentions" (initiative c3-brief-summary-mentions).
|
|
2
|
+
*
|
|
3
|
+
* Everything today's brief marks as a mention of the PM (Slack, Jira, Confluence, mail), from GET /api/brief-page `mentions`
|
|
4
|
+
* (routes-brief-page.js mentionsOf). Quiet by default: one line that names who waits on a reply. "Show all" opens the list, with a tab for
|
|
5
|
+
* each source that has a mention. A row: the person, the quoted text, the source and place, the time, "needs your reply" or "for your
|
|
6
|
+
* information", the initiative. Its actions:
|
|
7
|
+
* Open in <source> the mention's own https link, in a new tab (no link, no button)
|
|
8
|
+
* Add to Work POST /api/pm-items/add { line: "Reply to <who> (<source>) by today" } (a reply) or "Read <who>'s note (<source>)"
|
|
9
|
+
* Mark read POST /api/mentions/read { id, read: true }; Undo sends read: false
|
|
10
|
+
* A mention that is a worth-asking item (in_needs) is not listed: Needs you shows it (its row is need_key), and that row carries a "mentioned you" tag
|
|
11
|
+
* (tagNeeds, called after every Needs you redraw). Nothing here replies, reacts or posts in Slack, Jira, Confluence or mail. */
|
|
12
|
+
import { el } from './dom.js';
|
|
13
|
+
import { c3ic } from './pm-work-view.js';
|
|
14
|
+
import { pmPost } from './http.js';
|
|
15
|
+
import { toast } from './c3-toast.js';
|
|
16
|
+
import { onBriefPage, loadBriefPage } from './c3-brief-data.js';
|
|
17
|
+
import { button, tabs, SEL, ensureComponentsCss } from './primitives.js';
|
|
18
|
+
import { ensureSprite } from './icons.js';
|
|
19
|
+
import { srcName, waitingMentions } from './c3-brief-summary.js';
|
|
20
|
+
|
|
21
|
+
const p2 = (n) => String(n).padStart(2, '0');
|
|
22
|
+
const first = (s) => String(s || '').trim().split(/\s+/)[0];
|
|
23
|
+
const ICON = { slack: 'reply', jira: 'org', confluence: 'doc', mail: 'mail' };
|
|
24
|
+
const ORDER = ['slack', 'jira', 'confluence', 'mail'];
|
|
25
|
+
const post = (p, b) => pmPost(p, b).catch((e) => ({ ok: false, error: String((e && e.message) || e) }));
|
|
26
|
+
|
|
27
|
+
/** When, said the way the Briefing says times: "09:15" today, "yesterday 17:05" before. */
|
|
28
|
+
function whenOf(iso, now = new Date()) {
|
|
29
|
+
if (!iso) return null;
|
|
30
|
+
const d = new Date(iso); const hm = p2(d.getHours()) + ':' + p2(d.getMinutes());
|
|
31
|
+
const day = (x) => x.getFullYear() * 400 + x.getMonth() * 32 + x.getDate();
|
|
32
|
+
if (day(d) === day(now)) return hm;
|
|
33
|
+
const y = new Date(now); y.setDate(y.getDate() - 1);
|
|
34
|
+
return day(d) === day(y) ? 'yesterday ' + hm : d.getDate() + '/' + (d.getMonth() + 1) + ' ' + hm;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The closed line: who waits on a reply, then how many are for information. Pure. */
|
|
38
|
+
export function waitingLine(shown) {
|
|
39
|
+
const w = waitingMentions(shown); const rest = shown.length - w.length;
|
|
40
|
+
if (!w.length) return shown.length + (shown.length === 1 ? ' mention, for your information.' : ' mentions, all for your information.');
|
|
41
|
+
const names = w.map((m) => (m.who ? first(m.who) : 'Someone') + ' in ' + srcName(m.source));
|
|
42
|
+
const said = names.length < 2 ? names[0] : names.slice(0, -1).join(', ') + ' and ' + names[names.length - 1];
|
|
43
|
+
return said + (w.length === 1 ? ' is' : ' are') + ' waiting on your reply.' + (rest ? ' ' + rest + (rest === 1 ? ' more is' : ' more are') + ' for your information.' : '');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function mentionsSection(S) {
|
|
47
|
+
ensureComponentsCss(); ensureSprite();
|
|
48
|
+
const st = { open: false, tab: 'all', local: new Map(), list: null };
|
|
49
|
+
const count = el('span', { class: 'bdg g' }, '0');
|
|
50
|
+
const right = el('span', { class: 'chips2' });
|
|
51
|
+
const body = el('div', { 'data-mentions-body': '' });
|
|
52
|
+
const box = el('section', { class: 'card sect', 'data-c3pg': 'mentions', 'aria-label': 'Mentions' },
|
|
53
|
+
el('div', { class: 'sh2' }, el('h2', null, c3ic('reply', 'sm'), 'Mentions ', count), right), body);
|
|
54
|
+
box.hidden = true;
|
|
55
|
+
|
|
56
|
+
const all = () => (st.list || []).map((m) => (st.local.has(m.id) ? { ...m, read: st.local.get(m.id) } : m));
|
|
57
|
+
const shown = () => all().filter((m) => !m.in_needs && !m.read);
|
|
58
|
+
|
|
59
|
+
const markRead = (m, read) => post('/api/mentions/read', { id: m.id, read }).then((r) => {
|
|
60
|
+
if (!r || r.ok === false) { toast('That did not go through' + (r && r.error ? ': ' + r.error : '.')); return false; }
|
|
61
|
+
st.local.set(m.id, read); draw(); if (S.briefRedraw) S.briefRedraw(); return true;
|
|
62
|
+
});
|
|
63
|
+
const addToWork = (m) => {
|
|
64
|
+
const who = m.who ? first(m.who) : 'the sender';
|
|
65
|
+
const line = m.reply ? 'Reply to ' + who + ' (' + srcName(m.source) + ') by today' : 'Read ' + who + '\'s note (' + srcName(m.source) + ')';
|
|
66
|
+
return post('/api/pm-items/add', { line }).then((r) => {
|
|
67
|
+
if (!r || r.ok === false) { toast('That did not go through' + (r && r.error ? ': ' + r.error : '.')); return; }
|
|
68
|
+
toast('Added to Work: ' + line.replace(/ by today$/, ''));
|
|
69
|
+
if (S.workChanged) S.workChanged();
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const rowOf = (m) => {
|
|
74
|
+
const when = whenOf(m.at);
|
|
75
|
+
const chips = [m.reply ? el('span', { class: 'tag warn', 'data-mention-reply': '' }, 'needs your reply') : el('span', { class: 'tag', 'data-mention-fyi': '' }, 'for your information'),
|
|
76
|
+
el('span', { class: 'chip2', 'data-mention-where': '' }, srcName(m.source) + (m.where ? ' · ' + m.where : '')),
|
|
77
|
+
when ? el('span', { class: 'chip2' }, c3ic('clock', 'sm'), when) : null,
|
|
78
|
+
m.initiative ? el('span', { class: 'chip2' }, c3ic('init', 'sm'), m.initiative) : null];
|
|
79
|
+
const acts = el('div', { class: 'mn-acts' },
|
|
80
|
+
m.link ? el('a', { class: 'chip2 mn-open', href: m.link, target: '_blank', rel: 'noopener noreferrer', 'data-mention-open': m.id }, c3ic('out', 'sm'), 'Open in ' + srcName(m.source)) : null,
|
|
81
|
+
button({ label: 'Add to Work', variant: 'ghost', size: 'sm', icon: 'plus', attrs: { 'data-mention-work': m.id }, onClick: () => addToWork(m) }),
|
|
82
|
+
button({ label: 'Mark read', variant: 'ghost', size: 'sm', icon: 'check', attrs: { 'data-mention-read': m.id },
|
|
83
|
+
onClick: () => markRead(m, true).then((done) => { if (done) toast('Marked read', () => markRead(m, false)); }) }));
|
|
84
|
+
return el('li', { class: 'nrow mn-row', 'data-mention': m.id, 'data-source': m.source },
|
|
85
|
+
el('span', { class: 'ki' + (m.reply ? ' hot' : '') }, c3ic(ICON[m.source] || 'reply')),
|
|
86
|
+
el('div', { class: 'nb' }, el('span', { class: 'tt' }, m.who || srcName(m.source)), el('div', { class: 'mn-q' }, '"' + m.text + '"'),
|
|
87
|
+
el('div', { class: 'chips' }, ...chips.filter(Boolean)), acts),
|
|
88
|
+
el('span'));
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const toggle = button({ label: 'Show all', variant: 'ghost', size: 'sm', attrs: { 'data-mentions-toggle': '', 'aria-expanded': 'false' }, onClick: () => { st.open = !st.open; draw(); } });
|
|
92
|
+
function draw() {
|
|
93
|
+
if (!st.list) { box.hidden = st.list === undefined; body.replaceChildren(el('p', { class: 'dim c3s2', 'data-mentions-state': 'none' }, 'PMOS has no brief for today, so no mentions were read.')); count.textContent = '0'; right.replaceChildren(); return; }
|
|
94
|
+
box.hidden = false;
|
|
95
|
+
const list = shown(); const wait = waitingMentions(list);
|
|
96
|
+
count.textContent = String(list.length);
|
|
97
|
+
toggle.querySelector(SEL.btnLabel).textContent = st.open ? 'Hide' : 'Show all';
|
|
98
|
+
toggle.setAttribute('aria-expanded', String(st.open));
|
|
99
|
+
right.replaceChildren(...(wait.length ? [el('span', { class: 'tag warn', 'data-mentions-wait': String(wait.length) }, wait.length + (wait.length === 1 ? ' needs a reply' : ' need a reply'))] : []), ...(list.length ? [toggle] : []));
|
|
100
|
+
if (!list.length) { body.replaceChildren(el('p', { class: 'dim c3s2', 'data-mentions-state': 'empty' }, 'No one has mentioned you today.')); return; }
|
|
101
|
+
if (!st.open) { body.replaceChildren(el('p', { class: 'mn-line', 'data-mentions-line': '' }, waitingLine(list))); return; }
|
|
102
|
+
const srcs = ORDER.filter((k) => list.some((m) => m.source === k)).concat([...new Set(list.map((m) => m.source))].filter((k) => !ORDER.includes(k)));
|
|
103
|
+
if (st.tab !== 'all' && !srcs.includes(st.tab)) st.tab = 'all';
|
|
104
|
+
const bar = tabs({ ariaLabel: 'Mentions by source', value: st.tab, attrs: { class: 'mn-tabs', 'data-mentions-tabs': '' },
|
|
105
|
+
items: ['all', ...srcs].map((k) => ({ id: k, label: k === 'all' ? 'All' : srcName(k), count: k === 'all' ? list.length : list.filter((m) => m.source === k).length })),
|
|
106
|
+
onChange: (k) => { // the list draws again; keep the focus on the chosen tab
|
|
107
|
+
const had = bar.contains(document.activeElement); st.tab = k; draw();
|
|
108
|
+
const now = had && body.querySelector('[data-mentions-tabs] [data-tab="' + k + '"]'); if (now) now.focus();
|
|
109
|
+
} });
|
|
110
|
+
const rows = list.filter((m) => st.tab === 'all' || m.source === st.tab).sort((a, b) => (b.reply - a.reply) || ((b.at ? Date.parse(b.at) : 0) - (a.at ? Date.parse(a.at) : 0)));
|
|
111
|
+
body.replaceChildren(bar, el('ul', { class: 'list', role: 'list', 'data-mentions-list': '' }, ...rows.map(rowOf)));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* Needs you shows a worth-asking mention as its need_key row: tag it, first among its chips so the quiet layout keeps it */
|
|
115
|
+
S.tagNeeds = (host) => {
|
|
116
|
+
const by = new Map(all().filter((m) => m.in_needs && m.need_key).map((m) => [m.need_key, m]));
|
|
117
|
+
if (!by.size) return;
|
|
118
|
+
(host || document).querySelectorAll('[data-needs-row]').forEach((row) => {
|
|
119
|
+
const m = by.get(row.dataset.needsRow); const chips = row.querySelector('.nb > .chips');
|
|
120
|
+
if (!m || !chips || chips.querySelector('[data-mentioned-you]')) return;
|
|
121
|
+
chips.prepend(el('span', { class: 'tag', 'data-mentioned-you': '', title: (m.who ? m.who + ' mentioned you' : 'You were mentioned') + ' in ' + srcName(m.source) }, 'mentioned you'));
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
st.list = undefined;
|
|
126
|
+
onBriefPage((j) => {
|
|
127
|
+
st.list = j && j.ok !== false ? (Array.isArray(j.mentions) ? j.mentions : null) : null;
|
|
128
|
+
st.local.clear();
|
|
129
|
+
draw();
|
|
130
|
+
if (S.tagNeeds) S.tagNeeds(S.needsBox);
|
|
131
|
+
if (S.briefRedraw) S.briefRedraw();
|
|
132
|
+
});
|
|
133
|
+
S.mentionsNow = () => (st.list === undefined ? null : st.list ? all() : null);
|
|
134
|
+
S.reloadMentions = () => loadBriefPage();
|
|
135
|
+
return box;
|
|
136
|
+
}
|
|
@@ -133,6 +133,23 @@ export function needsSection(S) {
|
|
|
133
133
|
const f = list.querySelector('.fold');
|
|
134
134
|
if (!n && !f) list.replaceChildren(nothing());
|
|
135
135
|
else if (n && f && list.children.length === 1 && f.textContent.startsWith('Nothing needs')) f.remove();
|
|
136
|
+
if (S.tagNeeds) S.tagNeeds(box); // Mentions (c3-brief-mentions.js): the "mentioned you" tag on a worth-asking row
|
|
137
|
+
if (S.briefRedraw) S.briefRedraw(); // The brief (c3-brief-summary.js) says what this section holds
|
|
138
|
+
};
|
|
139
|
+
/* What The brief reads of this section: the rows on the page now, with what each row's data says. null until both reads came back. */
|
|
140
|
+
const meta = new Map();
|
|
141
|
+
S.needsSnapshot = () => {
|
|
142
|
+
if (!last || !page || page.ok === false) return null;
|
|
143
|
+
const rowsIn = (host) => [...host.querySelectorAll('.nrow[data-needs-row]')];
|
|
144
|
+
const decisions = [...rowsIn(list), ...rowsIn(extra)].map((r) => {
|
|
145
|
+
const k = r.dataset.needsRow; const m = meta.get(k) || {}; const tt = r.querySelector('.tt');
|
|
146
|
+
return { key: k, headline: tt ? tt.textContent : '', time: m.time || null, decide: !!m.decide, overdue: !!m.overdue, days_late: m.days_late || 0 };
|
|
147
|
+
});
|
|
148
|
+
const own0 = rowsIn(own).map((r) => {
|
|
149
|
+
const k = r.dataset.needsRow; const d = (page.needs || []).find((x) => x.key === k) || {}; const tt = r.querySelector('.tt');
|
|
150
|
+
return { key: k, headline: tt ? tt.textContent : (d.headline || ''), kind: r.dataset.kind || d.kind, days_late: d.days_late || 0 };
|
|
151
|
+
});
|
|
152
|
+
return { decisions, own: own0, agentN: agent.children.length };
|
|
136
153
|
};
|
|
137
154
|
const ctx = { changed: recount };
|
|
138
155
|
|
|
@@ -157,6 +174,12 @@ export function needsSection(S) {
|
|
|
157
174
|
const g = JSON.stringify([key, inc, !!(b && b.kept_out), S.pinNeed || '']);
|
|
158
175
|
if (g === sig) return; sig = g;
|
|
159
176
|
alertHost.replaceChildren(...(inc ? [el('div', { class: 'incident', role: 'status', 'data-c3pg': 'incident' }, c3ic('alert', 'lg'), el('div', null, el('b', null, 'Card payments failing since 07:40'), el('div', { class: 'dim c3s3' }, '12% of checkouts. Sam on call. Only deadlines shown.')))] : []));
|
|
177
|
+
meta.clear();
|
|
178
|
+
/* a decision only the PM can make: an engine card, or an assigned item marked Decide; an assigned item past its date is overdue */
|
|
179
|
+
(needs || decide.map((c, i) => fromCard({ key: c.key || String(i), card: c }, i))).forEach((n) => {
|
|
180
|
+
const late = /^(\d+) days? overdue$/.exec(String(n.dueText || ''));
|
|
181
|
+
meta.set(n.key, { time: n.time || null, decide: !!n.view || /^decide$/i.test(String(n.kind || '')), overdue: n.kind === 'Overdue', days_late: late ? Number(late[1]) : 0 });
|
|
182
|
+
});
|
|
160
183
|
if (needs) {
|
|
161
184
|
sumState.quick = needs.filter((n) => n.quick).length;
|
|
162
185
|
list.replaceChildren(...needs.slice(0, 5).map((n, i) => needRow(n, i, ctx)));
|
|
@@ -84,6 +84,7 @@ export function pulse(S) {
|
|
|
84
84
|
rows = rows.filter(shown);
|
|
85
85
|
if (!rows.length) { body.replaceChildren(note('No initiative has a promised date or a Jira epic read yet, so there is nothing to show.'), ...(noPlan ? [el('p', { class: 'dim mono c3s2' }, noPlan + ' initiatives have no plan date.')] : [])); return; }
|
|
86
86
|
const pr = rows.map(pulseRow);
|
|
87
|
+
S.pulseRows = pr; if (S.briefRedraw) S.briefRedraw(); // The brief (c3-brief-summary.js) names the rows off track or at risk
|
|
87
88
|
const need = pr.filter((r) => r.cls === 'bad' || r.cls === 'warn').length;
|
|
88
89
|
attn.textContent = need + ' need attention'; foot.hidden = false;
|
|
89
90
|
const by = new Map(); pr.forEach((r) => { const k = r.word.replace(/ \+.*/, ''); const o = by.get(k) || { n: 0, cls: r.cls }; o.n += 1; by.set(k, o); });
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/* pm/c3-brief-summary.js — "The brief": one paragraph under the greeting that says the day at a glance (initiative c3-brief-summary-mentions).
|
|
2
|
+
*
|
|
3
|
+
* composeBrief(input) → { parts, sources } PURE. parts is a list of { t } text and { t, go: { kind, key } } links, in reading order:
|
|
4
|
+
* 1. "The day turns on one call": the first decision only the PM can make; without one, "First up": the top row
|
|
5
|
+
* ("Still open from today" in Reflect)
|
|
6
|
+
* 2. how many things need the PM, how many are overdue, and the oldest
|
|
7
|
+
* 3. who waits on a reply to a mention
|
|
8
|
+
* 4. how many meetings are left and which one still has a to-do before it ("You held N meetings today." in Reflect)
|
|
9
|
+
* 5. which initiatives are off track or at risk
|
|
10
|
+
* A sentence whose read has not come back (null input) is left out. Nothing is invented: every number and title
|
|
11
|
+
* comes from a row the Briefing shows below.
|
|
12
|
+
* summarySection(S) → the card. It composes again when a read comes back, when Needs you changes (S.needsChanged) and when the part of
|
|
13
|
+
* the day changes (S.briefLens). A link goes to its row on the page through S.briefGo({ kind, key }).
|
|
14
|
+
*
|
|
15
|
+
* input: { lens, decisions: [{ key, headline, time, decide, overdue, days_late }] | null (the rows above the PM's own in Needs you; decide: a
|
|
16
|
+
* decision only the PM can make, overdue: an assigned item past its date),
|
|
17
|
+
* own: [{ key, headline, kind, days_late }] | null, agentN,
|
|
18
|
+
* mentions: [mention] | null, agenda: [meeting] | null, pulse: [{ id, title, cls, word }] | null }
|
|
19
|
+
* No model call and no write. */
|
|
20
|
+
import { el } from './dom.js';
|
|
21
|
+
import { c3ic } from './pm-work-view.js';
|
|
22
|
+
|
|
23
|
+
const p2 = (n) => String(n).padStart(2, '0');
|
|
24
|
+
const hm = (iso) => { const d = new Date(iso); return p2(d.getHours()) + ':' + p2(d.getMinutes()); };
|
|
25
|
+
const plural = (n, w) => n + ' ' + w + (n === 1 ? '' : 's');
|
|
26
|
+
const first = (s) => String(s || '').trim().split(/\s+/)[0];
|
|
27
|
+
const SRC = { slack: 'Slack', jira: 'Jira', confluence: 'Confluence', mail: 'Mail', figma: 'Figma', github: 'GitHub', drive: 'Drive', calendar: 'Calendar', wispr: 'Wispr Flow', website: 'Website', derived: 'PMOS' };
|
|
28
|
+
export const srcName = (s) => SRC[s] || String(s || 'a source');
|
|
29
|
+
/** a list said as a person says it: "a", "a and b", "a, b and c" */
|
|
30
|
+
const andList = (xs) => (xs.length < 2 ? xs.join('') : xs.slice(0, -1).join(', ') + ' and ' + xs[xs.length - 1]);
|
|
31
|
+
|
|
32
|
+
/** The mentions that wait on the PM's reply: not read, not already a Needs you row. */
|
|
33
|
+
export const waitingMentions = (ms) => (ms || []).filter((m) => m && m.reply && !m.read && !m.in_needs);
|
|
34
|
+
|
|
35
|
+
export function composeBrief(input) {
|
|
36
|
+
const x = input || {};
|
|
37
|
+
const reflect = x.lens === 'Reflect';
|
|
38
|
+
const out = []; const sources = [];
|
|
39
|
+
const t = (s) => out.push({ t: s });
|
|
40
|
+
const link = (s, kind, key) => out.push({ t: s, go: { kind, key } });
|
|
41
|
+
|
|
42
|
+
// 1 and 2: Needs you
|
|
43
|
+
if (x.decisions && x.own) {
|
|
44
|
+
sources.push('Needs you');
|
|
45
|
+
const dec = x.decisions.find((d) => d.decide);
|
|
46
|
+
const d0 = dec || x.own[0] || x.decisions[0]; // a decision only the PM can make, else the top row of Needs you
|
|
47
|
+
if (d0) {
|
|
48
|
+
t(reflect ? 'Still open from today: ' : dec ? 'The day turns on one call: ' : 'First up: '); link(d0.headline, 'need', d0.key);
|
|
49
|
+
t((d0.time ? (/overdue/.test(d0.time) ? ', ' + d0.time : ', due ' + d0.time) : d0.days_late > 0 ? ', ' + plural(d0.days_late, 'day') + ' late' : '') + '. ');
|
|
50
|
+
}
|
|
51
|
+
const agentN = Number(x.agentN) || 0;
|
|
52
|
+
const total = x.decisions.length + x.own.length + agentN;
|
|
53
|
+
const late = [...x.own.filter((r) => r.kind === 'overdue'), ...x.decisions.filter((d) => d.overdue)];
|
|
54
|
+
if (!total) t('Nothing needs you right now. ');
|
|
55
|
+
else {
|
|
56
|
+
const parts = [plural(x.decisions.length, 'decision'), x.own.length + ' of your own'];
|
|
57
|
+
if (agentN) parts.push(agentN + ' from the agent');
|
|
58
|
+
t(plural(total, 'thing') + (total === 1 ? ' needs' : ' need') + ' you (' + parts.join(', ') + ')');
|
|
59
|
+
if (late.length) {
|
|
60
|
+
const o = late.slice().sort((a, b) => (b.days_late || 0) - (a.days_late || 0))[0];
|
|
61
|
+
t(', and ' + late.length + (late.length === 1 ? ' is' : ' are') + ' overdue, the oldest ');
|
|
62
|
+
link(o.headline, 'need', o.key);
|
|
63
|
+
t(o.days_late > 0 ? ' (' + plural(o.days_late, 'day') + ' late)' : '');
|
|
64
|
+
}
|
|
65
|
+
t('. ');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// 3: mentions that wait on a reply
|
|
69
|
+
if (x.mentions) {
|
|
70
|
+
sources.push('Mentions');
|
|
71
|
+
const w = waitingMentions(x.mentions);
|
|
72
|
+
if (w.length) {
|
|
73
|
+
const who = w.map((m) => (m.who ? first(m.who) + ' in ' : 'someone in ') + srcName(m.source));
|
|
74
|
+
if (w.length === 1) t(who[0].charAt(0).toUpperCase() + who[0].slice(1) + ' is waiting on your reply to a mention. ');
|
|
75
|
+
else t(w.length + ' people are waiting on your reply to a mention (' + andList(who) + '). ');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// 4: the meetings
|
|
79
|
+
if (x.agenda) {
|
|
80
|
+
sources.push('Calendar');
|
|
81
|
+
const all = x.agenda; const left = all.filter((m) => m.state !== 'past');
|
|
82
|
+
if (reflect) t(all.length ? 'You held ' + plural(all.length - left.length, 'meeting') + ' today' + (left.length ? ', and ' + left.length + ' ' + (left.length === 1 ? 'is' : 'are') + ' still ahead' : '') + '. ' : 'You had no meetings today. ');
|
|
83
|
+
else if (!all.length) t('No meetings today. ');
|
|
84
|
+
else if (!left.length) t('No meetings are left today. ');
|
|
85
|
+
else {
|
|
86
|
+
const todo = left.filter((m) => (m.before || []).length);
|
|
87
|
+
t(plural(left.length, 'meeting') + (left.length === 1 ? ' is' : ' are') + ' left today');
|
|
88
|
+
if (!todo.length) t(', and nothing is left to do before ' + (left.length === 1 ? 'it' : 'them') + '. ');
|
|
89
|
+
else {
|
|
90
|
+
t(todo.length === left.length && left.length > 1 ? '; every one has a to-do before it, starting with ' : '; ');
|
|
91
|
+
link(todo[0].title, 'meet', todo[0].id);
|
|
92
|
+
t(' at ' + hm(todo[0].start) + (todo.length === left.length && left.length > 1 ? '. ' : ' still has a to-do before it. '));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// 5: the initiatives
|
|
97
|
+
if (x.pulse && x.pulse.length) {
|
|
98
|
+
sources.push('Initiatives');
|
|
99
|
+
const bad = x.pulse.filter((r) => r.cls === 'bad'); const warn = x.pulse.filter((r) => r.cls === 'warn'); const ok = x.pulse.filter((r) => r.cls === 'ok').length;
|
|
100
|
+
const flagged = [...bad, ...warn];
|
|
101
|
+
if (!flagged.length) t(ok ? (ok === 1 ? 'The one initiative with a plan is on track.' : 'All ' + ok + ' initiatives with a plan are on track.') : '');
|
|
102
|
+
else {
|
|
103
|
+
t('Across the portfolio, ');
|
|
104
|
+
flagged.slice(0, 3).forEach((r, i) => {
|
|
105
|
+
if (i) t(i === Math.min(flagged.length, 3) - 1 ? ' and ' : ', ');
|
|
106
|
+
link(r.title, 'init', r.id);
|
|
107
|
+
t(' is ' + String(r.word || (r.cls === 'bad' ? 'off track' : 'at risk')).toLowerCase());
|
|
108
|
+
});
|
|
109
|
+
if (flagged.length > 3) t(', and ' + (flagged.length - 3) + ' more need attention');
|
|
110
|
+
t(ok ? '; ' + ok + (ok === 1 ? ' is' : ' are') + ' on track.' : '.');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// tidy: one space between sentences, none at the end
|
|
114
|
+
const parts = out.filter((p) => p.t);
|
|
115
|
+
if (parts.length) parts[parts.length - 1] = { ...parts[parts.length - 1], t: parts[parts.length - 1].t.replace(/\s+$/, '') };
|
|
116
|
+
return { parts, sources };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function summarySection(S) {
|
|
120
|
+
const when = el('span', { class: 'mono dim brief-when', 'data-brief-when': '' });
|
|
121
|
+
const body = el('p', { class: 'brief-sum', 'data-brief-sum': '' }, 'Reading the day.');
|
|
122
|
+
const box = el('section', { class: 'card sect sum-card', 'data-c3pg': 'summary', 'aria-label': 'The brief' },
|
|
123
|
+
el('div', { class: 'sh2' }, el('h2', null, c3ic('spark', 'sm'), 'The brief'), when), body);
|
|
124
|
+
let queued = false;
|
|
125
|
+
const draw = () => {
|
|
126
|
+
queued = false;
|
|
127
|
+
const input = S.briefInput ? S.briefInput() : {};
|
|
128
|
+
const { parts, sources } = composeBrief(input);
|
|
129
|
+
if (!parts.length) { body.replaceChildren(sources.length ? 'Nothing to say yet: no decision, item, meeting or initiative needs you.' : 'Reading the day.'); when.textContent = ''; return; }
|
|
130
|
+
body.replaceChildren(...parts.map((p) => (p.go
|
|
131
|
+
? el('a', { class: 'brief-lk', href: '#', 'data-brief-go': p.go.kind + ':' + p.go.key, onclick: (e) => { e.preventDefault(); if (S.briefGo) S.briefGo(p.go); } }, p.t)
|
|
132
|
+
: document.createTextNode(p.t))));
|
|
133
|
+
const d = new Date();
|
|
134
|
+
when.textContent = 'As of ' + p2(d.getHours()) + ':' + p2(d.getMinutes()) + ' · ' + sources.join(', ');
|
|
135
|
+
};
|
|
136
|
+
S.briefRedraw = () => { if (queued) return; queued = true; Promise.resolve().then(draw); };
|
|
137
|
+
S.briefRedraw();
|
|
138
|
+
return box;
|
|
139
|
+
}
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* GET /api/brief-page (lib/routes-brief-page.js), for the demo day (Thu 8 Oct 2026, 08:12), with the demo's own people and initiatives.
|
|
3
3
|
* Today is the five meetings of the demo calendar (c3-demo-data.js MEET). Needs you gets two items that are overdue, one due today, and two outside-PMOS items
|
|
4
4
|
* (one with evidence: a meeting with Dara is booked today). Decisions are the demo briefing's own needs (c3-demo-data.js NEEDS), not repeated here.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Mentions (initiative c3-brief-summary-mentions): five mentions of the demo morning; Sam's Slack message is also the Needs you row i15, so it is not
|
|
6
|
+
* listed and that row carries the "mentioned you" tag.
|
|
7
|
+
* Writes: the pm-items routes the rows call (done, notyet, remove, reopen, undo, add) and POST /api/mentions/read are answered in memory, and every one
|
|
8
|
+
* is kept in window.__c3demoWrites ({ path, body }) so a test can see what the page asked to write. Real mode never loads this file. */
|
|
7
9
|
const iso = (h, m) => new Date(2026, 9, 8, h, m).toISOString();
|
|
8
10
|
const TODAY = [
|
|
9
11
|
{ id: 'm1', title: 'Standup', start: iso(9, 30), end: iso(10, 0), people: ['Dara', 'Lee', 'Sam'], initiative: 'Checkout v2', prepared: [], before: [] },
|
|
@@ -25,7 +27,14 @@ const NEEDS = [
|
|
|
25
27
|
evidence: { text: 'a meeting with Dara is booked Thu 14:00', event_id: 'm4' } },
|
|
26
28
|
];
|
|
27
29
|
|
|
28
|
-
|
|
30
|
+
const MENTIONS = [
|
|
31
|
+
{ id: 'd3m000000001', source: 'slack', who: 'Priya Nair', where: '#checkout-v2', text: 'Can you confirm the revised date before the 10:30 update? Leadership will ask.', at: iso(7, 58), reply: true, link: 'https://localhost/demo/slack/checkout-v2', initiative: 'Checkout v2', in_needs: false, read: false },
|
|
32
|
+
{ id: 'd3m000000002', source: 'jira', who: 'Dara Okafor', where: 'CHK-214 Flow 4 estimate', text: 'Which of the four flows do we cut if we hold 24 Oct? I need this before sprint planning.', at: iso(8, 4), reply: true, initiative: 'Checkout v2', in_needs: false, read: false },
|
|
33
|
+
{ id: 'd3m000000003', source: 'confluence', who: 'Marta Silva', where: 'Returns interview guide', text: 'I moved the support questions to the end, no change to the scope.', at: iso(7, 20), reply: false, initiative: 'Returns experience', in_needs: false, read: false },
|
|
34
|
+
{ id: 'd3m000000004', source: 'mail', who: 'Lee Park', where: 'Pricing page feedback', text: 'Copying you so the copy notes are in one thread.', at: new Date(2026, 9, 7, 17, 5).toISOString(), reply: false, in_needs: false, read: false },
|
|
35
|
+
{ id: 'd3m000000005', source: 'slack', who: 'Sam', where: '#search', text: 'Ok to ship the logging change today?', at: iso(7, 45), reply: true, in_needs: true, need_key: 'i15', read: false },
|
|
36
|
+
];
|
|
37
|
+
let gone = {}; const readMarks = {};
|
|
29
38
|
export function installBriefPage() {
|
|
30
39
|
if (window.__c3demoBriefPage) return; window.__c3demoBriefPage = true;
|
|
31
40
|
window.__c3demoWrites = [];
|
|
@@ -37,7 +46,14 @@ export function installBriefPage() {
|
|
|
37
46
|
const u = new URL(raw, location.origin);
|
|
38
47
|
const method = String((init && init.method) || (input && input.method) || 'GET').toUpperCase();
|
|
39
48
|
if (u.origin === location.origin) {
|
|
40
|
-
if (method === 'GET' && u.pathname === '/api/brief-page') return Promise.resolve(json({ ok: true, date: '2026-10-08', me: 'Wawan', today: TODAY.map((m) => ({ ...m, minutes: (Date.parse(m.end) - Date.parse(m.start)) / 60000, state: Date.parse(m.end) <= Date.now() ? 'past' : 'later' })).map((m, i, a) => ({ ...m, state: m.state === 'past' ? 'past' : (a.findIndex((x) => x.state !== 'past' && Date.parse(x.start) > Date.now()) === i ? 'next' : 'later') })), needs: NEEDS.filter((n) => !gone[n.id]) }));
|
|
49
|
+
if (method === 'GET' && u.pathname === '/api/brief-page') return Promise.resolve(json({ ok: true, date: '2026-10-08', me: 'Wawan', today: TODAY.map((m) => ({ ...m, minutes: (Date.parse(m.end) - Date.parse(m.start)) / 60000, state: Date.parse(m.end) <= Date.now() ? 'past' : 'later' })).map((m, i, a) => ({ ...m, state: m.state === 'past' ? 'past' : (a.findIndex((x) => x.state !== 'past' && Date.parse(x.start) > Date.now()) === i ? 'next' : 'later') })), needs: NEEDS.filter((n) => !gone[n.id]), mentions: MENTIONS.map((x) => ({ ...x, read: !!readMarks[x.id] })) }));
|
|
50
|
+
if (method !== 'GET' && (u.pathname === '/api/mentions/read' || u.pathname === '/api/pm-items/add')) {
|
|
51
|
+
let b = {}; try { b = init && init.body ? JSON.parse(init.body) : {}; } catch { b = {}; }
|
|
52
|
+
window.__c3demoWrites.push({ path: u.pathname, body: b });
|
|
53
|
+
if (u.pathname === '/api/pm-items/add') return prev(input, init); // recorded, then answered by the demo's own Work store
|
|
54
|
+
readMarks[b.id] = b.read === true;
|
|
55
|
+
return Promise.resolve(json({ ok: true }));
|
|
56
|
+
}
|
|
41
57
|
const m = /^\/api\/pm-items\/(done|notyet|remove|reopen|undo)$/.exec(u.pathname);
|
|
42
58
|
if (method !== 'GET' && m) {
|
|
43
59
|
let b = {}; try { b = init && init.body ? JSON.parse(init.body) : {}; } catch { b = {}; }
|
|
@@ -49,6 +49,12 @@ import { mountSlide } from './slide-confirm.js';
|
|
|
49
49
|
import { parseMd, toMd, canonMd, diffMd, toDom, fromDom, wordCounts, safeHref } from './c3-md.js';
|
|
50
50
|
import { ensureReportCss, formatOf, formatView, outlineLabels, downloadsOf, printPdf, copyKindFor } from './c3-doc-formats.js';
|
|
51
51
|
|
|
52
|
+
/* the sheet's width (initiative c3-doc-sheet-size): Normal (780px), Half, 3/4 or Whole window, as the session, meeting and studio sheets */
|
|
53
|
+
const DOC_SIZE_KEY = 'pmos.workspace.c3.doc-size';
|
|
54
|
+
export const DOC_SIZES = [['normal', 'Normal', 'Normal'], ['half', 'Half', 'Half of the window'], ['three-quarters', '3/4', 'Three-quarters of the window'], ['whole', 'Whole', 'Whole window']];
|
|
55
|
+
export const docSizeOf = (v) => (DOC_SIZES.some((x) => x[0] === v) ? v : 'normal');
|
|
56
|
+
const docSizeStore = { get() { try { return localStorage.getItem(DOC_SIZE_KEY); } catch { return null; } }, set(v) { try { localStorage.setItem(DOC_SIZE_KEY, v); } catch { /* no storage */ } } };
|
|
57
|
+
|
|
52
58
|
/* ── pure: sections, round trip, word diff ── */
|
|
53
59
|
|
|
54
60
|
/** Split a markdown text into sections at its headings. Each section keeps the exact text it came from, so joinDoc(splitDoc(t)) === t. */
|
|
@@ -1198,7 +1204,17 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
|
|
|
1198
1204
|
}
|
|
1199
1205
|
|
|
1200
1206
|
document.addEventListener('selectionchange', keepRange);
|
|
1201
|
-
|
|
1207
|
+
// the size control of the session and meeting sheets (Normal, Half, 3/4, Whole), remembered in this browser
|
|
1208
|
+
let size = docSizeOf(docSizeStore.get());
|
|
1209
|
+
const sizeBtns = DOC_SIZES.map(([k, label, tip]) => button({ label, variant: 'ghost', attrs: { 'aria-pressed': String(k === size), 'data-doc-size': k, title: tip, 'aria-label': tip },
|
|
1210
|
+
onClick: () => setSize(k) }));
|
|
1211
|
+
const sizeSeg = el('div', { class: 'seg ds-size', role: 'group', 'aria-label': 'Size', 'data-doc': 'size' }, ...sizeBtns);
|
|
1212
|
+
function setSize(k) {
|
|
1213
|
+
size = k; docSizeStore.set(k);
|
|
1214
|
+
DOC_SIZES.forEach(([x]) => ui.sheet.classList.toggle('tsz-' + x, x === k));
|
|
1215
|
+
sizeBtns.forEach((b) => b.setAttribute('aria-pressed', String(b.getAttribute('data-doc-size') === k)));
|
|
1216
|
+
}
|
|
1217
|
+
ui = openSheet({ title: doc.name, icon: 'doc', dw: true, cls: 'tsz-' + size, actions: [sizeSeg, toolsNode].filter(Boolean), body: el('div', { class: 'dpn' }), onClose: () => { S.closed = true; document.removeEventListener('selectionchange', keepRange); if (S.changed && onChanged) onChanged(); } });
|
|
1202
1218
|
S.open = (d) => { ui.close(); if (S.openOther) S.openOther(d); else openDocSheet({ ws, doc: d, library, openGraph }); };
|
|
1203
1219
|
ui.setOpener = (fn) => { S.openOther = fn; };
|
|
1204
1220
|
draw();
|
|
@@ -20,6 +20,8 @@ import { needsSection, needsCount } from './c3-brief-needs.js';
|
|
|
20
20
|
import { scanSection } from './c3-scan.js';
|
|
21
21
|
import { reportFoundSection } from './c3-report-store.js';
|
|
22
22
|
import { pulse } from './c3-brief-pulse.js';
|
|
23
|
+
import { summarySection } from './c3-brief-summary.js';
|
|
24
|
+
import { mentionsSection } from './c3-brief-mentions.js';
|
|
23
25
|
import { loadBriefPage, onBriefPage } from './c3-brief-data.js';
|
|
24
26
|
import { createDock } from './c3-dock.js';
|
|
25
27
|
import { reviewSection, footLine, rowApi, BX, toast, setNeedsTitle, hooks as briefHooks } from './c3-brief.js';
|
|
@@ -285,16 +287,44 @@ function briefTab(pane) {
|
|
|
285
287
|
if (S.why) whyMark();
|
|
286
288
|
});
|
|
287
289
|
S.needsBox = needsSection(S);
|
|
288
|
-
|
|
290
|
+
const summary = summarySection(S);
|
|
291
|
+
S.briefInput = briefInput;
|
|
292
|
+
S.briefGo = briefGo;
|
|
293
|
+
left.append(deckHost, scanSection(S), reportFoundSection(S), S.needsBox, mentionsSection(S)); // the Horizon scan card sits above Needs you, and shows only on a day a scan ran
|
|
289
294
|
right.append(todaySection(), pulse(S));
|
|
290
295
|
pane.append(el('div', { class: 'brief' }, el('div', { class: 'bcols' }, left, right), ftHost));
|
|
291
|
-
mountBriefing(deckHost, { noTicker: true, onLens: lensChanged });
|
|
296
|
+
mountBriefing(deckHost, { noTicker: true, onLens: lensChanged, afterStance: summary });
|
|
297
|
+
onBriefPage((j) => { S.briefPage = j; if (S.briefRedraw) S.briefRedraw(); });
|
|
292
298
|
loadBriefPage();
|
|
293
299
|
}
|
|
294
300
|
|
|
301
|
+
/* What The brief composes from (c3-brief-summary.js composeBrief): the rows Needs you shows, the mentions, today's meetings and the pulse rows.
|
|
302
|
+
* A read that has not come back is null, so its sentence is left out. */
|
|
303
|
+
function briefInput() {
|
|
304
|
+
const n = S.needsSnapshot ? S.needsSnapshot() : null;
|
|
305
|
+
const p = S.briefPage && S.briefPage.ok !== false ? S.briefPage : null;
|
|
306
|
+
return { lens: S.briefLens || null, decisions: n ? n.decisions : null, own: n ? n.own : null, agentN: n ? n.agentN : 0,
|
|
307
|
+
mentions: S.mentionsNow ? S.mentionsNow() : null, agenda: p ? (p.today || []) : null, pulse: S.pulseRows || null };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/* A link in The brief goes to its row: a Needs you row opens (goTarget), a meeting or an initiative row is scrolled to and focused. */
|
|
311
|
+
function briefGo(g) {
|
|
312
|
+
if (!g) return;
|
|
313
|
+
if (g.kind === 'need') { goTarget({ need: g.key }); return; }
|
|
314
|
+
const pane = S.panes && S.panes.brief; if (!pane) return;
|
|
315
|
+
const q = (v) => String(v).replace(/["\\]/g, '');
|
|
316
|
+
const row = g.kind === 'meet' ? pane.querySelector('[data-today-row="' + q(g.key) + '"]') : g.kind === 'init' ? pane.querySelector('[data-pulse-row="' + q(g.key) + '"]') : null;
|
|
317
|
+
if (!row) return;
|
|
318
|
+
row.scrollIntoView({ block: 'center', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
|
|
319
|
+
const target = g.kind === 'init' ? row.querySelector('[data-c3pg-prow]') : row;
|
|
320
|
+
if (target && !target.hasAttribute('tabindex') && !target.matches('button,a')) target.setAttribute('tabindex', '-1');
|
|
321
|
+
try { (target || row).focus({ preventScroll: true }); } catch { /* gone */ }
|
|
322
|
+
}
|
|
323
|
+
|
|
295
324
|
/* The clock's part of the day: Reflect names Needs you "Carries over" (the mockup's vBrief, line 1470). */
|
|
296
325
|
function lensChanged(l) {
|
|
297
326
|
setNeedsTitle(S.needsBox, l === 'Reflect');
|
|
327
|
+
S.briefLens = l; if (S.briefRedraw) S.briefRedraw();
|
|
298
328
|
}
|
|
299
329
|
|
|
300
330
|
/* ── global: where a notification goes, the person sheet, the palette and the keys ── */
|
|
@@ -2393,9 +2393,29 @@
|
|
|
2393
2393
|
.c3 .cal3-msize{flex:none}
|
|
2394
2394
|
.c3 .cal3-msize button{padding:4px 8px}
|
|
2395
2395
|
.c3.ph .cal3-msize{display:none}
|
|
2396
|
+
.c3 .ds-size{flex:none}
|
|
2397
|
+
.c3 .ds-size button{padding:4px 8px}
|
|
2398
|
+
.c3.ph .ds-size{display:none}
|
|
2399
|
+
@media (min-width:761px){
|
|
2400
|
+
.c3 .sheet.dw.tsz-half,.c3 .sheet.dw.dwx.tsz-half{width:50vw}
|
|
2401
|
+
.c3 .sheet.dw.tsz-three-quarters,.c3 .sheet.dw.dwx.tsz-three-quarters{width:75vw}
|
|
2402
|
+
.c3 .sheet.dw.tsz-whole,.c3 .sheet.dw.dwx.tsz-whole{width:calc(100vw - 24px)}
|
|
2403
|
+
}
|
|
2396
2404
|
.c3 .cal3-mcols{display:grid;gap:var(--sp-4);grid-template-columns:minmax(0,1fr);align-items:start}
|
|
2397
2405
|
.c3 .cal3-mcol{display:grid;gap:var(--sp-3);min-width:0;align-content:start}
|
|
2398
2406
|
@media (min-width:761px){.c3:not(.ph) .sheet.cal3-ms:not(.tsz-normal) .cal3-mcols:not(.one){grid-template-columns:minmax(0,1.2fr) minmax(0,1fr)}}
|
|
2399
2407
|
.c3 .cal3-mnote-b{display:grid;gap:4px;padding:var(--sp-2) var(--sp-3);border:1px solid var(--border);border-radius:8px;background:var(--muted);min-width:0;overflow-wrap:anywhere}
|
|
2400
2408
|
.c3 .cal3-mnote-b p{margin:0;font-size:var(--fs-2)}
|
|
2401
2409
|
.c3 .cal3-src{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
|
|
2410
|
+
/* The brief and Mentions on the Briefing (c3-brief-summary-mentions) */
|
|
2411
|
+
.c3 .sum-card .sh2{justify-content:space-between}
|
|
2412
|
+
.c3 .brief-when{font-size:var(--fs-1)}
|
|
2413
|
+
.c3 .brief-sum{margin:0;font-size:var(--fs-4);line-height:1.6;color:var(--foreground);max-width:72ch}
|
|
2414
|
+
.c3 .brief-lk{color:inherit;font-weight:600;text-decoration:underline;text-decoration-color:var(--ring);text-underline-offset:3px}
|
|
2415
|
+
@media (hover:hover){.c3 .brief-lk:hover{text-decoration-color:var(--acc)}}
|
|
2416
|
+
.c3 .brief-lk:focus-visible{outline:2px solid var(--ring);outline-offset:2px;border-radius:6px}
|
|
2417
|
+
.c3 [data-c3pg="mentions"] .mn-line{margin:0;font-size:var(--fs-3);line-height:1.5;color:var(--muted-foreground)}
|
|
2418
|
+
.c3 [data-c3pg="mentions"] .mn-q{font-size:var(--fs-3);line-height:1.5;color:var(--foreground);overflow-wrap:anywhere}
|
|
2419
|
+
.c3 [data-c3pg="mentions"] .mn-acts{display:flex;flex-wrap:wrap;gap:var(--sp-2);margin-top:4px}
|
|
2420
|
+
.c3 [data-c3pg="mentions"] .mn-tabs{flex-wrap:wrap;margin-bottom:var(--sp-2)}
|
|
2421
|
+
.c3 [data-c3pg="mentions"] .sh2{justify-content:space-between}
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"work-focus.test.js",
|
|
31
31
|
"work-evidence.test.js",
|
|
32
32
|
"brief-page.test.js",
|
|
33
|
+
"c3-brief-summary.test.js",
|
|
33
34
|
"work-assigned.test.js",
|
|
34
35
|
"briefing-real-data.test.js",
|
|
35
36
|
"calendar-events.test.js",
|
|
@@ -180,6 +181,7 @@
|
|
|
180
181
|
"work-focus.test.js": 1,
|
|
181
182
|
"work-evidence.test.js": 1,
|
|
182
183
|
"brief-page.test.js": 1,
|
|
184
|
+
"c3-brief-summary.test.js": 1,
|
|
183
185
|
"work-assigned.test.js": 1,
|
|
184
186
|
"briefing-real-data.test.js": 1,
|
|
185
187
|
"calendar-events.test.js": 1,
|