create-pmos 0.510.0 → 0.512.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 +239 -134
- 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/briefing-premium-board-test.js +6 -7
- package/workspace/changelog.json +349 -912
- package/workspace/ev-everywhere-test.js +4 -4
- package/workspace/fixtures/next-prompt-replay.json +40 -0
- package/workspace/guide/home.md +2 -1
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/initiative-boards.md +3 -1
- package/workspace/guide/next-prompt.md +62 -0
- package/workspace/guide/settings.md +11 -5
- package/workspace/lib/board-premium.js +3 -2
- package/workspace/lib/kit-update.js +5 -2
- package/workspace/lib/next-prompt.js +199 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-boards.js +15 -1
- package/workspace/lib/routes-next-prompt.js +248 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/panels-test.js +68 -1
- package/workspace/public/pm/bd-cols.js +2 -1
- package/workspace/public/pm/bd-deliveries.js +1 -1
- package/workspace/public/pm/bd-span.js +15 -10
- package/workspace/public/pm/cozy-ev.css +26 -10
- package/workspace/public/pm/cozy-home.css +45 -1
- package/workspace/public/pm/cozy-live-pill.css +9 -0
- package/workspace/public/pm/cozy-live-pill.js +50 -3
- package/workspace/public/pm/cozy-panels.css +9 -0
- package/workspace/public/pm/cozy-panels.js +99 -16
- package/workspace/public/pm/cozy-settings.js +70 -16
- package/workspace/public/pm/cozy-viewer.js +15 -3
- package/workspace/public/pm/home-chat.js +25 -4
- package/workspace/public/pm/next-prompt-ui.js +117 -0
- package/workspace/public/pm/page-chat.css +9 -0
- package/workspace/public/pm/page-chat.js +90 -0
- package/workspace/public/pm/premium-board.js +5 -0
- package/workspace/public/pm/upd-weeks.js +21 -7
- package/workspace/public/premium-board.html +2 -2
- package/workspace/test-lists/closed-not-late.json +5 -0
- package/workspace/test-lists/next-prompt.json +5 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-next-prompt.js — the chat's suggested next prompt and its Sharper card over HTTP (batch 26 item 2, design B; lib/next-prompt.js).
|
|
3
|
+
*
|
|
4
|
+
* GET /api/next-prompt/prefs → { ok, next, sharpen, sharpenSet, style, rules, api }
|
|
5
|
+
* next: suggestions after a reply are on. sharpen: Sharpen as I type is on now ('auto' is on when Claude's runs go to the API, where a
|
|
6
|
+
* call takes about 2 s; a background `claude -p` run takes 10 s or more, too slow to follow typing). sharpenSet: 'auto'|'on'|'off'.
|
|
7
|
+
* style: 'polish'|'engineer'|'own'. rules: whether Your own rules are written. api: a key for the Claude API is set.
|
|
8
|
+
* POST /api/next-prompt/prefs { key: 'next'|'sharpen'|'style'|'rules', value } → { ok } (saved in <runtime>/next-prompt/prefs.json)
|
|
9
|
+
* POST /api/next-prompt/next { key, asked, reply, initiative?, stage?, product?, title? } → { ok, on, cards: [{ text, move, rec }] }
|
|
10
|
+
* POST /api/next-prompt/sharpen { key, text, reply?, initiative?, title? } → { ok, on, text | null }
|
|
11
|
+
*
|
|
12
|
+
* Both POSTs are page presses (lib/pm-guard.js requirePagePress; an agent session header is refused) and run ONE short Claude call:
|
|
13
|
+
* on the API with the PM's key when one is set (lib/anthropic-key.js, model PMOS_NEXT_PROMPT_MODEL or claude-sonnet-5-5), else a headless
|
|
14
|
+
* read-only run (lib/headless.js kind 'ask', origin briefing-ask:next-prompt:…). The answer goes through the shared door, lib/ai-guard.js file():
|
|
15
|
+
* the kill switch (`pmos ai off next-prompt`, `pmos ai off sharpen-prompt`, PMOS_AI=off), the replay gate (fixtures/next-prompt-replay.json,
|
|
16
|
+
* recorded answers, never a model), the citation check and the audit (<runtime>/ai-audit.jsonl). A card with no verified source is never
|
|
17
|
+
* shown. The reply and the typed text a card cites are kept in <runtime>/next-prompt/ (the newest 30 files), outside every repository.
|
|
18
|
+
* Nothing here writes to a session, a record or a repository: a card is text the PM may put in the box.
|
|
19
|
+
*
|
|
20
|
+
* Tests inject ctx.askClaude(prompt, { job, key }) → Promise<string|null> instead of a real call. */
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const crypto = require('crypto');
|
|
24
|
+
const guard = require('./pm-guard.js');
|
|
25
|
+
const AG = require('./ai-guard.js');
|
|
26
|
+
const NP = require('./next-prompt.js');
|
|
27
|
+
|
|
28
|
+
const VERSION = '1';
|
|
29
|
+
const ROOT = path.join(__dirname, '..'); // the workspace package: the files that prove a move works today
|
|
30
|
+
const FIXTURE = path.join(ROOT, 'fixtures/next-prompt-replay.json');
|
|
31
|
+
const KEEP = 30;
|
|
32
|
+
const TIMEOUT_MS = 90000;
|
|
33
|
+
const DEFAULT_MODEL = 'claude-sonnet-5-5';
|
|
34
|
+
|
|
35
|
+
const text = (v, n) => (typeof v === 'string' ? v.slice(0, n) : '');
|
|
36
|
+
const bad = (status, error) => ({ status, body: { ok: false, error } });
|
|
37
|
+
const dirOf = (runtimeDir) => path.join(runtimeDir, 'next-prompt');
|
|
38
|
+
|
|
39
|
+
/* The PM's choices live in <runtime>/next-prompt/prefs.json, owned by this route (Settings › Claude and the sliders menu write them here). */
|
|
40
|
+
const PREFS = {
|
|
41
|
+
next: (v) => typeof v === 'boolean',
|
|
42
|
+
sharpen: (v) => v === 'auto' || v === 'on' || v === 'off',
|
|
43
|
+
style: (v) => Object.prototype.hasOwnProperty.call(NP.STYLES, v),
|
|
44
|
+
rules: (v) => typeof v === 'string' && v.length <= 1000,
|
|
45
|
+
};
|
|
46
|
+
const prefsFile = (runtimeDir) => path.join(dirOf(runtimeDir), 'prefs.json');
|
|
47
|
+
function readPrefs(runtimeDir) {
|
|
48
|
+
try { const o = JSON.parse(fs.readFileSync(prefsFile(runtimeDir), 'utf8')); return o && typeof o === 'object' ? o : {}; } catch { return {}; }
|
|
49
|
+
}
|
|
50
|
+
/** Save one choice → { ok } or { ok:false, error }. */
|
|
51
|
+
function setPref(runtimeDir, key, value) {
|
|
52
|
+
if (!Object.prototype.hasOwnProperty.call(PREFS, key)) return { ok: false, error: 'no such choice: ' + String(key).slice(0, 40) };
|
|
53
|
+
if (!PREFS[key](value)) return { ok: false, error: 'that value does not fit ' + key };
|
|
54
|
+
const o = readPrefs(runtimeDir); o[key] = value;
|
|
55
|
+
fs.mkdirSync(dirOf(runtimeDir), { recursive: true, mode: 0o700 });
|
|
56
|
+
const f = prefsFile(runtimeDir), tmp = f + '.' + process.pid + '.tmp';
|
|
57
|
+
fs.writeFileSync(tmp, JSON.stringify(o, null, 2) + '\n', { mode: 0o600 });
|
|
58
|
+
fs.renameSync(tmp, f);
|
|
59
|
+
return { ok: true };
|
|
60
|
+
}
|
|
61
|
+
const get = (ctx, k) => { const v = readPrefs(ctx.runtimeDir)[k]; return PREFS[k](v) ? v : undefined; };
|
|
62
|
+
const envOf = (ctx) => ctx.env || process.env;
|
|
63
|
+
|
|
64
|
+
function apiKey(ctx) {
|
|
65
|
+
const env = envOf(ctx);
|
|
66
|
+
if (/^(off|0|false|no)$/i.test(String(env.PMOS_NEXT_PROMPT_API || ''))) return null;
|
|
67
|
+
try { return require('./anthropic-key.js').getKey(ctx.runtimeDir, env); } catch { return null; }
|
|
68
|
+
}
|
|
69
|
+
function prefs(ctx) {
|
|
70
|
+
const set = get(ctx, 'sharpen') || 'auto', api = !!apiKey(ctx), style = get(ctx, 'style') || 'engineer', rules = String(get(ctx, 'rules') || '');
|
|
71
|
+
return { next: get(ctx, 'next') !== false, sharpen: set === 'on' || (set === 'auto' && api), sharpenSet: set, style, rules: !!rules.trim(), rulesText: rules, api };
|
|
72
|
+
}
|
|
73
|
+
function offWhy(ctx, job) {
|
|
74
|
+
const why = AG.off(job, { env: envOf(ctx), settingsDir: ctx.runtimeDir });
|
|
75
|
+
if (why) return why;
|
|
76
|
+
const P = prefs(ctx);
|
|
77
|
+
if (job === NP.JOB_NEXT && !P.next) return 'Suggestions after a reply are off (Settings › Claude)';
|
|
78
|
+
if (job === NP.JOB_SHARPEN && !P.sharpen) return 'Sharpen as I type is off (Settings › Claude)';
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* ── the call ────────────────────────────────────────────────────────────────────────────── */
|
|
83
|
+
|
|
84
|
+
async function viaApi(key, prompt, env) {
|
|
85
|
+
const Anthropic = require('@anthropic-ai/sdk');
|
|
86
|
+
const api = new Anthropic({ apiKey: key });
|
|
87
|
+
const res = await api.messages.create({ model: String(env.PMOS_NEXT_PROMPT_MODEL || '').trim() || DEFAULT_MODEL, max_tokens: 800, messages: [{ role: 'user', content: prompt }] });
|
|
88
|
+
return (res.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('\n');
|
|
89
|
+
}
|
|
90
|
+
function viaHeadless(ctx, prompt, origin) {
|
|
91
|
+
const headless = require('./headless.js');
|
|
92
|
+
if (typeof ctx.spawnSession !== 'function' || headless.kindFor({ agent: 'claude', origin }) !== 'ask') return Promise.resolve(null);
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
let settled = false; const done = (v) => { if (!settled) { settled = true; clearTimeout(t); resolve(v); } };
|
|
95
|
+
const t = setTimeout(() => done(null), TIMEOUT_MS);
|
|
96
|
+
let s = null;
|
|
97
|
+
try {
|
|
98
|
+
s = ctx.spawnSession({ name: 'chat: next prompt', agent: 'claude', origin, initialInput: prompt,
|
|
99
|
+
onDone: (d) => { const r = d && d.result; done(r && r.ok && !r.isError ? String(r.text || '') : null); } });
|
|
100
|
+
} catch { s = null; }
|
|
101
|
+
if (!s || s.state === 'exited') done(null);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
async function ask(ctx, prompt, { job, key }) {
|
|
105
|
+
if (typeof ctx.askClaude === 'function') return ctx.askClaude(prompt, { job, key });
|
|
106
|
+
const k = apiKey(ctx);
|
|
107
|
+
if (k) { try { return await viaApi(k, prompt, envOf(ctx)); } catch { return null; } }
|
|
108
|
+
return viaHeadless(ctx, prompt, `briefing-ask:next-prompt:${job}:${crypto.createHash('sha1').update(String(key)).digest('hex').slice(0, 10)}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* ── what a card cites ───────────────────────────────────────────────────────────────────── */
|
|
112
|
+
|
|
113
|
+
function prune(dir) {
|
|
114
|
+
let fsx = []; try { fsx = fs.readdirSync(dir).filter((f) => f.endsWith('.txt')).map((f) => ({ f, t: fs.statSync(path.join(dir, f)).mtimeMs })); } catch { return; }
|
|
115
|
+
fsx.sort((a, b) => b.t - a.t);
|
|
116
|
+
for (const x of fsx.slice(KEEP)) { try { fs.unlinkSync(path.join(dir, x.f)); } catch { /* gone already */ } }
|
|
117
|
+
}
|
|
118
|
+
/** Keep a text where the guard can read it at its lines; → its name under the state folder. */
|
|
119
|
+
function keep(runtimeDir, prefix, body) {
|
|
120
|
+
const dir = dirOf(runtimeDir);
|
|
121
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
122
|
+
const name = prefix + '-' + crypto.createHash('sha1').update(body).digest('hex').slice(0, 16) + '.txt';
|
|
123
|
+
fs.writeFileSync(path.join(dir, name), body, { mode: 0o600 });
|
|
124
|
+
prune(dir);
|
|
125
|
+
return 'next-prompt/' + name;
|
|
126
|
+
}
|
|
127
|
+
const flat = (s) => String(s == null ? '' : s).toLowerCase().replace(/\s+/g, ' ').trim();
|
|
128
|
+
/** The line of the kept reply that holds `from` (spacing and case ignored) → { ref, quote } for the guard, or null. */
|
|
129
|
+
function lineRef(rel, body, from) {
|
|
130
|
+
const want = flat(from); if (want.length < AG.QUOTE_MIN) return null;
|
|
131
|
+
const L = body.split('\n');
|
|
132
|
+
for (let i = 0; i < L.length; i++) if (flat(L.slice(Math.max(0, i - 2), i + 3).join(' ')).includes(want)) return { ref: `state:${rel}:${i + 1}`, quote: from };
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* ── the guard ───────────────────────────────────────────────────────────────────────────── */
|
|
137
|
+
|
|
138
|
+
const MOVES = () => NP.movesOf(ROOT);
|
|
139
|
+
function replayCases(runtimeDir, job = null) {
|
|
140
|
+
let F; try { F = JSON.parse(fs.readFileSync(FIXTURE, 'utf8')); } catch { return []; }
|
|
141
|
+
const moves = MOVES();
|
|
142
|
+
const N = job === NP.JOB_SHARPEN ? [] : F.next || [], H = job === NP.JOB_NEXT ? [] : F.sharpen || [];
|
|
143
|
+
return N.map((c) => ({ name: 'next: ' + c.name, expect: c.expect, run: async () => {
|
|
144
|
+
const reply = String(c.reply || ''), rel = keep(runtimeDir, 'replay', reply);
|
|
145
|
+
return NP.proposalsOfNext(NP.parseNext(JSON.stringify(c.answer), { moves, place: c.place || null }), { moves, replyRef: (from) => lineRef(rel, reply, from) });
|
|
146
|
+
} })).concat(H.map((c) => ({ name: 'sharpen: ' + c.name, expect: c.expect, run: async () => {
|
|
147
|
+
const out = NP.parseSharpen(JSON.stringify(c.answer), c.text);
|
|
148
|
+
if (!out) return [];
|
|
149
|
+
const rel = keep(runtimeDir, 'replay-typed', String(c.text));
|
|
150
|
+
return [NP.proposalOfSharpen(out, c.cite === false ? null : { ref: `state:${rel}:1`, quote: String(c.text).split('\n')[0] })];
|
|
151
|
+
} })));
|
|
152
|
+
}
|
|
153
|
+
async function gate(ctx, job) {
|
|
154
|
+
if (AG.ready(ctx.runtimeDir, job, VERSION)) return true;
|
|
155
|
+
const rp = await AG.replay(job, VERSION, replayCases(ctx.runtimeDir, job), { root: ROOT, stateDir: ctx.runtimeDir, max: NP.MAX_CARDS });
|
|
156
|
+
return rp.ok;
|
|
157
|
+
}
|
|
158
|
+
async function through(ctx, job, run, proposals) {
|
|
159
|
+
let shown = [];
|
|
160
|
+
const got = await AG.file({ job, version: VERSION, run, root: ROOT, stateDir: ctx.runtimeDir, settingsDir: ctx.runtimeDir, env: envOf(ctx), max: NP.MAX_CARDS }, proposals, async (filed) => { shown = filed; });
|
|
161
|
+
return got.off || got.held ? [] : shown;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* ── the two calls ───────────────────────────────────────────────────────────────────────── */
|
|
165
|
+
|
|
166
|
+
const placeOf = (b) => ({ initiative: text(b.initiative, 160) || null, stage: text(b.stage, 60) || null, product: text(b.product, 120) || null, title: text(b.title, 160) || null });
|
|
167
|
+
const inflight = new Map(); // the same reply asked twice (two tabs, a re-read) shares one call
|
|
168
|
+
function once(k, fn) {
|
|
169
|
+
if (inflight.has(k)) return inflight.get(k);
|
|
170
|
+
const p = fn().finally(() => setTimeout(() => inflight.delete(k), 60000));
|
|
171
|
+
inflight.set(k, p);
|
|
172
|
+
if (inflight.size > 100) inflight.delete(inflight.keys().next().value);
|
|
173
|
+
return p;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function next(ctx, b) {
|
|
177
|
+
const reply = text(b.reply, 6000).trim(), key = text(b.key, 200);
|
|
178
|
+
if (!reply) return { ok: true, on: true, cards: [] };
|
|
179
|
+
const job = NP.JOB_NEXT, run = 'np-' + crypto.randomBytes(4).toString('hex');
|
|
180
|
+
const P = prefs(ctx), place = placeOf(b), moves = MOVES();
|
|
181
|
+
if (!(await gate(ctx, job))) return { ok: true, on: true, held: true, cards: [] };
|
|
182
|
+
const k = 'n:' + key + ':' + crypto.createHash('sha1').update(reply + '\n' + P.style + '\n' + P.rulesText).digest('hex');
|
|
183
|
+
return once(k, async () => {
|
|
184
|
+
AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'start' });
|
|
185
|
+
const answer = await ask(ctx, NP.nextPrompt({ pm: text(b.asked, 2000), reply, place, style: P.style, rules: P.rulesText, moves }), { job, key });
|
|
186
|
+
const cards = NP.parseNext(answer, { moves, place });
|
|
187
|
+
if (!cards.length) { AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'end', filed: 0, dropped: 0, ...(answer == null ? { error: 'no answer' } : {}) }); return { ok: true, on: true, cards: [] }; }
|
|
188
|
+
const rel = keep(ctx.runtimeDir, 'reply', reply);
|
|
189
|
+
const shown = await through(ctx, job, run, NP.proposalsOfNext(cards, { moves, replyRef: (from) => lineRef(rel, reply, from) }));
|
|
190
|
+
return { ok: true, on: true, cards: shown.map((p, i) => ({ text: p.say, move: p.data.move, rec: i === 0 })) };
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function sharpen(ctx, b) {
|
|
195
|
+
const typed = text(b.text, 2000).trim(), key = text(b.key, 200);
|
|
196
|
+
if (!NP.rough(typed)) return { ok: true, on: true, text: null };
|
|
197
|
+
const job = NP.JOB_SHARPEN, run = 'sh-' + crypto.randomBytes(4).toString('hex');
|
|
198
|
+
const P = prefs(ctx);
|
|
199
|
+
if (!(await gate(ctx, job))) return { ok: true, on: true, held: true, text: null };
|
|
200
|
+
const k = 's:' + key + ':' + crypto.createHash('sha1').update(typed + '\n' + P.style + '\n' + P.rulesText).digest('hex');
|
|
201
|
+
return once(k, async () => {
|
|
202
|
+
AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'start' });
|
|
203
|
+
const answer = await ask(ctx, NP.sharpenPrompt({ text: typed, reply: text(b.reply, 3000), place: placeOf(b), style: P.style, rules: P.rulesText }), { job, key });
|
|
204
|
+
const out = NP.parseSharpen(answer, typed);
|
|
205
|
+
if (!out) { AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'end', filed: 0, dropped: 0, ...(answer == null ? { error: 'no answer' } : { same: true }) }); return { ok: true, on: true, text: null }; }
|
|
206
|
+
const first = typed.split('\n')[0], rel = keep(ctx.runtimeDir, 'typed', typed);
|
|
207
|
+
const shown = await through(ctx, job, run, [NP.proposalOfSharpen(out, { ref: `state:${rel}:1`, quote: first })]);
|
|
208
|
+
return { ok: true, on: true, text: shown.length ? shown[0].say : null };
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function handle(req, res, url, ctx) {
|
|
213
|
+
const send = (r) => { ctx.json(res, r.body, r.status); return true; };
|
|
214
|
+
const what = url.pathname.replace(/^\/api\/next-prompt\/?/, '');
|
|
215
|
+
if (what === 'prefs' && req.method === 'POST') {
|
|
216
|
+
if (req.headers && req.headers['x-pmos-session']) return send(bad(403, 'Only the page changes these choices.'));
|
|
217
|
+
const rd = await ctx.readJson(req);
|
|
218
|
+
if (!rd.ok) return send(bad(400, 'the body is not JSON'));
|
|
219
|
+
const body = rd.body || {};
|
|
220
|
+
const g = guard.requirePagePress(req, body);
|
|
221
|
+
if (!g.ok) return send(bad(g.status, g.error));
|
|
222
|
+
if (!ctx.runtimeDir) return send(bad(409, 'There is no runtime folder.'));
|
|
223
|
+
const r = setPref(ctx.runtimeDir, body.key, body.value);
|
|
224
|
+
return send(r.ok ? { status: 200, body: { ok: true } } : bad(400, r.error));
|
|
225
|
+
}
|
|
226
|
+
if (what === 'prefs') {
|
|
227
|
+
if (req.method !== 'GET') return send(bad(405, 'GET or POST only'));
|
|
228
|
+
const P = prefs(ctx), env = envOf(ctx);
|
|
229
|
+
const next = P.next && !AG.off(NP.JOB_NEXT, { env, settingsDir: ctx.runtimeDir }), sh = P.sharpen && !AG.off(NP.JOB_SHARPEN, { env, settingsDir: ctx.runtimeDir });
|
|
230
|
+
return send({ status: 200, body: { ok: true, next, nextSet: P.next, sharpen: sh, sharpenSet: P.sharpenSet, style: P.style, rules: P.rules, rulesText: P.rulesText, api: P.api } });
|
|
231
|
+
}
|
|
232
|
+
if (what !== 'next' && what !== 'sharpen') return send(bad(404, 'no such next-prompt route'));
|
|
233
|
+
if (req.method !== 'POST') return send(bad(405, 'POST only'));
|
|
234
|
+
if (req.headers && req.headers['x-pmos-session']) return send(bad(403, 'Only the page asks for a suggestion.'));
|
|
235
|
+
const rd = await ctx.readJson(req);
|
|
236
|
+
if (!rd.ok) return send(bad(400, 'the body is not JSON'));
|
|
237
|
+
const body = rd.body || {};
|
|
238
|
+
const g = guard.requirePagePress(req, body);
|
|
239
|
+
if (!g.ok) return send(bad(g.status, g.error));
|
|
240
|
+
if (!ctx.runtimeDir) return send(bad(409, 'There is no runtime folder.'));
|
|
241
|
+
const job = what === 'next' ? NP.JOB_NEXT : NP.JOB_SHARPEN;
|
|
242
|
+
const why = offWhy(ctx, job);
|
|
243
|
+
if (why) return send({ status: 200, body: { ok: true, on: false, why, ...(what === 'next' ? { cards: [] } : { text: null }) } });
|
|
244
|
+
try { return send({ status: 200, body: what === 'next' ? await next(ctx, body) : await sharpen(ctx, body) }); }
|
|
245
|
+
catch (e) { return send(bad(500, 'The suggestion failed: ' + String((e && e.message) || e).slice(0, 200))); }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = { prefixes: ['/api/next-prompt'], handle, VERSION, FIXTURE, prefs, setPref, lineRef, replayCases, next, sharpen };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.512.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.512.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@anthropic-ai/sdk": "^1.3.0",
|
package/workspace/package.json
CHANGED
package/workspace/panels-test.js
CHANGED
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
* over at 1440), the active edge, a plain click replaces the Viewer used last P5 a drop on a Viewer replaces it P6 the divider to the
|
|
21
21
|
* left edge: full screen; Split brings the page back; to the right edge: the Viewer closes P7 a reload reopens nothing; the share holds;
|
|
22
22
|
* Home resets it P8 1920: a drop at the far edge opens beside, three panels P9 Intelligence: split, ⌘-click P10 a phone as today
|
|
23
|
+
* P11 dock a Viewer on the left by its header, the divider on that side, kept after a reload, ⧉'s Dock on the right by the keyboard
|
|
24
|
+
* P12 Discuss in chat opens Home's chat in a panel on the Board (slides over at 1440, beside at 1920), with the same conversation key
|
|
25
|
+
* P13 the Live pill's list docks on the right by its grab bar, and a press moves it back (batch 26 item 4)
|
|
23
26
|
* Screenshots go to PMOS_SHOTS when it is set. Every wait polls the page. */
|
|
24
27
|
const fs = require('fs');
|
|
25
28
|
const path = require('path');
|
|
@@ -278,7 +281,7 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
|
|
|
278
281
|
await Bd.page.click('#czViewer [data-cz-vw="place"]');
|
|
279
282
|
await until(() => Bd.page.$('.cz-pn-menu'));
|
|
280
283
|
const m3 = await Bd.page.evaluate(() => [...document.querySelectorAll('.cz-pn-menu button')].map((b) => b.getAttribute('aria-label') + (b.getAttribute('aria-checked') === 'true' ? '*' : '')).join('|'));
|
|
281
|
-
ok('P3 ⧉ opens Full screen, Split, Slide over; Split is checked', m3 === 'Full screen|Split*|Slide over', m3);
|
|
284
|
+
ok('P3 ⧉ opens Full screen, Split, Slide over, then Dock on the left and Dock on the right (right checked); Split is checked', m3 === 'Full screen|Split*|Slide over|Dock on the left|Dock on the right*', m3);
|
|
282
285
|
await Bd.page.click('.cz-pn-menu [data-cz-pn-place="over"]');
|
|
283
286
|
const b3 = await until(() => pl(Bd.page).then((l) => (P(l, 'v0').mode === 'over' ? l : null)), 3000);
|
|
284
287
|
ok('P3 Slide over: the Viewer floats 560 px at the right with a grab bar; the page is whole again, with no divider', !!b3 && P(b3, 'v0').box.w === 560 && P(b3, 'page').box.w > 1300 && b3.divs.length === 0 && !b3.narrow
|
|
@@ -347,6 +350,70 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
|
|
|
347
350
|
await shot(Bw.page, 'p8-board-three-1920');
|
|
348
351
|
ok('0 no page error on the Board at 1920', Bw.errors.length === 0, Bw.errors.join(' | '));
|
|
349
352
|
await Bw.close();
|
|
353
|
+
/* ── batch 26 item 4: dock a panel on the left or the right; the chat as a panel on the Board ── */
|
|
354
|
+
const ctxD = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
|
355
|
+
const threads = [];
|
|
356
|
+
await ctxD.route(/\/api\/briefing-premium\/thread\?/, (r) => { threads.push(new URL(r.request().url()).searchParams.get('key')); return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, session: null, history: { messages: [] } }) }); });
|
|
357
|
+
const Dd = await open({ ctx: ctxD, at: '/premium-board.html', ready: boardReady });
|
|
358
|
+
const KD = await keys(Dd.page);
|
|
359
|
+
await Dd.page.click(nm(KD[0]));
|
|
360
|
+
await until(() => pl(Dd.page).then((l) => (P(l, 'v0') && P(l, 'v0').box.shown && l.divs.length === 1 ? l : null)));
|
|
361
|
+
let z11 = null;
|
|
362
|
+
await drag(Dd.page, '#czViewer .cz-vw-tile', 200, 400, { hold: async () => { z11 = await Dd.page.evaluate(() => { const z = document.querySelector('.cz-pn-drop:not([hidden])'); if (!z) return null; const r = z.getBoundingClientRect(); return { side: z.dataset.side, l: Math.round(r.left), w: Math.round(r.width) }; }); await shot(Dd.page, 'p11-dock-drag-1440'); } });
|
|
363
|
+
const d11 = await until(() => pl(Dd.page).then((l) => (P(l, 'v0').box.r < P(l, 'page').box.l ? l : null)), 3000);
|
|
364
|
+
const side11 = await Dd.page.evaluate(() => { try { return JSON.parse(localStorage.getItem('cz-pn-side')); } catch { return null; } });
|
|
365
|
+
ok('P11 a drag of the Viewer\'s header over the left half turns that half blue', !!z11 && z11.side === 'left' && z11.l <= 20 && z11.w > 600, JSON.stringify(z11));
|
|
366
|
+
ok('P11 the drop docks it on the left: the Viewer, the divider, then the page; the title is not renamed', !!d11 && d11.divs.length === 1 && P(d11, 'v0').box.r <= d11.divs[0].l + 12 && d11.divs[0].r <= P(d11, 'page').box.l + 12 && !(await Dd.page.$('#czViewer [data-cz-vw-ren]')), JSON.stringify(d11 || await pl(Dd.page)));
|
|
367
|
+
ok('P11 the side is kept for its kind (cz-pn-side)', !!side11 && Object.values(side11).includes('left'), JSON.stringify(side11));
|
|
368
|
+
await shot(Dd.page, 'p11-docked-left-1440');
|
|
369
|
+
{ const d = await divAt(Dd.page); const w0 = P(await pl(Dd.page), 'v0').box.w; await drag(Dd.page, d, d.x + 80, d.y); const w1 = await until(() => pl(Dd.page).then((l) => (P(l, 'v0').box.w > w0 + 40 ? P(l, 'v0').box.w : null)), 3000); ok('P11 the divider resizes it on the left: a drag to the right widens the Viewer', !!w1, JSON.stringify([w0, w1])); }
|
|
370
|
+
await Dd.page.reload();
|
|
371
|
+
await until(() => Dd.page.evaluate(boardReady), 20000);
|
|
372
|
+
await Dd.page.click(nm(KD[1]));
|
|
373
|
+
const d11b = await until(() => pl(Dd.page).then((l) => (P(l, 'v0') && P(l, 'v0').box.shown && l.divs.length === 1 ? l : null)), 4000);
|
|
374
|
+
ok('P11 after a reload an initiative opens on the left again', !!d11b && P(d11b, 'v0').box.r < P(d11b, 'page').box.l, JSON.stringify(d11b));
|
|
375
|
+
await Dd.page.click('#czViewer [data-cz-vw="place"]');
|
|
376
|
+
await until(() => Dd.page.$('.cz-pn-menu'));
|
|
377
|
+
await Dd.page.focus('.cz-pn-menu [data-cz-pn-dock="right"]'); await Dd.page.keyboard.press('Enter');
|
|
378
|
+
const d11c = await until(() => pl(Dd.page).then((l) => (P(l, 'v0').box.l > P(l, 'page').box.r ? l : null)), 3000);
|
|
379
|
+
ok('P11 ⧉ › Dock on the right (an icon with its aria-label, by the keyboard) docks it back on the right', !!d11c && !(await Dd.page.evaluate(() => localStorage.getItem('cz-pn-side'))), JSON.stringify(d11c));
|
|
380
|
+
// the chat
|
|
381
|
+
const dh = await Dd.page.evaluate(() => [...document.querySelectorAll('#czViewer .cz-vw-hd [data-cz-vw]')].map((b) => b.dataset.czVw));
|
|
382
|
+
ok('P12 the initiative panel has Discuss in chat', dh.includes('discuss'), JSON.stringify(dh));
|
|
383
|
+
const url0 = Dd.page.url();
|
|
384
|
+
await Dd.page.click('#czViewer [data-cz-vw="discuss"]');
|
|
385
|
+
const c12 = await until(() => Dd.page.evaluate(() => { const v = [...document.querySelectorAll('.cz-vw')].find((x) => !x.hidden && x.querySelector('.pc-chat')); if (!v) return null; const r = v.getBoundingClientRect(); return { id: v.dataset.czPanel, w: Math.round(r.width), log: !!v.querySelector('.hm-log'), box: !!v.querySelector('.hm-foot textarea'), title: v.querySelector('.cz-vw-title').textContent }; }), 6000);
|
|
386
|
+
const l12 = await pl(Dd.page);
|
|
387
|
+
ok('P12 Discuss in chat opens the chat in a panel on the Board (no page change): the log and the box', !!c12 && c12.log && c12.box && Dd.page.url() === url0, JSON.stringify([c12, Dd.page.url()]));
|
|
388
|
+
ok('P12 at 1440 the chat slides over; the initiative split under it stays', !!c12 && P(l12, c12.id).mode === 'over' && P(l12, 'v0').mode === 'split', JSON.stringify(l12));
|
|
389
|
+
const iniId = await Dd.page.evaluate(() => window.czViewer.current() && window.czViewer.current().id);
|
|
390
|
+
ok('P12 it is Home\'s conversation about the initiative (key init:<id>)', threads.some((k) => /^init:/.test(k || '')), JSON.stringify([threads, iniId]));
|
|
391
|
+
await shot(Dd.page, 'p12-chat-over-1440');
|
|
392
|
+
ok('0 no page error while docking and with the chat on the Board', Dd.errors.length === 0, Dd.errors.join(' | '));
|
|
393
|
+
// the Live pill's list: its grab bar docks it on the right; a press moves it back
|
|
394
|
+
await Dd.page.evaluate(() => window.czPanels.putAway());
|
|
395
|
+
await Dd.page.click('#czLive');
|
|
396
|
+
await until(() => Dd.page.$('#czLiveList:not([hidden]) .lv-side'), 3000);
|
|
397
|
+
const g13 = await Dd.page.evaluate(() => { const r = document.querySelector('#czLiveList .lv-side').getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, lab: document.querySelector('#czLiveList .lv-side').getAttribute('aria-label') }; });
|
|
398
|
+
await drag(Dd.page, g13, 1300, g13.y);
|
|
399
|
+
const t13 = await until(() => Dd.page.evaluate(() => { const t = document.getElementById('czLiveList'); const r = t.getBoundingClientRect(); return t.classList.contains('lv-r') ? { r: Math.round(r.right), lab: t.querySelector('.lv-side').getAttribute('aria-label'), kept: localStorage.getItem('cz-live-side') } : null; }), 3000);
|
|
400
|
+
ok('P13 the Live pill\'s list docks on the right by its grab bar (an icon with an aria-label), and the side is kept', !!t13 && Math.abs(t13.r - (1440 - 16)) <= 2 && t13.kept === 'right' && g13.lab === 'Move this list to the right side', JSON.stringify([g13, t13]));
|
|
401
|
+
await shot(Dd.page, 'p13-live-right-1440');
|
|
402
|
+
await Dd.page.focus('#czLiveList .lv-side'); await Dd.page.keyboard.press('Enter');
|
|
403
|
+
ok('P13 Enter on the grab bar moves it back to the left', !!(await until(() => Dd.page.evaluate(() => !document.getElementById('czLiveList').classList.contains('lv-r') && !localStorage.getItem('cz-live-side')), 2000)));
|
|
404
|
+
await Dd.close();
|
|
405
|
+
// 1920: the chat opens beside the initiative panel
|
|
406
|
+
const ctxW = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
|
407
|
+
await ctxW.route(/\/api\/briefing-premium\/thread\?/, (r) => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, session: null, history: { messages: [] } }) }));
|
|
408
|
+
const Dw = await open({ ctx: ctxW, at: '/premium-board.html', ready: boardReady });
|
|
409
|
+
await Dw.page.click(nm(KD[0]));
|
|
410
|
+
await until(() => pl(Dw.page).then((l) => (P(l, 'v0') && P(l, 'v0').box.shown ? l : null)));
|
|
411
|
+
await Dw.page.click('#czViewer [data-cz-vw="discuss"]');
|
|
412
|
+
const c12w = await until(() => pl(Dw.page).then((l) => (P(l, 'v1') && P(l, 'v1').box.shown && l.divs.length === 2 ? l : null)), 6000);
|
|
413
|
+
ok('P12 at 1920 the chat opens beside the initiative panel: the page, the panel, the chat, two dividers', !!c12w && P(c12w, 'page').box.r < P(c12w, 'v0').box.l && P(c12w, 'v0').box.r < P(c12w, 'v1').box.l && P(c12w, 'v1').mode === 'split', JSON.stringify(c12w || await pl(Dw.page)));
|
|
414
|
+
await shot(Dw.page, 'p12-chat-beside-1920');
|
|
415
|
+
ok('0 no page error with the chat at 1920', Dw.errors.length === 0, Dw.errors.join(' | '));
|
|
416
|
+
await Dw.close();
|
|
350
417
|
// Intelligence: a goal row opens a split; ⌘-click opens beside
|
|
351
418
|
const In = await open({ at: '/premium-intelligence.html', ready: () => !!window.czPanels && document.querySelectorAll('#goalTbl .ins-row[data-goal]').length > 1 });
|
|
352
419
|
ok('P9 Intelligence draws its goal rows and window.czPanels', !!In.up);
|
|
@@ -70,7 +70,8 @@ export function nextMs(r, now = new Date()) {
|
|
|
70
70
|
if (!x && es && !(r.milestones || []).some((m) => day(m.date)) && day(r.end) && k < 8 && !(r.track && r.track.prog && r.track.prog.total > 0 && r.track.prog.done >= r.track.prog.total)) x = { name: es, d: day(r.end) };
|
|
71
71
|
if (!x) return null;
|
|
72
72
|
const over = Math.round((t - x.d) / 864e5);
|
|
73
|
-
|
|
73
|
+
// a closed initiative (batch 26 item 5) is never late: its next milestone keeps its date and drops the days late
|
|
74
|
+
return r.closed ? { ...x, late: false, over: 0 } : { ...x, late: over > 0, over: Math.max(0, over) };
|
|
74
75
|
}
|
|
75
76
|
const STATUS = { late: 'Late', risk: 'At risk', q: 'On hold' };
|
|
76
77
|
const nextText = (r) => { const t = r.track || {}; if (typeof t.next === 'string') return t.next; if (t.next && t.next.text) return t.next.text; const l = (t.line || [])[0]; return l ? l.text || '' : ''; };
|
|
@@ -76,7 +76,7 @@ export function entriesOf(o) {
|
|
|
76
76
|
const slip = r.plan && r.plan.slip > 0 ? r.plan.slip : 0;
|
|
77
77
|
const atEnd = (d) => !!(e && +d === +e);
|
|
78
78
|
const one = (id, name, d, done, isEnd) => {
|
|
79
|
-
const late = !done && !h && (d < now || (isEnd && r.over && now > e));
|
|
79
|
+
const late = !done && !h && !r.closed && (d < now || (isEnd && r.over && now > e)); // never on a closed initiative (batch 26 item 5)
|
|
80
80
|
const risk = !done && !late && !h && isEnd && (r.status === 'at risk' || slip > 0);
|
|
81
81
|
const planned = isEnd && slip ? new Date(d.getFullYear(), d.getMonth(), d.getDate() - slip) : null;
|
|
82
82
|
out.push({ ...base, id, name, date: d, done: !!done, late, risk, fc: false, planned, slip: isEnd ? slip : 0 });
|
|
@@ -103,9 +103,10 @@ export function windowOf(span, now, phone, sprint, dates) {
|
|
|
103
103
|
const pctOf = (W) => (d) => Math.max(0, Math.min(100, ((d - W.from) / (W.to - W.from)) * 100));
|
|
104
104
|
const P = (x) => x.toFixed(2) + '%';
|
|
105
105
|
const doneOf = (r) => { const p = r.track && r.track.prog; return p ? Math.max(0, Math.min(100, (p.done / Math.max(1, p.total)) * 100)) : 0; };
|
|
106
|
-
/** A milestone task past its due date and not done (feedback batch 11 item 3): its ◇ turns orange, and so does the row's dot.
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
/** A milestone task past its due date and not done (feedback batch 11 item 3): its ◇ turns orange, and so does the row's dot. Never on a closed
|
|
107
|
+
* initiative (its workflow's terminal stage, r.closed from lib/routes-boards.js closedOf; batch 26 item 5): a closed initiative is not late. */
|
|
108
|
+
const msLate = (m, now, r) => !(r && r.closed) && !m.done && day(m.date) && day(m.date) < new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
109
|
+
export const anyMsLate = (r, now) => !!(now && (r.milestones || []).some((m) => msLate(m, now, r)));
|
|
109
110
|
/** The forecast range a row draws (FcBoard): { tone: 'risk' | 'miss' | 'held', low, high } past its promised date, else null. */
|
|
110
111
|
export function fcRange(r) {
|
|
111
112
|
const F = r && r.forecast, e = r && day(r.end);
|
|
@@ -119,8 +120,9 @@ const toneOf = (r, now) => (r.late || anyMsLate(r, now) ? 'late' : r.status ===
|
|
|
119
120
|
export const offTone = (r, now) => { const t = toneOf(r, now); return t === 'late' || t === 'risk' ? t : r.status === 'on hold' ? 'q' : null; };
|
|
120
121
|
const dotHtml = (r, now) => { const t = offTone(r, now); return t ? `<i class="cz-dot ${t}${t === 'q' ? '' : ' m-warn'}" aria-hidden="true"></i>` : ''; };
|
|
121
122
|
const dated = (r) => day(r.start) || day(r.end) || (r.track && day(r.track.forecast)) || (r.draft && day(r.draft.start));
|
|
122
|
-
/** Done work leaves no edge tab (TsCGrill 5): the initiative is
|
|
123
|
-
|
|
123
|
+
/** Done work leaves no edge tab (TsCGrill 5): the initiative is closed (its workflow's terminal stage, batch 26 item 5), past Deliver, or every
|
|
124
|
+
* part it counts is done. */
|
|
125
|
+
const isDone = (r) => !!r.closed || !!(r.track && (r.track.k >= 6 || (r.track.prog && r.track.prog.total > 0 && r.track.prog.done >= r.track.prog.total)));
|
|
124
126
|
/** A row's holds as spans on the bar: [{ a, b, kind, h, i }]. kind: past (resumed), open (the return date ahead), over (the return date passed:
|
|
125
127
|
* dotted on to today), none (no return date: dotted to today). Only the ones that touch the plan (XtiAHard). */
|
|
126
128
|
export function holdSpans(r, now) {
|
|
@@ -231,7 +233,7 @@ function qBar(r, X, now, phone, fmt, pxPct, ms = {}) {
|
|
|
231
233
|
if (clash) hover.push(clash);
|
|
232
234
|
const sw = slipWords(r, e); if (sw) hover.push(sw);
|
|
233
235
|
// the committed end is the ◇ (PgTimeline); a milestone task in the stage it stands for IS that ◇ (say it once, batch 11 item 3): its name and its done
|
|
234
|
-
const isLate = !!(over || clash || r.late || (et && msLate(et, now)));
|
|
236
|
+
const isLate = !!(over || clash || r.late || (et && msLate(et, now, r)));
|
|
235
237
|
// the ◇ and its label carry the milestone's own date; the initiative's end, when it differs, is the hover (batch 14 item 1)
|
|
236
238
|
const ed = et ? day(et.date) : e; if (+ed !== +e) hover.push('Initiative ends ' + dy(e, now));
|
|
237
239
|
const name = landsOf(r, et), eTitle = name + ' · ' + md(ed) + (et && et.stage ? ' · ◆ ' + et.stage : '') + (et && et.done ? ' · done' : isLate ? ' · late' : '');
|
|
@@ -316,6 +318,9 @@ function gapsHtml(r, X, now, phone, fmt, pxPct, s, e, tone, note = '', lands = n
|
|
|
316
318
|
if (x && x.kind === 'none') faded = true;
|
|
317
319
|
}
|
|
318
320
|
if (at < e) pieces.push([at, e, faded]);
|
|
321
|
+
// a plan of one day (its only dated work starts and ends the same day) still draws its bar: before, it drew no piece, and a late one showed
|
|
322
|
+
// only its line to Today (batch 26 item 5, CRA to Vite)
|
|
323
|
+
if (!pieces.length && +s === +e) pieces.push([s, e, false]);
|
|
319
324
|
const days = (p) => Math.max(0, (p[1] - p[0]) / 86400000), total = pieces.reduce((n, p) => n + days(p), 0);
|
|
320
325
|
let left = (doneOf(r) / 100) * total;
|
|
321
326
|
// now is blue (batch 19 item 5, StsA8Board): from where the done part ends to the end of this week, while the work is under way
|
|
@@ -327,7 +332,7 @@ function gapsHtml(r, X, now, phone, fmt, pxPct, s, e, tone, note = '', lands = n
|
|
|
327
332
|
let html = pieces.map((p, i) => {
|
|
328
333
|
const d = days(p), f = d ? Math.max(0, Math.min(1, left / d)) : 0; left -= d;
|
|
329
334
|
const xl = i > 0 || p[0] > s ? ' xl' : '', xr = i < pieces.length - 1 || p[1] < e ? ' xr' : '';
|
|
330
|
-
return `<span class="tg-bar${xl}${xr}${p[2] ? ' fd' : ''}" data-key="${esc(r.key)}" style="left:${P(X(p[0]))};width:${P(Math.max(0.15, X(p[1]) - X(p[0])))}" title="${esc('PMOS plan ' + md(s) + ' to ' + md(e) + hold)}"><b style="width:${(f * 100).toFixed(1)}%"></b>${nowIn(p)}</span>`;
|
|
335
|
+
return `<span class="tg-bar${xl}${xr}${p[2] ? ' fd' : ''}" data-key="${esc(r.key)}" style="left:${P(X(p[0]))};width:${P(Math.max(+p[1] === +p[0] && pxPct ? 20 / pxPct : 0.15, X(p[1]) - X(p[0])))}" title="${esc('PMOS plan ' + md(s) + ' to ' + md(e) + hold)}"><b style="width:${(f * 100).toFixed(1)}%"></b>${nowIn(p)}</span>`;
|
|
331
336
|
}).join('');
|
|
332
337
|
// each plain gap: the hairline, one press (a 24 px area) with its words; a phone draws the line alone and its row sheet says it
|
|
333
338
|
plain.forEach(([a, b], gi) => {
|
|
@@ -382,7 +387,7 @@ function taskMarks(r, X, now, phone, pxPct, ms) {
|
|
|
382
387
|
const M = (r.milestones || []).filter((m) => day(m.date));
|
|
383
388
|
if (!M.length) return { html: '', end: null };
|
|
384
389
|
const end = endTask(r, M, ms.reach || 0), show = new Set(shownMs(r, ms.mode || 'pinned').map((m) => m.id));
|
|
385
|
-
const L = M.filter((m) => (!end || m.id !== end.id) && show.has(m.id)).map((m) => ({ ...m, late: msLate(m, now), stage: msStage(r, m) }))
|
|
390
|
+
const L = M.filter((m) => (!end || m.id !== end.id) && show.has(m.id)).map((m) => ({ ...m, late: msLate(m, now, r), stage: msStage(r, m) }))
|
|
386
391
|
.filter((m) => { const x = X(day(m.date)); return x > 0 && x < 100; });
|
|
387
392
|
const C = mergeClose(L, Math.max(1, ms.reach || 7));
|
|
388
393
|
const html = C.map((c) => {
|
|
@@ -405,9 +410,9 @@ export function laneMilestones(rows, now, reach = 0) {
|
|
|
405
410
|
const out = [];
|
|
406
411
|
for (const r of rows || []) {
|
|
407
412
|
const M = (r.milestones || []).filter((m) => day(m.date)), s = day(r.start), e = day(r.end), et = endTask(r, M, reach);
|
|
408
|
-
for (const m of M) if (!et || m.id !== et.id) out.push({ id: m.id, name: m.name, date: m.date, done: !!m.done, late: msLate(m, now), row: r });
|
|
413
|
+
for (const m of M) if (!et || m.id !== et.id) out.push({ id: m.id, name: m.name, date: m.date, done: !!m.done, late: msLate(m, now, r), row: r });
|
|
409
414
|
if (!s || !e || (!et && !endStage(r))) continue;
|
|
410
|
-
const ob = openBack(r, now), late = (r.over && now > e) || (ob && ob > e) || (et && msLate(et, now));
|
|
415
|
+
const ob = openBack(r, now), late = (r.over && now > e) || (ob && ob > e) || (et && msLate(et, now, r));
|
|
411
416
|
out.push({ id: et ? et.id : '', name: et ? et.name : endStage(r), date: et ? String(et.date).slice(0, 10) : iso(e), done: !!(et && et.done), late: !!late, row: r });
|
|
412
417
|
}
|
|
413
418
|
return out.sort((a, b) => day(a.date) - day(b.date));
|
|
@@ -222,23 +222,37 @@
|
|
|
222
222
|
.ev-st .ev-sw[aria-checked="true"] { background: var(--cz-bluebg); color: var(--cz-blueink); }
|
|
223
223
|
.ev-st .ev-add { display: flex; gap: 8px; margin-top: 8px; }
|
|
224
224
|
.ev-st [role="status"] { min-height: 1.2em; margin: 0; font-size: var(--fs-2); color: var(--cz-ink3); }
|
|
225
|
-
/* Updates › what's new:
|
|
225
|
+
/* Updates › what's new (design E, UpE): summary lines, the newest kit at version level, "Earlier this week" as one flat list of
|
|
226
|
+
stories, the earlier weeks folded into an Archive. A blue dot is New, an amber dot is Fixed, no dot is Improved. */
|
|
226
227
|
.ev-st .ev-wk { display: flex; flex-direction: column; gap: 14px; margin-top: 10px; padding-top: 14px; border-top: 1px solid var(--cz-hair); }
|
|
227
228
|
.ev-st .ev-wkh { display: flex; align-items: baseline; gap: 4px 10px; flex-wrap: wrap; }
|
|
228
|
-
.ev-st .ev-wkh b { font-size: var(--fs-
|
|
229
|
-
.ev-st .ev-
|
|
230
|
-
.ev-st .ev-
|
|
231
|
-
.ev-st .ev-
|
|
232
|
-
.ev-st .ev-
|
|
233
|
-
.ev-st .ev-
|
|
234
|
-
.ev-st .ev-
|
|
235
|
-
.ev-st .ev-
|
|
229
|
+
.ev-st .ev-wkh b { font-size: var(--fs-5); font-weight: 800; }
|
|
230
|
+
.ev-st .ev-sum { display: flex; flex-direction: column; gap: 8px; padding: 14px 16px; border-radius: 16px; background: var(--cz-bluebg); }
|
|
231
|
+
.ev-st .ev-sum p { display: flex; gap: 10px; margin: 0; font-size: var(--fs-4); line-height: 1.5; color: var(--cz-ink); max-width: 72ch; }
|
|
232
|
+
.ev-st .ev-sum svg { flex: none; margin-top: 5px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linejoin: round; color: var(--cz-blueink); }
|
|
233
|
+
.ev-st .ev-leg { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: var(--fs-2); color: var(--cz-ink3); }
|
|
234
|
+
.ev-st .ev-leg span { display: inline-flex; align-items: center; gap: 6px; }
|
|
235
|
+
.ev-st .ev-rel { display: grid; grid-template-columns: 84px minmax(0, 1fr); gap: 0 14px; }
|
|
236
|
+
.ev-st .ev-rv { display: flex; flex-direction: column; padding-top: 6px; color: var(--cz-blueink); font-variant-numeric: tabular-nums; }
|
|
237
|
+
.ev-st .ev-rv b { font-size: var(--fs-4); font-weight: 800; }
|
|
238
|
+
.ev-st .ev-rv span { font-weight: 600; }
|
|
239
|
+
.ev-st .ev-sl { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
|
|
240
|
+
.ev-st .ev-sy { display: flex; gap: 10px; padding: 6px 0; font-size: var(--fs-3); line-height: 1.5; color: var(--cz-ink2); max-width: 72ch; overflow-wrap: anywhere; }
|
|
241
|
+
.ev-st .ev-sy b { color: var(--cz-ink); font-weight: 700; }
|
|
242
|
+
.ev-st .ev-sx { flex: 1; min-width: 0; }
|
|
243
|
+
.ev-st .ev-dc { width: 7px; flex: none; display: inline-flex; justify-content: center; padding-top: 8px; }
|
|
244
|
+
.ev-st .ev-grp { display: flex; flex-direction: column; border-top: 1px solid var(--cz-hair); }
|
|
245
|
+
.ev-st .ev-gh { display: flex; align-items: center; gap: 10px; min-height: 44px; padding-top: 6px; font-size: var(--fs-4); }
|
|
246
|
+
.ev-st .ev-gh b { font-weight: 800; }
|
|
247
|
+
.ev-st .ev-more { align-self: flex-start; min-height: 44px; padding: 0 0 0 17px; border: 0; background: none; font: inherit; font-size: var(--fs-2); color: var(--cz-ink3); cursor: pointer; }
|
|
248
|
+
.ev-st .ev-more:hover { color: var(--cz-ink); }
|
|
249
|
+
.ev-st .ev-wkr > .ev-sum { margin-bottom: 4px; }
|
|
250
|
+
@media (max-width: 560px) { .ev-st .ev-rel { grid-template-columns: minmax(0, 1fr); } .ev-st .ev-rv { flex-direction: row; gap: 8px; align-items: baseline; padding-top: 0; } }
|
|
236
251
|
.ev-st .ev-arc { display: flex; flex-direction: column; border-top: 1px solid var(--cz-hair); }
|
|
237
252
|
.ev-st .ev-arcb, .ev-st .ev-wkt { display: flex; align-items: baseline; gap: 4px 10px; flex-wrap: wrap; width: 100%; min-height: 44px; padding: 10px 0; border: 0; background: none; font: inherit; color: var(--cz-ink); text-align: left; cursor: pointer; }
|
|
238
253
|
.ev-st .ev-arcb svg, .ev-st .ev-wkt svg { margin-left: auto; align-self: center; fill: none; stroke: currentColor; stroke-width: 2; color: var(--cz-ink3); transition: transform .15s; }
|
|
239
254
|
.ev-st .ev-arcb[aria-expanded="true"] svg, .ev-st .ev-wkt[aria-expanded="true"] svg { transform: rotate(180deg); }
|
|
240
255
|
.ev-st .ev-wkr { display: flex; flex-direction: column; gap: 12px; padding-bottom: 12px; border-top: 1px solid var(--cz-hair); }
|
|
241
|
-
.ev-st .ev-wkr .ev-wkm { padding: 0 0 4px; }
|
|
242
256
|
.ev-st .ev-wkt .ev-tl { margin-right: 8px; }
|
|
243
257
|
@media (prefers-reduced-motion: reduce) { .ev-st .ev-arcb svg, .ev-st .ev-wkt svg { transition: none; } }
|
|
244
258
|
@media (max-width: 760px) {
|
|
@@ -365,3 +379,5 @@
|
|
|
365
379
|
.ev-st [data-claude-setup] .cs-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
366
380
|
.ev-st [data-claude-setup] .cs-term { width: 100%; min-height: 280px; border-radius: 14px; overflow: hidden; }
|
|
367
381
|
@media (max-width: 760px) { .ev-st [data-claude-setup] .wl-btn, .ev-st [data-claude-setup] select { min-height: 44px; } .ev-st [data-claude-setup] .cs-label { flex-wrap: wrap; } }
|
|
382
|
+
/* Settings › Claude › Your own rules (batch 26 item 2) */
|
|
383
|
+
.ev-st textarea.ev-rules { display: block; width: 100%; box-sizing: border-box; margin-top: 8px; min-height: 72px; padding: 10px 12px; border: 0; border-radius: 14px; background: var(--cz-well); color: var(--cz-ink); font: inherit; font-size: var(--fs-3); line-height: 1.45; resize: vertical; }
|
|
@@ -20,7 +20,8 @@ body.cz-home { min-height: 100vh; overflow: hidden; }
|
|
|
20
20
|
* ground, 16px apart, the list 360px wide (cozy-today.css) */
|
|
21
21
|
@media (min-width: 761px) {
|
|
22
22
|
body.cz-home .hm-desk { inset: 0; padding: 16px; gap: 16px; background: none; box-shadow: none; border-radius: 0; }
|
|
23
|
-
|
|
23
|
+
/* batch 26 item 2 (NpBw): the chat sits on the same soft gradient as on a phone */
|
|
24
|
+
body.cz-home .hm-chat { background: var(--cz-ground); border-radius: 24px; padding: 16px 24px 20px; box-shadow: 0 10px 30px -24px var(--cz-c-9da3ac); }
|
|
24
25
|
body.cz-home .hm-vw .cz-vw { flex-basis: min(500px, max(380px, calc(100vw - 852px))); width: min(500px, max(380px, calc(100vw - 852px))); }
|
|
25
26
|
body.cz-home.hm-folded:not(.cz-vw-rail) .hm-desk { padding-left: 56px; }
|
|
26
27
|
body.cz-home .hm-reopen { top: 12px; right: 12px; }
|
|
@@ -791,3 +792,46 @@ form.hm-box textarea { min-height: 48px; border: 0; background: none; resize: no
|
|
|
791
792
|
.hm-chat .ctxmore[open] > summary { display: none; }
|
|
792
793
|
.hm-chat .ctxmore > .ctxc { display: contents; }
|
|
793
794
|
.hm-chat .ctxmore:not([open]) > .ctxc { display: none; }
|
|
795
|
+
|
|
796
|
+
/* ── the suggested next prompt and the Sharper card (batch 26 item 2, canvas NpB, NpBw; pm/next-prompt-ui.js) ──
|
|
797
|
+
* Above the box: up to two cards, the recommended one lit (card white, blue ring, blue ✦), the other grey. A tap fills the box. */
|
|
798
|
+
.np { display: flex; flex-direction: column; gap: 8px; padding-top: 8px; }
|
|
799
|
+
.np[hidden] { display: none; }
|
|
800
|
+
.np-list { display: flex; flex-direction: column; gap: 8px; }
|
|
801
|
+
.np-card { position: relative; display: flex; align-items: flex-start; gap: 10px; width: 100%; box-sizing: border-box; min-height: 44px; padding: 11px 14px; border-radius: 16px;
|
|
802
|
+
border: 1px solid transparent; background: var(--cz-well); color: var(--cz-ink); font: inherit; font-size: var(--fs-3); line-height: 1.5; text-align: left; cursor: pointer; }
|
|
803
|
+
.np-card .np-star { flex: none; margin-top: 3px; fill: currentColor; stroke: currentColor; color: var(--cz-ink3); }
|
|
804
|
+
.np-card.rec { background: var(--cz-card); border-color: var(--cz-blue); box-shadow: 0 0 0 3px var(--cz-bluebg); }
|
|
805
|
+
.np-card.rec .np-star { color: var(--cz-blue); }
|
|
806
|
+
.np-card:hover { border-color: var(--cz-blue); }
|
|
807
|
+
.np-card:focus-visible, .np-use:focus-visible, .np-ib:focus-visible, .np-mi:focus-visible { outline: 2px solid var(--cz-blue); outline-offset: 2px; }
|
|
808
|
+
.np-t { flex: 1; min-width: 0; }
|
|
809
|
+
.np-sh { padding: 0 6px 0 0; cursor: default; align-items: center; }
|
|
810
|
+
.np-use { display: flex; align-items: flex-start; gap: 10px; flex: 1; min-width: 0; padding: 11px 0 11px 14px; border: 0; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }
|
|
811
|
+
.np-ib { flex: none; width: 36px; height: 36px; display: inline-grid; place-items: center; border: 0; border-radius: 999px; background: none; color: var(--cz-ink2); cursor: pointer; }
|
|
812
|
+
.np-ib:hover, .np-ib[aria-expanded="true"] { background: var(--cz-well); }
|
|
813
|
+
.np-spin { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 16px; background: var(--cz-bluebg); color: var(--cz-blue); }
|
|
814
|
+
.np-sp { display: inline-flex; animation: np-rot 1.1s linear infinite; }
|
|
815
|
+
@keyframes np-rot { to { transform: rotate(360deg); } }
|
|
816
|
+
@media (prefers-reduced-motion: reduce) { .np-sp { animation: none; } }
|
|
817
|
+
.np-sk { flex: 1; display: flex; flex-direction: column; gap: 8px; }
|
|
818
|
+
.np-sk i { display: block; height: 12px; border-radius: 6px; background: var(--cz-bluebg); }
|
|
819
|
+
.np-sk i:first-child { width: 88%; } .np-sk i:last-child { width: 60%; }
|
|
820
|
+
.np-menu { position: absolute; right: 0; bottom: calc(100% + 8px); z-index: 20; width: 310px; max-width: calc(100vw - 32px); box-sizing: border-box; padding: 8px; border-radius: 20px;
|
|
821
|
+
background: var(--cz-card); box-shadow: 0 24px 60px -24px rgba(42,46,53,.45), 0 0 0 1px var(--cz-hair); display: flex; flex-direction: column; gap: 2px; }
|
|
822
|
+
.np-mh { margin: 0; padding: 8px 12px 4px; font-size: var(--fs-1); font-weight: 800; color: var(--cz-ink2); }
|
|
823
|
+
.np-mi { display: flex; align-items: flex-start; gap: 10px; min-height: 44px; padding: 8px 12px; border: 0; border-radius: 12px; background: none; color: var(--cz-ink); font: inherit; text-align: left; cursor: pointer; }
|
|
824
|
+
.np-mi:hover, .np-mi[aria-checked="true"]:not(.np-swr) { background: var(--cz-well); }
|
|
825
|
+
.np-mi b { display: block; font-size: var(--fs-3); font-weight: 600; }
|
|
826
|
+
.np-mi[aria-checked="true"] b { font-weight: 700; }
|
|
827
|
+
.np-mi em { display: block; font-style: normal; font-size: var(--fs-1); color: var(--cz-ink2); }
|
|
828
|
+
.np-ck { flex: none; width: 18px; margin-top: 2px; color: var(--cz-blue); display: inline-flex; }
|
|
829
|
+
.np-sep { height: 1px; margin: 6px 8px; background: var(--cz-hair); }
|
|
830
|
+
.np-swr { align-items: center; }
|
|
831
|
+
.np-swr b { flex: 1; }
|
|
832
|
+
.np-sw { flex: none; width: 40px; height: 24px; border-radius: 12px; background: var(--cz-well2); display: inline-flex; align-items: center; padding: 0 3px; box-sizing: border-box; }
|
|
833
|
+
.np-sw i { width: 18px; height: 18px; border-radius: 50%; background: var(--cz-card); box-shadow: 0 1px 2px rgba(0,0,0,.2); }
|
|
834
|
+
.np-sw.on { background: var(--cz-solid); justify-content: flex-end; }
|
|
835
|
+
@media (max-width: 760px) {
|
|
836
|
+
.np-t { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } /* a phone shows 3 lines; a tap still fills the whole text */
|
|
837
|
+
}
|
|
@@ -56,11 +56,20 @@
|
|
|
56
56
|
|
|
57
57
|
/* the list opens above the pill */
|
|
58
58
|
@media (min-width: 761px) { .lv-tray { bottom: 120px; } }
|
|
59
|
+
/* its grab bar (batch 26 item 4): drag it to the left or the right side; a press moves it to the other side. A phone's sheet has none. */
|
|
60
|
+
.lv-rows { display: flex; flex-direction: column; }
|
|
61
|
+
.lv-side { align-self: center; width: 64px; height: 16px; margin: -2px 0 0; padding: 0; border: 0; border-radius: 8px; background: none; display: grid; place-items: center; cursor: grab; touch-action: none; }
|
|
62
|
+
.lv-side i { display: block; width: 36px; height: 4px; border-radius: 2px; background: color-mix(in srgb, var(--cz-ink3) 40%, transparent); }
|
|
63
|
+
.lv-side:hover i { background: var(--cz-ink3); }
|
|
64
|
+
.lv-side:focus-visible { outline: 2px solid var(--cz-blue); outline-offset: 1px; }
|
|
65
|
+
.lv-tray.lv-moving { transition: none; cursor: grabbing; opacity: .92; }
|
|
66
|
+
@media (min-width: 761px) { .lv-tray.lv-r { position: fixed; left: auto; right: 16px; } }
|
|
59
67
|
/* a phone: the menu button is bottom right, so the pill stacks above it there, and the list is a short sheet */
|
|
60
68
|
@media (max-width: 760px) {
|
|
61
69
|
.cz-live { left: auto; right: 6px; }
|
|
62
70
|
.lv-scrim { position: fixed; inset: 0; background: rgba(42,46,53,.18); z-index: 0; }
|
|
63
71
|
.lv-tray.sheet { position: fixed; left: 0; right: 0; bottom: 0; width: auto; padding: 18px 8px 28px; border-radius: 26px 26px 0 0; border: 0; }
|
|
72
|
+
.lv-side { display: none; }
|
|
64
73
|
.lv-tray.sheet::before { content: ''; position: absolute; top: 8px; left: 50%; width: 40px; height: 5px; margin-left: -20px; border-radius: 3px; background: var(--cz-well2); }
|
|
65
74
|
}
|
|
66
75
|
/* the phone keyboard is up: no pill and no menu button over the chat box */
|