create-pmos 0.523.0 → 0.524.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 +44 -16
- 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/changelog.json +43 -0
- package/workspace/guide/agent-capabilities.md +6 -3
- package/workspace/guide/home.md +1 -1
- package/workspace/guide/horizon-scan.md +2 -2
- package/workspace/guide/idle-lab.md +46 -0
- package/workspace/guide/index.md +2 -0
- package/workspace/guide/moments.md +43 -0
- package/workspace/guide/welcome.md +37 -28
- package/workspace/lib/bg-queue.js +226 -0
- package/workspace/lib/driver-run.js +8 -1
- package/workspace/lib/first-reveal.js +311 -0
- package/workspace/lib/idle-lab.js +275 -0
- package/workspace/lib/moments.js +241 -0
- package/workspace/lib/morning-goals.js +191 -0
- package/workspace/lib/onboard-fundamentals.js +205 -0
- package/workspace/lib/onboarding-progress.js +13 -1
- package/workspace/lib/orchestrator-spawn.js +9 -2
- package/workspace/lib/project-onboard.js +86 -6
- package/workspace/lib/proposals.js +9 -5
- package/workspace/lib/route-modules.js +4 -0
- package/workspace/lib/routes-lab.js +137 -0
- package/workspace/lib/routes-loose.js +1 -0
- package/workspace/lib/routes-moments.js +42 -0
- package/workspace/lib/routes-morning-goals.js +109 -0
- package/workspace/lib/routes-onboard.js +28 -8
- package/workspace/lib/routes-proposals.js +12 -2
- package/workspace/lib/routes-reveal.js +73 -0
- package/workspace/lib/scan-run.js +4 -3
- package/workspace/lib/scan.js +5 -3
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/project-onboard-flow-test.js +70 -57
- package/workspace/public/pm/cozy-ev.css +3 -0
- package/workspace/public/pm/cozy-lab-ins.js +87 -0
- package/workspace/public/pm/cozy-lab.css +86 -0
- package/workspace/public/pm/cozy-lab.js +138 -0
- package/workspace/public/pm/cozy-nav.js +22 -8
- package/workspace/public/pm/cozy-onboard.css +31 -0
- package/workspace/public/pm/cozy-onboard.js +217 -111
- package/workspace/public/pm/cozy-reveal.css +52 -0
- package/workspace/public/pm/cozy-reveal.js +130 -0
- package/workspace/public/pm/home-app.js +38 -3
- package/workspace/public/pm/home-impact.css +12 -0
- package/workspace/public/pm/home-moment.js +41 -0
- package/workspace/public/pm/ini-props.js +15 -6
- package/workspace/public/pm/premium-board.js +2 -0
- package/workspace/public/pm/premium-first.js +1 -1
- package/workspace/public/pm/premium-insights.js +6 -1
- package/workspace/public/pm/quiet-place.js +31 -0
- package/workspace/public/premium-intelligence.html +3 -0
- package/workspace/replaced-tests.json +14 -0
- package/workspace/server.js +46 -5
- package/workspace/test-files.json +2 -0
- package/workspace/test-lists/first-reveal.json +9 -0
- package/workspace/test-lists/idle-lab.json +9 -0
- package/workspace/test-lists/morning-goals.json +9 -0
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* 1. closes a scan that was alive when the server stopped (`scan-end`, reason "interrupted"), and writes the report documents it already held;
|
|
8
8
|
* 2. starts at most one job, and none while one is running (one scan at a time across the workspace):
|
|
9
9
|
* - a suggest run for a workspace with no watch list (lib/scan.js suggestDue), or
|
|
10
|
-
* - the portfolio scan, when it is due (lib/scan.js dueNow: the cadence, 09:00, the working hours, quiet hours, once per day)
|
|
10
|
+
* - the portfolio scan, when it is due (lib/scan.js dueNow: the cadence, 09:00, the working hours, quiet hours, once per day) and the
|
|
11
|
+
* queue gives it an idle turn (`idle`, lib/bg-queue.js step 5: the PM stepped away, or the last hour of the working day came first).
|
|
11
12
|
* The routes call the other three: Scan now (`startNow`: at most 3 a day, never in quiet hours, one at a time), an approved research proposal
|
|
12
13
|
* (`startResearch`) and Stop (`stop`).
|
|
13
14
|
*
|
|
@@ -292,7 +293,7 @@ function create(deps) {
|
|
|
292
293
|
return { ok: true, scans: stopped };
|
|
293
294
|
}
|
|
294
295
|
|
|
295
|
-
function tick({ now = Date.now(), env } = {}) {
|
|
296
|
+
function tick({ now = Date.now(), env, idle = true } = {}) {
|
|
296
297
|
if (off(env || deps.env)) return { off: true };
|
|
297
298
|
const summary = { closed: 0, started: 0 };
|
|
298
299
|
const settings = deps.settings();
|
|
@@ -318,7 +319,7 @@ function create(deps) {
|
|
|
318
319
|
if (got && got.initiatives.length) { startSuggest(w, file, got, now); summary.started++; }
|
|
319
320
|
continue;
|
|
320
321
|
}
|
|
321
|
-
if (S.dueNow(state, null, now, { open }).ok) {
|
|
322
|
+
if (S.dueNow(state, null, now, { open, idle }).ok) {
|
|
322
323
|
const sel = S.selectScan(state, {});
|
|
323
324
|
if (!sel.ok) continue;
|
|
324
325
|
let ctx = { boards: [], initiatives: [] };
|
package/workspace/lib/scan.js
CHANGED
|
@@ -430,7 +430,7 @@ function isWorkingDay(ms, cadence) {
|
|
|
430
430
|
return false;
|
|
431
431
|
}
|
|
432
432
|
const slotMs = (ms) => { const d = new Date(ms); const [h, m] = SLOT.split(':').map(Number); d.setHours(h, m, 0, 0); return d.getTime(); };
|
|
433
|
-
const scheduleText = (cadence) => (cadence === 'daily' ? 'Every working day, 09:00' : cadence === 'weekly' ? 'Every Monday, 09:00' : 'Off. No scan runs');
|
|
433
|
+
const scheduleText = (cadence) => (cadence === 'daily' ? 'Every working day, when you step away after 09:00' : cadence === 'weekly' ? 'Every Monday, when you step away after 09:00' : 'Off. No scan runs');
|
|
434
434
|
|
|
435
435
|
/** The report types a scan can write, given the repository switch and the number of web items it will search. Web reports (benchmark, competitive, insight)
|
|
436
436
|
* need an item or the repository; the productivity report needs the repository. */
|
|
@@ -441,8 +441,9 @@ function effectiveTypes(types, { repo, items }) {
|
|
|
441
441
|
|
|
442
442
|
/** May a scheduled scan start now? `open` is the day-check window and quiet hours as one boolean (lib/driver-run.js dayCheckOpen). `board` null = the portfolio.
|
|
443
443
|
* → { ok } | { ok: false, why }. A scan that missed 09:00 starts at the first tick inside the working hours of that day, never outside them.
|
|
444
|
-
* One scan per day across the portfolio, whoever started it: a manual scan today is today's scan.
|
|
445
|
-
|
|
444
|
+
* One scan per day across the portfolio, whoever started it: a manual scan today is today's scan. `idle` is the turn lib/bg-queue.js gave
|
|
445
|
+
* (idle lab, frame I4): the daily scan starts in the first idle stretch of the day after 09:00, or in the last working hour if none came. */
|
|
446
|
+
function dueNow(state, board, now, { open = true, idle = true } = {}) {
|
|
446
447
|
const cadence = cadenceOf(state);
|
|
447
448
|
if (cadence === 'off') return { ok: false, why: 'off' };
|
|
448
449
|
const kept = keptItems(state, board);
|
|
@@ -453,6 +454,7 @@ function dueNow(state, board, now, { open = true } = {}) {
|
|
|
453
454
|
if (scanDay(state, board, D.localDay(now)).length) return { ok: false, why: 'done-today' };
|
|
454
455
|
if (openScans(state).some((x) => !board || x.board === board)) return { ok: false, why: 'running' };
|
|
455
456
|
if (!effectiveTypes(state.setting.types, { repo: state.setting.repo, items: kept.length }).length) return { ok: false, why: 'no-reports' };
|
|
457
|
+
if (!idle) return { ok: false, why: 'waiting-for-idle' };
|
|
456
458
|
return { ok: true };
|
|
457
459
|
}
|
|
458
460
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.524.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.524.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@anthropic-ai/sdk": "^1.3.0",
|
package/workspace/package.json
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
/* project-onboard-flow-test.js — the project-first setup conversation in the real Home page, against the real server, with a FIXTURE `claude`
|
|
4
4
|
* (fixtures/fake-claude-onboard.js). It proves the plumbing and the page, not a live account: no real sign-in, no real connector.
|
|
5
|
-
* Covers: login
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Covers (onboarding build B1): the silent login check (a failed check asks once, as ✦ Sign in), the composer open from the first screen, the
|
|
6
|
+
* product question, one ✦ for the folder + read-only search + sign-ins with ✓ ✕ only, ✕ answered in the composer, the read-only search flags,
|
|
7
|
+
* a denied source contributing nothing, the source tiles, two Jira matches needing a choice, a real kit install in a temporary folder, the
|
|
8
|
+
* fundamentals card with a source on every line and a one-press change, reuse of an existing PMOS folder, and desktop and phone widths.
|
|
9
|
+
* SHOTS=<dir> saves screenshots. */
|
|
8
10
|
const fs = require('fs');
|
|
9
11
|
const os = require('os');
|
|
10
12
|
const path = require('path');
|
|
@@ -33,76 +35,88 @@ const shot = async (p, name) => { if (SHOTS) { fs.mkdirSync(SHOTS, { recursive:
|
|
|
33
35
|
const log = () => fs.existsSync(LOG) ? fs.readFileSync(LOG, 'utf8').trim().split('\n').map((l) => JSON.parse(l)) : [];
|
|
34
36
|
const btn = (label) => p.locator('#obChat button', { hasText: label }).first();
|
|
35
37
|
|
|
36
|
-
|
|
38
|
+
const press = (act) => p.locator(`#obChat [data-ob="${act}"]`).last().click();
|
|
39
|
+
const say = async (pg, text) => { await pg.fill('#obChat textarea', text); await pg.keyboard.press('Enter'); };
|
|
40
|
+
|
|
41
|
+
await step('The old Welcome page opens the Home conversation; the login is checked by itself and the composer is open', async () => {
|
|
37
42
|
await p.goto(T.BASE + '/premium-welcome.html?ws=x&step=sources');
|
|
38
43
|
assert.match(p.url(), /\/premium\.html\?onboard=1$/);
|
|
39
|
-
await p.waitForSelector('#obChat .ob-
|
|
44
|
+
await p.waitForSelector('#obChat .ob-prop');
|
|
40
45
|
assert(await p.locator('#hmChat').isHidden(), 'one chat at a time'); assert(await p.locator('#hmList').isVisible(), 'the list stays');
|
|
46
|
+
assert.match(await p.locator('#obChat .ob-prop').innerText(), /Sign in to Claude Code/, 'only a failed check asks');
|
|
47
|
+
assert(!(await p.locator('#obChat input[name="ob-login"]').count()), 'no login picker on the first screen');
|
|
48
|
+
assert(await p.locator('#obChat textarea').isEnabled(), 'the composer is open from the first screen');
|
|
49
|
+
assert.match(await p.locator('#obChat').innerText(), /What product or project is this for\?/);
|
|
50
|
+
const check = log().find((a) => a.includes('-p') && a.includes('--output-format') && a[a.indexOf('--output-format') + 1] === 'json');
|
|
51
|
+
assert(check && check.includes('--safe-mode') && check[check.indexOf('--tools') + 1] === '', 'the silent check is the bounded, tool-free check');
|
|
52
|
+
await shot(p, 'onboard-1-first-open');
|
|
41
53
|
});
|
|
42
|
-
await step('
|
|
43
|
-
await
|
|
44
|
-
await
|
|
45
|
-
await p.waitForSelector('#obChat .ob-bad');
|
|
46
|
-
assert.match(await p.locator('#obChat .ob-bad').innerText(), /won’t switch to a different account/);
|
|
47
|
-
assert(await p.locator('#obChat textarea').isDisabled(), 'no project question before a passing test');
|
|
48
|
-
});
|
|
49
|
-
await step('A passing test of that login asks about the project', async () => {
|
|
54
|
+
await step('An answer typed before the check passes waits; a passing test of another login sends it', async () => {
|
|
55
|
+
await say(p, 'Checkout redesign, payments team. Jira key PAY.');
|
|
56
|
+
await p.waitForFunction(() => /I start as soon as the Claude check passes/.test(document.querySelector('#obChat').innerText));
|
|
50
57
|
fs.writeFileSync(MODE, 'ready');
|
|
58
|
+
await press('chooser');
|
|
51
59
|
await btn('Test this login').click();
|
|
52
60
|
await p.waitForSelector('#obChat .ob-good');
|
|
53
|
-
assert(await p.locator('#obChat
|
|
54
|
-
const check = log().find((a) => a.includes('-p') && a.includes('--output-format') && a[a.indexOf('--output-format') + 1] === 'json');
|
|
55
|
-
assert(check && check.includes('--safe-mode') && check[check.indexOf('--tools') + 1] === '', 'the test is the bounded, tool-free check');
|
|
56
|
-
await shot(p, 'onboard-2-tested');
|
|
61
|
+
assert.match(await p.locator('#obChat .ob-good').innerText(), /signed in on this computer, and I just tested it/);
|
|
57
62
|
});
|
|
58
|
-
await step('
|
|
59
|
-
await p.
|
|
60
|
-
await p.
|
|
61
|
-
await p.waitForSelector('#obChat input[name="ob-folder"][value="new"]');
|
|
62
|
-
const card = await p.locator('#obChat .ob-card').first().innerText();
|
|
63
|
+
await step('One ✦ proposes the folder, the read-only search and what needs a sign-in; nothing is created or searched before ✓', async () => {
|
|
64
|
+
await p.waitForSelector('#obChat [data-ob="plan-yes"]', { timeout: 15000 });
|
|
65
|
+
const card = await p.locator('#obChat .ob-prop').last().innerText();
|
|
63
66
|
assert(card.includes(path.join(HOME, 'PMOS', 'checkout-redesign')), card);
|
|
67
|
+
assert.match(card, /Atlassian, Slack for “Checkout redesign” and PAY/); assert.match(card, /Google Drive needs signing in inside Claude/);
|
|
64
68
|
assert(!fs.existsSync(path.join(HOME, 'PMOS', 'checkout-redesign')), 'nothing created before the confirmation');
|
|
69
|
+
assert(!log().some((a) => a.includes('--allowedTools')), 'nothing searched before ✓');
|
|
70
|
+
assert(log().some((a) => a[0] === 'mcp' && a[1] === 'list'), 'the sources come from the verified login, with no model call');
|
|
71
|
+
assert.equal(await p.locator('#obChat .ob-prop').last().locator('button.ut-ib').count(), 2, '✓ and ✕ only');
|
|
72
|
+
await shot(p, 'onboard-2-plan');
|
|
65
73
|
});
|
|
66
|
-
await step('
|
|
67
|
-
|
|
68
|
-
await
|
|
69
|
-
await p
|
|
70
|
-
|
|
71
|
-
assert(await p.locator('#obChat input[name="ob-src"][value="claude.ai Google Drive"]').isDisabled(), 'a source that needs sign-in cannot be ticked');
|
|
72
|
-
await shot(p, 'onboard-3-folder-sources');
|
|
74
|
+
await step('✕ asks what to change in the composer; leaving a source out changes the ✦', async () => {
|
|
75
|
+
await press('plan-no');
|
|
76
|
+
await p.waitForFunction(() => /What should I change\?/.test(document.querySelector('#obChat').innerText));
|
|
77
|
+
await say(p, 'leave out Google Drive');
|
|
78
|
+
await p.waitForFunction(() => /Left out: Google Drive/.test(document.querySelector('#obChat').innerText));
|
|
73
79
|
});
|
|
74
|
-
await step('
|
|
75
|
-
await
|
|
76
|
-
await p.waitForSelector('#obChat
|
|
80
|
+
await step('✓ searches read-only by its flags; a denied source contributes nothing; the findings are source tiles', async () => {
|
|
81
|
+
await press('plan-yes');
|
|
82
|
+
await p.waitForSelector('#obChat .ob-tile', { timeout: 30000 });
|
|
77
83
|
const run = log().find((a) => a.includes('--allowedTools'));
|
|
78
84
|
const at = (f) => run[run.indexOf(f) + 1];
|
|
79
85
|
assert.equal(at('--permission-mode'), 'dontAsk'); assert.equal(at('--tools'), ''); assert.deepEqual(JSON.parse(at('--settings')), { disableAllHooks: true });
|
|
80
|
-
|
|
81
|
-
assert.deepEqual(allow.sort(), ['mcp__claude_ai_Atlassian__getVisibleJiraProjects', 'mcp__claude_ai_Atlassian__searchJiraIssuesUsingJql', 'mcp__claude_ai_Slack__slack_search_public']);
|
|
86
|
+
assert.deepEqual(at('--allowedTools').split(',').sort(), ['mcp__claude_ai_Atlassian__getVisibleJiraProjects', 'mcp__claude_ai_Atlassian__searchJiraIssuesUsingJql', 'mcp__claude_ai_Slack__slack_search_public']);
|
|
82
87
|
const text = await p.locator('#obChat').innerText();
|
|
83
|
-
assert.match(text, /
|
|
84
|
-
|
|
85
|
-
assert.
|
|
86
|
-
|
|
88
|
+
assert.match(text, /I found one thing about/); assert.match(text, /Slack: This login is not allowed to search it/); assert(!text.includes('#checkout'), 'nothing from the denied source');
|
|
89
|
+
const ctxCard = await p.locator('#obChat form[data-ob-form="review"]').innerText();
|
|
90
|
+
assert.match(ctxCard, /Reduce checkout drop-off on mobile/); assert.match(ctxCard, /Owner\s*Not found\. Tell me, or leave blank/);
|
|
91
|
+
assert(!/Invented Person/.test(ctxCard), 'a field cited from a failed source stays empty');
|
|
92
|
+
await shot(p, 'onboard-3-found');
|
|
87
93
|
});
|
|
88
|
-
await step('Setup waits for the Jira choice,
|
|
89
|
-
await
|
|
94
|
+
await step('Setup waits for the Jira choice, installs PMOS for real, and shows the fundamentals card', async () => {
|
|
95
|
+
await press('init');
|
|
90
96
|
await p.waitForFunction(() => /Choose which Jira project/.test((document.querySelector('#obChat .ob-live') || {}).innerText || ''), null, { timeout: 15000 });
|
|
91
97
|
await p.check('#obChat input[name="ob-jira"][value="PAY"]');
|
|
98
|
+
await p.locator('#obChat [data-ob-edit="goals"]').click();
|
|
92
99
|
await p.fill('#obChat input[name="goals"]', 'Raise mobile checkout conversion');
|
|
93
|
-
await
|
|
94
|
-
try { await p.
|
|
95
|
-
await p.waitForSelector('#obChat .ob-done');
|
|
100
|
+
await press('init');
|
|
101
|
+
try { await p.waitForSelector('#obChat .ob-fund', { timeout: 60000 }); } catch (e) { await shot(p, 'onboard-x-stuck'); console.log(await p.locator('#obChat').innerText()); throw e; }
|
|
96
102
|
const root = path.join(HOME, 'PMOS', 'checkout-redesign');
|
|
97
|
-
for (const f of ['.git', 'pmos/AGENTS.md', 'pmos/state', 'CLAUDE.md', '.pmos-profile']) assert(fs.existsSync(path.join(root, f)), f);
|
|
103
|
+
for (const f of ['.git', 'pmos/AGENTS.md', 'pmos/state', 'CLAUDE.md', '.pmos-profile', 'pmos/planning/okrs/okrs-draft.md']) assert(fs.existsSync(path.join(root, f)), f);
|
|
98
104
|
const ctxText = fs.readFileSync(path.join(root, 'pmos', 'planning', 'project-context.md'), 'utf8');
|
|
99
105
|
assert.match(ctxText, /Reduce checkout drop-off on mobile \(source: Atlassian, Checkout brief/); assert.match(ctxText, /Raise mobile checkout conversion \(source: the PM\)/);
|
|
100
106
|
assert.match(ctxText, /Jira:\*\* PAY · Payments Platform/); assert(!/Invented Person/.test(ctxText));
|
|
107
|
+
const fund = await p.locator('#obChat .ob-fund').innerText();
|
|
108
|
+
assert.match(fund, /A document product/); assert.match(fund, /Workflow: Product feature/); assert.match(fund, /Jira PAY linked: read-only/); assert.match(fund, /About: “Reduce checkout drop-off on mobile”/);
|
|
109
|
+
assert(!/Applications/.test(fund), 'a scratch server writes no launcher, and says nothing about it');
|
|
110
|
+
assert.equal((await p.locator('#obChat .ob-fund li').count()), (await p.locator('#obChat .ob-fund li em').count()), 'every line names its source');
|
|
111
|
+
await shot(p, 'onboard-4-fundamentals');
|
|
112
|
+
await p.locator('#obChat [data-ob="f:goal"]').click();
|
|
113
|
+
await p.waitForFunction(() => /No goal file/.test(document.querySelector('#obChat .ob-fund').innerText));
|
|
114
|
+
assert(!fs.existsSync(path.join(root, 'pmos', 'planning', 'okrs', 'okrs-draft.md')), 'one press removes the draft');
|
|
115
|
+
await Promise.all([p.waitForURL(/onboarded=1&first=draft/, { timeout: 15000 }), press('first-yes')]);
|
|
116
|
+
await p.waitForSelector('#rvChat .rv-ticks'); await p.waitForSelector('#rvChat .rv-draft'); // B2: Home opens on the reveal, with the drafted initiative
|
|
101
117
|
await p.waitForFunction(() => /Checkout redesign/i.test(document.querySelector('#hmList').innerText), null, { timeout: 15000 });
|
|
102
|
-
assert(await p.locator('#obChat a.hm-chip').count() >= 3, 'a next action, not an empty success');
|
|
103
|
-
await shot(p, 'onboard-5-done');
|
|
104
118
|
const ph = await (await browser.newContext({ viewport: { width: 390, height: 844 } })).newPage();
|
|
105
|
-
await ph.goto(
|
|
119
|
+
await ph.goto(T.BASE + '/premium.html?onboard=1'); await ph.waitForSelector('#obChat .ob-fund'); await shot(ph, 'onboard-5-fundamentals-phone');
|
|
106
120
|
assert(await ph.locator('#obChat').isVisible()); await ph.close();
|
|
107
121
|
});
|
|
108
122
|
await step('The same folder again is reused as existing PMOS, never reinstalled', async () => {
|
|
@@ -112,17 +126,16 @@ const shot = async (p, name) => { if (SHOTS) { fs.mkdirSync(SHOTS, { recursive:
|
|
|
112
126
|
await fetch(T.BASE + '/api/onboard/reset', { method: 'POST', headers: h, body: '{}' });
|
|
113
127
|
const ph = await (await browser.newContext({ viewport: { width: 390, height: 844 } })).newPage();
|
|
114
128
|
await ph.goto(T.BASE + '/premium.html?onboard=1');
|
|
115
|
-
await ph.
|
|
116
|
-
await ph.
|
|
117
|
-
await ph.
|
|
118
|
-
await ph.
|
|
119
|
-
assert.match(await ph.locator('#obChat .ob-card').first().innerText(), /PMOS is already here/);
|
|
129
|
+
await ph.waitForSelector('#obChat .ob-good', { timeout: 15000 });
|
|
130
|
+
await say(ph, `Checkout redesign. The folder is ${root}`);
|
|
131
|
+
await ph.waitForSelector('#obChat [data-ob="plan-yes"]', { timeout: 15000 });
|
|
132
|
+
assert.match(await ph.locator('#obChat .ob-prop').last().innerText(), /PMOS is already here/);
|
|
120
133
|
await shot(ph, 'onboard-6-existing-pmos-phone');
|
|
121
|
-
await ph.locator('#obChat
|
|
122
|
-
await ph
|
|
123
|
-
await
|
|
124
|
-
await ph.
|
|
125
|
-
|
|
134
|
+
await ph.locator('#obChat [data-ob="plan-no"]').click();
|
|
135
|
+
await say(ph, 'no search');
|
|
136
|
+
await ph.waitForSelector('#obChat [data-ob="init"]');
|
|
137
|
+
await ph.locator('#obChat [data-ob="init"]').click();
|
|
138
|
+
await ph.waitForSelector('#obChat .ob-fund', { timeout: 60000 });
|
|
126
139
|
assert.equal(fs.readFileSync(path.join(root, 'pmos', 'AGENTS.md'), 'utf8'), '# edited by the PM');
|
|
127
140
|
await ph.close();
|
|
128
141
|
});
|
|
@@ -381,3 +381,6 @@
|
|
|
381
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
382
|
/* Settings › Claude › Your own rules (batch 26 item 2) */
|
|
383
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; }
|
|
384
|
+
/* a quiet place (pm/quiet-place.js, onboarding B3 ObA4Gradual): one honest line instead of empty tables, until its moment comes */
|
|
385
|
+
.qp-on > :not(.qp-line) { display: none !important; }
|
|
386
|
+
.qp-line { margin: 18vh auto 0; max-width: 520px; padding: 0 16px; text-align: center; font-size: var(--fs-row); font-weight: var(--fw-strong); color: var(--cz-ink2); line-height: 1.5; }
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/* pm/cozy-lab-ins.js — Intelligence › Lab (build U, idle-lab B+C 2026-11-04; canvas main, In development I1 IlBC). One section under "Did it work?".
|
|
2
|
+
* Top to bottom: Claude's ✦ line for the week (the idle time it used, how many you kept, what fits no initiative), the ✦ that fit no
|
|
3
|
+
* initiative (✓ keep, ✕ drop with an optional reason, Undo in the toast), then two cards side by side: "What Claude did in the background"
|
|
4
|
+
* (every background job this week, links only: a result on an initiative waits there, said once) and "You kept" with "Next time you step
|
|
5
|
+
* away" (read only: to skip one, say so in chat). Hidden while GET /api/lab answers null, so the Lab appears only once there is something
|
|
6
|
+
* (zero is silence; onboarding A4, gradual exposure). Every press is icon-only.
|
|
7
|
+
*
|
|
8
|
+
* mount({ el, where() → { ws, board }, iniHref(id), toast(text, undo?) }) then load() after each read of the page
|
|
9
|
+
* load() reads GET /api/lab and draws */
|
|
10
|
+
import { pmGet, pmPost } from './http.js';
|
|
11
|
+
import * as LC from './cozy-lab.js';
|
|
12
|
+
|
|
13
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
14
|
+
const SVG = (d, w = 16, sw = 1.8) => `<svg aria-hidden="true" width="${w}" height="${w}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round"><path d="${d}"/></svg>`;
|
|
15
|
+
const ICON = { prototyped: LC.I.proto, improved: LC.I.round, scanned: LC.I.scan, loose: LC.I.scan, job: 'M4 6h16M4 12h10M4 18h7' };
|
|
16
|
+
const S = { H: null, lab: null, why: null };
|
|
17
|
+
const ib = (attr, label, icon, cls = '') => `<button type="button" class="lab-ib${cls ? ' ' + cls : ''}" ${attr} aria-label="${esc(label)}" title="${esc(label)}">${SVG(icon, 16, 2.2)}</button>`;
|
|
18
|
+
|
|
19
|
+
function looseHtml(p) {
|
|
20
|
+
const why = S.why === p.id, id = esc(p.id);
|
|
21
|
+
const src = (p.sources || []).filter((s) => /^https?:\/\//.test(s.ref || '')).map((s) => `<a href="${esc(s.ref)}" target="_blank" rel="noopener noreferrer">${esc(s.label)}</a>`).join(', ');
|
|
22
|
+
const ask = p.type === 'start' && p.data ? ` Start an initiative <i>“${esc(p.data.title)}”</i>${p.data.stage ? ', ' + esc(p.data.stage) : ''}?` : '';
|
|
23
|
+
return `<div class="lab-card lab-lo" data-lab-id="${id}"><div class="lab-bar"><span class="lab-sp" aria-hidden="true">✦</span><b>${esc(p.title)}</b><span class="lab-acts">`
|
|
24
|
+
+ ib(`data-lab-why="${id}" aria-expanded="${why}"`, 'Drop it', LC.I.x) + ib(`data-lab-keep="${id}"`, p.type === 'start' ? 'Start it' : 'Keep it', LC.I.check, 'ok') + '</span></div>'
|
|
25
|
+
+ `<p class="lab-tx">${esc(p.why)}${ask}</p><small class="lab-ok">Read: ${(p.sources || []).length} ${(p.sources || []).length === 1 ? 'page' : 'pages'}${p.data && p.data.refreshed ? ' · ' + esc(p.data.refreshed) : ''}${src ? ' · ' + src : ''}</small>`
|
|
26
|
+
+ (why ? `<div class="lab-why"><label class="vh" for="labLw-${id}">Why drop it? Claude learns from it</label><input id="labLw-${id}" data-lab-reason="${id}" maxlength="200" placeholder="Why not? Claude learns from it (you can leave it empty)">${ib(`data-lab-drop="${id}"`, 'Drop it now', LC.I.x, 'solid')}</div>` : '')
|
|
27
|
+
+ '</div>';
|
|
28
|
+
}
|
|
29
|
+
function pillOf(r) {
|
|
30
|
+
if (r.state === 'kept') return '<span class="lab-pill ok">kept</span>';
|
|
31
|
+
if (r.state === 'dropped') return `<span class="lab-pill">dropped${r.why ? ' · “' + esc(r.why) + '”' : ''}</span>`;
|
|
32
|
+
if (r.kind === 'loose') return '<span class="lab-pill y">✦ above</span>';
|
|
33
|
+
if (r.ini) return `<a class="lab-pill y" href="${esc(S.H.iniHref(r.ini))}">✦ on initiative</a>`;
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
function recRow(r) {
|
|
37
|
+
const open = r.compare ? ib(`data-lab-cmp="${esc(r.id)}" data-lab-board="${esc(r.board || '')}"`, 'Open in the compare view', LC.I.open) : '';
|
|
38
|
+
const pill = r.kind === 'job' ? (r.ini ? `<a class="lab-pill" href="${esc(S.H.iniHref(r.ini))}">initiative</a>` : '') : pillOf(r);
|
|
39
|
+
return `<div class="lab-row"><span class="lab-ico">${SVG(ICON[r.kind] || ICON.job, 18)}</span><span class="w"><b>${esc(r.title)}</b><small>${esc(r.meta || '')}</small></span>${pill}${open}</div>`;
|
|
40
|
+
}
|
|
41
|
+
function html(L) {
|
|
42
|
+
const k = L.kept;
|
|
43
|
+
const keptCard = k ? `<div class="lab-card"><small>You kept</small><b class="lab-big">${k.kept} of ${k.filed}</b><small>${[k.proto[1] ? 'prototypes ' + k.proto[0] + ' of ' + k.proto[1] : '', k.scan[1] ? 'scans ' + k.scan[0] + ' of ' + k.scan[1] : ''].filter(Boolean).join(' · ')}. Claude learns from each reason you give.</small></div>` : '';
|
|
44
|
+
const next = L.next.length ? `<div class="lab-card"><b>Next time you step away</b>${L.next.map((x) => `<div class="lab-row"><span class="lab-dot open"></span><span class="w">${esc(x.title)}${x.why ? `<small>${esc(x.why)}</small>` : ''}</span></div>`).join('')}<small>Claude picks these by itself. To skip one, say so in chat.</small></div>` : '';
|
|
45
|
+
const rec = L.record.length ? `<div class="lab-card lab-rec"><div class="lab-bar"><b>What Claude did in the background</b><small>this week, every job</small></div>${L.record.map(recRow).join('')}${L.more ? `<small>and ${L.more} more</small>` : ''}</div>` : '';
|
|
46
|
+
return `<h2 class="ins-h3" id="hLab">Lab</h2>`
|
|
47
|
+
+ (L.say ? `<p class="lab-say"><span aria-hidden="true">✦</span> ${esc(L.say)}</p>` : '')
|
|
48
|
+
+ L.loose.map(looseHtml).join('')
|
|
49
|
+
+ (rec || keptCard || next ? `<div class="lab-2">${rec}${keptCard || next ? `<div class="lab-side">${keptCard}${next}</div>` : ''}</div>` : '');
|
|
50
|
+
}
|
|
51
|
+
function draw() {
|
|
52
|
+
const el = S.H && S.H.el; if (!el) return;
|
|
53
|
+
if (!S.lab) { el.hidden = true; el.innerHTML = ''; return; }
|
|
54
|
+
el.hidden = false; el.innerHTML = html(S.lab);
|
|
55
|
+
}
|
|
56
|
+
export async function load() {
|
|
57
|
+
if (!S.H) return;
|
|
58
|
+
const at = S.H.where() || {};
|
|
59
|
+
const r = await pmGet('/api/lab?ws=' + encodeURIComponent(at.ws || '') + '&board=' + encodeURIComponent(at.board || '')).catch(() => null);
|
|
60
|
+
// a failed read draws nothing (quiet): the section is an extra, the page around it stays
|
|
61
|
+
S.lab = r && r.ok ? r.lab : null; draw();
|
|
62
|
+
}
|
|
63
|
+
async function post(route, body) { const at = S.H.where() || {}; return pmPost(route, { ws: at.ws, ...body }).catch(() => null); }
|
|
64
|
+
async function press(e) {
|
|
65
|
+
const b = e.target.closest('button'); if (!b || b.disabled) return;
|
|
66
|
+
const d = b.dataset, say = (m, u) => S.H.toast(m, u);
|
|
67
|
+
if (d.labWhy) { S.why = S.why === d.labWhy ? null : d.labWhy; draw(); const i = S.H.el.querySelector(`[data-lab-reason="${CSS.escape(d.labWhy)}"]`); if (i) i.focus(); return; }
|
|
68
|
+
if (d.labDrop) {
|
|
69
|
+
const i = S.H.el.querySelector(`[data-lab-reason="${CSS.escape(d.labDrop)}"]`); b.disabled = true; S.why = null;
|
|
70
|
+
const r = await post('/api/lab/drop', { id: d.labDrop, ...(i && i.value.trim() ? { why: i.value.trim() } : {}) });
|
|
71
|
+
if (!r || r.ok === false) { say((r && r.error) || 'Nothing was changed.'); b.disabled = false; return; }
|
|
72
|
+
say('Dropped.', async () => { await post('/api/lab/undo', { batch: r.batch }); await load(); }); await load(); return;
|
|
73
|
+
}
|
|
74
|
+
if (d.labKeep) {
|
|
75
|
+
b.disabled = true;
|
|
76
|
+
const r = await post('/api/lab/keep', { id: d.labKeep });
|
|
77
|
+
if (!r || r.ok === false) { say((r && r.error) || 'Nothing was kept.'); b.disabled = false; return; }
|
|
78
|
+
say(r.said, async () => { await post('/api/lab/undo', { batch: r.batch }); await load(); }); await load(); return;
|
|
79
|
+
}
|
|
80
|
+
if (d.labCmp) { const at = S.H.where() || {}; LC.openCompare({ where: { ws: at.ws, board: d.labBoard || at.board }, id: d.labCmp, H: { toast: S.H.toast, reload: load } }); }
|
|
81
|
+
}
|
|
82
|
+
export function mount(H) {
|
|
83
|
+
S.H = H;
|
|
84
|
+
if (!H.el) return;
|
|
85
|
+
H.el.addEventListener('click', press);
|
|
86
|
+
H.el.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target.matches('[data-lab-reason]')) { e.preventDefault(); const x = e.target.closest('.lab-card').querySelector('[data-lab-drop]'); if (x) x.click(); } });
|
|
87
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/* cozy-lab.css — the idle lab on the screen (build U, idle-lab B+C 2026-11-04; canvas main, In development I1 IlBC, I2 IlB, I3 IlCompare).
|
|
2
|
+
* The ✦ cards under an initiative's blue ✦ (pm/cozy-lab.js cardHtml, inside pm/ini-props.js), the compare view in the Viewer
|
|
3
|
+
* (openCompare) and Intelligence › Lab (pm/cozy-lab-ins.js). Agreed type roles only (--fs-/--fw-/--lh-), the cozy tokens for colour.
|
|
4
|
+
* Loaded once by pm/cozy-lab.js. */
|
|
5
|
+
.lab-sec .vh, .lab-cmp .vh, .pp-p.lab .vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
6
|
+
|
|
7
|
+
/* ── the ✦ cards (I2) ── */
|
|
8
|
+
.pp-p.lab .w { gap: 6px; }
|
|
9
|
+
.lab-ths { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; margin-top: 4px; }
|
|
10
|
+
.lab-th { display: flex; flex-direction: column; border: 1px solid var(--cz-hair); border-radius: 12px; overflow: hidden; background: var(--cz-card); text-align: left; cursor: pointer; }
|
|
11
|
+
.lab-th:hover { border-color: var(--cz-blue); }
|
|
12
|
+
.lab-th:focus-visible, .lab-ib:focus-visible, .lab-key:focus-visible, .lab-hr:focus-visible { outline: 2px solid var(--cz-blue); outline-offset: 2px; }
|
|
13
|
+
.lab-thf { display: block; position: relative; height: 112px; overflow: hidden; background: var(--cz-well); pointer-events: none; }
|
|
14
|
+
.lab-thf iframe { position: absolute; left: 0; top: 0; width: 1280px; height: 800px; border: 0; transform: scale(.14); transform-origin: 0 0; background: var(--cz-card); }
|
|
15
|
+
.lab-cap { display: flex; flex-direction: column; gap: 1px; padding: 8px 10px; font-size: var(--fs-meta); line-height: var(--lh-meta); color: var(--cz-ink2); }
|
|
16
|
+
.lab-cap b { font-size: var(--fs-meta); font-weight: var(--fw-strong); color: var(--cz-ink); }
|
|
17
|
+
.lab-tx { margin: 0; font-size: var(--fs-body); line-height: var(--lh-body); color: var(--cz-ink2); }
|
|
18
|
+
.lab-tx b { font-weight: var(--fw-strong); color: var(--cz-ink); }
|
|
19
|
+
.lab-ok { font-size: var(--fs-meta); line-height: var(--lh-meta); color: var(--cz-ok); }
|
|
20
|
+
.lab-ok a, .lab-tx a { color: inherit; }
|
|
21
|
+
.lab-why { display: flex; align-items: center; gap: 6px; margin-top: 4px; }
|
|
22
|
+
.lab-why input { flex: 1; min-width: 0; min-height: 36px; padding: 6px 10px; border-radius: 10px; border: 1px solid var(--cz-hair); background: var(--cz-card); color: var(--cz-ink); font: inherit; font-size: var(--fs-meta); line-height: var(--lh-meta); }
|
|
23
|
+
.lab-why input:focus-visible { outline: 2px solid var(--cz-blue); outline-offset: 1px; }
|
|
24
|
+
.pp .pp-ib.solid, .lab-ib.solid { background: var(--cz-solid); color: var(--cz-onsolid); }
|
|
25
|
+
|
|
26
|
+
/* ── the compare view (I3) ── */
|
|
27
|
+
.lab-cmp { display: flex; flex-direction: column; gap: 14px; font-family: var(--cz-sans); color: var(--cz-ink); }
|
|
28
|
+
.lab-say { margin: 0; display: flex; gap: 8px; align-items: baseline; padding: 10px 12px; border-radius: 14px; background: var(--cz-bluebg); color: var(--cz-ink2); font-size: var(--fs-body); line-height: var(--lh-body); }
|
|
29
|
+
.lab-say > span { color: var(--cz-blueink); font-weight: var(--fw-strong); }
|
|
30
|
+
.lab-cols { display: grid; grid-template-columns: repeat(var(--n, 1), minmax(0, 1fr)); gap: 14px; }
|
|
31
|
+
.lab-col { display: flex; flex-direction: column; gap: 8px; padding: 12px; border: 1px solid var(--cz-hair); border-radius: 16px; background: var(--cz-card); }
|
|
32
|
+
.lab-col.now { border-color: var(--cz-blue); }
|
|
33
|
+
.lab-ch { display: flex; align-items: baseline; gap: 8px; }
|
|
34
|
+
.lab-ch b { font-size: var(--fs-row); font-weight: var(--fw-strong); line-height: var(--lh-row); }
|
|
35
|
+
.lab-ch span { font-size: var(--fs-meta); color: var(--cz-ink3); line-height: var(--lh-meta); }
|
|
36
|
+
.lab-frame { display: block; border: 1px solid var(--cz-hair); border-radius: 12px; overflow: hidden; background: var(--cz-card); }
|
|
37
|
+
.lab-frame iframe { display: block; width: 100%; height: 460px; border: 0; }
|
|
38
|
+
.lab-left { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-meta); color: var(--cz-ink3); line-height: var(--lh-meta); }
|
|
39
|
+
.lab-left b { font-weight: var(--fw-strong); color: var(--cz-ink2); }
|
|
40
|
+
.lab-below { display: grid; grid-template-columns: 1.3fr 1fr; gap: 14px; }
|
|
41
|
+
.lab-below > .lab-hist:only-child { grid-column: 1 / -1; }
|
|
42
|
+
.lab-card { display: flex; flex-direction: column; gap: 4px; padding: 12px 14px; border: 1px solid var(--cz-hair); border-radius: 16px; background: var(--cz-card); font-size: var(--fs-body); line-height: var(--lh-body); }
|
|
43
|
+
.lab-card > b { font-size: var(--fs-row); font-weight: var(--fw-strong); line-height: var(--lh-row); }
|
|
44
|
+
.lab-card small { font-size: var(--fs-meta); color: var(--cz-ink3); line-height: var(--lh-meta); }
|
|
45
|
+
.lab-hn { font-size: var(--fs-meta); color: var(--cz-ink3); line-height: var(--lh-meta); }
|
|
46
|
+
.lab-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-top: 1px solid var(--cz-hair); font-size: var(--fs-body); line-height: var(--lh-row); color: var(--cz-ink); }
|
|
47
|
+
.lab-card > b + .lab-row, .lab-hn + .lab-row, .lab-bar + .lab-row { border-top: 0; }
|
|
48
|
+
.lab-row .w { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
|
|
49
|
+
.lab-row .w b { font-weight: var(--fw-strong); }
|
|
50
|
+
.lab-hr { width: 100%; text-align: left; font: inherit; color: inherit; background: none; border: 0; border-top: 1px solid var(--cz-hair); cursor: pointer; border-radius: 0; }
|
|
51
|
+
.lab-hr[aria-current="true"] .w b { color: var(--cz-blueink); }
|
|
52
|
+
.lab-pin { width: 24px; height: 24px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; flex: none; background: var(--cz-blueink); color: var(--cz-onsolid); font-size: var(--fs-badge); font-weight: var(--fw-strong); line-height: var(--lh-badge); }
|
|
53
|
+
.lab-pin.production { background: var(--cz-risk); }
|
|
54
|
+
.lab-pin.design { background: var(--cz-ok); }
|
|
55
|
+
.lab-dot { width: 9px; height: 9px; border-radius: 50%; flex: none; background: var(--cz-blue); }
|
|
56
|
+
.lab-dot.kept { background: var(--cz-ok); }
|
|
57
|
+
.lab-dot.dropped { background: var(--cz-ink3); }
|
|
58
|
+
.lab-keys { display: inline-flex; gap: 4px; padding: 0 0 6px 19px; }
|
|
59
|
+
.lab-key { min-width: 32px; height: 28px; padding: 0 8px; border-radius: 999px; border: 1px solid var(--cz-hair); background: var(--cz-card); color: var(--cz-ink2); font: inherit; font-size: var(--fs-meta); font-weight: var(--fw-strong); cursor: pointer; }
|
|
60
|
+
.lab-key[aria-pressed="true"] { background: var(--cz-bluebg); color: var(--cz-blueink); border-color: transparent; }
|
|
61
|
+
|
|
62
|
+
/* ── Intelligence › Lab (I1) ── */
|
|
63
|
+
.lab-sec { gap: 14px; }
|
|
64
|
+
.lab-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
65
|
+
.lab-bar b { font-size: var(--fs-row); font-weight: var(--fw-strong); line-height: var(--lh-row); }
|
|
66
|
+
.lab-bar small { font-size: var(--fs-meta); color: var(--cz-ink3); }
|
|
67
|
+
.lab-sp { color: var(--cz-blueink); font-weight: var(--fw-strong); }
|
|
68
|
+
.lab-acts { margin-left: auto; display: inline-flex; gap: 4px; }
|
|
69
|
+
.lab-ib { width: 32px; height: 32px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; flex: none; color: var(--cz-ink2); background: none; border: 1px solid var(--cz-hair); cursor: pointer; padding: 0; }
|
|
70
|
+
.lab-ib.ok { background: var(--cz-blueink); color: var(--cz-onsolid); border-color: transparent; }
|
|
71
|
+
.lab-ib:hover { color: var(--cz-ink); }
|
|
72
|
+
.lab-ico { width: 34px; height: 34px; border-radius: 10px; background: var(--cz-well); color: var(--cz-ink2); display: inline-flex; align-items: center; justify-content: center; flex: none; }
|
|
73
|
+
.lab-pill { display: inline-flex; align-items: center; height: 22px; padding: 0 9px; border-radius: 999px; background: var(--cz-well); color: var(--cz-ink2); font-size: var(--fs-badge); font-weight: var(--fw-strong); line-height: var(--lh-badge); white-space: nowrap; text-decoration: none; max-width: 240px; overflow: hidden; text-overflow: ellipsis; }
|
|
74
|
+
.lab-pill.ok { background: var(--cz-okbg); color: var(--cz-ok); }
|
|
75
|
+
.lab-pill.y { background: var(--cz-bluebg); color: var(--cz-blueink); }
|
|
76
|
+
.lab-2 { display: grid; grid-template-columns: 1.5fr 1fr; gap: 14px; align-items: start; }
|
|
77
|
+
.lab-2 > .lab-rec:only-child { grid-column: 1 / -1; }
|
|
78
|
+
.lab-side { display: flex; flex-direction: column; gap: 14px; }
|
|
79
|
+
.lab-big { font-size: var(--fs-page); font-weight: var(--fw-strong); line-height: var(--lh-page); }
|
|
80
|
+
|
|
81
|
+
@media (max-width: 760px) {
|
|
82
|
+
.lab-cols, .lab-below, .lab-2 { grid-template-columns: 1fr; }
|
|
83
|
+
.lab-frame iframe { height: 380px; }
|
|
84
|
+
.lab-ib { width: 44px; height: 44px; }
|
|
85
|
+
.lab-ths { grid-template-columns: 1fr 1fr; }
|
|
86
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/* pm/cozy-lab.js — the idle lab on the screen (build U, idle-lab B+C 2026-11-04; canvas main, In development I2 IlB, I3 IlCompare).
|
|
2
|
+
*
|
|
3
|
+
* isLab(p) a proposal of the idle lab (group prototyped, improved or scanned; lib/idle-lab.js result)
|
|
4
|
+
* cardHtml(p, { why }) its ✦ card under the initiative's blue ✦ (pm/ini-props.js draws the list): "Claude prototyped it" shows round 1
|
|
5
|
+
* as thumbnails, each one a press that opens the compare view; "Claude improved it" says what changed and from
|
|
6
|
+
* what; "Claude scanned for it" is a finding to attach as evidence, with the pages it read. Icon-only presses:
|
|
7
|
+
* ⤢ compare, ✕ drop (it asks why first: Claude learns from it; the reason is optional), ✓ keep.
|
|
8
|
+
* openCompare({ where: { ws, board }, id, key?, H? }) the compare view in the Viewer: the ✦ line, the columns (Production, Design,
|
|
9
|
+
* Prototype), "Where they differ" and History. A column whose source is not proven is left out with one line
|
|
10
|
+
* why (lib/idle-lab.js PROVEN). A press on a round in History puts it in the Prototype column. ✓ and ✕ in the
|
|
11
|
+
* header keep or drop the round shown.
|
|
12
|
+
* Every specimen loads from GET /api/lab/specimen in a sandboxed frame: it cannot reach PMOS. Nothing is written before the PM's ✓. */
|
|
13
|
+
import { pmGet, pmPost } from './http.js';
|
|
14
|
+
|
|
15
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
16
|
+
const SVG = (d, w = 16, sw = 1.8) => `<svg aria-hidden="true" width="${w}" height="${w}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round"><path d="${d}"/></svg>`;
|
|
17
|
+
export const I = {
|
|
18
|
+
proto: 'M3 4h18v14H3zM3 9h18', round: 'M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5', scan: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18M12 3a15 15 0 0 1 0 18M3 12h18',
|
|
19
|
+
open: 'M7 17 17 7M8 7h9v9', check: 'M5 12l5 5 9-10', x: 'M6 6l12 12M18 6L6 18',
|
|
20
|
+
};
|
|
21
|
+
const GROUPS = ['prototyped', 'improved', 'scanned'];
|
|
22
|
+
const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
23
|
+
const when = (iso) => { const d = new Date(iso); if (isNaN(d)) return ''; const t = new Date(); const same = d.toDateString() === t.toDateString(); return same ? 'today ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') : d.getDate() + ' ' + MON[d.getMonth()]; };
|
|
24
|
+
const specUrl = (file) => '/api/lab/specimen?file=' + encodeURIComponent(file);
|
|
25
|
+
const ib = (attr, label, icon, cls = '') => `<button type="button" class="pp-ib${cls ? ' ' + cls : ''}" ${attr} aria-label="${esc(label)}" title="${esc(label)}">${SVG(icon, 16, 2.2)}</button>`;
|
|
26
|
+
|
|
27
|
+
// the look, loaded once (cozy-lab.css)
|
|
28
|
+
if (typeof document !== 'undefined' && typeof document.createElement === 'function' && document.head && !document.querySelector('link[data-lab-css]')) {
|
|
29
|
+
const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = new URL('./cozy-lab.css', import.meta.url).href; l.dataset.labCss = '1'; document.head.appendChild(l);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const isLab = (p) => !!p && GROUPS.includes(p.group);
|
|
33
|
+
|
|
34
|
+
/** A specimen as a still thumbnail: the page drawn at desk size and scaled down, in a frame that cannot reach PMOS. */
|
|
35
|
+
export function thumbHtml(file, label) {
|
|
36
|
+
return `<span class="lab-thf" aria-hidden="true"><iframe src="${esc(specUrl(file))}" sandbox="allow-scripts" loading="lazy" tabindex="-1" title="${esc(label)}"></iframe></span>`;
|
|
37
|
+
}
|
|
38
|
+
function srcLine(p) {
|
|
39
|
+
const s = p.sources || [];
|
|
40
|
+
if (p.group === 'scanned') {
|
|
41
|
+
const d = p.data || {}, n = d.pages || s.length;
|
|
42
|
+
const links = s.filter((x) => /^https?:\/\//.test(x.ref || '')).map((x) => `<a href="${esc(x.ref)}" target="_blank" rel="noopener noreferrer">${esc(x.label)}</a>`).join(', ');
|
|
43
|
+
return `Read: ${n} ${n === 1 ? 'page' : 'pages'}${d.refreshed ? ' · ' + esc(d.refreshed) : ''}${links ? ' · ' + links : ''}`;
|
|
44
|
+
}
|
|
45
|
+
return esc(s.map((x) => x.label).join(', '));
|
|
46
|
+
}
|
|
47
|
+
/** The ✦ card. why: the ✕ was pressed, so the card asks why before it drops. */
|
|
48
|
+
export function cardHtml(p, { why = false } = {}) {
|
|
49
|
+
const d = p.data || {}, id = esc(p.id);
|
|
50
|
+
const acts = (p.group !== 'scanned' ? ib(`data-lab-open="${id}"`, 'Compare it with production and the design', I.open) : '')
|
|
51
|
+
+ ib(`data-lab-why="${id}" aria-expanded="${why}"`, 'Drop it', I.x) + ib(`data-pp-keep="${id}"`, 'Keep it', I.check, 'ok');
|
|
52
|
+
let head = '', body = '';
|
|
53
|
+
if (p.group === 'prototyped') {
|
|
54
|
+
const sp = d.specimens || [];
|
|
55
|
+
head = p.title || `Round 1 · ${sp.length} ways to do it, so you can look instead of read`;
|
|
56
|
+
body = `<div class="lab-ths">${sp.map((x) => `<button type="button" class="lab-th" data-lab-open="${id}" data-lab-key="${esc(x.key)}" aria-label="${esc('Open ' + x.key + ', ' + x.title + ', in the compare view')}">${thumbHtml(x.file, x.key + ' · ' + x.title)}<span class="lab-cap"><b>${esc(x.key)} · ${esc(x.title)}</b>${esc(x.caption)}</span></button>`).join('')}</div>`
|
|
57
|
+
+ `<small class="lab-ok">Checked: ${esc(d.checked || '')}${p.sources && p.sources.length ? ' · built from ' + srcLine(p) : ''}</small>`;
|
|
58
|
+
} else if (p.group === 'improved') {
|
|
59
|
+
head = p.title || `Round ${d.round} of ${d.base || ''}`;
|
|
60
|
+
body = `<p class="lab-tx">${esc(p.why)} <b>${esc(d.changed || '')}</b></p><small class="lab-ok">Source: ${srcLine(p)}${d.checked ? ' · ' + esc(d.checked) : ''}</small>`;
|
|
61
|
+
} else {
|
|
62
|
+
head = p.title || 'Attach as evidence';
|
|
63
|
+
body = `<p class="lab-tx">${esc(p.why)}</p><small class="lab-ok">${srcLine(p)}</small>`;
|
|
64
|
+
}
|
|
65
|
+
const ask = why ? `<div class="lab-why"><label class="vh" for="labWhy-${id}">Why drop it? Claude learns from it</label><input id="labWhy-${id}" data-lab-reason="${id}" maxlength="200" placeholder="Why not? Claude learns from it (you can leave it empty)">${ib(`data-lab-drop="${id}"`, 'Drop it now', I.x, 'solid')}</div>` : '';
|
|
66
|
+
const icon = p.group === 'prototyped' ? I.proto : p.group === 'improved' ? I.round : I.scan;
|
|
67
|
+
return `<div class="pp-p lab" data-pp="${id}"><div class="pp-ph1"><span class="pp-ic gr">${SVG(icon, 16)}</span><span class="w"><b>${esc(head)}</b>${body}${ask}</span><span class="pp-gacts">${acts}</span></div></div>`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* ── the compare view (I3) ── */
|
|
71
|
+
const C = { at: null, id: null, key: '', c: null, H: null };
|
|
72
|
+
async function read() {
|
|
73
|
+
const at = C.at || {};
|
|
74
|
+
return pmGet('/api/lab/compare?ws=' + encodeURIComponent(at.ws || '') + '&board=' + encodeURIComponent(at.board || '') + '&id=' + encodeURIComponent(C.id) + (C.key ? '&key=' + encodeURIComponent(C.key) : '')).catch(() => null);
|
|
75
|
+
}
|
|
76
|
+
function colHtml(col) {
|
|
77
|
+
return `<section class="lab-col${col.id === 'prototype' ? ' now' : ''}" aria-label="${esc(col.title)}"><div class="lab-ch"><b>${esc(col.title)}</b><span>${esc(col.meta || '')}</span></div>`
|
|
78
|
+
+ `<span class="lab-frame"><iframe src="${esc(col.url || specUrl(col.file))}" sandbox="allow-scripts" title="${esc(col.title)}"></iframe></span>`
|
|
79
|
+
+ (col.read ? `<small class="lab-ok">${esc(col.read)}</small>` : '') + '</section>';
|
|
80
|
+
}
|
|
81
|
+
function bodyHtml(c) {
|
|
82
|
+
const shown = c.columns.filter((x) => !x.left), left = c.columns.filter((x) => x.left);
|
|
83
|
+
const pins = c.pins.length ? `<section class="lab-card"><b>Where they differ</b>${c.pins.map((p) => `<div class="lab-row"><span class="lab-pin ${esc(p.vs)}">${p.n}</span><span>${esc(p.text)}</span></div>`).join('')}</section>` : '';
|
|
84
|
+
const hist = `<section class="lab-card lab-hist" aria-label="History"><b>History</b><span class="lab-hn">Press a round to show it in the Prototype column</span>`
|
|
85
|
+
+ c.history.map((h) => `<button type="button" class="lab-row lab-hr" data-lab-round="${esc(h.id)}" aria-current="${h.current}"><span class="lab-dot ${esc(h.state)}"></span><span class="w"><b>Round ${h.round}</b> · ${esc(when(h.at))}<small>${esc(h.state === 'kept' ? 'you kept it · ' : h.state === 'dropped' ? 'you dropped it · ' : '')}${esc(h.meta)}</small></span></button>`
|
|
86
|
+
+ (h.keys && h.current ? `<span class="lab-keys">${h.keys.map((k) => `<button type="button" class="lab-key" data-lab-key="${esc(k.key)}" aria-pressed="${k.key === c.key}" aria-label="${esc('Show ' + k.key + ', ' + k.title)}">${esc(k.key)}</button>`).join('')}</span>` : '')).join('')
|
|
87
|
+
+ '</section>';
|
|
88
|
+
return `<div class="lab-cmp">${c.say ? `<p class="lab-say"><span aria-hidden="true">✦</span> ${esc(c.say)}</p>` : ''}`
|
|
89
|
+
+ `<div class="lab-cols" style="--n:${shown.length}">${shown.map(colHtml).join('')}</div>`
|
|
90
|
+
+ (left.length ? `<ul class="lab-left">${left.map((x) => `<li><b>${esc(x.title)}</b> ${esc(x.left)}</li>`).join('')}</ul>` : '')
|
|
91
|
+
+ `<div class="lab-below">${pins}${hist}</div></div>`;
|
|
92
|
+
}
|
|
93
|
+
function presses(c) {
|
|
94
|
+
return c && c.state === 'open' ? [{ act: 'drop', label: 'Drop this round', icon: I.x }, { act: 'keep', label: 'Keep this round', icon: I.check, blue: true }] : [];
|
|
95
|
+
}
|
|
96
|
+
function draw() {
|
|
97
|
+
const VW = window.czViewer, b = VW && VW.body(), cur = VW && VW.current();
|
|
98
|
+
if (!b || !cur || cur.id !== 'lab:cmp' || !C.c) return;
|
|
99
|
+
b.innerHTML = bodyHtml(C.c);
|
|
100
|
+
VW.setPresses(cur.id, presses(C.c));
|
|
101
|
+
if (VW.retitle) VW.retitle(cur.id, C.c.title);
|
|
102
|
+
}
|
|
103
|
+
async function load() {
|
|
104
|
+
const r = await read();
|
|
105
|
+
if (!r || r.ok === false) { const b = window.czViewer && window.czViewer.body(); if (b) b.innerHTML = `<p class="pp-none">${esc((r && r.error) || 'The prototype could not be read.')}</p>`; return; }
|
|
106
|
+
C.c = r.compare; C.key = r.compare.key;
|
|
107
|
+
// two or three columns side by side need the room: the Viewer opens wide (one column stays as it is)
|
|
108
|
+
if (C.c.columns.filter((x) => !x.left).length > 1 && window.czViewer.wide) window.czViewer.wide(true);
|
|
109
|
+
draw();
|
|
110
|
+
}
|
|
111
|
+
async function act(a) {
|
|
112
|
+
const c = C.c, at = C.at || {}; if (!c) return;
|
|
113
|
+
const say = (m, u) => { if (C.H && C.H.toast) C.H.toast(m, u); };
|
|
114
|
+
if (a === 'keep') {
|
|
115
|
+
const r = await pmPost('/api/proposals/keep', { ws: at.ws, board: at.board || c.board, initiative: c.ini, ids: [c.id] }).catch(() => null);
|
|
116
|
+
if (!r || r.ok === false) { say((r && r.error) || 'Nothing was kept.'); return; }
|
|
117
|
+
say(r.said, async () => { await pmPost('/api/proposals/undo', { ws: at.ws, board: at.board || c.board, batch: r.batch }).catch(() => null); await load(); if (C.H && C.H.reload) C.H.reload(); });
|
|
118
|
+
} else if (a === 'drop') {
|
|
119
|
+
const r = await pmPost('/api/proposals/drop', { ws: at.ws, board: at.board || c.board, initiative: c.ini, sig: c.sig }).catch(() => null);
|
|
120
|
+
if (!r || r.ok === false) { say((r && r.error) || 'Nothing was changed.'); return; }
|
|
121
|
+
say('Dropped round ' + c.round + '.');
|
|
122
|
+
}
|
|
123
|
+
await load(); if (C.H && C.H.reload) C.H.reload();
|
|
124
|
+
}
|
|
125
|
+
export function openCompare({ where, id, key = '', H = null } = {}) {
|
|
126
|
+
const VW = window.czViewer; if (!VW || !id) return;
|
|
127
|
+
Object.assign(C, { at: where || {}, id, key, c: null, H });
|
|
128
|
+
VW.drill();
|
|
129
|
+
VW.open({ id: 'lab:cmp', kind: 'Prototype', title: 'Prototype', icon: I.proto, presses: [], onPress: (a) => act(a),
|
|
130
|
+
render: (b) => {
|
|
131
|
+
b.innerHTML = '<p class="pp-none">Reading the prototype.</p>';
|
|
132
|
+
b.onclick = (e) => {
|
|
133
|
+
const r = e.target.closest('[data-lab-round]'); if (r) { C.id = r.dataset.labRound; C.key = ''; load(); return; }
|
|
134
|
+
const k = e.target.closest('[data-lab-key]'); if (k) { C.key = k.dataset.labKey; load(); }
|
|
135
|
+
};
|
|
136
|
+
load();
|
|
137
|
+
} });
|
|
138
|
+
}
|