create-pmos 0.240.0 → 0.241.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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/c3-report-structure-real-test.js +211 -0
  7. package/workspace/c3-report-structure-test.js +382 -0
  8. package/workspace/guide/index.md +2 -0
  9. package/workspace/guide/report-design.md +4 -0
  10. package/workspace/guide/report-structure.md +91 -0
  11. package/workspace/guide/reports-dashboards.md +1 -1
  12. package/workspace/guide/reports-set-up.md +2 -0
  13. package/workspace/lib/charts-store.js +124 -0
  14. package/workspace/lib/headless.js +17 -3
  15. package/workspace/lib/needs-providers.js +1 -0
  16. package/workspace/lib/report-structure-needs.js +64 -0
  17. package/workspace/lib/report-structure.js +515 -0
  18. package/workspace/lib/route-modules.js +1 -0
  19. package/workspace/lib/routes-report-setups.js +11 -6
  20. package/workspace/lib/routes-report-structure.js +619 -0
  21. package/workspace/package-lock.json +2 -2
  22. package/workspace/package.json +1 -1
  23. package/workspace/public/pm/c3-demo-rd.js +4 -0
  24. package/workspace/public/pm/c3-design-studio.js +7 -3
  25. package/workspace/public/pm/c3-report-setup.js +3 -0
  26. package/workspace/public/pm/c3-report-structure.css +40 -0
  27. package/workspace/public/pm/c3-report-structure.js +455 -0
  28. package/workspace/public/pm/c3-reports.css +2 -0
  29. package/workspace/public/pm/c3-reports.js +22 -3
  30. package/workspace/public/pm/c3-shell.js +2 -0
  31. package/workspace/server.js +38 -9
  32. package/workspace/skills/report-source-check.skill +45 -0
  33. package/workspace/skills/report-structure-dry.skill +51 -0
  34. package/workspace/skills/report-structure.skill +61 -0
  35. package/workspace/skills/report-template.skill +10 -0
  36. package/workspace/test-lists/report-structure-backend.json +5 -0
  37. package/workspace/test-lists/report-structure-ui.json +11 -0
@@ -0,0 +1,382 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /* c3-report-structure-test.js — the Structure step of the report studio, UI slice (initiative report-structure; mockup 43-reportstructure;
4
+ * API contract planning/prd/report-structure-contract.md). A real server and a real Chromium drive /c3.html?tab=rep; the routes of the
5
+ * contract (/api/report-structure*, /api/charts) and the report reads are answered by Playwright route interception, so the page is
6
+ * tested against the contract alone, not against the backend (c3-report-structure-real-test.js does that).
7
+ *
8
+ * entry the Ask PMOS answer (Shape it with PMOS sets the report up, then opens Structure), a set-up row (Shape the report, before Design
9
+ * how it looks) and the step bar of the Design studio (1 Structure, 2 Design)
10
+ * turn a first turn shows PMOS working, lands S1 with its questions as answer chips; a chip fills the box; a refine turn lands S2; the
11
+ * strip lists both; S1 can be shown again and Go back to S1 posts a revert
12
+ * plan What it answers; Sources in From PMOS and Outside PMOS with their state tags and actions (Open, Test pull, Check it works, How to
13
+ * connect); Charts tagged New chart / From Charts; a row opens to question, Used in, what PMOS reads, why and the test pull;
14
+ * Outline with chart chips and PMOS notes
15
+ * chart Discuss this chart fills the box; Remove it posts a remove and lands the next version
16
+ * source Add a source offers every From PMOS and Outside PMOS kind; a From PMOS source and a Chrome source are added; the Chrome check
17
+ * shows its steps with Needs you and Check again, and passes on the second check
18
+ * options Other structures: three side by side, the recommended first
19
+ * dry Try it with real data runs one job and shows headline, values, flags, could not get, options
20
+ * accept Use this structure posts the shown version; the setup sheet shows the saved structure with Refine the structure
21
+ * charts the Charts tab lists a data chart beside the built-ins with its source and Used in
22
+ * phone at 400 wide none of it scrolls sideways
23
+ * Set RX_SHOTS=<dir> to save screenshots at 1440 and 400 wide. No script error and no failed request may be raised. */
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const { spawn } = require('child_process');
27
+ const { findBrowser } = require('./lib/find-browser');
28
+ const T = require('./test-run').configure({ name: 'c3-report-structure' });
29
+
30
+ const BASE = T.BASE;
31
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
32
+ const until = async (fn, ms = 10000, step = 100) => { const t0 = Date.now(); for (;;) { const v = await fn(); if (v) return v; if (Date.now() - t0 > ms) return v; await sleep(step); } };
33
+ const results = [];
34
+ const ok = (name, cond, extra = '') => { results.push({ name, cond: !!cond }); console.log(`${cond ? '✓' : '✗'} ${name}${!cond && extra !== '' ? ' — ' + String(extra).slice(0, 400) : ''}`); };
35
+ let srv = null; let browser = null;
36
+ const finish = async (code) => {
37
+ if (browser) await browser.close().catch(() => {});
38
+ if (srv) { try { srv.kill('SIGKILL'); } catch { /* gone */ } }
39
+ T.killTmux();
40
+ const bad = results.filter((x) => !x.cond).length;
41
+ console.log(`\n${results.length - bad}/${results.length} c3-report-structure assertions passed`);
42
+ process.exit(code !== undefined ? code : (bad ? 1 : 0));
43
+ };
44
+ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
45
+
46
+ /* ── the stub: the contract's routes, as a small state machine ── */
47
+ const SETUP = { id: 'app-monthly', name: 'MySPH app performance, monthly', source: 'work', output: 'doc', status: 'active', schedule: { cadence: 'monthly', dayOfMonth: 3, time: '09:00' }, label: 'Monthly on the 3rd 09:00, draft only', next_draft: '2026-11-03', last_issued: null, cycles: 0, copy: null };
48
+ const PROPOSAL = { name: 'MySPH app performance, monthly', purpose: 'Where to invest next quarter.', audience: 'Leadership', source: 'work', output: 'doc', schedule: { cadence: 'monthly', dayOfMonth: 3, time: '09:00' }, sources: ['Google Analytics'], sections: ['Headline'], learned_from: null, fixes: [] };
49
+ const src = (id, kind, name, o) => ({ id, kind, name, via: '', ref: '', use: '', state: 'reached', group: ['connector', 'link', 'upload', 'person', 'chrome', 'computer'].includes(kind) ? 'outside' : 'pmos', ...o });
50
+ const GA = src('ga', 'connector', 'Google Analytics 4: MySPH web + app', { via: 'Read through your Supermetrics connector', use: 'Test pull Mon 12 Oct 22:31. App streams iOS and Android, Sep 2026.' });
51
+ const BOARD = src('board', 'pmos-level', 'Board: MySPH', { via: 'What the report covers', use: 'Its initiatives, releases, key results and risks.' });
52
+ const DASH = src('dash', 'dashboard', 'Dashboard: MySPH health', { via: '2 of its 6 charts used', use: 'New users is already a chart there.' });
53
+ const PAST = src('past', 'past-report', 'Past report: none yet', { via: 'From the second cycle', use: 'From November, PMOS reads the October report.', state: 'later' });
54
+ const BQ = src('bq', 'connector', 'BigQuery: GA4 export', { via: 'Not connected', use: 'Optional. Only needed for retention older than 14 months.', state: 'not-connected' });
55
+ const CH_NEW = { id: 'cpa', chart: null, name: 'Cost per new user', source: 'ga', from: 'GA4 cost', how: 'Paid spend divided by new users from paid campaigns', compare: 'Previous month', flag: 'Up 15% or more', question: 'What do new users cost us?', query: 'GA4 Data API: metric advertiserAdCost; filter sessionMedium = cpc', why: 'Spend explains the drop in new users.' };
56
+ const CH_OLD = { id: 'new-users', chart: 'new-users', name: 'New users', source: 'ga', from: 'GA4 newUsers', how: 'Month total, app streams only', compare: 'Previous month', flag: 'Down 10% or more', question: 'Are we still bringing people in?', query: 'GA4 Data API: metric newUsers', why: 'The top of the funnel.' };
57
+ const CH_RET = { id: 'ret', chart: null, name: 'D30 retention', source: 'ga', from: 'GA4 cohort exploration', how: 'Share of each monthly cohort still active on day 30', compare: 'The last 3 cohorts', flag: 'Below 25%', question: 'Do they stay?', query: 'GA4 Data API runReport with cohortSpec', why: 'You asked for it in S2.' };
58
+ const SEC = (id, title, o) => ({ id, title, fixed: false, charts: [], notes: '', show: '', ...o });
59
+ const OPTIONS = [
60
+ { name: 'Goals first', sections: ['Headline', 'One block per Q4 objective', 'Where to invest'], good: 'A team that reviews against OKRs.', misses: 'Numbers outside an OKR disappear.', recommended: false },
61
+ { name: 'Funnel first', sections: ['Headline', 'New users, activation, subscriptions', 'Where to invest'], good: 'Leadership deciding where to invest.', misses: 'Less detail per feature.', recommended: true },
62
+ { name: 'Features first', sections: ['Headline', 'One block per feature', 'What to build next'], good: 'A product team deciding what to fix next.', misses: 'Leadership has to add the money side.', recommended: false },
63
+ ];
64
+ const plan1 = () => ({ purpose: { readers: 'Leadership: Gita Rahma and the product directors', decision: 'Where to invest next quarter', scope: 'MySPH app, iOS and Android', cadence: 'monthly', questions: ['Where do users drop between installing and paying?', 'What should we fund next quarter?'] },
65
+ sources: [BOARD, DASH, PAST, GA, BQ].map((s) => ({ ...s })), charts: [CH_OLD, CH_NEW].map((c) => ({ ...c })),
66
+ outline: [SEC('headline', 'Headline', { fixed: true, notes: 'The one thing leadership must know this month', show: 'Two sentences' }), SEC('funnel', 'The funnel this month', { charts: ['new-users', 'cpa'], notes: 'Where do users drop?', show: 'Funnel chart' }),
67
+ SEC('needs', 'Needs your call', { fixed: true, notes: 'Asks to leadership' }), SEC('sources', 'Sources', { fixed: true, notes: 'Sources reached and not reached' })], options: OPTIONS });
68
+ const Q1 = [{ q: 'Who reads it, and what do they decide with it?', chips: ['Leadership: where to invest next quarter', 'Product team: what to fix next', 'Both'] }, { q: 'Anything that must be in it?', chips: ['Revenue', 'Releases this month', 'Nothing more'] }];
69
+
70
+ function makeStub() {
71
+ const S = { versions: [], plans: {}, log: [], current: null, saved: null, running: null, ticks: 0, dry: null, dryResult: null, checks: {}, posts: [], gets: [], chartsRead: 0, polls: 0, setupMade: 0, designStep: 0 };
72
+ const now = () => '2026-10-12T22:' + String(10 + S.versions.length * 3 + S.log.length).padStart(2, '0') + ':00.000Z';
73
+ const land = (turn, plan, answer, extra) => { const n = S.versions.length + 1; S.versions.push({ n, at: now(), turn }); S.plans[n] = plan; S.current = n; S.log.push({ who: 'pmos', text: answer, at: now(), n, questions: [], options: [], ...(extra || {}) }); };
74
+ const view = () => {
75
+ if (S.running) { S.ticks++; if (S.ticks >= 2) { const r = S.running; S.running = null; r.done(); } }
76
+ return { ok: true, setup: { id: SETUP.id, name: SETUP.name, cadence: 'monthly' }, structure: { running: !!S.running, job: S.running ? { kind: S.running.kind } : null, current: S.current, saved: S.saved, versions: S.versions.slice(), log: S.log.slice(),
77
+ plan: S.current ? S.plans[S.current] : null, dry: S.dry, checks: S.checks, errors: [] } };
78
+ };
79
+ const chromeChecks = (pass) => ({ source: 'looker', kind: 'chrome', at: now(), running: false, ok: pass, steps: [
80
+ { id: 'mac', label: 'PMOS runs on this Mac', state: 'works', reason: 'The read runs on your MacBook.' }, { id: 'session', label: 'A session can start with Claude in Chrome', state: 'works', reason: 'PMOS started a test session.' },
81
+ { id: 'extension', label: 'Claude in Chrome answers', state: 'works', reason: 'The extension answered.' },
82
+ { id: 'signed-in', label: 'You are signed in to Looker Studio', state: pass ? 'works' : 'needs-you', reason: pass ? 'Opened the report as you.' : 'Chrome showed the Google sign-in page. Sign in once in Chrome yourself, then check again.' },
83
+ { id: 'read', label: 'PMOS can read the numbers', state: pass ? 'works' : 'waiting', reason: pass ? 'Read the table "Spend by campaign".' : 'Waits for the step above.' }] });
84
+ const fixedPlan = (n) => JSON.parse(JSON.stringify(S.plans[n]));
85
+ const route = async (route) => {
86
+ const req = route.request(); const u = new URL(req.url()); const p = u.pathname; const m = req.method();
87
+ const json = (b, status) => route.fulfill({ status: status || 200, contentType: 'application/json', body: JSON.stringify(b) });
88
+ const body = m === 'POST' ? JSON.parse(req.postData() || '{}') : null;
89
+ if (m === 'POST') S.posts.push({ path: p, body });
90
+ if (m === 'GET' && p === '/api/report-structure') { S.polls++; return json(view()); }
91
+ if (m === 'GET' && p === '/api/report-structure/version') return json({ ok: true, plan: S.plans[Number(u.searchParams.get('n'))] });
92
+ if (m === 'GET' && p === '/api/report-structure/dry') return json({ ok: true, dry: S.dryResult });
93
+ if (m === 'GET' && p === '/api/charts') { S.chartsRead++; return json({ ok: true, charts: [{ id: 'new-users', name: 'New users', question: 'Are we still bringing people in?', source: { kind: 'connector', name: 'Google Analytics 4', via: 'Supermetrics' }, from: 'GA4 newUsers', how: 'Month total', compare: 'Previous month', flag: 'Down 10% or more', query: 'GA4 Data API: metric newUsers', why: 'The top of the funnel.', users: [{ kind: 'dashboard', id: 'health', name: 'MySPH health' }, { kind: 'report', id: SETUP.id, name: SETUP.name }] }] }); }
94
+ if (m === 'POST' && p === '/api/report-structure/turn') {
95
+ if (S.running) return json({ ok: false, error: 'a report-structure job is already running' }, 409);
96
+ S.ticks = 0; S.log.push({ who: 'pm', text: body.text, at: now() });
97
+ let done;
98
+ if (!S.versions.length && !S.asked) done = () => { S.asked = true; S.log.push({ who: 'pmos', text: 'Before I propose a structure, two questions.', at: now(), questions: Q1, options: [] }); };
99
+ else if (!S.versions.length) done = () => land('From your answers', plan1(), 'Three ways to shape it. I recommend Funnel first. I drafted it as S1.', { options: OPTIONS });
100
+ else if (/^Test pull/.test(body.text)) done = () => { S.log.push({ who: 'pmos', text: 'Read it: both charts returned a number.', at: now(), questions: [], options: [] }); };
101
+ else done = () => { const pl = fixedPlan(S.current); pl.charts.push({ ...CH_RET }); pl.outline.splice(2, 0, SEC('ret', 'Retention by cohort', { charts: ['ret'], notes: 'Do the people we bring in stay?', show: 'Cohort table' })); land('Retention added', pl, 'S2: D30 retention added.'); };
102
+ S.running = { kind: 'report-structure', done };
103
+ return json({ ok: true, running: true });
104
+ }
105
+ if (m === 'POST' && p === '/api/report-structure/revert') { S.current = body.n; return json({ ok: true, structure: view().structure }); }
106
+ if (m === 'POST' && p === '/api/report-structure/remove') { const pl = fixedPlan(S.current); if (body.kind === 'chart') { pl.charts = pl.charts.filter((c) => c.id !== body.ref); pl.outline.forEach((o) => { o.charts = o.charts.filter((c) => c !== body.ref); }); } else pl.sources = pl.sources.filter((s) => s.id !== body.ref); land('Remove ' + body.ref, pl, 'Removed ' + body.ref + '.'); return json({ ok: true, structure: view().structure }); }
107
+ if (m === 'POST' && p === '/api/report-structure/source') {
108
+ const pl = fixedPlan(S.current); const s = body.source; const grp = ['connector', 'link', 'upload', 'person', 'chrome', 'computer'].includes(s.kind) ? 'outside' : 'pmos';
109
+ pl.sources.push({ via: '', ref: '', ...s, group: grp, state: s.kind === 'chrome' ? 'needs-you' : 'reached' });
110
+ land('Add ' + s.name, pl, s.name + ' added.');
111
+ if (s.kind === 'chrome') S.checks[s.id] = { ...chromeChecks(false), source: s.id };
112
+ return json({ ok: true, structure: view().structure });
113
+ }
114
+ if (m === 'POST' && p === '/api/report-structure/check') { S.checks[body.source] = { ...chromeChecks(true), source: body.source }; const pl = S.plans[S.current]; const s = pl.sources.find((x) => x.id === body.source); if (s) s.state = 'reached'; return json({ ok: true, check: S.checks[body.source] }); }
115
+ if (m === 'POST' && p === '/api/report-structure/dry') { S.ticks = 0; S.running = { kind: 'report-structure-dry', done: () => { S.dry = { run: 'r1', n: body.n || S.current, at: now(), ok: true };
116
+ S.dryResult = { n: S.dry.n, period: '2026-09', headline: 'New users fell 8.9% while cost per new user rose 19%.', values: [{ chart: 'new-users', value: '48,210', was: '52,930 in Aug', delta: '-8.9%', flag: true, note: 'down 10% or more is the rule' }, { chart: 'cpa', value: 'IDR 21,900', was: 'IDR 18,400 in Aug', delta: '+19%', flag: false, note: '' }], missing: [{ source: 'bq', reason: 'BigQuery is not connected.' }], options: ['Fix the first 30 days', 'Restart paid acquisition'] }; } }; return json({ ok: true, running: true }); }
117
+ if (m === 'POST' && p === '/api/report-structure/accept') { S.saved = body.n; return json({ ok: true, structure: view().structure, brief: 'structure/brief.md', charts: ['cpa'] }); }
118
+ if (m === 'GET' && p === '/api/report-setups') return json({ ok: true, setups: S.setupMade ? [SETUP] : [SETUP], findings: [], asks: S.askShown ? [{ id: 'ask1', text: 'Monthly product performance for leadership, numbers from Google Analytics', state: 'ready', proposal: PROPOSAL }] : [], finder: { cadence: 'off' } });
119
+ if (m === 'POST' && p === '/api/report-setups/setup') { S.setupMade++; return json({ ok: true, setup: SETUP, drafted: null }); }
120
+ if (m === 'GET' && p === '/api/report-documents') return json({ ok: true, docs: [] });
121
+ if (m === 'GET' && p === '/api/report-dashboards') return json({ ok: true, library: [{ id: 'north', title: 'North star', question: 'Are we moving?', source: 'PMOS ledgers' }], layouts: {}, defaults: { board: [], initiative: [] }, boards: [], initiatives: [], schedules: [] });
122
+ if (m === 'GET' && p === '/api/report-design') return json({ ok: true, setup: { id: SETUP.id, name: SETUP.name }, design: { running: false, current: null, saved: null, versions: [], log: [], samples: [], description: '', format: 'deck' } });
123
+ return route.continue();
124
+ };
125
+ return { S, route };
126
+ }
127
+
128
+ (async () => {
129
+ const exe = findBrowser();
130
+ if (!exe) { console.log('· no Chrome/Chromium on this machine, c3-report-structure suite skipped'); process.exit(0); }
131
+ let chromium;
132
+ try { ({ chromium } = require('playwright-core')); } catch { console.log('· playwright-core not installed, c3-report-structure suite skipped'); process.exit(0); }
133
+ srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT) }) });
134
+ await T.waitForServer(BASE);
135
+ browser = await chromium.launch({ executablePath: exe });
136
+ require('./lib/c3-full-console').fullConsole(browser); // the desktop layout at phone width (c3-phone-companion)
137
+ const shots = process.env.RX_SHOTS || ''; if (shots) fs.mkdirSync(shots, { recursive: true });
138
+ const shot = async (pg, n) => { if (shots) await pg.screenshot({ path: path.join(shots, n + '.png') }); };
139
+
140
+ const errors = [];
141
+ const stub = makeStub(); const S = stub.S;
142
+ const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
143
+ await ctx.route(/\/api\/(report-structure|charts|report-setups|report-documents|report-dashboards|report-design)/, stub.route);
144
+ const page = await ctx.newPage();
145
+ page.on('pageerror', (e) => errors.push('pageerror: ' + String((e && e.message) || e)));
146
+ page.on('console', (m) => { if (m.type() === 'error' && !/favicon|Failed to load resource/.test(m.text())) errors.push('console: ' + m.text()); });
147
+ page.on('response', (r) => { if (r.status() >= 400 && !/\/api\/(scan\/(?:today|portfolio)|settings|report-structure)\b/.test(r.url()) && !(r.status() === 409)) errors.push('http ' + r.status() + ' ' + r.url()); });
148
+ page.on('dialog', (d) => d.dismiss().catch(() => {}));
149
+ const text = (sel) => page.textContent(sel).catch(() => '');
150
+ const sent = (p) => S.posts.filter((x) => x.path === p);
151
+ const closeAll = async () => { for (let i = 0; i < 3 && await page.$('.sheet'); i++) { await page.keyboard.press('Escape'); await sleep(250); } };
152
+ const goReports = async () => { await page.goto(BASE + '/c3.html?tab=rep'); await page.waitForSelector('[data-rp3]'); await page.click('[data-rp3-tab="reports"]'); await page.waitForSelector('[data-rs-reg="' + SETUP.id + '"]', { timeout: 15000 }); };
153
+ const idle = () => page.waitForFunction(() => { const b = document.querySelector('[data-st-act="send"]'); return b && !b.disabled && !document.querySelector('[data-st-running]'); }, null, { timeout: 15000 });
154
+ const noOverflow = () => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1);
155
+
156
+ /* ── entry points ── */
157
+ S.askShown = true;
158
+ await goReports();
159
+ ok('the set-up row has Shape the report before Design how it looks', await page.$$eval('[data-rs-reg="' + SETUP.id + '"] .acts button', (a) => { const t = a.map((b) => b.textContent.trim()); return t.indexOf('Shape the report') > -1 && t.indexOf('Shape the report') < t.indexOf('Design how it looks'); }));
160
+ await page.waitForSelector('[data-rs-card="ask1"]');
161
+ ok('an Ask PMOS answer has Shape it with PMOS', !!(await page.$('[data-rs-card="ask1"] [data-rs-act="shape"]')));
162
+ await page.click('[data-rs-card="ask1"] [data-rs-act="shape"]');
163
+ await page.waitForSelector('[data-st-studio]');
164
+ ok('Shape it with PMOS sets the report up, then opens the Structure step', sent('/api/report-setups/setup').length === 1 && sent('/api/report-setups/setup')[0].body.ask === 'ask1' && /Not saved yet/.test(await text('[data-st-studio]')));
165
+ ok('the step bar says 1 Structure, 2 Design with Structure pressed', await page.getAttribute('[data-st-step-btn="structure"]', 'aria-pressed') === 'true' && /2 Design/.test(await text('[data-st-steps]')));
166
+ ok('with no version yet the studio asks what the report is for and the Use button waits', !!(await page.$('[data-st-studio] .fold')) && await page.$eval('[data-st-act="save"]', (b) => b.disabled));
167
+ await closeAll();
168
+ S.askShown = false;
169
+ await goReports();
170
+ await page.click('[data-rs-reg="' + SETUP.id + '"] [data-rs-act="shape"]');
171
+ await page.waitForSelector('[data-st-studio]');
172
+ ok('Shape the report on the row opens the same studio', /Not saved yet/.test(await text('[data-st-studio]')));
173
+ await closeAll();
174
+ await page.click('[data-rs-reg="' + SETUP.id + '"] [data-rs-act="design"]');
175
+ await page.waitForSelector('[data-rd-studio]');
176
+ ok('the Design studio shows the step bar with 2 Design pressed', await page.getAttribute('[data-rd-step="design"]', 'aria-pressed') === 'true' && await page.getAttribute('[data-rd-step="structure"]', 'aria-pressed') === 'false');
177
+ await page.click('[data-rd-step="structure"]');
178
+ await page.waitForSelector('[data-st-studio]');
179
+ ok('1 Structure in the Design studio opens the Structure step', !!(await page.$('[data-st-steps]')));
180
+ await page.click('[data-st-step-btn="design"]');
181
+ await page.waitForSelector('[data-rd-studio]');
182
+ ok('2 Design in the Structure step opens the Design studio', true);
183
+ await page.click('[data-rd-step="structure"]');
184
+ await page.waitForSelector('[data-st-studio]');
185
+
186
+ /* ── a first turn: PMOS asks, answer chips ── */
187
+ await page.fill('[data-st-in]', 'I need a monthly product performance report for leadership. Get the numbers from Google Analytics.');
188
+ await page.click('[data-st-act="send"]');
189
+ ok('a turn posts the words as ONE turn', !!(await until(() => sent('/api/report-structure/turn').length === 1)) && /monthly product performance/.test(sent('/api/report-structure/turn')[0].body.text));
190
+ ok('while the job runs the conversation says PMOS is working and Make the next version waits', !!(await until(() => page.$('[data-st-running]'))));
191
+ await page.waitForSelector('[data-st-question]', { timeout: 15000 });
192
+ ok('PMOS asks its questions with answer chips', (await page.$$('[data-st-question]')).length === 2 && (await page.$$('[data-st-question] [data-st-act="chip"]')).length === 6);
193
+ await page.click('[data-st-act="chip"][data-v="Leadership: where to invest next quarter"]');
194
+ ok('a chip puts its answer in the box', (await page.inputValue('[data-st-in]')) === 'Leadership: where to invest next quarter.');
195
+ await page.click('[data-st-act="chip"][data-v="Revenue"]');
196
+ ok('a second chip adds to the box', /next quarter\. Revenue\.$/.test(await page.inputValue('[data-st-in]')));
197
+ await page.click('[data-st-act="send"]');
198
+ await page.waitForSelector('[data-st-v="1"]', { timeout: 15000 });
199
+ ok('the second turn lands S1', (await page.$$('[data-st-v]')).length === 1 && await page.getAttribute('[data-st-v="1"]', 'aria-pressed') === 'true');
200
+ await page.waitForSelector('[data-st-purpose]');
201
+ ok('the plan shows What it answers with readers, decision, scope and the questions', /Leadership: Gita Rahma/.test(await text('[data-st-purpose]')) && /Where to invest next quarter/.test(await text('[data-st-purpose]')) && /Where do users drop/.test(await text('[data-st-purpose]')));
202
+
203
+ /* ── sources ── */
204
+ const grp = (g) => page.$$eval('[data-st-group="' + g + '"] + ul > li', (a) => a.map((li) => li.getAttribute('data-st-src')));
205
+ ok('Sources are grouped: From PMOS and Outside PMOS', JSON.stringify(await grp('pmos')) === '["board","dash","past"]' && JSON.stringify(await grp('outside')) === '["ga","bq"]', JSON.stringify([await grp('pmos'), await grp('outside')]));
206
+ ok('each source shows its state', await page.getAttribute('[data-st-src="ga"] [data-st-state]', 'data-st-state') === 'reached' && /Not connected/.test(await text('[data-st-src="bq"] [data-st-state]')) && /First cycle: nothing to read/.test(await text('[data-st-src="past"] [data-st-state]')));
207
+ ok('the actions fit the source: Open, Test pull, How to connect', /Open/.test(await text('[data-st-src="board"] .acts')) && /Test pull/.test(await text('[data-st-src="ga"] .acts')) && /How to connect/.test(await text('[data-st-src="bq"] .acts')));
208
+ ok('a source says how many charts use it', /2 charts/.test(await text('[data-st-src="ga"]')) && !/ chart/.test(await text('[data-st-src="board"] .chips')));
209
+ await page.click('[data-st-src="ga"] [data-st-act="test"]');
210
+ ok('Test pull asks PMOS to read that source (one turn)', !!(await until(() => sent('/api/report-structure/turn').length === 3)) && /Test pull from Google Analytics 4/.test(sent('/api/report-structure/turn')[2].body.text));
211
+ await idle();
212
+ await page.click('[data-st-src="bq"] [data-st-act="connect"]');
213
+ ok('How to connect says where to connect it', /Settings, Connectors/.test(await text('[data-c3-toast]')));
214
+
215
+ /* ── charts ── */
216
+ ok('Charts are tagged New chart and From Charts', await text('[data-st-chart="cpa"] [data-st-tag]') === 'New chart' && await text('[data-st-chart="new-users"] [data-st-tag]') === 'From Charts' && /1 new, 1 from Charts/.test(await text('[data-st-metrics]')));
217
+ await page.click('[data-st-chart="new-users"] [data-st-act="chart"]');
218
+ await page.waitForSelector('[data-st-detail="new-users"]');
219
+ ok('a chart row opens to question, used in, what PMOS reads and why', /Are we still bringing people in/.test(await text('[data-st-q]')) && /GA4 Data API: metric newUsers/.test(await text('[data-st-query]')) && /top of the funnel/.test(await text('[data-st-why]')));
220
+ ok('Used in names the dashboard and report that use a chart from Charts', !!(await until(async () => /dashboard MySPH health/.test(await text('[data-st-used]')) && /this report/.test(await text('[data-st-used]')))), await text('[data-st-used]'));
221
+ ok('a chart from Charts has Open in Charts, a new chart does not', !!(await page.$('[data-st-detail="new-users"] [data-st-act="opencharts"]')));
222
+ await page.click('[data-st-chart="cpa"] [data-st-act="chart"]');
223
+ ok('only one row is open and a new chart says it is saved in Charts when you use the structure', !!(await page.$('[data-st-detail="cpa"]')) && !(await page.$('[data-st-detail="new-users"]')) && /Saved in Charts when you use this structure/.test(await text('[data-st-used]')) && !(await page.$('[data-st-detail="cpa"] [data-st-act="opencharts"]')));
224
+ ok('the test pull says it is not tried yet', /Not tried on this version/.test(await text('[data-st-testpull]')));
225
+ await shot(page, 'structure-logic-1440');
226
+ await page.click('[data-st-detail="cpa"] [data-st-act="discuss"]');
227
+ ok('Discuss this chart fills the box with the chart name and focuses it', (await page.inputValue('[data-st-in]')) === 'About Cost per new user: ' && await page.evaluate(() => document.activeElement && document.activeElement.hasAttribute('data-st-in')));
228
+
229
+ /* ── the outline ── */
230
+ ok('the Outline lists the sections in order with chart chips and PMOS notes', JSON.stringify(await page.$$eval('[data-st-section]', (a) => a.map((x) => x.getAttribute('data-st-section')))) === '["headline","funnel","needs","sources"]'
231
+ && /New users/.test(await text('[data-st-section="funnel"]')) && /PMOS notes/.test(await text('[data-st-section="funnel"]')) && !/PMOS notes/.test(await text('[data-st-section="headline"]')) && /In every report/.test(await text('[data-st-section="headline"]')));
232
+
233
+ /* ── a refine turn: S2 ── */
234
+ await idle();
235
+ await page.fill('[data-st-in]', 'Add retention by cohort.');
236
+ await page.click('[data-st-act="send"]');
237
+ await page.waitForSelector('[data-st-v="2"]', { timeout: 15000 });
238
+ ok('a refine turn lands S2 and the strip lists S1 and S2', (await page.$$('[data-st-v]')).length === 2 && await page.getAttribute('[data-st-v="2"]', 'aria-pressed') === 'true');
239
+ await page.waitForSelector('[data-st-chart="ret"]');
240
+ ok('S2 has the retention chart and a section that says New in S2', !!(await page.$('[data-st-chart="ret"]')) && /New in S2/.test(await text('[data-st-section="ret"]')) && !/New in S2/.test(await text('[data-st-section="funnel"]')));
241
+ await shot(page, 'structure-1440');
242
+ await page.$eval('[data-st-sources]', (n) => n.scrollIntoView({ block: 'start' })); await sleep(200); await shot(page, 'structure-sources-1440');
243
+ ok('the conversation names each version with a link', (await page.$$('[data-st-log] [data-st-act="ver"]')).length === 2);
244
+ await page.click('[data-st-v="1"]');
245
+ await until(async () => await page.getAttribute('[data-st-v="1"]', 'aria-pressed') === 'true' && !(await page.$('[data-st-chart="ret"]')));
246
+ ok('S1 shows again without the retention chart and says it is an older version', !(await page.$('[data-st-chart="ret"]')) && /older version/.test(await text('[data-st-vcap]')));
247
+ await page.click('[data-st-act="revert"]');
248
+ ok('Go back to S1 posts a revert for n 1', !!(await until(() => sent('/api/report-structure/revert').length === 1)) && sent('/api/report-structure/revert')[0].body.n === 1);
249
+ await until(async () => !(await page.$('[data-st-act="revert"]')));
250
+ await page.click('[data-st-v="2"]');
251
+ await page.waitForSelector('[data-st-chart="ret"]');
252
+ await page.click('[data-st-act="revert"]');
253
+ ok('going back to S2 the same way makes it current again', !!(await until(() => sent('/api/report-structure/revert').length === 2)) && sent('/api/report-structure/revert')[1].body.n === 2);
254
+ await until(async () => !(await page.$('[data-st-act="revert"]')));
255
+
256
+ /* ── Remove it ── */
257
+ await page.click('[data-st-chart="ret"] [data-st-act="chart"]');
258
+ await page.click('[data-st-detail="ret"] [data-st-act="drop"]');
259
+ ok('Remove it posts a remove of that chart', !!(await until(() => sent('/api/report-structure/remove').length === 1)) && sent('/api/report-structure/remove')[0].body.kind === 'chart' && sent('/api/report-structure/remove')[0].body.ref === 'ret');
260
+ await page.waitForSelector('[data-st-v="3"]', { timeout: 10000 });
261
+ ok('removing makes the next version, S3, without the chart', !(await page.$('[data-st-chart="ret"]')) && (await page.$$('[data-st-v]')).length === 3);
262
+
263
+ /* ── Add a source ── */
264
+ await page.click('[data-st-act="panel"][aria-expanded]');
265
+ await page.waitForSelector('[data-st-panel]');
266
+ const optKinds = await page.$$eval('[data-st-panel] [data-st-act="opt"]', (a) => a.map((b) => b.getAttribute('data-v')));
267
+ ok('Add a source offers every From PMOS and Outside PMOS kind', JSON.stringify(optKinds) === JSON.stringify(['pmos-level', 'dashboard', 'chart', 'document', 'past-report', 'connector', 'link', 'upload', 'person', 'chrome', 'computer']), optKinds.join(','));
268
+ ok('the panel has the two groups and says PMOS only reads', /From PMOS/.test(await text('[data-st-panel]')) && /Outside PMOS/.test(await text('[data-st-panel]')) && /PMOS only reads/.test(await text('[data-st-panel]')));
269
+ await shot(page, 'structure-pmos-1440');
270
+ await page.click('[data-st-panel] [data-st-act="opt"][data-v="document"]');
271
+ await page.waitForSelector('[data-st-form="document"]');
272
+ await page.click('[data-st-form="document"] [data-st-act="add"]');
273
+ ok('a form needs its name before it adds', sent('/api/report-structure/source').length === 0);
274
+ await page.fill('[data-st-form="document"] [data-st-field="name"]', 'MySPH strategy 2026');
275
+ await page.click('[data-st-form="document"] [data-st-act="add"]');
276
+ ok('a From PMOS source (a document) posts a source of that kind', !!(await until(() => sent('/api/report-structure/source').length === 1)) && sent('/api/report-structure/source')[0].body.source.kind === 'document' && sent('/api/report-structure/source')[0].body.source.name === 'MySPH strategy 2026' && !('state' in sent('/api/report-structure/source')[0].body.source));
277
+ await page.waitForSelector('[data-st-v="4"]', { timeout: 10000 });
278
+ ok('it joins the From PMOS group in S4', JSON.stringify(await grp('pmos')) === '["board","dash","past","mysph-strategy-2026"]', JSON.stringify(await grp('pmos')));
279
+ await page.click('[data-st-act="panel"][aria-expanded]');
280
+ await page.click('[data-st-panel] [data-st-act="opt"][data-v="chrome"]');
281
+ await page.waitForSelector('[data-st-form="chrome"]');
282
+ await page.fill('[data-st-form="chrome"] [data-st-field="name"]', 'Looker Studio: Paid acquisition');
283
+ await page.fill('[data-st-form="chrome"] [data-st-field="ref"]', 'not a link');
284
+ await page.click('[data-st-form="chrome"] [data-st-act="add"]');
285
+ ok('a Chrome page needs a link that starts with https://', /starts with https/.test(await text('[data-c3-toast]')) && !S.posts.some((x) => x.body && x.body.source && x.body.source.kind === 'chrome'));
286
+ await page.fill('[data-st-form="chrome"] [data-st-field="ref"]', 'https://lookerstudio.google.com/reporting/abc');
287
+ await page.click('[data-st-form="chrome"] [data-st-act="add"]');
288
+ await page.waitForSelector('[data-st-check]', { timeout: 10000 });
289
+ const lid = sent('/api/report-structure/source').pop().body.source.id;
290
+ ok('a Chrome source shows its check step by step', (await page.$$('[data-st-check] [data-st-step]')).length === 5 && await page.getAttribute('[data-st-check] [data-st-step="signed-in"]', 'data-st-step-state') === 'needs-you');
291
+ ok('a failing step says Needs you with its reason; the steps after it Wait', /Needs you/.test(await text('[data-st-step="signed-in"]')) && /Sign in once in Chrome/.test(await text('[data-st-step="signed-in"]')) && /Waiting/.test(await text('[data-st-step="read"]')) && /Works/.test(await text('[data-st-step="mac"]')));
292
+ ok('the check says one step needs you, what the session may do, and the 4 hour wait', /1 step needs you/.test(await text('[data-st-check-state]')) && /Only the browser tools/.test(await text('[data-st-check]')) && /up to 4 hours/.test(await text('[data-st-check]')));
293
+ ok('the source row is in Outside PMOS with Needs you and Check it works', /Needs you/.test(await text('[data-st-src="' + lid + '"] [data-st-state]')) && /Check it works/.test(await text('[data-st-src="' + lid + '"] .acts')) && /Re-checked 1 hour before each draft/.test(await text('[data-st-src="' + lid + '"]')));
294
+ await shot(page, 'structure-browser-1440');
295
+ ok('Check again is offered', /Check again/.test(await text('[data-st-check] [data-st-act="chkagain"]')));
296
+ await page.click('[data-st-check] [data-st-act="chkagain"]');
297
+ ok('Check again posts a check for that source', !!(await until(() => sent('/api/report-structure/check').length === 1)) && sent('/api/report-structure/check')[0].body.source === lid);
298
+ await until(async () => /All steps work/.test(await text('[data-st-check-state]')));
299
+ ok('the second check passes: every step Works and the source is Reached', /All steps work/.test(await text('[data-st-check-state]')) && (await page.$$eval('[data-st-check] [data-st-step-state="works"]', (a) => a.length)) === 5 && /Reached/.test(await text('[data-st-src="' + lid + '"] [data-st-state]')));
300
+ await shot(page, 'structure-browser-ok-1440');
301
+ await page.click('[data-st-src="' + lid + '"] [data-st-act="chkopen"]');
302
+ ok('Check it works on the row posts a check', !!(await until(() => sent('/api/report-structure/check').length === 2)));
303
+
304
+ /* ── Other structures ── */
305
+ await page.click('[data-st-view="options"]');
306
+ await page.waitForSelector('[data-st-options]');
307
+ ok('Other structures shows three side by side with the recommended first', JSON.stringify(await page.$$eval('[data-st-option]', (a) => a.map((x) => x.getAttribute('data-st-option')))) === '["Funnel first","Goals first","Features first"]' && /Recommended/.test(await text('[data-st-option="Funnel first"]')));
308
+ ok('each option has its sections, good for and misses', /GOOD FOR/.test(await text('[data-st-option="Goals first"]')) && /MISSES/.test(await text('[data-st-option="Goals first"]')) && (await page.$$('[data-st-option="Goals first"] li')).length === 3);
309
+ const cols = await page.$$eval('[data-st-option]', (a) => a.map((x) => Math.round(x.getBoundingClientRect().top)));
310
+ ok('the three options sit in one row at 1440 wide', cols.every((t) => Math.abs(t - cols[0]) < 4), cols.join(','));
311
+ await shot(page, 'structure-options-1440');
312
+ await page.click('[data-st-option="Goals first"] [data-st-act="choose"]');
313
+ ok('Use this instead puts the choice in the box and goes back to the plan', (await page.inputValue('[data-st-in]')) === 'Use the "Goals first" structure instead.' && await page.getAttribute('[data-st-view="plan"]', 'aria-pressed') === 'true');
314
+ await page.fill('[data-st-in]', '');
315
+
316
+ /* ── Try it with real data ── */
317
+ await page.click('[data-st-view="dry"]');
318
+ await page.waitForSelector('[data-st-nodry]');
319
+ ok('Try it with real data explains that it reads only and saves nothing', /saves nothing/.test(await text('[data-st-nodry]')));
320
+ await page.click('[data-st-act="dryrun"]');
321
+ ok('the run posts ONE dry job for the version on screen', !!(await until(() => sent('/api/report-structure/dry').length === 1)) && sent('/api/report-structure/dry')[0].body.n === 5);
322
+ ok('while it runs the view says so and nothing is saved', !!(await until(() => page.$('[data-st-dryrunning]'))));
323
+ await page.waitForSelector('[data-st-dry-headline]', { timeout: 15000 });
324
+ ok('the result shows the headline PMOS would write', /New users fell 8\.9%/.test(await text('[data-st-dry-headline]')));
325
+ ok('the values show each chart with its comparison', /New users/.test(await text('[data-st-dry-values]')) && /48,210/.test(await text('[data-st-dry-values]')) && /52,930 in Aug/.test(await text('[data-st-dry-values]')) && /\+19%/.test(await text('[data-st-dry-values]')));
326
+ ok('a flagged value is flagged and listed under Flags', /Flagged/.test(await text('[data-st-dry-values]')) && /New users/.test(await text('[data-st-dry-flags]')));
327
+ ok('what it could not get names the source and why', /BigQuery/.test(await text('[data-st-dry-missing]')) && /not connected/.test(await text('[data-st-dry-missing]')));
328
+ ok('the options PMOS would propose show, with the note that the PM decides', /Fix the first 30 days/.test(await text('[data-st-dry-options]')) && /PMOS never decides them/.test(await text('[data-st-dry-options]')));
329
+ await shot(page, 'structure-dry-1440');
330
+ await page.click('[data-st-view="plan"]');
331
+ await page.click('[data-st-chart="cpa"] [data-st-act="chart"]');
332
+ ok('a chart row now shows its test pull value', /IDR 21,900/.test(await text('[data-st-testpull]')) && /\+19%/.test(await text('[data-st-testpull]')));
333
+
334
+ /* ── Use this structure ── */
335
+ await page.click('[data-st-act="save"]');
336
+ ok('Use this structure posts the version on screen', !!(await until(() => sent('/api/report-structure/accept').length === 1)) && sent('/api/report-structure/accept')[0].body.n === 5);
337
+ await page.waitForSelector('[data-rd-brief]', { timeout: 10000 });
338
+ await page.waitForSelector('[data-st-saved-wrap]');
339
+ ok('the report setup sheet shows the saved structure beside the Design', /Structure S5/.test(await text('[data-st-saved-tag]')) && !!(await page.$('[data-rd-design]')));
340
+ ok('the saved structure lists what it answers, the sources, the charts and the outline', /Where to invest next quarter/.test(await text('[data-st-answers]')) && /MySPH strategy 2026/.test(await text('[data-st-brief]')) && /Cost per new user/.test(await text('[data-st-brief]')) && /Needs your call/.test(await text('[data-st-brief]')));
341
+ ok('it offers Refine the structure and Try it with real data', /Refine the structure/.test(await text('[data-st-brief]')) && /Try it with real data/.test(await text('[data-st-brief]')));
342
+ ok('it says a change applies from the next cycle and the Chrome source is re-checked', /applies from the next cycle/.test(await text('[data-st-saved-wrap]')) && /Looker Studio: Paid acquisition/.test(await text('[data-st-saved-wrap]')) && /waits up to 4 hours/.test(await text('[data-st-saved-wrap]')));
343
+ await shot(page, 'structure-saved-1440');
344
+ await page.click('[data-st-brief] [data-st-act="opendry"]');
345
+ await page.waitForSelector('[data-st-studio]');
346
+ ok('Try it with real data in the setup sheet opens the studio on that view', await page.getAttribute('[data-st-view="dry"]', 'aria-pressed') === 'true' && /Saved structure: S5/.test(await text('[data-st-studio]')));
347
+ await closeAll();
348
+
349
+ /* ── the Charts tab ── */
350
+ await goReports();
351
+ await page.click('[data-rp3-tab="charts"]');
352
+ await page.waitForSelector('[data-rp3-data-chart="new-users"]');
353
+ ok('the Charts tab lists a data chart beside the built-in ones', !!(await page.$('[data-rp3-lib] [data-home="north"]')) && !!(await page.$('[data-rp3-lib] [data-rp3-data-chart="new-users"]')) && /Data chart/.test(await text('[data-rp3-data-chart="new-users"]')));
354
+ ok('the data chart shows its source and Used in', /Google Analytics 4 through Supermetrics/.test(await text('[data-rp3-source="new-users"]')) && /Used in: .*MySPH health.*MySPH app performance, monthly/.test(await text('[data-rp3-data-chart="new-users"] [data-rp3-used]')));
355
+ ok('the Charts tab counts both kinds', /2/.test(await text('[data-rp3-tab="charts"]')));
356
+ await page.click('[data-rp3-data-chart="new-users"] .tt').catch(() => {});
357
+ await shot(page, 'structure-charts-tab-1440');
358
+
359
+ /* ── the saved structure and the studio at 400 wide ── */
360
+ await page.setViewportSize({ width: 400, height: 900 });
361
+ await goReports();
362
+ ok('at 400 wide the Reports tab does not scroll sideways', await noOverflow());
363
+ await page.click('[data-rs-reg="' + SETUP.id + '"] [data-rs-act="shape"]');
364
+ await page.waitForSelector('[data-st-chart]');
365
+ ok('at 400 wide the Structure step does not scroll sideways', await noOverflow());
366
+ await shot(page, 'structure-400');
367
+ await page.click('[data-st-chart="cpa"] [data-st-act="chart"]');
368
+ await page.click('[data-st-act="panel"][aria-expanded]');
369
+ await page.waitForSelector('[data-st-panel]');
370
+ ok('at 400 wide the open chart and the Add a source panel do not scroll the page sideways', await noOverflow());
371
+ await shot(page, 'structure-pmos-400');
372
+ await page.click('[data-st-view="options"]');
373
+ ok('at 400 wide Other structures stack and do not scroll sideways', await noOverflow());
374
+ await shot(page, 'structure-options-400');
375
+ await page.click('[data-st-view="dry"]');
376
+ ok('at 400 wide the dry run does not scroll sideways', await noOverflow());
377
+ await shot(page, 'structure-dry-400');
378
+ await page.setViewportSize({ width: 1440, height: 1000 });
379
+
380
+ ok('no script error and no failed request', errors.length === 0, errors.join(' | '));
381
+ await finish();
382
+ })().catch((e) => { console.error(e); finish(1); });
@@ -74,6 +74,8 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
74
74
  - [Report slides](report-slides.md): how the Reports page shows a report as the PM-OS biweekly slides, Present and Print / PDF, and how your notes become the words on the slides.
75
75
  - [Reports: dashboards of reports](reports-dashboards.md): the console Reports page, its dashboards at portfolio, board and initiative scope, how to edit one, how to send one as a report, and the Charts tab.
76
76
  - [Set up a report by asking, and the report finder](reports-set-up.md): how to ask PMOS to draft a report you send, how the finder finds reports you already send, and how a cycle becomes a document.
77
+ - [Shape what a report says: the Structure step](report-structure.md): how to decide with PMOS what a report answers, where each number comes from and how it is worked out, add a page in Chrome or an app as a source, and try the plan on real data.
78
+ - [Shape what a report says: the Structure step](report-structure.md): how to decide with PMOS what a report answers, where each number comes from and how it is worked out, add a page in Chrome or an app as a source, and try the plan on real data.
77
79
  - [Design how a report looks, and read it as a deck](report-design.md): how to design a report in the design studio, read each cycle as a deck or an interactive document, present it, and use speaker notes and notes on the slide.
78
80
  - [The document sheet](document-sheet.md): read a document as rich text, edit it, accept the agent's proposals section by section, read the edit log, and clear a stale document.
79
81
  - [Ask the agent from the page](ask-the-agent.md): the five presses that start an agent from the console, what the agent may do, and what stays yours.
@@ -13,6 +13,10 @@ There is no template to fill in. You describe the report, add samples, and refin
13
13
 
14
14
  ## Open the design studio
15
15
 
16
+ Design is step 2 of the report studio. Step 1 is Structure, which decides what the report says (see [Shape what a report says](report-structure.md)). Press **1 Structure** at the top of the studio to go to it.
17
+
18
+ Design is step 2 of the report studio. Step 1 is Structure, which decides what the report says (see [Shape what a report says](report-structure.md)). Press **1 Structure** at the top of the studio to go to it.
19
+
16
20
  Use one of these two buttons:
17
21
 
18
22
  - In the **Set up** list of the Reports tab, press **Design how it looks** on the row of the report.
@@ -0,0 +1,91 @@
1
+ ---
2
+ type: Guide
3
+ title: "Shape what a report says: the Structure step"
4
+ description: "How to decide with PMOS what a report answers, where each number comes from and how it is worked out, in the Structure step of the report studio, and how to try the plan on real data before you design it."
5
+ timestamp: 2026-10-14
6
+ ---
7
+
8
+ # Shape what a report says
9
+
10
+ The report studio has two steps: **1 Structure** and **2 Design**. Structure decides what the report says. Design decides how it looks. Do Structure first.
11
+
12
+ In Structure you talk with PMOS. You say what the report is for. PMOS asks what it needs and writes a report plan. The plan shows what the report answers, where each number comes from, how each number is worked out, and the order of the sections.
13
+
14
+ ## Open the Structure step
15
+
16
+ Use one of these:
17
+
18
+ - In **Ask PMOS** on the Reports tab, press **Shape it with PMOS** on the answer. PMOS sets the report up and opens Structure.
19
+ - In the **Set up** list, press **Shape the report** on the row of the report.
20
+ - In the Design studio, press **1 Structure** at the top.
21
+
22
+ To go to the Design studio from Structure, press **2 Design**.
23
+
24
+ ## Talk it through
25
+
26
+ 1. Write what the report is for, who reads it and what they decide with it. Press **Make the first version**.
27
+ 2. PMOS can ask questions first. Each question has answer chips. Press a chip to put the answer in the box. You can press more than one, and edit the words. Press **Make the next version**.
28
+ 3. PMOS makes **S1**. It can also propose up to three structures. Press **See the three structures side by side** to read them.
29
+ 4. Write what to change and press **Make the next version**. A turn that changes the plan makes a new version: S2, S3 and so on. A turn that only asks a question makes no new version.
30
+
31
+ A turn takes a minute or two. You can close the studio. The version is ready when you open it again.
32
+
33
+ ## Read the plan
34
+
35
+ The strip at the top right lists the versions. Press a version to read it. On an older version, press **Go back to** that version to build on it again. The later versions stay in the strip.
36
+
37
+ The plan has these parts:
38
+
39
+ - **What it answers.** The readers, the decision, the scope, how often, and the questions the report answers.
40
+ - **Sources.** Where the numbers come from. They are in two groups.
41
+ - **From PMOS.** A portfolio, board or initiative, a dashboard, a chart, a document, or a past report. A document gives PMOS context for its notes, never numbers. A past report gives last cycle's numbers. In the first cycle there is nothing to read.
42
+ - **Outside PMOS.** A connector you have (for example Google Analytics through Supermetrics), a link, a file you upload each cycle, a person who gives a number each cycle, a page read in Chrome, or an app on your Mac.
43
+ - Each source shows its state: **Reached**, **Not connected**, **First cycle: nothing to read** or **Needs you**. It also shows how it is read and how many charts use it. Press **Test pull** to ask PMOS to read it now. Press **Check it works** on a Chrome page or an app on your Mac. Press **How to connect** for a connector that is not connected. Press **Open** for a source in PMOS.
44
+ - **Charts.** Every number is a chart: one question, its source, how it is worked out, what it is compared with, and when it is flagged. A chart that PMOS proposes is tagged **New chart**. A chart that already exists in Reports is tagged **From Charts**. Press a chart to see the question, where it is used, exactly what PMOS reads, why PMOS proposed it and its test value. Press **Discuss this chart** to put its name in the box. Press **Open in Charts** to see a chart from Charts. Press **Remove it** to make the next version without the chart.
45
+ - **Outline.** The sections in order. Each section lists its charts and says if PMOS writes notes on them. **Headline**, **Needs your call** and **Sources** are in every report. You decide everything between them.
46
+
47
+ Press **Other structures** to read the structures PMOS proposed side by side. The one PMOS recommends is first. Press **Use this instead** to put the choice in the box, then press **Make the next version**.
48
+
49
+ ## Add a source
50
+
51
+ Press **Add a source** under the conversation. Choose the kind, write its name and press **Add**. A source from outside PMOS that needs a link asks for one. PMOS reads every source and never writes to one. A connector that your Claude settings do not allow stays closed.
52
+
53
+ ## Add a page in Chrome or an app on your Mac
54
+
55
+ Some numbers are in a page that has no connector, or in an app. PMOS can read a page with Claude in Chrome, or an app with computer use. Both run on your Mac, with your own sign-in.
56
+
57
+ When you add one, PMOS checks it step by step. Each step says **Works**, **Needs you** or **Waiting**, with a plain reason:
58
+
59
+ 1. PMOS runs on this Mac.
60
+ 2. A session can start with Claude in Chrome (or with computer use).
61
+ 3. Claude in Chrome answers (or macOS allows the screen and the mouse and keyboard).
62
+ 4. You are signed in.
63
+ 5. PMOS can read the numbers.
64
+
65
+ If a step says **Needs you**, do what it says, then press **Check again**. PMOS never types a password. The check session can only open the page, read it and scroll. It cannot submit a form or change a setting, and it closes its tab. Computer use runs only when your own Claude Code settings allow it. If they do not, the check says so.
66
+
67
+ When a draft is due, PMOS checks that source again. If the check fails, Work shows what to do. The draft waits up to 4 hours. After that it is drafted without that source and says so under Sources. PMOS never guesses a number.
68
+
69
+ ## Try it with real data
70
+
71
+ Press **Try it with real data** to run the plan on the last complete period, before any design. PMOS shows:
72
+
73
+ - the headline it would write,
74
+ - the value of each chart, with what it is compared with,
75
+ - the flags,
76
+ - what it could not get, and why,
77
+ - the options it would propose where the outline asks for a choice.
78
+
79
+ This only reads. It saves nothing in the report and sends nothing.
80
+
81
+ ## Save the structure
82
+
83
+ Press **Use this structure** to save the version on screen. PMOS saves each new chart in **Reports**, **Charts**. A chart from Charts is not copied: if you change it there, it changes everywhere it is used.
84
+
85
+ The report setup sheet then shows the saved structure with **Refine the structure** and **Try it with real data**. Each next draft follows it: the sections in order, each number worked out by the saved logic, and the sources it reached and did not reach listed under **Sources**. A change to the structure applies from the next cycle. A cycle that was already issued keeps the structure it was drafted with.
86
+
87
+ Then press **Design how it looks** to do step 2.
88
+
89
+ Nothing is sent from Structure.
90
+
91
+ See also [Design how a report looks](report-design.md), [Set up a report by asking](reports-set-up.md) and [Reports: dashboards of reports](reports-dashboards.md).
@@ -15,7 +15,7 @@ Each tab has one job. A line under the tabs repeats it: **Live** goes to Dashboa
15
15
 
16
16
  - **Dashboards.** The charts of one scope, drawn from the repository's records. A dashboard is live: it changes when your work changes. The tab shows how many dashboards there are.
17
17
  - **Reports.** The documents you send, with their set-ups. See [Set up a report by asking](reports-set-up.md). The tab shows how many drafts wait for you.
18
- - **Charts.** Every chart the workspace can draw, with the question it answers, the dashboards that use it and the reports that use it. The tab shows how many charts there are.
18
+ - **Charts.** Every chart the workspace can draw, with the question it answers, the dashboards that use it and the reports that use it. The tab shows how many charts there are. A chart that the Structure step of a report saved is listed too, tagged **Data chart**, with its source and the dashboards and reports that use it. See [Shape what a report says](report-structure.md).
19
19
 
20
20
  The old tab names still work. A link to Library opens Charts. A link to Drafts opens Reports.
21
21
 
@@ -43,6 +43,8 @@ The most likely finding also shows on the Briefing as **Report found**. Press **
43
43
  - **Draft it now** shows in place of Open when the report has no draft. PMOS writes the draft of the next cycle and opens it.
44
44
  - **All cycles** lists every cycle of the report.
45
45
  - **Pause** stops the drafts. **Resume** starts them again.
46
+ - **Shape the report** opens the Structure step of the report studio. There you decide what the report answers, where each number comes from and how it is worked out. See [Shape what a report says](report-structure.md). **Shape it with PMOS** on an answer sets the report up and opens the same step.
47
+ - **Shape the report** opens the Structure step of the report studio. There you decide what the report answers, where each number comes from and how it is worked out. See [Shape what a report says](report-structure.md). **Shape it with PMOS** on an answer sets the report up and opens the same step.
46
48
  - **Design how it looks** opens the design studio. There you design how the report looks, as a deck or a document. See [Design how a report looks](report-design.md).
47
49
 
48
50
  **Documents** has one row for each cycle: Draft or Issued, the output, the copy state and who made it. Filter by state or by source. Press **Open** to read a document in the [document sheet](document-sheet.md).