create-pmos 0.127.0 → 0.128.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/c3-reports-test.js +314 -0
- package/workspace/fixtures/one-home-baseline.json +29 -9
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/reports-dashboards.md +57 -0
- package/workspace/lib/report-dashboards.js +194 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-c3-reports.js +108 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-cal-strategy.js +2 -1
- package/workspace/public/pm/c3-demo-rep3.js +97 -0
- package/workspace/public/pm/c3-demo.js +2 -1
- package/workspace/public/pm/c3-reports.css +62 -0
- package/workspace/public/pm/c3-reports.js +454 -0
- package/workspace/public/pm/c3-shell.js +3 -1
- package/workspace/test-lists/console3-reports.json +6 -0
- package/workspace/test-lists/local-test-kit.json +5 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* report-dashboards.js — the Reports dashboards of /c3.html (console3 Reports). Pure except the layout store.
|
|
3
|
+
*
|
|
4
|
+
* LIBRARY · DEFAULTS the seven reports and which ones each scope shows until the PM changes it
|
|
5
|
+
* boardOf(ini, fallbackSlug) the board an initiative sits on
|
|
6
|
+
* currentQuarter(today) { start, end } of the quarter that holds today
|
|
7
|
+
* deliveryRows(inis, range, today, filterFn) rows in the shape deliveryCard of public/pm/c3-cal-strategy.js reads
|
|
8
|
+
* scopeFilter(body, facts, scope, id, today, opts) the /api/c3-strategy body narrowed to a scope, plus delivery and range
|
|
9
|
+
* validKey · readLayouts · writeLayout the layout store: <runtimeDir>/report-dashboards/layouts.json
|
|
10
|
+
*
|
|
11
|
+
* The layout store is the only I/O. It writes under the runtime directory the caller names, with a temp file and a rename, and never
|
|
12
|
+
* touches the repository. Everything else reads values it is handed. */
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
const model = require('../../scripts/calendar-model.js');
|
|
17
|
+
|
|
18
|
+
const ALL = ['portfolio', 'board', 'initiative'];
|
|
19
|
+
const LIBRARY = Object.freeze([
|
|
20
|
+
{ id: 'north', title: 'North star', question: 'Is the one number we steer by moving toward its target?', source: 'KR metric records', scopes: ['portfolio'] },
|
|
21
|
+
{ id: 'outcomes', title: 'Quarter outcomes', question: 'Which key results are on their line, and which are behind?', source: 'KR metric records', scopes: ALL.slice() },
|
|
22
|
+
{ id: 'signals', title: 'Early signals', question: 'What is drifting before it becomes a miss?', source: 'Signal rules and KR metric records', scopes: ALL.slice() },
|
|
23
|
+
{ id: 'delivery', title: 'Delivery against promise', question: 'Will each initiative land by the date we promised?', source: 'Initiative records', scopes: ALL.slice() },
|
|
24
|
+
{ id: 'maturity', title: 'Maturity by pillar', question: 'How mature is each pillar, and which one lags?', source: 'Pillar maturity record', scopes: ['portfolio'] },
|
|
25
|
+
{ id: 'bets', title: 'Bets and checkpoints', question: 'Which bets are placed in which quarter, and when do we review them?', source: 'Bet and hypothesis records', scopes: ALL.slice() },
|
|
26
|
+
{ id: 'synergy', title: 'Synergy coverage', question: 'Which key results have no active bet behind them?', source: 'Bet and KR metric records', scopes: ALL.slice() },
|
|
27
|
+
].map((r) => Object.freeze({ ...r, scopes: Object.freeze(r.scopes) })));
|
|
28
|
+
const IDS = LIBRARY.map((r) => r.id);
|
|
29
|
+
|
|
30
|
+
const DEFAULTS = Object.freeze({
|
|
31
|
+
portfolio: Object.freeze(['north', 'outcomes', 'signals', 'delivery', 'maturity', 'bets']),
|
|
32
|
+
board: Object.freeze(['outcomes', 'signals', 'delivery', 'bets']),
|
|
33
|
+
initiative: Object.freeze(['signals', 'delivery', 'bets']),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const ID_RE = /^[A-Za-z0-9._-]{1,80}$/;
|
|
37
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
38
|
+
const isDate = (s) => typeof s === 'string' && /^\d{4}-\d{2}-\d{2}/.test(s) && !Number.isNaN(Date.parse(s.slice(0, 10) + 'T00:00:00Z'));
|
|
39
|
+
const DAY = 864e5;
|
|
40
|
+
const ms = (s) => Date.parse(String(s).slice(0, 10) + 'T00:00:00Z');
|
|
41
|
+
|
|
42
|
+
/** The board of an initiative: the domain of a `domain:<id>` anchor, else the fallback slug (the workspace). */
|
|
43
|
+
function boardOf(ini, fallbackSlug) {
|
|
44
|
+
const a = ini && typeof ini.parent_kr === 'string' ? ini.parent_kr.trim() : '';
|
|
45
|
+
const m = /^domain:([A-Za-z0-9][A-Za-z0-9._-]{0,79})$/.exec(a);
|
|
46
|
+
if (m) return m[1];
|
|
47
|
+
return fallbackSlug == null || fallbackSlug === '' ? null : String(fallbackSlug);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function currentQuarter(today) {
|
|
51
|
+
const r = model.rangeFor('quarter', String(today).slice(0, 10));
|
|
52
|
+
return { start: r.start, end: r.end };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* ---- delivery ------------------------------------------------------------------------------------------------------------------ */
|
|
56
|
+
const STATUS = { 'on time': 'ok', 'at risk': 'risk', delayed: 'late', overdue: 'over' };
|
|
57
|
+
const isParked = (i) => i.parked === true || i.lane === 'parked' || i.stage === 'parked' || i.status === 'parked';
|
|
58
|
+
|
|
59
|
+
/** Rows for the Delivery against promise card. Only initiatives with a promised date; no plan, no row. Dates are YYYY-MM-DD. */
|
|
60
|
+
function deliveryRows(inis, range, today, filterFn) {
|
|
61
|
+
const list = (inis || []).filter((i) => i && i.id && (typeof filterFn !== 'function' || filterFn(i)));
|
|
62
|
+
const byId = new Map(list.map((i) => [i.id, i]));
|
|
63
|
+
const gantt = model.ganttRows(list, 'quarter', range.start, { today });
|
|
64
|
+
const rows = [];
|
|
65
|
+
for (const g of gantt) {
|
|
66
|
+
const ini = byId.get(g.id) || {};
|
|
67
|
+
if (!g.promised) continue;
|
|
68
|
+
let st = STATUS[g.status];
|
|
69
|
+
if (ini.done) st = 'done';
|
|
70
|
+
else if (isParked(ini)) st = 'park';
|
|
71
|
+
if (!st) continue;
|
|
72
|
+
const fc = (g.forecast && (g.forecast.end || g.forecast.start)) || g.promised;
|
|
73
|
+
const start = (g.planned && g.planned.start) || g.promised;
|
|
74
|
+
const p = typeof ini.progress === 'number' ? ini.progress : typeof ini.pct === 'number' ? ini.pct : null;
|
|
75
|
+
const late = st === 'over' ? Math.max(1, Math.round((ms(today) - ms(g.promised)) / DAY)) : 0;
|
|
76
|
+
rows.push({ id: g.id, n: g.title || g.id, kind: 'init', start, due: g.promised, fc, pct: p !== null && p >= 0 && p <= 1 ? p : null, st,
|
|
77
|
+
why: (g.title || g.id) + ': ' + g.status + (g.slip_days > 0 ? ', ' + g.slip_days + ' days past the plan' : ''), late, go: g.id });
|
|
78
|
+
}
|
|
79
|
+
return rows;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* ---- scope filter -------------------------------------------------------------------------------------------------------------- */
|
|
83
|
+
function notesOf(cols, rows) {
|
|
84
|
+
const notes = [];
|
|
85
|
+
cols.forEach((c, j) => { if (!rows.some((r) => !r.parked && r.weights[j] > 0)) notes.push({ icon: 'alert', tone: 'bad', text: c.n + ' has no active bet.' }); });
|
|
86
|
+
rows.forEach((r) => { if (!r.weights.some((w) => w > 0)) notes.push({ icon: 'info', tone: 'warn', text: r.title + ' is not anchored to a KR metric, so it adds to no outcome.' }); });
|
|
87
|
+
return notes;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Narrow the c3-strategy body to a scope.
|
|
92
|
+
* portfolio everything
|
|
93
|
+
* board bets and timeline rows whose initiative sits on the board; the key results those bets name; their signals
|
|
94
|
+
* initiative the bets of that initiative; the key results its parent_kr or its bets name; its own delivery row
|
|
95
|
+
* Rule signals ('rule:...') and the north star stay at portfolio. Checkpoints narrow only when opts.hypotheses (the hypothesis records) is
|
|
96
|
+
* given, because the body carries no initiative for a checkpoint; without it a board or initiative shows none.
|
|
97
|
+
* opts: { slug (the workspace slug, the board of an initiative with no domain anchor), hypotheses }.
|
|
98
|
+
*/
|
|
99
|
+
function scopeFilter(body, facts, scope, id, today, opts = {}) {
|
|
100
|
+
const b = body || {};
|
|
101
|
+
const inis = (facts && facts.inis) || [];
|
|
102
|
+
const range = currentQuarter(today);
|
|
103
|
+
const slug = opts.slug;
|
|
104
|
+
const base = { ...b, scope, id: scope === 'portfolio' ? null : id, today, range };
|
|
105
|
+
if (scope === 'portfolio') return { ...base, delivery: { rows: deliveryRows(inis, range, today) } };
|
|
106
|
+
|
|
107
|
+
let onScope;
|
|
108
|
+
if (scope === 'board') onScope = (i) => boardOf(i, slug) === id;
|
|
109
|
+
else if (scope === 'initiative') onScope = (i) => i.id === id;
|
|
110
|
+
else throw new Error('scope must be portfolio, board or initiative');
|
|
111
|
+
const iniIds = new Set(inis.filter(onScope).map((i) => i.id));
|
|
112
|
+
const tl = b.timeline || { rows: [] };
|
|
113
|
+
const syn = b.synergy || { columns: [], rows: [] };
|
|
114
|
+
const betIds = new Set(syn.rows.filter((r) => r.initiative && iniIds.has(r.initiative)).map((r) => r.id));
|
|
115
|
+
tl.rows.forEach((r) => { if (r.initiative && iniIds.has(r.initiative)) betIds.add(r.id); });
|
|
116
|
+
|
|
117
|
+
// the key results: those the scoped bets name (by rid or by metric name), plus, for an initiative, its own anchor
|
|
118
|
+
const outcomes = b.outcomes || [];
|
|
119
|
+
const named = new Set();
|
|
120
|
+
const nameKr = (ref) => { if (!ref) return; const s = String(ref); outcomes.forEach((o) => { if (o.id === s || o.name === s) named.add(o.id); }); };
|
|
121
|
+
syn.rows.forEach((r, i) => { if (betIds.has(r.id)) syn.columns.forEach((c, j) => { if (r.weights[j] > 0) named.add(c.id); }); });
|
|
122
|
+
tl.rows.forEach((r) => { if (betIds.has(r.id)) nameKr(r.sub); });
|
|
123
|
+
if (scope === 'initiative') inis.filter(onScope).forEach((i) => nameKr(i.parent_kr));
|
|
124
|
+
const krs = new Set(outcomes.filter((o) => named.has(o.id)).map((o) => o.id));
|
|
125
|
+
|
|
126
|
+
const cols = syn.columns.map((c, j) => ({ c, j })).filter((x) => krs.has(x.c.id));
|
|
127
|
+
const rows = syn.rows.filter((r) => betIds.has(r.id)).map((r) => ({ ...r, weights: cols.map((x) => r.weights[x.j]) }));
|
|
128
|
+
const synergy = b.synergy && rows.length ? { ...syn, columns: cols.map((x) => x.c), rows, notes: notesOf(cols.map((x) => x.c), rows) } : null;
|
|
129
|
+
|
|
130
|
+
const trows = tl.rows.filter((r) => betIds.has(r.id));
|
|
131
|
+
let cps = [];
|
|
132
|
+
if (Array.isArray(opts.hypotheses)) {
|
|
133
|
+
const labels = new Set(opts.hypotheses.filter((h) => h && h.initiative && iniIds.has(h.initiative) && isDate(h.review_by)).map((h) => String(h.title) + ', ' + String(h.review_by).slice(0, 10)));
|
|
134
|
+
cps = (tl.checkpoints || []).filter((c) => labels.has(c.label));
|
|
135
|
+
}
|
|
136
|
+
const timeline = b.timeline && (trows.length || cps.length) ? { ...tl, rows: trows, checkpoints: cps } : null;
|
|
137
|
+
|
|
138
|
+
const signals = (b.signals || []).filter((s) => /^kr:/.test(s.id) && krs.has(s.id.slice(3)));
|
|
139
|
+
const out = {
|
|
140
|
+
...base, north: null, outcomes: outcomes.filter((o) => krs.has(o.id)), signals, synergy, timeline,
|
|
141
|
+
sources: { ...(b.sources || {}), kr_metrics: krs.size, bets: betIds.size, hypotheses: cps.length },
|
|
142
|
+
delivery: { rows: deliveryRows(inis, range, today, onScope) },
|
|
143
|
+
};
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* ---- layout store -------------------------------------------------------------------------------------------------------------- */
|
|
148
|
+
const validKey = (key) => typeof key === 'string' && (key === 'portfolio' || /^(?:board|initiative):[A-Za-z0-9._-]{1,80}$/.test(key));
|
|
149
|
+
const validId = (id) => typeof id === 'string' && ID_RE.test(id);
|
|
150
|
+
const dirOf = (runtimeDir) => path.join(runtimeDir, 'report-dashboards');
|
|
151
|
+
const fileOf = (runtimeDir) => path.join(dirOf(runtimeDir), 'layouts.json');
|
|
152
|
+
|
|
153
|
+
/** { [key]: [reportId] }. A missing or damaged file reads as no layouts; an entry that is not valid is dropped. */
|
|
154
|
+
function readLayouts(runtimeDir) {
|
|
155
|
+
let raw;
|
|
156
|
+
try { raw = JSON.parse(fs.readFileSync(fileOf(runtimeDir), 'utf8')); } catch { return {}; }
|
|
157
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
|
158
|
+
const src = raw.layouts && typeof raw.layouts === 'object' ? raw.layouts : raw;
|
|
159
|
+
const out = {};
|
|
160
|
+
for (const [k, v] of Object.entries(src)) {
|
|
161
|
+
if (!validKey(k) || !Array.isArray(v)) continue;
|
|
162
|
+
const ids = [...new Set(v.filter((x) => IDS.includes(x)))];
|
|
163
|
+
out[k] = ids;
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Store the reports for a key, or drop the key when reports is null. Returns { ok, layouts } or { ok:false, error }. */
|
|
169
|
+
function writeLayout(runtimeDir, key, reports) {
|
|
170
|
+
if (!runtimeDir || typeof runtimeDir !== 'string') return { ok: false, error: 'no runtime directory' };
|
|
171
|
+
if (!validKey(key)) return { ok: false, error: 'key must be portfolio, board:<id> or initiative:<id>' };
|
|
172
|
+
let list = null;
|
|
173
|
+
if (reports !== null) {
|
|
174
|
+
if (!Array.isArray(reports)) return { ok: false, error: 'reports must be a list of report ids, or null to reset' };
|
|
175
|
+
const bad = reports.find((r) => typeof r !== 'string' || !IDS.includes(r));
|
|
176
|
+
if (bad !== undefined) return { ok: false, error: 'unknown report id: ' + String(bad).slice(0, 80) };
|
|
177
|
+
list = [...new Set(reports)];
|
|
178
|
+
}
|
|
179
|
+
const layouts = readLayouts(runtimeDir);
|
|
180
|
+
if (list === null) delete layouts[key]; else layouts[key] = list;
|
|
181
|
+
const dir = dirOf(runtimeDir);
|
|
182
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
183
|
+
const tmp = path.join(dir, 'layouts.json.' + process.pid + '.' + crypto.randomBytes(4).toString('hex') + '.tmp');
|
|
184
|
+
try {
|
|
185
|
+
fs.writeFileSync(tmp, JSON.stringify({ layouts }, null, 2) + '\n');
|
|
186
|
+
fs.renameSync(tmp, fileOf(runtimeDir));
|
|
187
|
+
} catch (e) {
|
|
188
|
+
try { fs.unlinkSync(tmp); } catch { /* nothing to remove */ }
|
|
189
|
+
return { ok: false, error: 'the layout could not be saved: ' + String((e && e.message) || e) };
|
|
190
|
+
}
|
|
191
|
+
return { ok: true, layouts };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
module.exports = { LIBRARY, DEFAULTS, boardOf, currentQuarter, deliveryRows, scopeFilter, validKey, validId, readLayouts, writeLayout };
|
|
@@ -58,6 +58,7 @@ const MODULES = Object.freeze([
|
|
|
58
58
|
'routes-c3-timeline', // console3 Initiatives Timeline of /c3.html: every check of the plan as a whole frame, three sources in one Gantt (read-only)
|
|
59
59
|
'routes-c3-life', // console3 Lifecycle lens panels: newest evaluation per initiative, corrections per run, evidence sources (read-only)
|
|
60
60
|
'routes-c3-strategy', // console3 Calendar Quarter and Year panes: outcomes, north star, signals, synergy, bets (read-only)
|
|
61
|
+
'routes-c3-reports', // console3 Reports dashboards: library, per-scope data, page-press layouts (layouts are the only write)
|
|
61
62
|
'routes-hooks', // planning-flows run 7: CLI hook endpoints (session token, per-run JSONL)
|
|
62
63
|
]);
|
|
63
64
|
const PM_DIR = path.join(__dirname, '..', 'public', 'pm');
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-c3-reports.js — the Reports dashboards of /c3.html (console3 Reports). The only write is a layout, by a page press.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/report-dashboards?ws= → { ok, library, layouts, defaults, boards: [{ id, title }], initiatives: [{ id, title, board }] }
|
|
5
|
+
* GET /api/report-dashboards/data?scope=&id=&ws= → the GET /api/c3-strategy body narrowed to the scope, plus delivery: { rows }, range: { start, end },
|
|
6
|
+
* and scope, id, today echoed. 400 on a bad scope or a missing id; 404 for an id nothing holds.
|
|
7
|
+
* POST /api/report-dashboards/layout { key, reports } → { ok, key, reports, layouts } reports: null resets the key to its default.
|
|
8
|
+
* 400 on an unknown key or report id; 403 without a page press.
|
|
9
|
+
*
|
|
10
|
+
* Initiatives come from the workspace's facts (lib/facts-async.js), like routes-calendar-views.js. Key results, bets and hypotheses
|
|
11
|
+
* come from the same records as routes-c3-strategy.js. The handlers are pure over their inputs so a test needs no server.
|
|
12
|
+
*/
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const guard = require('./pm-guard.js');
|
|
15
|
+
const strategy = require('./routes-c3-strategy.js');
|
|
16
|
+
const rd = require('./report-dashboards.js');
|
|
17
|
+
|
|
18
|
+
const fail = (status, error, extra) => ({ status, body: { ok: false, error, ...(extra || {}) } });
|
|
19
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
20
|
+
const todayStr = () => { const d = new Date(); return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); };
|
|
21
|
+
const cap = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
|
|
22
|
+
|
|
23
|
+
/** boards and initiatives from the facts. A board is boardOf(ini, slug); its title is its id, capitalised. */
|
|
24
|
+
function catalogue(facts, slug) {
|
|
25
|
+
const inis = ((facts && facts.inis) || []).filter((i) => i && i.id);
|
|
26
|
+
const initiatives = inis.map((i) => ({ id: String(i.id), title: String(i.title || i.id), board: rd.boardOf(i, slug) }));
|
|
27
|
+
const seen = new Map();
|
|
28
|
+
initiatives.forEach((i) => { if (i.board && !seen.has(i.board)) seen.set(i.board, { id: i.board, title: cap(i.board) }); });
|
|
29
|
+
return { boards: [...seen.values()], initiatives };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function list({ facts, runtimeDir, slug }) {
|
|
33
|
+
const c = catalogue(facts, slug);
|
|
34
|
+
return { status: 200, body: { ok: true, library: rd.LIBRARY, layouts: rd.readLayouts(runtimeDir), defaults: rd.DEFAULTS, boards: c.boards, initiatives: c.initiatives } };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** query { scope, id }; deps { facts, build() → the c3-strategy body, today, slug, hypotheses }. */
|
|
38
|
+
function data(query, deps) {
|
|
39
|
+
const scope = query && query.scope; const id = query && query.id;
|
|
40
|
+
if (!['portfolio', 'board', 'initiative'].includes(scope)) return fail(400, 'scope must be portfolio, board or initiative');
|
|
41
|
+
if (scope !== 'portfolio') {
|
|
42
|
+
if (typeof id !== 'string' || !id) return fail(400, 'id is required for the ' + scope + ' scope');
|
|
43
|
+
if (!rd.validId(id)) return fail(400, 'id is not a valid id');
|
|
44
|
+
const c = catalogue(deps.facts, deps.slug);
|
|
45
|
+
const found = scope === 'board' ? c.boards.some((b) => b.id === id) : c.initiatives.some((i) => i.id === id);
|
|
46
|
+
if (!found) return fail(404, 'no ' + scope + ' has the id ' + id);
|
|
47
|
+
}
|
|
48
|
+
const today = deps.today || todayStr();
|
|
49
|
+
try {
|
|
50
|
+
const body = deps.build(today);
|
|
51
|
+
return { status: 200, body: rd.scopeFilter(body, deps.facts, scope, id || null, today, { slug: deps.slug, hypotheses: deps.hypotheses }) };
|
|
52
|
+
} catch (e) { return fail(500, String((e && e.message) || e)); }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function layout(req, body, runtimeDir) {
|
|
56
|
+
const g = guard.requirePagePress(req, body);
|
|
57
|
+
if (!g.ok) return fail(g.status, g.error);
|
|
58
|
+
if (!rd.validKey(body.key)) return fail(400, 'key must be portfolio, board:<id> or initiative:<id>');
|
|
59
|
+
if (!('reports' in body)) return fail(400, 'reports must be a list of report ids, or null to reset');
|
|
60
|
+
const r = rd.writeLayout(runtimeDir, body.key, body.reports);
|
|
61
|
+
if (!r.ok) return fail(/could not be saved|no runtime/.test(r.error) ? 500 : 400, r.error);
|
|
62
|
+
return { status: 200, body: { ok: true, key: body.key, reports: r.layouts[body.key] || null, layouts: r.layouts } };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const rootOf = (ctx) => (ctx && ctx.recordsDir) || path.join((ctx && ctx.repoRoot) || path.join(__dirname, '..', '..'), 'planning', 'records');
|
|
66
|
+
|
|
67
|
+
async function readFacts(w) {
|
|
68
|
+
return require('./facts-async.js').readFactsAsync(path.resolve(w.root));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = {
|
|
72
|
+
prefixes: ['/api/report-dashboards'],
|
|
73
|
+
catalogue, list, data, layout,
|
|
74
|
+
handle: async (req, res, url, ctx) => {
|
|
75
|
+
const p = url.pathname;
|
|
76
|
+
const send = (r) => { ctx.json(res, r.body, r.status); return true; };
|
|
77
|
+
if (p === '/api/report-dashboards' || p === '/api/report-dashboards/data') {
|
|
78
|
+
if (req.method !== 'GET') return send(fail(405, 'GET only'));
|
|
79
|
+
const w = ctx.getWs(url.searchParams.get('ws'));
|
|
80
|
+
if (!w) return send(fail(404, 'no workspace is registered'));
|
|
81
|
+
const slug = w.id || (w.root ? path.basename(path.resolve(w.root)) : null);
|
|
82
|
+
if (p === '/api/report-dashboards/data') {
|
|
83
|
+
const scope = url.searchParams.get('scope'); const id = url.searchParams.get('id');
|
|
84
|
+
// answer a bad request before reading the repository
|
|
85
|
+
if (!['portfolio', 'board', 'initiative'].includes(scope)) return send(fail(400, 'scope must be portfolio, board or initiative'));
|
|
86
|
+
if (scope !== 'portfolio' && !id) return send(fail(400, 'id is required for the ' + scope + ' scope'));
|
|
87
|
+
}
|
|
88
|
+
let facts;
|
|
89
|
+
try { facts = await readFacts(w); }
|
|
90
|
+
catch (e) { return send(fail(500, 'the dashboards could not read the repository: ' + (e && e.message ? e.message : String(e)))); }
|
|
91
|
+
if (p === '/api/report-dashboards') return send(list({ facts, runtimeDir: ctx.runtimeDir, slug }));
|
|
92
|
+
const root = rootOf(ctx);
|
|
93
|
+
let hypotheses = [];
|
|
94
|
+
try { hypotheses = require('../../scripts/records/index.js').list(root, 'hypotheses'); } catch { hypotheses = []; }
|
|
95
|
+
return send(data({ scope: url.searchParams.get('scope'), id: url.searchParams.get('id') }, {
|
|
96
|
+
facts, slug, hypotheses,
|
|
97
|
+
build: (today) => strategy.build(root, path.join(ctx.runtimeDir || '.', 'signals', 'signals.jsonl'), today),
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
if (p === '/api/report-dashboards/layout') {
|
|
101
|
+
if (req.method !== 'POST') return send(fail(405, 'POST only'));
|
|
102
|
+
const got = await ctx.readJson(req);
|
|
103
|
+
if (!got.ok) return send(fail(400, 'invalid JSON body'));
|
|
104
|
+
return send(layout(req, got.body || {}, ctx.runtimeDir));
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.128.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.128.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -523,6 +523,7 @@ export function dispose(host) {
|
|
|
523
523
|
}
|
|
524
524
|
|
|
525
525
|
/* The panel builders, for the other places the mockup draws the same panels (Initiatives > Lifecycle > Strategize, Reports > Product): see c3-strategy-panels.js. */
|
|
526
|
-
export const PANEL_BUILDERS = { north: northCard, outcomes: outcomesCard, signals: signalsCard, synergy: synergyCard, maturity: maturityCard, bets: betsCard
|
|
526
|
+
export const PANEL_BUILDERS = { north: northCard, outcomes: outcomesCard, signals: signalsCard, synergy: synergyCard, maturity: maturityCard, bets: betsCard,
|
|
527
|
+
delivery: deliveryCard };
|
|
527
528
|
export { readModel, fresh as modelFresh, dstr as todayStr };
|
|
528
529
|
export const cachedModel = () => (fresh() ? cache.model : null);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/* pm/c3-demo-rep3.js — DEMO answers for the Reports dashboards (c3-reports.js) on /c3.html?demo=1.
|
|
2
|
+
*
|
|
3
|
+
* GET /api/report-dashboards the library of seven reports, the defaults, two boards and five initiatives
|
|
4
|
+
* GET /api/report-dashboards/data the Calendar's demo strategy answer (c3-demo-cal3.js STRATEGY) narrowed to the scope
|
|
5
|
+
* POST /api/report-dashboards/layout checks the key and the report ids as the real route does (400 on an unknown one), and
|
|
6
|
+
* keeps the layout in this tab's sessionStorage, so a reload shows it. Nothing reaches the server.
|
|
7
|
+
* POST /api/outbox/draft answers with an entry in the real route's shape. Nothing is queued.
|
|
8
|
+
*
|
|
9
|
+
* Wiring: c3-demo.js imports installRep3 and calls it in installDemo() AFTER its own fetch wrapper is in place, the way it installs
|
|
10
|
+
* c3-demo-rep2.js. This wrapper must sit OVER c3-demo.js's: that wrapper answers every non-GET with { ok: true }, so a wrapper under it
|
|
11
|
+
* (as c3-demo-cal3.js is, for a GET) would never see the layout POST. Real mode never loads this file.
|
|
12
|
+
* The board and initiative narrowing is the demo's own: the real route narrows by boardOf(ini) in lib/report-dashboards.js. */
|
|
13
|
+
import { STRATEGY } from './c3-demo-cal3.js';
|
|
14
|
+
|
|
15
|
+
const LIBRARY = [
|
|
16
|
+
{ id: 'north', title: 'North star', question: 'Is the north-star metric on its plan?', source: 'KR metric readings', scopes: ['portfolio'] },
|
|
17
|
+
{ id: 'outcomes', title: 'Quarter outcomes', question: 'Where is each key result, where should it be, and where will it land?', source: 'KR metric records', scopes: ['portfolio', 'board', 'initiative'] },
|
|
18
|
+
{ id: 'signals', title: 'Early signals', question: 'What is drifting before the numbers say so?', source: 'Signal rules and KR readings', scopes: ['portfolio', 'board', 'initiative'] },
|
|
19
|
+
{ id: 'delivery', title: 'Delivery against promise', question: 'Which promised dates will hold?', source: 'Initiative plans and promised dates', scopes: ['portfolio', 'board', 'initiative'] },
|
|
20
|
+
{ id: 'maturity', title: 'Maturity by pillar', question: 'Where does each pillar stand against its target?', source: 'Pillar maturity records', scopes: ['portfolio'] },
|
|
21
|
+
{ id: 'bets', title: 'Bets and their checkpoints', question: 'When does each bet persevere or pivot?', source: 'Bets and hypotheses', scopes: ['portfolio', 'board', 'initiative'] },
|
|
22
|
+
{ id: 'synergy', title: 'Synergy and coverage', question: 'Do the bets add up to the key results?', source: 'Bets and their parent KR', scopes: ['portfolio', 'board'] },
|
|
23
|
+
];
|
|
24
|
+
const DEFAULTS = { portfolio: ['north', 'outcomes', 'signals', 'delivery', 'bets', 'synergy', 'maturity'], board: ['outcomes', 'delivery', 'signals', 'bets', 'synergy'], initiative: ['delivery', 'signals', 'outcomes', 'bets'] };
|
|
25
|
+
const BOARDS = [{ id: 'shop', title: 'Shop' }, { id: 'enterprise', title: 'Enterprise' }];
|
|
26
|
+
const INITIATIVES = [{ id: 'checkout', title: 'Checkout v2', board: 'shop' }, { id: 'search', title: 'Search relevance', board: 'shop' }, { id: 'returns', title: 'Returns experience', board: 'shop' },
|
|
27
|
+
{ id: 'onb', title: 'Self-serve onboarding', board: 'shop' }, { id: 'sso', title: 'SSO and SCIM', board: 'enterprise' }];
|
|
28
|
+
/* which initiatives each demo signal is about (the rule's subject); 'rel' is about the whole portfolio */
|
|
29
|
+
const SIG_INI = { conv: ['checkout'], ret: ['returns'], slip: ['checkout'], churn: ['checkout'], dep: ['checkout', 'sso'], gate: ['search'], ent: ['sso'] };
|
|
30
|
+
const KEY = 'pmos.c3.demo.rep3.layouts';
|
|
31
|
+
const RANGE = { start: '2026-10-01', end: '2026-12-31' };
|
|
32
|
+
|
|
33
|
+
const store = {
|
|
34
|
+
read() { try { return JSON.parse(sessionStorage.getItem(KEY) || '{}') || {}; } catch { return {}; } },
|
|
35
|
+
write(v) { try { sessionStorage.setItem(KEY, JSON.stringify(v)); } catch { /* no storage: the page keeps the layout itself */ } },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function scoped(scope, id) {
|
|
39
|
+
const S = STRATEGY;
|
|
40
|
+
const base = { ok: true, demo: true, scope, id: id || null, today: S.today, range: RANGE };
|
|
41
|
+
if (scope == 'portfolio') return { ...base, north: S.north, outcomes: S.outcomes, signals: S.signals, synergy: S.synergy, timeline: S.timeline, maturity: S.maturity, delivery: S.delivery, sources: S.sources };
|
|
42
|
+
const set = new Set(scope === 'board' ? INITIATIVES.filter((x) => x.board === id).map((x) => x.id) : INITIATIVES.some((x) => x.id === id) ? [id] : []);
|
|
43
|
+
const rows = S.synergy.rows.filter((r) => set.has(r.initiative));
|
|
44
|
+
const cols = S.synergy.columns.map((c, j) => rows.some((r) => r.weights[j] > 0));
|
|
45
|
+
const pillars = new Set(S.synergy.columns.filter((_, j) => cols[j]).map((c) => c.n));
|
|
46
|
+
return { ...base,
|
|
47
|
+
north: null, maturity: null,
|
|
48
|
+
outcomes: S.outcomes.filter((k) => pillars.has(k.pillar)),
|
|
49
|
+
signals: S.signals.filter((s) => (SIG_INI[s.id] || []).some((i) => set.has(i))),
|
|
50
|
+
synergy: { ...S.synergy, columns: S.synergy.columns.filter((_, j) => cols[j]), rows: rows.map((r) => ({ ...r, weights: r.weights.filter((_, j) => cols[j]) })), notes: [] },
|
|
51
|
+
timeline: { ...S.timeline, rows: S.timeline.rows.filter((r) => set.has(r.initiative)), checkpoints: set.has('returns') ? S.timeline.checkpoints.slice(0, 1) : [] },
|
|
52
|
+
delivery: { rows: S.delivery.rows.filter((r) => r.kind === 'init' && set.has(r.id)) },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const json = (body, status) => new Response(JSON.stringify(body), { status: status || 200, headers: { 'content-type': 'application/json' } });
|
|
57
|
+
const KEY_RE = /^(portfolio|board:([\w.-]+)|initiative:([\w.-]+))$/;
|
|
58
|
+
function layout(b) {
|
|
59
|
+
const m = KEY_RE.exec(String(b.key || ''));
|
|
60
|
+
if (!m || (m[2] && !BOARDS.some((x) => x.id === m[2])) || (m[3] && !INITIATIVES.some((x) => x.id === m[3]))) return json({ ok: false, error: 'unknown dashboard key: ' + b.key }, 400);
|
|
61
|
+
const L = store.read();
|
|
62
|
+
if (b.reports === null) { delete L[b.key]; store.write(L); return json({ ok: true, key: b.key, reports: null }); }
|
|
63
|
+
if (!Array.isArray(b.reports)) return json({ ok: false, error: 'reports must be a list of report ids, or null' }, 400);
|
|
64
|
+
const bad = b.reports.find((r) => !LIBRARY.some((x) => x.id === r));
|
|
65
|
+
if (bad !== undefined) return json({ ok: false, error: 'unknown report id: ' + bad }, 400);
|
|
66
|
+
L[b.key] = [...new Set(b.reports)]; store.write(L);
|
|
67
|
+
return json({ ok: true, key: b.key, reports: L[b.key] });
|
|
68
|
+
}
|
|
69
|
+
let seq = 0;
|
|
70
|
+
function draft(b) {
|
|
71
|
+
if (!b.body || !String(b.body).trim()) return json({ ok: false, error: 'body required' }, 400);
|
|
72
|
+
return json({ ok: true, entry: { id: 'out-demo-rep3-' + (++seq), channel: b.channel, recipient: b.recipient || null, subject: b.subject || '', body: b.body, origin: 'pm', state: 'queued', hash: 'demo' + seq, claims: { ok: true, refused: [] }, last_result: null } });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function installRep3() {
|
|
76
|
+
const prev = window.fetch;
|
|
77
|
+
window.fetch = (input, init) => {
|
|
78
|
+
try {
|
|
79
|
+
const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input && input.url;
|
|
80
|
+
const method = String((init && init.method) || (input && input.method) || 'GET').toUpperCase();
|
|
81
|
+
const u = new URL(raw, location.origin);
|
|
82
|
+
if (u.origin === location.origin) {
|
|
83
|
+
if (method === 'GET' && u.pathname === '/api/report-dashboards') return Promise.resolve(json({ ok: true, library: LIBRARY, layouts: store.read(), defaults: DEFAULTS, boards: BOARDS, initiatives: INITIATIVES }));
|
|
84
|
+
if (method === 'GET' && u.pathname === '/api/report-dashboards/data') {
|
|
85
|
+
const scope = u.searchParams.get('scope') || 'portfolio'; const id = u.searchParams.get('id') || '';
|
|
86
|
+
if (!['portfolio', 'board', 'initiative'].includes(scope)) return Promise.resolve(json({ ok: false, error: 'unknown scope: ' + scope }, 400));
|
|
87
|
+
return Promise.resolve(json(scoped(scope, id)));
|
|
88
|
+
}
|
|
89
|
+
if (method === 'POST' && (u.pathname === '/api/report-dashboards/layout' || u.pathname === '/api/outbox/draft')) {
|
|
90
|
+
let b = {}; try { b = init && init.body ? JSON.parse(init.body) : {}; } catch { b = {}; }
|
|
91
|
+
return Promise.resolve(u.pathname === '/api/outbox/draft' ? draft(b) : layout(b));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} catch { /* an unreadable request goes on as it is */ }
|
|
95
|
+
return prev(input, init);
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -22,6 +22,7 @@ import './c3-demo-life.js'; // Lifecycle panels: ideas, hypotheses, lessons,
|
|
|
22
22
|
import './c3-demo-misc2.js'; // Work Today lens as the mockup's; the Reflect lens's "What I could stop" (installMisc2 wraps the fetch after the briefing fixtures)
|
|
23
23
|
import { timeline } from './c3-demo-time.js'; // Initiatives Timeline: every check as a frame, in the real /api/c3-timeline shape
|
|
24
24
|
import './c3-demo-plan2.js'; // Commitments, Why it slips, Dependencies and Releases of Deliver: sets window.__c3planfx (c3-plan-panels.js, timeline-forecast-view.js, releases-view.js read it in demo only)
|
|
25
|
+
import { installRep3 } from './c3-demo-rep3.js'; // Reports dashboards: GET /api/report-dashboards and /data, POST layout (installed OVER this file's wrapper, see its header)
|
|
25
26
|
|
|
26
27
|
const realFetch = window.fetch.bind(window);
|
|
27
28
|
|
|
@@ -110,4 +111,4 @@ export function demoPill() {
|
|
|
110
111
|
}
|
|
111
112
|
|
|
112
113
|
export const DEMO_WS = D.WS_ID;
|
|
113
|
-
export function installDemo() { freezeClock(); install(); installInit(); import('./c3-demo-rep2.js').then((m) => m.installRep2()); window.__c3demo = true; window.__c3demoReady = import('./c3-demo-brief-ix.js').then((m) => m.installBriefIx()).then(() => import('./c3-demo-misc2.js')).then((m) => m.installMisc2()); } // Briefing case files, people and a simulated agent (c3-shell.js waits for __c3demoReady)
|
|
114
|
+
export function installDemo() { freezeClock(); install(); installRep3(); installInit(); import('./c3-demo-rep2.js').then((m) => m.installRep2()); window.__c3demo = true; window.__c3demoReady = import('./c3-demo-brief-ix.js').then((m) => m.installBriefIx()).then(() => import('./c3-demo-misc2.js')).then((m) => m.installMisc2()); } // Briefing case files, people and a simulated agent (c3-shell.js waits for __c3demoReady)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/* pm/c3-reports.css — the Reports dashboards of the console3 page (c3-reports.js links it once, as ensureC3Css links console3.css).
|
|
2
|
+
* Tokens only. Corners: 6px on a control, 8px on a container, else 0 (R27 as amended 2026-10-07). The console3 primitives (.card, .seg,
|
|
3
|
+
* .fold, .nrow, .xp) come from console3.css; this file adds the page's own layout under .rp3. */
|
|
4
|
+
.rp3{display:grid;gap:var(--sp-4);min-width:0;align-content:start}
|
|
5
|
+
.rp3-intro{margin:0;color:var(--muted-foreground);font-size:var(--fs-3)}
|
|
6
|
+
.rp3-pane{display:grid;gap:var(--sp-4);min-width:0;align-content:start}
|
|
7
|
+
.rp3-pane[hidden]{display:none}
|
|
8
|
+
.rp3 .rp3-tabs{justify-self:start;flex-wrap:wrap;max-width:100%}
|
|
9
|
+
.rp3 .rp3-tabs button[aria-selected="true"]{background:var(--background);color:var(--foreground);box-shadow:0 0 0 1px var(--border)}
|
|
10
|
+
.rp3 .rp3-tabs .tag{margin-left:2px}
|
|
11
|
+
.rp3-bar{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center;min-width:0}
|
|
12
|
+
.rp3 .rp3-scope{flex-wrap:wrap;max-width:100%}
|
|
13
|
+
.rp3-pick{display:inline-flex;gap:8px;align-items:center;min-width:0;max-width:100%;font-size:var(--fs-2)}
|
|
14
|
+
.rp3-select{min-width:0;max-width:100%;min-height:32px;border:1px solid var(--border);border-radius:6px;background:var(--background);color:var(--foreground);font:var(--fs-2) var(--mono);padding:4px 8px}
|
|
15
|
+
.rp3-select:disabled{opacity:.6;cursor:not-allowed}
|
|
16
|
+
.rp3-head{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center;justify-content:space-between;min-width:0}
|
|
17
|
+
.rp3-ttl{display:grid;gap:2px;min-width:0}
|
|
18
|
+
.rp3-ttl h2{margin:0;font:600 var(--fs-5)/1.3 var(--sans);overflow-wrap:anywhere}
|
|
19
|
+
.rp3-ttl .dim{font:var(--fs-2) var(--mono)}
|
|
20
|
+
.rp3-acts{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
|
|
21
|
+
.rp3-btn{display:inline-flex;gap:6px;align-items:center;min-height:32px;padding:4px 10px;border:1px solid var(--border);border-radius:6px;background:var(--background);color:var(--foreground);font:500 var(--fs-2) var(--mono);cursor:pointer}
|
|
22
|
+
.rp3-btn[aria-pressed="true"],.rp3-btn.rp3-pri{background:var(--foreground);color:var(--background);border-color:var(--foreground)}
|
|
23
|
+
.rp3-btn:disabled{opacity:.5;cursor:not-allowed}
|
|
24
|
+
.rp3-btn:focus-visible,.rp3-select:focus-visible,.rp3-about:focus-visible,.rp3-librow:focus-visible{outline:2px solid color-mix(in oklab, var(--ring) 50%, var(--foreground));outline-offset:2px}
|
|
25
|
+
.rp3-ib{padding:4px 6px}
|
|
26
|
+
.rp3-rot .ic{transform:rotate(90deg)}
|
|
27
|
+
@media (hover:hover){
|
|
28
|
+
.rp3-btn:not(:disabled):hover{border-color:var(--ring)}
|
|
29
|
+
.rp3-about:hover{color:var(--foreground)}
|
|
30
|
+
}
|
|
31
|
+
@media (pointer:coarse){
|
|
32
|
+
.rp3-btn,.rp3-select{min-height:44px}
|
|
33
|
+
.rp3-about{min-height:44px}
|
|
34
|
+
.rp3-librow{min-height:48px}
|
|
35
|
+
}
|
|
36
|
+
.rp3-note{margin:0;font-size:var(--fs-3)}
|
|
37
|
+
.rp3-status{margin:0;font-size:var(--fs-2)}
|
|
38
|
+
.rp3-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,440px),1fr));gap:var(--sp-4);align-items:start;min-width:0}
|
|
39
|
+
.rp3-w{display:grid;gap:6px;min-width:0;align-content:start}
|
|
40
|
+
.rp3-w>.card{min-width:0}
|
|
41
|
+
.rp3-w svg{max-width:100%}
|
|
42
|
+
.rp3.editing .rp3-w{outline:1px dashed var(--ring);outline-offset:6px;border-radius:8px}
|
|
43
|
+
.rp3-wh{display:flex;flex-wrap:wrap;gap:6px 10px;align-items:center;justify-content:space-between;min-width:0}
|
|
44
|
+
.rp3-wt{font:600 var(--fs-3) var(--sans);overflow-wrap:anywhere}
|
|
45
|
+
.rp3-about{display:flex;gap:6px;align-items:center;color:var(--muted-foreground);font:var(--fs-2) var(--mono);cursor:pointer;padding:4px 2px;border-radius:6px}
|
|
46
|
+
.rp3-case{display:grid;gap:6px;padding:8px 2px 4px;font-size:var(--fs-3)}
|
|
47
|
+
.rp3-case p{margin:0}
|
|
48
|
+
.rp3-add .sh h3{margin:0;font:600 var(--fs-3) var(--sans)}
|
|
49
|
+
.rp3-addrow{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;padding:8px 2px;border-bottom:1px solid var(--border)}
|
|
50
|
+
.rp3-librow .tt{font-size:var(--fs-4)}
|
|
51
|
+
.rp3-libcard{padding-top:10px;min-width:0}
|
|
52
|
+
.rp3-share{display:grid;gap:var(--sp-4);min-width:0}
|
|
53
|
+
.rp3-frame{border:1px solid var(--border);background:var(--card);border-radius:8px;padding:14px;display:grid;gap:12px;min-width:0}
|
|
54
|
+
.rp3-frame h4{margin:0 0 4px;padding-bottom:4px;border-bottom:1px solid var(--border);font:500 var(--fs-1) var(--mono);text-transform:uppercase;letter-spacing:.07em;color:var(--muted-foreground)}
|
|
55
|
+
.rp3-frame h5{margin:8px 0 2px;font:600 var(--fs-3) var(--sans)}
|
|
56
|
+
.rp3-frame p{margin:0;overflow-wrap:anywhere}
|
|
57
|
+
.rp3-hl{font:600 var(--fs-4)/1.35 var(--sans)}
|
|
58
|
+
.rp3-nums{margin:4px 0 0;padding-left:18px;color:var(--muted-foreground);font-size:var(--fs-2)}
|
|
59
|
+
.rp3-why{margin:0;border-left:3px solid var(--warning);background:var(--muted);padding:8px 10px;font-size:var(--fs-2)}
|
|
60
|
+
.rp3-wrap{flex-wrap:wrap}
|
|
61
|
+
.ph .rp3-grid,.rp3.ph .rp3-grid{grid-template-columns:minmax(0,1fr)}
|
|
62
|
+
.ph .rp3-addrow,.rp3.ph .rp3-addrow{grid-template-columns:minmax(0,1fr)}
|