sign-off-axi 0.5.1 → 0.5.2
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/README.md +2 -2
- package/dist/cli.js +35 -11
- package/dist/contract.js +3 -2
- package/package.json +1 -1
- package/web/index.html +455 -9
- package/web/plan-template.json +99 -0
- package/web/report-template.json +98 -0
package/README.md
CHANGED
|
@@ -88,9 +88,9 @@ One line on what the agent runs per kind; the full reference is [docs/question-k
|
|
|
88
88
|
| Proof update | `post --type evidence --media shot.png` | the capture, its result, Pass / Fail / Needs work |
|
|
89
89
|
| Proof with a file to install | `post --type evidence --media shot.png --attach app.apk` | the capture, and the file with its size and a Download button (any type, up to 1 GB, `SIGN_OFF_ATTACH_MAX_MB` to change) |
|
|
90
90
|
| Explainer video | `post --type explainer --media clip.mp4 --chapter 0:00=Start` | a player with chapter buttons, then Yes or No |
|
|
91
|
-
| Report
|
|
91
|
+
| Report | `report init <folder>`, fill `report.json`, then `post --kind report --from <folder>/report.json` | one scroll: the answer and a picture, what I understood, what I found, the next step; numbers behind Details, Go deeper on every part, who read it and when |
|
|
92
92
|
| Diagram, interactive page | `post --type diagram --view flow.svg` | the picture on the card; interactive pages run sandboxed |
|
|
93
|
-
| Plan | `post --kind plan --from plan.json` |
|
|
93
|
+
| Plan | `report init <folder> --plan`, fill `plan.json`, then `post --kind plan --from plan.json` | the same scroll, plus each decision with my pick and a Go ahead |
|
|
94
94
|
| Sign-in link | `post --type signin --service npm --link <url> --unlocks ...` | an Open button and the time left |
|
|
95
95
|
|
|
96
96
|
## How agents use it
|
package/dist/cli.js
CHANGED
|
@@ -40,7 +40,10 @@ examples:
|
|
|
40
40
|
sign-off-axi post .sign-off/decisions.json --type design --title "Pick the sign-in layout" --option a=A --option b=B --mock a=mocks/a.html --mock b=mocks/b.html --recommend b
|
|
41
41
|
sign-off-axi post .sign-off/decisions.json --type diagram --title "Is this the sign-in flow?" --view mocks/sign-in/flow.svg
|
|
42
42
|
sign-off-axi post --type explainer --title "Is the new sign-in clear?" --about "The new sign-in has 2 steps." --media explainer.mp4 --chapter 0:00=Start
|
|
43
|
-
sign-off-axi report init .sign-off/
|
|
43
|
+
sign-off-axi report init .sign-off/reports/sign-in-flow
|
|
44
|
+
sign-off-axi post --kind report --from .sign-off/reports/sign-in-flow/report.json
|
|
45
|
+
sign-off-axi report init .sign-off/plans/sign-in --plan
|
|
46
|
+
sign-off-axi post --kind plan --from .sign-off/plans/sign-in/plan.json --filer my-session-id
|
|
44
47
|
sign-off-axi deeper
|
|
45
48
|
sign-off-axi post .sign-off/decisions.json --type report --title "Sign-in now takes 2 steps" --about "Why the code step moved first." --view mocks/sign-in-flow/index.html --preview mocks/sign-in-flow/preview.svg
|
|
46
49
|
sign-off-axi post --type signin --key npm-login --service npm --link https://example.com/login --unlocks "Publish the release" --waits "Main" --expires 15m
|
|
@@ -94,7 +97,7 @@ examples:
|
|
|
94
97
|
validate: `usage: sign-off-axi validate <message.json>... [--kind <kind>] [--expect-invalid]
|
|
95
98
|
Checks message files against the communication contract without posting them: ${CONTRACT}
|
|
96
99
|
A message is one JSON object with "contract": 1, its "kind" and its "schema". This build checks kinds ask, evidence, gate, finding and design at schema 1, report at schema 3 (schema 2 still passes) and plan at schema 2 (schema 1 still passes).
|
|
97
|
-
A report 3 and a plan 2 are guided pages: fields the board draws as one scroll, no page of your own.
|
|
100
|
+
A report 3 and a plan 2 are guided pages: fields the board draws as one scroll, no page of your own (see report init).
|
|
98
101
|
An evidence message's media paths read from the message file's folder: each file must be there, inside it, and 50 MB or less.
|
|
99
102
|
A report is a page to tap through: its "view" (.html) and "preview" (its picture, beside it) are checked for their shape here; the files themselves are checked at post, from the board file's folder, filled with no FILL: lines left (see report init).
|
|
100
103
|
A plan's "view" (.html) is checked for its shape here; the file itself is checked at post, from the board file's folder.
|
|
@@ -277,7 +280,13 @@ examples:
|
|
|
277
280
|
restore: `usage: sign-off-axi restore <key> [board.json]
|
|
278
281
|
Brings back a card that dismiss hid. See \`sign-off-axi dismiss --help\`.
|
|
279
282
|
`,
|
|
280
|
-
report: `usage: sign-off-axi report init <folder>
|
|
283
|
+
report: `usage: sign-off-axi report init <folder> [--plan] [--page]
|
|
284
|
+
Writes the guided template to fill: <folder>/report.json, or <folder>/plan.json with --plan. A file already there is kept.
|
|
285
|
+
The board draws it as one scroll: what this is (the answer and one picture), what I understood, what I found and recommend, what you decide (plans), and the next step.
|
|
286
|
+
Numbers sit behind a Details tap; every part has a Go deeper button that asks you (see \`sign-off-axi deeper --help\`) and a read receipt that shows when the person read it.
|
|
287
|
+
Replace every FILL: line, then post it: post --kind report --from <folder>/report.json, or post --kind plan --from <folder>/plan.json --filer <your id>.
|
|
288
|
+
A report is for an explanation the user asked for, or a concept, design or trade-off they must understand. Proof that something works is evidence.
|
|
289
|
+
--page writes the older scaffold page instead, for a report that needs a page of its own (report schema 2). It is:
|
|
281
290
|
Writes a report page to fill: <folder>/index.html and its card picture <folder>/preview.svg. A file already there is kept.
|
|
282
291
|
The page is one self-contained file, phone first, light and dark, in four parts, each one idea:
|
|
283
292
|
1. the answer in one plain sentence and a picture of it 2. a diagram of how it works
|
|
@@ -1353,7 +1362,7 @@ function postReport(file, named, on, values) {
|
|
|
1353
1362
|
throw fail(`--${odd} does not apply to --type report: a report takes no answer`, 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
|
|
1354
1363
|
const missing = ['title', 'about', 'view', 'preview'].filter((f) => values[f] === undefined).map((f) => `--${f}`);
|
|
1355
1364
|
if (missing.length) {
|
|
1356
|
-
throw fail(`a report is a page to tap through, not text: ${missing.join(' and ').replace(/ and (?=.* and )/g, ', ')} ${missing.length > 1 ? 'are' : 'is'} missing`, 'VALIDATION_ERROR', `Run \`${REPORT_INIT}\` to make the page and its picture, fill them, then post --type report --title "<the answer>" --about "<why it matters>" --view <folder>/index.html --preview <folder>/preview.svg`);
|
|
1365
|
+
throw fail(`a report is a page to tap through, not text: ${missing.join(' and ').replace(/ and (?=.* and )/g, ', ')} ${missing.length > 1 ? 'are' : 'is'} missing`, 'VALIDATION_ERROR', `Run \`${REPORT_INIT} --page\` to make the page and its picture, fill them, then post --type report --title "<the answer>" --about "<why it matters>" --view <folder>/index.html --preview <folder>/preview.svg`);
|
|
1357
1366
|
}
|
|
1358
1367
|
const key = values.key ?? slug(values.title), had = existsSync(file) ? readItems(file).items.find((i) => i?.key === key) : undefined;
|
|
1359
1368
|
// Posting the same report again is a no-op, so it keeps the time it was first filed.
|
|
@@ -2002,21 +2011,38 @@ function init(args) {
|
|
|
2002
2011
|
`Run \`sign-off-axi doctor ${shellPath(dir)}\` to check the setup`, `Run \`sign-off-axi open ${shellPath(board)}\` to show the board to the user`
|
|
2003
2012
|
]);
|
|
2004
2013
|
}
|
|
2005
|
-
/** report init <folder>: the
|
|
2014
|
+
/** report init <folder>: the guided template (report.json, or plan.json with --plan) to fill in, or with --page the older scaffold page and its picture. */
|
|
2006
2015
|
function report(args) {
|
|
2007
2016
|
let parsed;
|
|
2008
2017
|
try {
|
|
2009
|
-
parsed = parseArgs({ args, allowPositionals: true, strict: true, options: {} });
|
|
2018
|
+
parsed = parseArgs({ args, allowPositionals: true, strict: true, options: { plan: { type: 'boolean' }, page: { type: 'boolean' } } });
|
|
2010
2019
|
}
|
|
2011
2020
|
catch (e) {
|
|
2012
2021
|
throw fail(e.message, 'VALIDATION_ERROR', 'Run `sign-off-axi report --help`');
|
|
2013
2022
|
}
|
|
2014
|
-
const [sub, dir, extra] = parsed.positionals;
|
|
2015
|
-
if (sub !== 'init' || dir === undefined || extra !== undefined) {
|
|
2016
|
-
throw fail(sub !== 'init' ? `unknown report command ${sub ?? '(none)'}` : extra !== undefined ? `unexpected argument ${extra}: report init takes one folder` : 'report init needs the folder to write the
|
|
2023
|
+
const [sub, dir, extra] = parsed.positionals, { plan, page } = parsed.values;
|
|
2024
|
+
if (sub !== 'init' || dir === undefined || extra !== undefined || (plan && page)) {
|
|
2025
|
+
throw fail(sub !== 'init' ? `unknown report command ${sub ?? '(none)'}` : extra !== undefined ? `unexpected argument ${extra}: report init takes one folder` : plan && page ? '--plan and --page do not go together' : 'report init needs the folder to write the template in', 'VALIDATION_ERROR', 'sign-off-axi report init <folder> [--plan]');
|
|
2017
2026
|
}
|
|
2018
2027
|
const out = resolve(dir), created = [], kept = [];
|
|
2019
2028
|
mkdirSync(out, { recursive: true });
|
|
2029
|
+
// The board serves a page from its own folder, so the post command names it from there.
|
|
2030
|
+
const board = boardHere(), rel = relative(dirname(board), out).split(sep).join('/'), served = rel && !rel.startsWith('../') && rel !== '..' && !isAbsolute(rel);
|
|
2031
|
+
if (!page) {
|
|
2032
|
+
const kind = plan ? 'plan' : 'report', path = join(out, `${kind}.json`);
|
|
2033
|
+
if (existsSync(path))
|
|
2034
|
+
kept.push(shellPath(path));
|
|
2035
|
+
else {
|
|
2036
|
+
const filed = new Date().toISOString().replace(/\.\d+Z$/, 'Z');
|
|
2037
|
+
writeFileSync(path, readFileSync(new URL(`../web/${kind}-template.json`, import.meta.url), 'utf8').replace('"FILL: UTC time"', JSON.stringify(filed)), { flag: 'wx' });
|
|
2038
|
+
created.push(shellPath(path));
|
|
2039
|
+
}
|
|
2040
|
+
return toon({ folder: shellPath(out), created: created.length ? created : 'nothing new (re-run is a no-op)', kept }, [
|
|
2041
|
+
`Replace every FILL: line in ${kind}.json with your own words: the answer first, one picture (bars, steps or tiles), what you understood, then the sections${plan ? ' and the decisions' : ''}`,
|
|
2042
|
+
'Keep each line short: the board shows it in one scroll, the numbers behind a Details tap and the long answer behind Go deeper',
|
|
2043
|
+
`Run \`sign-off-axi post --kind ${kind} --from ${shellPath(path)}\` to put it on the board`,
|
|
2044
|
+
]);
|
|
2045
|
+
}
|
|
2020
2046
|
for (const [name, from] of [['index.html', 'report.html'], ['preview.svg', 'report-preview.svg']]) {
|
|
2021
2047
|
const path = join(out, name);
|
|
2022
2048
|
if (existsSync(path))
|
|
@@ -2026,8 +2052,6 @@ function report(args) {
|
|
|
2026
2052
|
created.push(shellPath(path));
|
|
2027
2053
|
}
|
|
2028
2054
|
}
|
|
2029
|
-
// The board serves a report's page from its own folder, so the post command names the page from there.
|
|
2030
|
-
const board = boardHere(), rel = relative(dirname(board), out).split(sep).join('/'), served = rel && !rel.startsWith('../') && rel !== '..' && !isAbsolute(rel);
|
|
2031
2055
|
return toon({ folder: shellPath(out), created: created.length ? created : 'nothing new (re-run is a no-op)', kept }, [
|
|
2032
2056
|
'Replace every FILL: line: the answer and its picture first, then the diagram, the steps to tap, and the folded detail',
|
|
2033
2057
|
'Open index.html in a browser 390 px wide and look before posting: mostly text means it is not ready',
|
package/dist/contract.js
CHANGED
|
@@ -42,7 +42,8 @@ const ACTIONS = {
|
|
|
42
42
|
export const MEDIA_BYTES = 50_000_000;
|
|
43
43
|
/** The command that makes a report's page, named in every refusal of a report without one. */
|
|
44
44
|
export const REPORT_INIT = 'sign-off-axi report init <folder>';
|
|
45
|
-
const PAGE_HELP = `Run \`${REPORT_INIT}\` to make the page, fill it, then post it with "view" and "preview"`;
|
|
45
|
+
const PAGE_HELP = `Run \`${REPORT_INIT} --page\` to make the page, fill it, then post it with "view" and "preview"`;
|
|
46
|
+
const GUIDE_HELP = `Run \`${REPORT_INIT}\` to write report.json, fill every FILL: line, then post it with \`post --kind report --from <folder>/report.json\``;
|
|
46
47
|
/** Parses one message; a field written twice in any object refuses it, since plain JSON.parse keeps the last one quietly. */
|
|
47
48
|
export function parseMessage(bytes) {
|
|
48
49
|
let text, doc;
|
|
@@ -296,7 +297,7 @@ export function checkMessage(m, want, flags = false) {
|
|
|
296
297
|
if (!whole(m.schema))
|
|
297
298
|
throw bad('schema', `must be a whole number, not ${show(m.schema)}`, `Set "schema": ${SCHEMAS[m.kind]}`);
|
|
298
299
|
if (m.kind === 'report' && m.schema === 1)
|
|
299
|
-
throw bad('schema', '1 of report is text only: a report must be a page to tap through, with "view" and "preview"',
|
|
300
|
+
throw bad('schema', '1 of report is text only: a report must be a page to tap through, with "view" and "preview"', GUIDE_HELP);
|
|
300
301
|
if (m.schema !== SCHEMAS[m.kind] && !OLDER[m.kind]?.includes(m.schema))
|
|
301
302
|
throw bad('schema', `${m.schema} of ${m.kind} is not in this sign-off-axi`, `Upgrade sign-off-axi, or post ${m.kind} schema ${SCHEMAS[m.kind]}`);
|
|
302
303
|
if (g && /FILL:/.test(JSON.stringify(m)))
|
package/package.json
CHANGED
package/web/index.html
CHANGED
|
@@ -760,6 +760,182 @@
|
|
|
760
760
|
.ev-title { font-size: 2.5rem; line-height: 1.1; margin-top: 1.25rem; }
|
|
761
761
|
.ev-intro { font-size: 1.3125rem; }
|
|
762
762
|
}
|
|
763
|
+
/* The guided plan and report: one scroll with a rail and Your answers on a desk, a progress bar and a Go ahead bar on a phone. */
|
|
764
|
+
#reader .gd-scroll { flex: 1; min-height: 0; overflow: auto; padding: 0 1rem 7rem; scroll-behavior: smooth; }
|
|
765
|
+
#reader .gd, #reader .gd-bar { --hair: color-mix(in srgb, var(--text) 12%, transparent); --hair2: color-mix(in srgb, var(--text) 7%, transparent); --tint: color-mix(in srgb, var(--action) 11%, transparent);
|
|
766
|
+
--ok-bg: color-mix(in srgb, var(--ok) 13%, transparent); --warn-bg: color-mix(in srgb, var(--warn) 13%, transparent); --bad: var(--danger); --bad-bg: color-mix(in srgb, var(--danger) 11%, transparent); --well: var(--page); }
|
|
767
|
+
@media (prefers-color-scheme: dark) { #reader .gd, #reader .gd-bar { --well: #0C0C0D; } }
|
|
768
|
+
#reader .gd { max-width: 72rem; margin: 0 auto; font-size: 1.0625rem; line-height: 1.47; }
|
|
769
|
+
:where(#reader .gd, #reader .gd-bar) :where(button, input, textarea) { font: inherit; color: inherit; letter-spacing: inherit; }
|
|
770
|
+
:where(#reader .gd, #reader .gd-bar) button { cursor: pointer; border: 0; background: none; padding: 0; }
|
|
771
|
+
#reader .gd [hidden] { display: none !important; }
|
|
772
|
+
#reader .gd .ic { width: 1.25rem; height: 1.25rem; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; flex: none; }
|
|
773
|
+
#reader .gd :focus-visible { outline: 2px solid var(--action); outline-offset: 2px; border-radius: 0.5rem; }
|
|
774
|
+
.gd-rail { display: none; }
|
|
775
|
+
.gd-progress { position: sticky; top: 0; z-index: 20; margin: 0 -1rem; padding: 0.625rem 1rem 0.5rem; background: color-mix(in srgb, var(--page) 94%, transparent); -webkit-backdrop-filter: saturate(180%) blur(16px); backdrop-filter: saturate(180%) blur(16px); border-bottom: 1px solid var(--hair2); }
|
|
776
|
+
.gd-progress .now { display: flex; justify-content: space-between; font-size: 0.875rem; color: var(--muted); margin-bottom: 0.5rem; }
|
|
777
|
+
.gd-progress .now b { color: var(--text); font-weight: 600; }
|
|
778
|
+
.gd-segs { display: flex; gap: 0.25rem; }
|
|
779
|
+
.gd-segs button { flex: 1; min-height: 0; height: 0.375rem; border-radius: 0.1875rem; background: var(--sunk); position: relative; }
|
|
780
|
+
.gd-segs button::after { content: ""; position: absolute; inset: -0.875rem 0; }
|
|
781
|
+
.gd-segs button.on { background: color-mix(in srgb, var(--action) 40%, var(--sunk)); }
|
|
782
|
+
.gd-segs button.cur { background: var(--action); height: 0.5rem; margin-top: -0.0625rem; }
|
|
783
|
+
.gd-main { min-width: 0; max-width: 45rem; margin: 0 auto; }
|
|
784
|
+
@media (min-width: 62.5rem) {
|
|
785
|
+
#reader .gd-scroll { padding: 0 2rem 4rem; }
|
|
786
|
+
.gd-cols { display: grid; grid-template-columns: 17rem minmax(0, 45rem); gap: 3rem; justify-content: center; align-items: start; margin-top: 0.5rem; }
|
|
787
|
+
.gd-rail { display: block; position: sticky; top: 1rem; }
|
|
788
|
+
.gd-progress, .gd-bar { display: none !important; }
|
|
789
|
+
#reader .gd-scroll { padding-bottom: 4rem; }
|
|
790
|
+
}
|
|
791
|
+
.gd-rail h4 { margin: 0 0 0.5rem; font-size: 0.8125rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); }
|
|
792
|
+
.gd-rail nav { display: flex; flex-direction: column; gap: 0.125rem; }
|
|
793
|
+
.gd-rail nav a { display: flex; gap: 0.625rem; align-items: flex-start; padding: 0.5rem 0.625rem; border-radius: 0.625rem; color: var(--text); text-decoration: none; font-size: 0.9375rem; line-height: 1.35; }
|
|
794
|
+
.gd-rail nav a:hover { background: var(--sunk); }
|
|
795
|
+
.gd-rail nav a.cur { background: var(--tint); color: var(--accent); font-weight: 600; }
|
|
796
|
+
.gd-rail nav a .gn { width: 1.375rem; height: 1.375rem; border-radius: 50%; border: 1.5px solid var(--hair); display: grid; place-items: center; font-size: 0.75rem; font-weight: 600; color: var(--muted); flex: none; margin-top: 1px; }
|
|
797
|
+
.gd-rail nav a.seen .gn { background: var(--ok); border-color: var(--ok); color: var(--on-accent); }
|
|
798
|
+
.gd-rail nav a.seen .gn .ic { width: 0.8125rem; height: 0.8125rem; stroke-width: 2.6; }
|
|
799
|
+
.gd-sum { margin-top: 1.25rem; padding: 1rem; border-radius: 1rem; background: var(--tile); border: 1px solid var(--hair2); }
|
|
800
|
+
.gd-sum h4 { margin-bottom: 0.375rem; }
|
|
801
|
+
.gd-sum ul { list-style: none; margin: 0 0 0.875rem; padding: 0; font-size: 0.9375rem; line-height: 1.35; }
|
|
802
|
+
.gd-sum li { display: flex; justify-content: space-between; gap: 0.625rem; padding: 0.25rem 0; }
|
|
803
|
+
.gd-sum li span:first-child { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
804
|
+
.gd-sum li span:last-child { color: var(--muted); text-align: right; max-width: 50%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
805
|
+
.gd-sum li .chg { color: var(--accent); font-weight: 600; }
|
|
806
|
+
.gd-top { padding: 0.75rem 0 1.125rem; }
|
|
807
|
+
.gd-top .geye { margin: 0 0 0.5rem; font-size: 0.875rem; font-weight: 600; color: var(--accent); }
|
|
808
|
+
#reader .gd-top h1 { margin: 0 0 0.625rem; font-size: 2.125rem; line-height: 1.1; letter-spacing: -0.034em; font-weight: 700; white-space: normal; overflow: visible; }
|
|
809
|
+
.gd-top .sub { margin: 0; color: var(--muted); font-size: 1.1875rem; line-height: 1.42; }
|
|
810
|
+
.gd-top .howto { margin-top: 1rem; padding: 0.75rem 0.875rem; border-radius: 0.875rem; background: var(--tint); font-size: 0.9375rem; line-height: 1.4; display: flex; gap: 0.625rem; align-items: flex-start; }
|
|
811
|
+
.gd-top .howto .ic { margin-top: 0.125rem; color: var(--accent); }
|
|
812
|
+
@media (min-width: 43.75rem) { #reader .gd-top h1 { font-size: 2.75rem; letter-spacing: -0.04em; } .gd-top .sub { font-size: 1.3125rem; } }
|
|
813
|
+
.gd-stage { display: flex; align-items: center; gap: 0.625rem; margin: 1.75rem 0 0.625rem; font-size: 0.875rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); scroll-margin-top: 5.5rem; }
|
|
814
|
+
.gd-stage i { font-style: normal; width: 1.5rem; height: 1.5rem; border-radius: 50%; background: var(--sunk); display: grid; place-items: center; font-size: 0.8125rem; color: var(--text); letter-spacing: 0; }
|
|
815
|
+
.gd-stage::after { content: ""; flex: 1; height: 1px; background: var(--hair2); }
|
|
816
|
+
.gd-sec { background: var(--tile); border: 1px solid var(--hair2); border-radius: 1.375rem; padding: 1.25rem 1.125rem 0.875rem; margin: 0 0 0.875rem; scroll-margin-top: 5.5rem; }
|
|
817
|
+
@media (min-width: 43.75rem) { .gd-sec { padding: 1.75rem 2rem 1.125rem; border-radius: 1.5rem; } }
|
|
818
|
+
.gd-sec > .gh .gk { display: block; font-size: 0.875rem; font-weight: 600; color: var(--muted); margin-bottom: 0.375rem; }
|
|
819
|
+
#reader .gd-sec h2 { margin: 0 0 0.5rem; font-size: 1.5rem; line-height: 1.18; letter-spacing: -0.028em; font-weight: 700; }
|
|
820
|
+
@media (min-width: 43.75rem) { #reader .gd-sec h2 { font-size: 1.75rem; } }
|
|
821
|
+
.gd-sec > .gh:not(:has(.gd-lead)) h2 { margin-bottom: 0.875rem; }
|
|
822
|
+
.gd-lead { margin: 0 0 1rem; line-height: 1.5; }
|
|
823
|
+
.gd-fig { margin: 0.25rem 0 1rem; border-radius: 1rem; background: var(--well); padding: 1rem; }
|
|
824
|
+
.gd-cap { margin: 0.625rem 0 0; font-size: 0.875rem; color: var(--muted); line-height: 1.4; }
|
|
825
|
+
.gd-acts { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.125rem 0 0.25rem; }
|
|
826
|
+
.gd-lnk { display: inline-flex; align-items: center; gap: 0.375rem; min-height: 2.75rem; padding: 0 0.875rem; border-radius: 999px; background: var(--sunk); font-size: 0.9375rem; font-weight: 600; color: var(--text); }
|
|
827
|
+
.gd-lnk.deep { color: var(--accent); background: var(--tint); }
|
|
828
|
+
.gd-lnk .ic { width: 1.0625rem; height: 1.0625rem; transition: transform 0.2s; }
|
|
829
|
+
.gd-lnk[aria-expanded=true] .ic.gchev { transform: rotate(180deg); }
|
|
830
|
+
.gd-panel { margin: 0.625rem 0 0.375rem; border-radius: 1rem; background: var(--well); padding: 0.875rem 1rem; font-size: 1rem; line-height: 1.5; }
|
|
831
|
+
.gd-panel[hidden] { display: none; }
|
|
832
|
+
.gd-dt { width: 100%; border-collapse: collapse; font-size: 0.9375rem; line-height: 1.35; }
|
|
833
|
+
.gd-dt th, .gd-dt td { padding: 0.5625rem 0.375rem; text-align: left; border-bottom: 1px solid var(--hair2); vertical-align: top; }
|
|
834
|
+
.gd-dt th { font-weight: 500; color: var(--muted); }
|
|
835
|
+
.gd-dt td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
|
|
836
|
+
.gd-dt tr:last-child td, .gd-dt tr:last-child th { border-bottom: 0; }
|
|
837
|
+
.gd-ans { border: 1px solid var(--hair2); }
|
|
838
|
+
.gd-ans .gask { padding: 0 0 0.75rem; margin: 0 0 0.75rem; border-bottom: 1px solid var(--hair2); }
|
|
839
|
+
.gd-ans .gask:last-of-type { border-bottom: 0; margin-bottom: 0.25rem; padding-bottom: 0; }
|
|
840
|
+
.gd-ans .hd { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; font-weight: 600; color: var(--muted); margin-bottom: 0.5rem; }
|
|
841
|
+
.gd-ans .dot { width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--faint); flex: none; }
|
|
842
|
+
.gd-ans .live .dot { background: var(--action); animation: gd-pulse 1s ease-in-out infinite; }
|
|
843
|
+
@keyframes gd-pulse { 50% { opacity: 0.25; } }
|
|
844
|
+
.gd-ans .q { font-weight: 600; margin: 0 0 0.5rem; }
|
|
845
|
+
.gd-ans p { margin: 0 0 0.625rem; }
|
|
846
|
+
.gd-ans .caret { display: inline-block; width: 2px; height: 1.05em; background: var(--action); vertical-align: -0.15em; margin-left: 1px; animation: gd-pulse 1s steps(2) infinite; }
|
|
847
|
+
.gd-ans .shim { height: 0.625rem; border-radius: 0.3125rem; background: linear-gradient(90deg, var(--sunk), var(--hair), var(--sunk)); background-size: 200% 100%; animation: gd-sh 1.2s linear infinite; margin: 0.5rem 0; }
|
|
848
|
+
@keyframes gd-sh { to { background-position: -200% 0; } }
|
|
849
|
+
.gd-ans .slow { font-size: 0.875rem; color: var(--muted); margin: 0.25rem 0 0; }
|
|
850
|
+
.gd-fu { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
|
|
851
|
+
.gd-fu input { flex: 1; min-width: 0; min-height: 2.75rem; border-radius: 0.75rem; border: 1px solid var(--hair); background: var(--tile); padding: 0 0.75rem; }
|
|
852
|
+
.gd-fu button { min-height: 2.75rem; padding: 0 1rem; border-radius: 0.75rem; background: var(--action); color: var(--on-accent); font-weight: 600; }
|
|
853
|
+
.gd-chip { display: inline-flex; align-items: center; gap: 0.375rem; font-size: 0.8125rem; line-height: 1.25; padding: 0.375rem 0.625rem; border-radius: 0.75rem; background: var(--sunk); color: var(--muted); max-width: 100%; text-align: left; }
|
|
854
|
+
.gd-chip.ok { background: var(--ok-bg); color: var(--ok); } .gd-chip.bad { background: var(--bad-bg); color: var(--bad); } .gd-chip.warn { background: var(--warn-bg); color: var(--warn); } .gd-chip.acc { background: var(--tint); color: var(--accent); }
|
|
855
|
+
.gd-rc { display: flex; flex-wrap: wrap; gap: 0.125rem 1rem; margin: 0.625rem -0.125rem 0; padding: 0.625rem 0.125rem 0.125rem; border-top: 1px solid var(--hair2); font-size: 0.875rem; line-height: 1.35; color: var(--muted); }
|
|
856
|
+
.gd-rc span { display: inline-flex; align-items: center; gap: 0.375rem; }
|
|
857
|
+
.gd-rc .gdot { width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--ok); flex: none; }
|
|
858
|
+
.gd-rc .gdot.hollow { background: none; border: 1.5px solid var(--faint); width: 0.5625rem; height: 0.5625rem; }
|
|
859
|
+
.gd-rc .gdot.agent { background: var(--action); }
|
|
860
|
+
.gd-rc b { font-weight: 600; color: var(--text); }
|
|
861
|
+
.gd-rc.s { margin-top: 0.5rem; padding-top: 0.5rem; border-top: 0; }
|
|
862
|
+
.gd-uq { display: grid; margin: 0 0 1rem; border-radius: 1rem; overflow: hidden; border: 1px solid var(--hair2); }
|
|
863
|
+
.gd-uq div { padding: 0.75rem 0.875rem; border-bottom: 1px solid var(--hair2); }
|
|
864
|
+
.gd-uq div:last-child { border-bottom: 0; }
|
|
865
|
+
.gd-uq dt { font-size: 0.8125rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); margin: 0 0 0.125rem; }
|
|
866
|
+
.gd-uq dd { margin: 0; }
|
|
867
|
+
.gd-seg { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
|
868
|
+
.gd-choice { min-height: 2.75rem; padding: 0 1.125rem; border-radius: 999px; border: 1.5px solid var(--hair) !important; background: var(--tile) !important; font-weight: 600; display: inline-flex; align-items: center; gap: 0.5rem; }
|
|
869
|
+
.gd-choice[aria-pressed=true] { border-color: var(--action) !important; background: var(--tint) !important; color: var(--accent); }
|
|
870
|
+
.gd-choice:disabled { opacity: 0.6; cursor: default; }
|
|
871
|
+
.gd-note { width: 100%; margin-top: 0.625rem; min-height: 4.5rem; border-radius: 0.75rem; border: 1px solid var(--hair); background: var(--tile); padding: 0.625rem 0.75rem; resize: vertical; }
|
|
872
|
+
.gd-tell { margin-top: 0.5rem; }
|
|
873
|
+
.gd-bars { display: grid; gap: 0.875rem; }
|
|
874
|
+
.gd-bars .br { display: grid; gap: 0.375rem; }
|
|
875
|
+
.gd-bars .lab { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 0.625rem; font-size: 0.9375rem; font-weight: 600; }
|
|
876
|
+
.gd-bars .lab small { font-weight: 500; color: var(--muted); font-size: 0.875rem; text-align: right; }
|
|
877
|
+
.gd-bars .lab .gv { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
878
|
+
.gd-bars .pair { display: grid; gap: 0.3125rem; }
|
|
879
|
+
.gd-track { position: relative; height: 0.875rem; border-radius: 0.4375rem; background: var(--sunk); }
|
|
880
|
+
.gd-track.thin { height: 0.5rem; border-radius: 0.25rem; }
|
|
881
|
+
.gd-track i { position: absolute; left: 0; top: 0; bottom: 0; border-radius: inherit; background: var(--faint); }
|
|
882
|
+
.gd-track i.bad { background: var(--bad); } .gd-track i.warn { background: var(--warn); } .gd-track i.ok { background: var(--ok); } .gd-track i.ghost { opacity: 0.55; }
|
|
883
|
+
.gd-chain { list-style: none; margin: 0; padding: 0; }
|
|
884
|
+
.gd-chain li { display: grid; grid-template-columns: 1.75rem 1fr; gap: 0.75rem; position: relative; padding-bottom: 1rem; }
|
|
885
|
+
.gd-chain li::before { content: ""; position: absolute; left: 0.8125rem; top: 1.75rem; bottom: 0; width: 2px; background: var(--hair); }
|
|
886
|
+
.gd-chain li:last-child { padding-bottom: 0; } .gd-chain li:last-child::before { display: none; }
|
|
887
|
+
.gd-chain .nd { width: 1.75rem; height: 1.75rem; border-radius: 50%; display: grid; place-items: center; background: var(--sunk); color: var(--muted); font-size: 0.8125rem; font-weight: 700; z-index: 1; }
|
|
888
|
+
.gd-chain .nd.bad { background: var(--bad); color: var(--on-accent); } .gd-chain .nd.warn { background: var(--warn); color: var(--on-accent); } .gd-chain .nd.ok { background: var(--ok); color: var(--on-accent); }
|
|
889
|
+
.gd-chain .tx { padding-top: 0.125rem; line-height: 1.4; } .gd-chain .tx small { display: block; color: var(--muted); font-size: 0.875rem; }
|
|
890
|
+
.gd-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.375rem, 1fr)); gap: 0.625rem; }
|
|
891
|
+
.gd-tile { padding: 0.75rem; border-radius: 0.875rem; background: var(--tile); border: 1px solid var(--hair2); display: grid; gap: 0.25rem; align-content: start; }
|
|
892
|
+
.gd-tile > .gd-chip { justify-self: start; }
|
|
893
|
+
.gd-tile b { font-size: 0.9375rem; line-height: 1.3; } .gd-tile span.gt { font-size: 0.875rem; color: var(--muted); line-height: 1.35; }
|
|
894
|
+
.gd-tiles > .gd-tile:last-child:nth-child(odd) { grid-column: 1/-1; }
|
|
895
|
+
.gd-rows { list-style: none; margin: 0 0 0.375rem; padding: 0; border-radius: 1rem; border: 1px solid var(--hair2); overflow: hidden; }
|
|
896
|
+
.gd-rows > li { border-bottom: 1px solid var(--hair2); }
|
|
897
|
+
.gd-rows > li:last-child { border-bottom: 0; }
|
|
898
|
+
.gd-rows .rowbtn, .gd-rows .rowstat { width: 100%; display: grid; grid-template-columns: 1fr auto; gap: 0.125rem 0.75rem; align-items: center; text-align: left; padding: 0.75rem 0.875rem; min-height: 3.5rem; }
|
|
899
|
+
.gd-rows .rowbtn:hover { background: var(--sunk); }
|
|
900
|
+
.gd-rows .ti { font-weight: 600; line-height: 1.3; display: flex; align-items: baseline; gap: 0.5rem; }
|
|
901
|
+
.gd-rows .sm { display: block; font-weight: 400; color: var(--muted); font-size: 0.9375rem; line-height: 1.35; }
|
|
902
|
+
.gd-rows .ic.gchev { transition: transform 0.2s; color: var(--faint); }
|
|
903
|
+
.gd-rows .rowbtn[aria-expanded=true] .ic.gchev { transform: rotate(180deg); }
|
|
904
|
+
.gd-rows .dotk { width: 0.625rem; height: 0.625rem; border-radius: 50%; background: var(--faint); flex: none; }
|
|
905
|
+
.gd-rows .dotk.bad { background: var(--bad); } .gd-rows .dotk.ok { background: var(--ok); } .gd-rows .dotk.warn { background: var(--warn); }
|
|
906
|
+
.gd-rows .body { padding: 0 0.875rem 0.5rem; }
|
|
907
|
+
.gd-rows .body[hidden] { display: none; }
|
|
908
|
+
.gd-rows .body p { margin: 0 0 0.625rem; }
|
|
909
|
+
.gd-opts { display: grid; gap: 0.625rem; margin: 0 0 0.75rem; }
|
|
910
|
+
.gd-opt { display: grid; grid-template-columns: 1.5rem 1fr; gap: 0.75rem; align-items: start; text-align: left; padding: 0.875rem; border-radius: 1rem; border: 1.5px solid var(--hair) !important; background: var(--tile) !important; width: 100%; }
|
|
911
|
+
.gd-opt .rdo { width: 1.375rem; height: 1.375rem; border-radius: 50%; border: 2px solid var(--hair); margin-top: 1px; display: grid; place-items: center; }
|
|
912
|
+
.gd-opt[aria-checked=true] { border-color: var(--action) !important; background: var(--tint) !important; }
|
|
913
|
+
.gd-opt[aria-checked=true] .rdo { border-color: var(--action); }
|
|
914
|
+
.gd-opt[aria-checked=true] .rdo::after { content: ""; width: 0.625rem; height: 0.625rem; border-radius: 50%; background: var(--action); }
|
|
915
|
+
.gd-opt:disabled { cursor: default; }
|
|
916
|
+
.gd-opt .ol { font-weight: 600; line-height: 1.3; display: flex; flex-wrap: wrap; gap: 0.375rem 0.5rem; align-items: center; }
|
|
917
|
+
.gd-opt .oo { display: block; color: var(--muted); font-size: 0.9375rem; line-height: 1.4; margin-top: 0.125rem; }
|
|
918
|
+
.gd-pick { font-size: 0.75rem; font-weight: 700; letter-spacing: 0.03em; text-transform: uppercase; color: var(--accent); background: var(--tint); padding: 0.1875rem 0.5rem; border-radius: 999px; }
|
|
919
|
+
.gd-why { padding: 0.75rem 0.875rem; border-radius: 0.875rem; background: var(--well); font-size: 0.9375rem; line-height: 1.45; margin: 0 0 0.625rem; }
|
|
920
|
+
.gd-why b { font-weight: 600; }
|
|
921
|
+
.gd-quiet { display: flex; gap: 0.5rem; align-items: flex-start; font-size: 0.9375rem; color: var(--muted); line-height: 1.4; margin: 0 0 0.75rem; }
|
|
922
|
+
.gd-quiet .ic { width: 1.125rem; height: 1.125rem; margin-top: 0.125rem; }
|
|
923
|
+
.gd-checks { list-style: none; margin: 0 0 0.875rem; padding: 0; display: grid; gap: 0.625rem; }
|
|
924
|
+
.gd-checks li { display: grid; grid-template-columns: 1.5rem 1fr; gap: 0.75rem; align-items: start; }
|
|
925
|
+
.gd-checks .tk { width: 1.5rem; height: 1.5rem; border-radius: 50%; background: var(--ok-bg); color: var(--ok); display: grid; place-items: center; }
|
|
926
|
+
.gd-checks .tk .ic { width: 0.875rem; height: 0.875rem; stroke-width: 2.8; }
|
|
927
|
+
#reader .gd-btn { display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; min-height: 3.125rem; padding: 0 1.5rem; border-radius: 999px; background: var(--action) !important; color: var(--on-accent); font-weight: 600; font-size: 1.0625rem; }
|
|
928
|
+
.gd-btn.block { width: 100%; }
|
|
929
|
+
.gd-btn:disabled { opacity: 0.45; cursor: default; }
|
|
930
|
+
.gd-sent { padding: 1.125rem; border-radius: 1.125rem; background: var(--ok-bg); }
|
|
931
|
+
.gd-sent h3 { margin: 0 0 0.25rem; font-size: 1.25rem; }
|
|
932
|
+
.gd-sent p { margin: 0 0 0.25rem; }
|
|
933
|
+
.gd-err { color: var(--danger); font-size: 0.9375rem; margin: 0.5rem 0 0; }
|
|
934
|
+
.gd-bar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 30; padding: 0.625rem 1rem calc(0.625rem + env(safe-area-inset-bottom)); background: color-mix(in srgb, var(--page) 90%, transparent); -webkit-backdrop-filter: saturate(180%) blur(16px); backdrop-filter: saturate(180%) blur(16px); border-top: 1px solid var(--hair2); display: flex; align-items: center; gap: 0.75rem; }
|
|
935
|
+
.gd-bar .txt { flex: 1; min-width: 0; font-size: 0.9375rem; line-height: 1.3; color: var(--muted); }
|
|
936
|
+
.gd-bar .txt b { display: block; color: var(--text); font-weight: 600; }
|
|
937
|
+
.gd-bar .gd-btn { min-height: 3rem; padding: 0 1.25rem; white-space: nowrap; }
|
|
938
|
+
@media (prefers-reduced-motion: reduce) { #reader .gd *, #reader .gd-scroll { animation: none !important; transition: none !important; scroll-behavior: auto; } }
|
|
763
939
|
</style>
|
|
764
940
|
</head>
|
|
765
941
|
<body>
|
|
@@ -1416,7 +1592,7 @@
|
|
|
1416
1592
|
return a === 'reports' && !state.readonly && d.type !== 'explainer' && !reads(d) ? 'notes' : a;
|
|
1417
1593
|
};
|
|
1418
1594
|
/** A page to open: an .html view, framed in a sandbox. */
|
|
1419
|
-
const reads = (d) => /\.html$/i.test(d.view ?? '');
|
|
1595
|
+
const reads = (d) => /\.html$/i.test(d.view ?? '') || !!d.guided;
|
|
1420
1596
|
const hhmm = (at) => new Date(at).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
|
1421
1597
|
/** Where an item opens: its own area, and inside Waiting on you a plan or a design has its own screen; every other ask opens on Decide. */
|
|
1422
1598
|
const askHref = (d) => {
|
|
@@ -1859,10 +2035,279 @@
|
|
|
1859
2035
|
|
|
1860
2036
|
/** The report a #reports/<key> link opens (or the plan a #waiting/<key> link opens), over the whole board: a bar with the way back and its title, then its page in a sandboxed frame.
|
|
1861
2037
|
* The frame is made again only for another page, so a redraw never resets a demo halfway through. */
|
|
2038
|
+
/* ---- A guided plan (plan 2) or report (report 3): one scroll, built from the message's fields. ----
|
|
2039
|
+
Read receipts and Go deeper are real: a part read for a moment is saved on the board (/api/reads), and Go deeper asks the agent (/api/deeper),
|
|
2040
|
+
whose answer (sign-off-axi deeper) is polled and shown under the part that was tapped. */
|
|
2041
|
+
const GSTAGES = { this: 'What this is', understood: 'What I understood', found: 'What I found', recommend: 'What I recommend', decide: 'What you decide', next: 'Next step' };
|
|
2042
|
+
const GICONS = { chev: '<path d="m6 9 6 6 6-6"/>', check: '<path d="m5 12.5 4.5 4.5L19 7.5"/>', clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
|
2043
|
+
deeper: '<path d="M5 5h14a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-9l-4 3.5V16H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z"/>', send: '<path d="M21 3 10 14M21 3l-6.5 18-3.5-7.5L3.5 10z"/>', info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/>' };
|
|
2044
|
+
const gic = (n, c = '') => { const s = el('span', { 'aria-hidden': 'true', style: 'display:inline-flex' }); s.innerHTML = `<svg class="ic ${c}" viewBox="0 0 24 24">${GICONS[n]}</svg>`; return s; };
|
|
2045
|
+
const E = (tag, cls, ...kids) => el(tag, cls ? { class: cls } : {}, ...kids);
|
|
2046
|
+
const gnum = (n) => String(Number.isInteger(n) ? n : +n.toFixed(2));
|
|
2047
|
+
async function postJson(path, body) {
|
|
2048
|
+
const res = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
2049
|
+
const out = await res.json().catch(() => ({ error: `The server replied ${res.status}` }));
|
|
2050
|
+
if (!res.ok) throw new Error(out.error);
|
|
2051
|
+
state = out;
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
function guideView(d) {
|
|
2055
|
+
const plan = d.type === 'plan', key = d.key, sections = d.sections ?? [], decisions = plan ? d.decisions ?? [] : [], by = (stage) => sections.filter((s) => s.stage === stage);
|
|
2056
|
+
const live = !FROZEN && !state.readonly;
|
|
2057
|
+
const stages = ['this', 'understood', by('found').length && 'found', by('recommend').length && 'recommend', decisions.length && 'decide', 'next'].filter(Boolean);
|
|
2058
|
+
const saved0 = answerOf(key);
|
|
2059
|
+
const sentNote = (saved0?.notes ?? []).find((n) => n.section === 'understood');
|
|
2060
|
+
const S = { understood: sentNote ? (sentNote.verdict === 'approve' ? 'yes' : 'no') : null, note: sentNote?.note ?? '', picks: Object.fromEntries(decisions.map((q) => [q.key, saved0?.decisions?.[q.key] ?? q.recommend])), told: false };
|
|
2061
|
+
const sent = () => answerOf(key)?.answer === 'sent';
|
|
2062
|
+
const parts = new Map(); // part id -> its card
|
|
2063
|
+
const stageOf = new Map(); // part id -> stage
|
|
2064
|
+
const rcs = new Map(), panels = new Map(), deeps = new Map();
|
|
2065
|
+
const readAt = (part) => state.reads?.[`${key}/${part}`];
|
|
2066
|
+
const changes = () => (S.understood === 'no' ? 1 : 0) + decisions.filter((q) => S.picks[q.key] !== q.recommend).length;
|
|
2067
|
+
|
|
2068
|
+
// ---- receipts: who read this part and when
|
|
2069
|
+
const rd = (cls) => E('i', `gdot ${cls ?? ''}`);
|
|
2070
|
+
function receipt(part) {
|
|
2071
|
+
const at = readAt(part), seen = state.seen_at?.[key], mine = answerOf(key);
|
|
2072
|
+
const you = at ? E('span', '', rd(), E('b', '', 'Read by you'), ` ${hhmm(at)}`) : E('span', '', rd('hollow'), 'You have not read this yet');
|
|
2073
|
+
const told = part === 'understood' && !plan && (state.messages ?? []).filter((m) => m.key === key && m.text.startsWith(NOTOFF)).at(-1);
|
|
2074
|
+
let agent;
|
|
2075
|
+
if (plan && mine?.answer === 'sent' && ((part === 'understood' && sentNoteNow()) || part === 'next' || part.startsWith('d:'))) {
|
|
2076
|
+
const what = part === 'understood' ? 'note' : 'answers';
|
|
2077
|
+
agent = seen ? E('span', '', rd('agent'), E('b', '', `Agent read your ${what}`), ` ${hhmm(seen)}`) : E('span', '', rd('hollow'), `Agent has not read your ${what} yet`);
|
|
2078
|
+
} else if (told) agent = told.delivered ? E('span', '', rd('agent'), E('b', '', 'Agent read your note'), ` ${hhmm(told.delivered)}`) : E('span', '', rd('hollow'), 'Agent has not read your note yet');
|
|
2079
|
+
else agent = E('span', '', rd('agent'), E('b', '', 'Agent wrote this'), d.filed ? ` ${hhmm(d.filed)}` : '');
|
|
2080
|
+
return [you, agent];
|
|
2081
|
+
}
|
|
2082
|
+
const NOTOFF = 'What I understood is off: ';
|
|
2083
|
+
const sentNoteNow = () => (answerOf(key)?.notes ?? []).some((n) => n.section === 'understood' && n.note);
|
|
2084
|
+
const rc = (part, small) => { const box = E('div', `gd-rc${small ? ' s' : ''}`); rcs.set(part + (small ? '~s' : ''), box); return box; };
|
|
2085
|
+
const paintRc = () => rcs.forEach((box, k) => box.replaceChildren(...receipt(k.replace(/~s$/, ''))));
|
|
2086
|
+
|
|
2087
|
+
// ---- Go deeper: the person's ask, the agent's answer streaming in under the part
|
|
2088
|
+
const asksOf = (part) => (state.deeper ?? []).filter((x) => x.key === key && x.part === part);
|
|
2089
|
+
const unfinished = () => (state.deeper ?? []).some((x) => x.key === key && !x.done);
|
|
2090
|
+
function paintAns(part) {
|
|
2091
|
+
const panel = panels.get(part), asks = asksOf(part), sig = JSON.stringify(asks);
|
|
2092
|
+
if (!panel || panel.dataset.sig === sig) return;
|
|
2093
|
+
panel.dataset.sig = sig;
|
|
2094
|
+
const blocks = asks.map((a) => {
|
|
2095
|
+
const waiting = !a.read, writing = !!a.text && !a.done, late = waiting && Date.now() - Date.parse(a.at) > 60_000;
|
|
2096
|
+
const head = a.done ? [E('i', 'dot'), `Agent answered ${hhmm(a.answered)}`] : writing ? [E('i', 'dot'), 'Agent is answering'] : waiting ? [E('i', 'dot'), 'Waiting for the agent to read it'] : [E('i', 'dot'), `Agent read it ${hhmm(a.read)}, working on it`];
|
|
2097
|
+
return E('div', `gask${a.done ? '' : ' live'}`, E('div', 'hd', ...head), E('p', 'q', a.q ? `You asked: ${a.q}` : `Go deeper: ${a.about}`),
|
|
2098
|
+
...(a.text ? a.text.split(/\n+/).map((t, i, all) => E('p', '', t, ...(writing && i === all.length - 1 ? [E('span', 'caret')] : []))) : [E('div', 'shim', ''), E('div', 'shim', '')]),
|
|
2099
|
+
E('div', 'gd-rc s', E('span', '', rd(), E('b', '', 'You asked'), ` ${hhmm(a.at)}`), a.read ? E('span', '', rd('agent'), E('b', '', 'Agent read it'), ` ${hhmm(a.read)}`) : E('span', '', rd('hollow'), 'Agent has not read it yet')),
|
|
2100
|
+
...(late ? [E('p', 'slow', 'The agent has not picked this up yet. It answers the next time it checks the board.')] : []));
|
|
2101
|
+
});
|
|
2102
|
+
const done = asks.length && asks.every((a) => a.done);
|
|
2103
|
+
const form = el('form', { class: 'gd-fu' }), input = el('input', { 'aria-label': 'Ask a follow-up', placeholder: 'Ask a follow-up', maxlength: '500' }), go = el('button', { type: 'submit' }, 'Ask');
|
|
2104
|
+
form.append(input, go);
|
|
2105
|
+
form.onsubmit = (e) => { e.preventDefault(); const q = input.value.trim(); if (q) ask(part, q); };
|
|
2106
|
+
panel.replaceChildren(...blocks, ...(done && live ? [form] : []));
|
|
2107
|
+
}
|
|
2108
|
+
async function ask(part, q) {
|
|
2109
|
+
const panel = panels.get(part);
|
|
2110
|
+
try { await postJson('/api/deeper', { key, part, ...(q && { q }) }); sync(); } catch (e) { panel.append(E('p', 'gd-err', `Could not ask: ${e.message}`)); }
|
|
2111
|
+
}
|
|
2112
|
+
const deepBtn = (part) => {
|
|
2113
|
+
const b = el('button', { class: 'gd-lnk deep', type: 'button', 'aria-expanded': 'false', 'aria-controls': `gd-a-${part.replace(/\W/g, '-')}` }, gic('deeper'), 'Go deeper');
|
|
2114
|
+
deeps.set(part, b);
|
|
2115
|
+
b.onclick = () => {
|
|
2116
|
+
const p = panels.get(part), open = b.getAttribute('aria-expanded') === 'true';
|
|
2117
|
+
b.setAttribute('aria-expanded', String(!open)); p.hidden = open;
|
|
2118
|
+
if (!open && !asksOf(part).length) ask(part);
|
|
2119
|
+
};
|
|
2120
|
+
return b;
|
|
2121
|
+
};
|
|
2122
|
+
const ansPanel = (part) => { const p = el('div', { class: 'gd-panel gd-ans', id: `gd-a-${part.replace(/\W/g, '-')}`, hidden: '', 'aria-live': 'polite' }); panels.set(part, p); return p; };
|
|
2123
|
+
const detailsBtn = (part, rows) => {
|
|
2124
|
+
const id = `gd-d-${part.replace(/\W/g, '-')}`, b = el('button', { class: 'gd-lnk', type: 'button', 'aria-expanded': 'false', 'aria-controls': id }, 'Details', gic('chev', 'gchev'));
|
|
2125
|
+
const p = el('div', { class: 'gd-panel', id, hidden: '' }, el('table', { class: 'gd-dt' }, el('caption', { style: 'position:absolute;width:1px;height:1px;overflow:hidden' }, 'The numbers'),
|
|
2126
|
+
...rows.map((r) => el('tr', {}, el('th', { scope: 'row' }, r.label), el('td', {}, r.value)))));
|
|
2127
|
+
b.onclick = () => { const open = b.getAttribute('aria-expanded') !== 'true'; b.setAttribute('aria-expanded', String(open)); p.hidden = !open; };
|
|
2128
|
+
return [b, p];
|
|
2129
|
+
};
|
|
2130
|
+
const acts = (part, rows) => { const [db, dp] = rows?.length ? detailsBtn(part, rows) : []; return [E('div', 'gd-acts', ...(db ? [db] : []), ...(live ? [deepBtn(part)] : [])), ...(dp ? [dp] : []), ...(live ? [ansPanel(part)] : [])]; };
|
|
2131
|
+
|
|
2132
|
+
// ---- the picture
|
|
2133
|
+
function visual(v) {
|
|
2134
|
+
let body;
|
|
2135
|
+
if (v.type === 'bars') {
|
|
2136
|
+
const pair = v.rows.some((r) => r.before !== undefined), max = Math.max(...v.rows.flatMap((r) => [r.value, r.before ?? 0])) * 1.04 || 1, u = v.unit ? ` ${v.unit}` : '';
|
|
2137
|
+
body = E('div', 'gd-bars', ...v.rows.map((r) => E('div', 'br', E('div', 'lab', E('span', '', r.label), E('small', '', r.note ? `${r.note} · ` : '', E('span', 'gv', pair && r.before !== undefined ? `${gnum(r.before)}${u} to ${gnum(r.value)}${u}` : `${gnum(r.value)}${u}`))),
|
|
2138
|
+
pair ? E('div', 'pair', E('div', 'gd-track thin', el('i', { class: 'ghost', style: `width:${((r.before ?? 0) / max) * 100}%` })), E('div', 'gd-track', el('i', { class: r.tone ?? '', style: `width:${(r.value / max) * 100}%` })))
|
|
2139
|
+
: E('div', 'gd-track', el('i', { class: r.tone ?? '', style: `width:${(r.value / max) * 100}%` })))));
|
|
2140
|
+
} else if (v.type === 'steps') {
|
|
2141
|
+
body = E('ol', 'gd-chain', ...v.rows.map((r, i) => E('li', '', E('span', `nd ${r.tone ?? ''}`, String(i + 1)), E('div', 'tx', ...(r.note ? [E('small', '', r.note)] : []), r.label, ...(r.text ? [E('small', '', r.text)] : [])))));
|
|
2142
|
+
} else body = E('div', 'gd-tiles', ...v.rows.map((r) => E('div', 'gd-tile', ...(r.note ? [E('span', `gd-chip ${r.tone ?? ''}`, r.note)] : []), E('b', '', r.label), ...(r.text ? [E('span', 'gt', r.text)] : []))));
|
|
2143
|
+
return E('figure', 'gd-fig', body);
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
// ---- cards
|
|
2147
|
+
const card = (part, stage, ...kids) => { const c = el('section', { class: 'gd-sec', 'data-part': part, id: `gd-${part.replace(/\W/g, '-')}` }, ...kids); parts.set(part, c); stageOf.set(part, stage); return c; };
|
|
2148
|
+
const head = (h, lead, kicker) => el('div', { class: 'gh' }, ...(kicker ? [E('span', 'gk', kicker)] : []), E('h2', '', h), ...(lead ? [E('p', 'gd-lead', lead)] : []));
|
|
2149
|
+
const hero = card('this', 'this', head(d.visual.heading, d.visual.text), visual(d.visual), ...acts('this', d.visual.details), rc('this'));
|
|
2150
|
+
|
|
2151
|
+
const noteBox = el('textarea', { class: 'gd-note', 'aria-label': plan ? 'What is off' : 'What is off, for the agent', placeholder: 'What is off? One line is enough.', maxlength: '500', hidden: '' });
|
|
2152
|
+
noteBox.value = S.note;
|
|
2153
|
+
const tell = el('button', { class: 'gd-lnk gd-tell', type: 'button', hidden: '' }, 'Tell the agent');
|
|
2154
|
+
const choices = [['yes', 'That is right', true], ['no', 'Not quite']].map(([v, label, tick]) => {
|
|
2155
|
+
const b = el('button', { class: 'gd-choice', type: 'button', 'aria-pressed': 'false', 'data-u': v }, ...(tick ? [gic('check')] : []), label);
|
|
2156
|
+
b.onclick = () => { S.understood = S.understood === v ? null : v; syncU(); paintSummary(); };
|
|
2157
|
+
return b;
|
|
2158
|
+
});
|
|
2159
|
+
noteBox.oninput = () => { S.note = noteBox.value; if (!plan) syncU(); };
|
|
2160
|
+
tell.onclick = async () => {
|
|
2161
|
+
const err = tell.parentElement.querySelector('.gd-err'); err?.remove(); tell.disabled = true;
|
|
2162
|
+
try { await postJson('/api/messages', { kind: 'comment', key, text: `${NOTOFF}${S.note.trim()}` }); S.told = true; syncU(); sync(); } catch (e) { tell.after(E('p', 'gd-err', `Could not send: ${e.message}`)); tell.disabled = false; }
|
|
2163
|
+
};
|
|
2164
|
+
function syncU() {
|
|
2165
|
+
const lock = sent() || !live;
|
|
2166
|
+
choices.forEach((b) => { b.setAttribute('aria-pressed', String(S.understood === b.dataset.u)); b.disabled = lock; });
|
|
2167
|
+
noteBox.hidden = S.understood !== 'no'; noteBox.disabled = lock;
|
|
2168
|
+
tell.hidden = plan || S.understood !== 'no' || !S.note.trim() || S.told || !live; tell.disabled = false;
|
|
2169
|
+
}
|
|
2170
|
+
const understood = card('understood', 'understood', head('Check that I have this right', 'This is your ask in my words. If it is off, say what is off. That is the only correction that matters.'),
|
|
2171
|
+
el('dl', { class: 'gd-uq' }, ...d.understood.map((u) => E('div', '', el('dt', {}, u.label), el('dd', {}, u.text)))),
|
|
2172
|
+
E('div', 'gd-seg', ...choices), noteBox, tell, ...(live ? acts('understood') : []), rc('understood'));
|
|
2173
|
+
|
|
2174
|
+
const secCard = (s) => {
|
|
2175
|
+
const items = (s.items ?? []).map((it, i) => {
|
|
2176
|
+
if (!it.more) return E('li', '', E('div', 'rowstat', E('span', '', E('span', 'ti', ...(it.tone ? [E('i', `dotk ${it.tone}`)] : []), it.title), E('span', 'sm', it.text))));
|
|
2177
|
+
const part = `${s.id}:${i}`, body = E('div', 'body', E('p', '', it.more), ...acts(part), ...[]);
|
|
2178
|
+
body.hidden = true;
|
|
2179
|
+
const b = el('button', { class: 'rowbtn', type: 'button', 'aria-expanded': 'false' }, E('span', '', E('span', 'ti', ...(it.tone ? [E('i', `dotk ${it.tone}`)] : []), it.title), E('span', 'sm', it.text)), gic('chev', 'gchev'));
|
|
2180
|
+
b.onclick = () => { const open = b.getAttribute('aria-expanded') !== 'true'; b.setAttribute('aria-expanded', String(open)); body.hidden = !open; };
|
|
2181
|
+
return E('li', '', b, body);
|
|
2182
|
+
});
|
|
2183
|
+
return card(s.id, s.stage, head(s.heading, s.body), ...(items.length ? [E('ul', 'gd-rows', ...items)] : []), ...acts(s.id, s.details), rc(s.id));
|
|
2184
|
+
};
|
|
2185
|
+
|
|
2186
|
+
const optBtns = new Map();
|
|
2187
|
+
const decCard = (q, n) => {
|
|
2188
|
+
const opts = q.options.map((o) => {
|
|
2189
|
+
const b = el('button', { class: 'gd-opt', type: 'button', role: 'radio', 'aria-checked': 'false' }, E('span', 'rdo'),
|
|
2190
|
+
E('span', '', E('span', 'ol', o.label, ...(o.value === q.recommend ? [E('span', 'gd-pick', 'My pick')] : [])), E('span', 'oo', o.outcome)));
|
|
2191
|
+
b.onclick = () => { S.picks[q.key] = o.value; paintOpts(); paintSummary(); };
|
|
2192
|
+
(optBtns.get(q.key) ?? optBtns.set(q.key, []).get(q.key)).push([o.value, b]);
|
|
2193
|
+
return b;
|
|
2194
|
+
});
|
|
2195
|
+
return card(`d:${q.key}`, 'decide', head(q.question, q.why, `Decision ${n + 1} of ${decisions.length}`), E('div', 'gd-opts', ...opts),
|
|
2196
|
+
...(q.reason ? [E('div', 'gd-why', E('b', '', 'Why my pick: '), q.reason)] : []), ...(q.quiet ? [E('p', 'gd-quiet', gic('clock'), E('span', '', `If you stay quiet: ${q.quiet}`))] : []), ...acts(`d:${q.key}`), rc(`d:${q.key}`));
|
|
2197
|
+
};
|
|
2198
|
+
const paintOpts = () => {
|
|
2199
|
+
const lock = sent() || !live;
|
|
2200
|
+
optBtns.forEach((list, k) => list.forEach(([v, b]) => { b.setAttribute('aria-checked', String(S.picks[k] === v)); b.disabled = lock; }));
|
|
2201
|
+
};
|
|
2202
|
+
|
|
2203
|
+
// ---- next step and Go ahead
|
|
2204
|
+
const sendSlot = E('div', ''), err = E('p', 'gd-err');
|
|
2205
|
+
err.setAttribute('role', 'alert');
|
|
2206
|
+
const nextCard = card('next', 'next', head(d.next.heading), E('ul', 'gd-checks', ...d.next.lines.map((l) => E('li', '', E('span', 'tk', gic('check')), E('span', '', l)))), ...(plan ? [sendSlot, err] : []), rc('next'));
|
|
2207
|
+
async function go(b) {
|
|
2208
|
+
if (S.understood === 'no' && !S.note.trim()) { noteBox.focus(); noteBox.scrollIntoView({ block: 'center' }); err.textContent = 'Say what is off first, or choose That is right.'; return; }
|
|
2209
|
+
err.textContent = ''; b.disabled = true;
|
|
2210
|
+
const notes = S.understood ? [{ section: 'understood', verdict: S.understood === 'yes' ? 'approve' : 'change', ...(S.note.trim() && { note: S.note.trim() }) }] : [];
|
|
2211
|
+
try { await postJson('/api/answers', { key, note: '', notes, decisions: S.picks }); sync(); } catch (e) { err.textContent = `Couldn't send: ${e.message}`; b.disabled = false; }
|
|
2212
|
+
}
|
|
2213
|
+
const goBtn = () => { const b = el('button', { class: 'gd-btn block', type: 'button' }, gic('send'), changes() ? `Send ${plural(changes(), 'change')}` : 'Go ahead'); b.onclick = () => go(b); return b; };
|
|
2214
|
+
|
|
2215
|
+
// ---- rail, progress and the phone bar
|
|
2216
|
+
const sumHost = E('div', 'gd-sum'), bar = E('div', 'gd-bar'), barTxt = E('div', 'txt'), nav = el('nav', { 'aria-label': 'Parts' }), segs = E('div', 'gd-segs');
|
|
2217
|
+
const now = E('div', 'now', E('span', '', E('b', '')), E('span', ''));
|
|
2218
|
+
stages.forEach((st, i) => {
|
|
2219
|
+
const a = el('a', { href: `#gd-st-${st}`, 'data-nav': st }, E('span', 'gn', String(i + 1)), E('span', 'gt', GSTAGES[st]));
|
|
2220
|
+
a.onclick = (e) => { e.preventDefault(); stageEl.get(st)?.scrollIntoView({ block: 'start' }); };
|
|
2221
|
+
nav.append(a);
|
|
2222
|
+
const s = el('button', { type: 'button', 'aria-label': GSTAGES[st] }); s.onclick = () => stageEl.get(st)?.scrollIntoView({ block: 'start' });
|
|
2223
|
+
segs.append(s);
|
|
2224
|
+
});
|
|
2225
|
+
const stageEl = new Map();
|
|
2226
|
+
const summaryRows = () => [['Understood', S.understood === 'yes' ? 'Right' : S.understood === 'no' ? E('span', 'chg', 'Not quite') : 'My reading stands'],
|
|
2227
|
+
...decisions.map((q, i) => [q.question, S.picks[q.key] === q.recommend ? 'My pick' : E('span', 'chg', q.options.find((o) => o.value === S.picks[q.key])?.label ?? S.picks[q.key])])];
|
|
2228
|
+
function paintSummary() {
|
|
2229
|
+
const gone = sent(), n = changes();
|
|
2230
|
+
if (plan) {
|
|
2231
|
+
const sendBtn = el('button', { class: 'gd-btn block', type: 'button' }, gic('send'), gone ? 'Sent' : n ? `Send ${plural(n, 'change')}` : 'Go ahead');
|
|
2232
|
+
sendBtn.disabled = gone || !live; sendBtn.onclick = () => go(sendBtn);
|
|
2233
|
+
sumHost.replaceChildren(E('h4', '', 'Your answers'), E('ul', '', ...summaryRows().map(([k, v]) => E('li', '', E('span', '', k), E('span', '', v)))), sendBtn);
|
|
2234
|
+
const b2 = el('button', { class: 'gd-btn', type: 'button' }, gic('send'), gone ? 'Sent' : n ? `Send ${plural(n, 'change')}` : 'Go ahead');
|
|
2235
|
+
b2.disabled = gone || !live; b2.onclick = () => go(b2);
|
|
2236
|
+
barTxt.replaceChildren(...(gone ? [E('b', '', 'Sent'), 'Agent is on it'] : n ? [E('b', '', plural(n, 'change')), 'My picks for the rest'] : [E('b', '', 'My picks are set'), decisions.length ? `${plural(decisions.length, 'question')}, nothing required` : 'Nothing is required']));
|
|
2237
|
+
bar.replaceChildren(barTxt, b2);
|
|
2238
|
+
const mine = answerOf(key), seen = state.seen_at?.[key];
|
|
2239
|
+
sendSlot.replaceChildren(...(gone
|
|
2240
|
+
? [E('div', 'gd-sent', E('h3', '', 'Sent. I am on it.'), E('p', '', mine.notes?.length || decisions.some((q) => mine.decisions?.[q.key] !== q.recommend) ? 'Your changes are with the agent.' : 'Your answers are my picks.'),
|
|
2241
|
+
E('p', 'gd-cap', seen ? `Agent read your answers ${hhmm(seen)}` : 'Agent has not read it yet'))]
|
|
2242
|
+
: [goBtn(), E('p', 'gd-cap', n ? 'I use my picks for everything you did not change.' : 'Nothing is required. This accepts my picks.')]));
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
function paintRail() {
|
|
2246
|
+
const readAll = (st) => [...parts].filter(([id]) => stageOf.get(id) === st).every(([id]) => readAt(id));
|
|
2247
|
+
nav.querySelectorAll('a').forEach((a) => { const on = readAll(a.dataset.nav); a.classList.toggle('seen', on); a.querySelector('.gn').replaceChildren(on ? gic('check') : String(stages.indexOf(a.dataset.nav) + 1)); });
|
|
2248
|
+
[...segs.children].forEach((b, i) => b.classList.toggle('on', readAll(stages[i])));
|
|
2249
|
+
}
|
|
2250
|
+
let cur = stages[0];
|
|
2251
|
+
function setStage(st) {
|
|
2252
|
+
cur = st; const i = stages.indexOf(st);
|
|
2253
|
+
nav.querySelectorAll('a').forEach((a) => a.classList.toggle('cur', a.dataset.nav === st));
|
|
2254
|
+
[...segs.children].forEach((b, k) => b.classList.toggle('cur', k === i));
|
|
2255
|
+
now.firstChild.firstChild.textContent = GSTAGES[st]; now.lastChild.textContent = `${i + 1} of ${stages.length}`;
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
// ---- the page
|
|
2259
|
+
const cards = [hero, understood, ...by('found').map(secCard), ...by('recommend').map(secCard), ...decisions.map(decCard), nextCard];
|
|
2260
|
+
const stageBlocks = [];
|
|
2261
|
+
let last = '';
|
|
2262
|
+
for (const c of cards) {
|
|
2263
|
+
const st = stageOf.get(c.dataset.part);
|
|
2264
|
+
if (st !== last) { last = st; const s = el('div', { class: 'gd-stage', id: `gd-st-${st}`, 'data-stage': st }, E('i', '', String(stages.indexOf(st) + 1)), GSTAGES[st]); stageEl.set(st, s); stageBlocks.push(s); }
|
|
2265
|
+
stageBlocks.push(c);
|
|
2266
|
+
}
|
|
2267
|
+
const word = plan ? 'Plan' : 'Report', who = plan ? 'Planning agent' : d.asked_by ?? 'Reporting agent';
|
|
2268
|
+
const top = E('div', 'gd-top', E('p', 'geye', [word, plan ? d.project : d.group, [who, d.filed && when(d.filed)].filter(Boolean).join(', ')].filter(Boolean).join(' · ')), E('h1', '', d.title), E('p', 'sub', d.about),
|
|
2269
|
+
E('div', 'howto', gic('info'), E('span', '', `A few short parts, in order. Tap Details for the numbers and Go deeper to ask me more.${plan ? ' Nothing is required: Go ahead accepts my picks.' : ''}`)));
|
|
2270
|
+
const rail = el('aside', { class: 'gd-rail' }, E('h4', '', plan ? 'This plan' : 'This report'), nav, ...(plan ? [sumHost] : []));
|
|
2271
|
+
const progress = E('div', 'gd-progress', now, segs);
|
|
2272
|
+
const main = el('div', { class: 'gd-main' }, top, ...stageBlocks);
|
|
2273
|
+
const scroll = E('div', 'gd-scroll', E('div', 'gd', progress, E('div', 'gd-cols', rail, main)));
|
|
2274
|
+
const root = E('div', '', scroll, ...(plan ? [bar] : []));
|
|
2275
|
+
root.style.cssText = 'display:flex;flex-direction:column;flex:1;min-height:0';
|
|
2276
|
+
|
|
2277
|
+
// ---- reading: a part on screen for a moment is read
|
|
2278
|
+
const mark = (part) => { if (!live || readAt(part) !== undefined) return; (state.reads ??= {})[`${key}/${part}`] = new Date().toISOString(); paintRc(); paintRail(); postJson('/api/reads', { key, part }).then(sync).catch(() => {}); };
|
|
2279
|
+
const timers = new Map();
|
|
2280
|
+
const quick = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
2281
|
+
const io = new IntersectionObserver((es) => es.forEach((en) => { const p = en.target.dataset.part; if (en.isIntersecting) timers.set(p, setTimeout(() => mark(p), 1400)); else clearTimeout(timers.get(p)); }), { root: scroll, threshold: 0.35 });
|
|
2282
|
+
parts.forEach((c) => io.observe(c));
|
|
2283
|
+
const io2 = new IntersectionObserver((es) => es.forEach((en) => { if (en.isIntersecting) setStage(en.target.dataset.stage); }), { root: scroll, rootMargin: '-30% 0px -60% 0px' });
|
|
2284
|
+
stageEl.forEach((s) => io2.observe(s));
|
|
2285
|
+
parts.forEach((c) => c.dataset.stage = stageOf.get(c.dataset.part));
|
|
2286
|
+
parts.forEach((c) => io2.observe(c));
|
|
2287
|
+
|
|
2288
|
+
// ---- keeping the page true to the board
|
|
2289
|
+
let timer;
|
|
2290
|
+
function sync() {
|
|
2291
|
+
if (!root.isConnected && timer) { clearInterval(timer); timer = undefined; return; }
|
|
2292
|
+
syncU(); paintOpts(); paintSummary(); paintRc(); paintRail();
|
|
2293
|
+
panels.forEach((_, part) => { paintAns(part); if (asksOf(part).length && deeps.get(part).getAttribute('aria-expanded') === 'false' && !deeps.get(part).dataset.shut) { deeps.get(part).setAttribute('aria-expanded', 'true'); panels.get(part).hidden = false; } });
|
|
2294
|
+
const need = live && (unfinished() || (plan && sent() && !state.seen_at?.[key]) || (!plan && S.told && !(state.messages ?? []).some((m) => m.key === key && m.text.startsWith(NOTOFF) && m.delivered)));
|
|
2295
|
+
if (need && !timer) timer = setInterval(async () => {
|
|
2296
|
+
if (!root.isConnected) { clearInterval(timer); timer = undefined; return; }
|
|
2297
|
+
try { const res = await fetch('/api/decisions'); if (res.ok) { state = await res.json(); sync(); } } catch {}
|
|
2298
|
+
}, 1500);
|
|
2299
|
+
else if (!need && timer) { clearInterval(timer); timer = undefined; }
|
|
2300
|
+
}
|
|
2301
|
+
root.sync = sync;
|
|
2302
|
+
deeps.forEach((b) => b.addEventListener('click', () => { b.dataset.shut = b.getAttribute('aria-expanded') === 'true' ? '' : '1'; }));
|
|
2303
|
+
setStage(stages[0]);
|
|
2304
|
+
queueMicrotask(sync);
|
|
2305
|
+
return root;
|
|
2306
|
+
}
|
|
1862
2307
|
const reader = document.getElementById('reader');
|
|
1863
2308
|
function readPage(d, from = 'reports') {
|
|
1864
2309
|
// A design or a report takes the board's place beside the sidebar on a desk; a plan, and anything on a phone, opens over the whole board.
|
|
1865
|
-
const pane = !!d && d.type !== 'plan', inline = pane && matchMedia('(min-width: 60rem)').matches;
|
|
2310
|
+
const pane = !!d && d.type !== 'plan' && !d.guided, inline = pane && matchMedia('(min-width: 60rem)').matches;
|
|
1866
2311
|
document.body.classList.toggle('reading', !!d);
|
|
1867
2312
|
document.body.classList.toggle('pane-reading', pane);
|
|
1868
2313
|
reader.setAttribute('role', inline ? 'region' : 'dialog');
|
|
@@ -1871,11 +2316,11 @@
|
|
|
1871
2316
|
document.getElementById('nav').inert = !!d && !inline;
|
|
1872
2317
|
document.getElementById('tabs').inert = !!d;
|
|
1873
2318
|
reader.hidden = !d;
|
|
1874
|
-
const url = d?.kind === 'design' ? `design:${d.key}` : d?.type === 'evidence' ? `evidence:${d.key}` : d ? src(mockUrl(d.key, '', d.view)) : '';
|
|
1875
|
-
const answer = d?.type === 'plan' || d?.kind === 'design' || d?.type === 'evidence' ? JSON.stringify(state.answers[d.key] ?? null) : '';
|
|
1876
|
-
const plan = d?.type === 'plan' ? JSON.stringify(d) : '';
|
|
2319
|
+
const url = d?.guided ? `guide:${d.key}` : d?.kind === 'design' ? `design:${d.key}` : d?.type === 'evidence' ? `evidence:${d.key}` : d ? src(mockUrl(d.key, '', d.view)) : '';
|
|
2320
|
+
const answer = d?.guided ? '' : d?.type === 'plan' || d?.kind === 'design' || d?.type === 'evidence' ? JSON.stringify(state.answers[d.key] ?? null) : '';
|
|
2321
|
+
const plan = d?.type === 'plan' || d?.guided ? JSON.stringify(d) : '';
|
|
1877
2322
|
const evidence = d?.type === 'evidence' ? JSON.stringify([d, (state.messages ?? []).filter((m) => m.key === d.key), (state.rounds ?? []).filter((r) => r.key === d.key)]) : '';
|
|
1878
|
-
if (reader.dataset.url === url && reader.dataset.answer === answer && reader.dataset.plan === plan && reader.dataset.evidence === evidence) return d && (reader.querySelector('h1').textContent = d.type === 'evidence' ? headline(d) : d.title);
|
|
2323
|
+
if (reader.dataset.url === url && reader.dataset.answer === answer && reader.dataset.plan === plan && reader.dataset.evidence === evidence) return d && (reader._guide?.sync(), 1) && (reader.querySelector('h1').textContent = d.type === 'evidence' ? headline(d) : d.title);
|
|
1879
2324
|
Object.assign(reader.dataset, { answer, plan, evidence });
|
|
1880
2325
|
Object.assign(reader.dataset, { url, key: d?.key ?? '', from });
|
|
1881
2326
|
if (!d) return reader.replaceChildren();
|
|
@@ -1885,7 +2330,8 @@
|
|
|
1885
2330
|
if (change) { const s = el('span', { 'aria-hidden': 'true' }); s.innerHTML = '<svg viewBox="0 0 24 24"><path d="M14 3h7v7M21 3l-9 9M19 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h5"/></svg>'; out.append(s); }
|
|
1886
2331
|
// A report's page reads under its own head: what it is and when, its title, what it says and decided; a plan or a design keeps its title in the bar;
|
|
1887
2332
|
// an update shows its own title on its screen, with the change link in the bar.
|
|
1888
|
-
reader.
|
|
2333
|
+
reader._guide = undefined;
|
|
2334
|
+
reader.replaceChildren(...(d.guided ? [el('header', {}, back), (reader._guide = guideView(d))] : d.type === 'plan' ? [el('header', {}, back, Object.assign(h1, { className: 'plan-name' })), planReview(d, url)] : d.kind === 'design' ? [el('header', {}, back), designReview(d, h1)]
|
|
1889
2335
|
: d.type === 'evidence' ? [el('header', {}, back, out), evidenceItem(d)]
|
|
1890
2336
|
: [el('header', {}, back), el('div', { class: 'read' }, el('p', { class: 'eyebrow' }, [kind(d) || 'Report', d.filed ? when(d.filed) : ''].filter(Boolean).join(' · ')), h1,
|
|
1891
2337
|
d.about ? el('p', { class: 'about', dir: 'auto' }, d.about) : '', decided(d), el('iframe', { src: url, title: `Report: ${d.title}`, sandbox: 'allow-scripts' }), dismissReport(d))]));
|
|
@@ -1953,7 +2399,7 @@
|
|
|
1953
2399
|
function planCard(d) {
|
|
1954
2400
|
const meta = [el('span', {}, icon('list'), plural(d.sections.length, 'section')), el('span', {}, icon('split'), plural(d.decisions.length, 'decision')),
|
|
1955
2401
|
el('span', {}, icon('clock'), `Planning agent${d.filed ? `, ${when(d.filed)}` : ''}`)];
|
|
1956
|
-
return el('article', { class: 'plan-hero', id: `item-${d.key}` }, el('div', { class: 'plan-preview' }, rung(d)),
|
|
2402
|
+
return el('article', { class: 'plan-hero', id: `item-${d.key}` }, ...(d.guided ? [] : [el('div', { class: 'plan-preview' }, rung(d))]),
|
|
1957
2403
|
el('div', { class: 'plan-copy' }, el('p', { class: 'eyebrow' }, icon('plan'), `Plan${d.project ? ` · ${d.project}` : ''}`),
|
|
1958
2404
|
el('h2', { dir: 'auto' }, d.title), el('p', { class: 'about', dir: 'auto' }, d.about ?? ''),
|
|
1959
2405
|
el('div', { class: 'plan-meta' }, ...meta), el('div', { class: 'plan-actions' }, el('a', { class: 'open-link plan-open', href: `#waiting/${d.key}` }, 'Open plan'), xBtn(d, 'Dismiss'))));
|
|
@@ -2128,7 +2574,7 @@
|
|
|
2128
2574
|
el('div', { class: 'tiles' }, tile('waiting', by.waiting.length, 'Need you'), tile('evidence', by.provenToday.length, 'Proven today'), tile('reports', filedToday(by.reports).length, 'New report')),
|
|
2129
2575
|
el('div', { class: 'areas' }, waitingSection(by.waiting, by.answered, by.designs), proven(by.evidence, by.provenToday), newReport(by.reports)), ...(state.leash ? leash() : []),
|
|
2130
2576
|
...(FROZEN || state.readonly || state.answers_only ? [] : [ask()])]));
|
|
2131
|
-
readPage(key ? state.decisions.find((d) => d.key === key && !gone.has(d.key) && (at === 'reports' && reads(d) || at === 'designs' && d.kind === 'design' || at === 'evidence' && d.type === 'evidence' || d.type === 'plan' && area(d) === at)) : undefined, at);
|
|
2577
|
+
readPage(key ? state.decisions.find((d) => d.key === key && !gone.has(d.key) && (at === 'reports' && reads(d) || at === 'designs' && d.kind === 'design' || at === 'evidence' && d.type === 'evidence' || d.type === 'plan' && (area(d) === at || (d.guided && (at === 'waiting' || at === 'answered'))))) : undefined, at);
|
|
2132
2578
|
if (at === 'decide') document.querySelector('#board .decide')?.prepend(bulkBar(bulk));
|
|
2133
2579
|
applyPicks();
|
|
2134
2580
|
if (FROZEN) freeze();
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
{
|
|
2
|
+
"contract": 1,
|
|
3
|
+
"kind": "plan",
|
|
4
|
+
"schema": 2,
|
|
5
|
+
"key": "FILL: short-id",
|
|
6
|
+
"title": "FILL: the plan's answer in one line",
|
|
7
|
+
"project": "FILL: project",
|
|
8
|
+
"about": "FILL: the plan in 2 sentences, answer first",
|
|
9
|
+
"understood": [
|
|
10
|
+
{
|
|
11
|
+
"label": "You asked",
|
|
12
|
+
"text": "FILL: the request in your own words"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"label": "Done when",
|
|
16
|
+
"text": "FILL: how the person will know it is finished"
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"visual": {
|
|
20
|
+
"type": "bars",
|
|
21
|
+
"heading": "FILL: the one point the picture makes, in one line",
|
|
22
|
+
"text": "FILL: what the bars measure and where the number comes from",
|
|
23
|
+
"unit": "min",
|
|
24
|
+
"rows": [
|
|
25
|
+
{
|
|
26
|
+
"label": "FILL: first thing",
|
|
27
|
+
"value": 4.5,
|
|
28
|
+
"note": "FILL: tag",
|
|
29
|
+
"tone": "bad"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"label": "FILL: second thing",
|
|
33
|
+
"value": 2.8,
|
|
34
|
+
"note": "FILL: tag",
|
|
35
|
+
"tone": "ok"
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
"details": [
|
|
39
|
+
{
|
|
40
|
+
"label": "FILL: a number",
|
|
41
|
+
"value": "FILL: its value, unit and when it was measured"
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
"sections": [
|
|
46
|
+
{
|
|
47
|
+
"id": "steps",
|
|
48
|
+
"stage": "recommend",
|
|
49
|
+
"heading": "FILL: what you recommend, as a statement",
|
|
50
|
+
"body": "FILL: one or two lines of lead",
|
|
51
|
+
"items": [
|
|
52
|
+
{
|
|
53
|
+
"title": "FILL: step or fix",
|
|
54
|
+
"text": "FILL: one line",
|
|
55
|
+
"more": "FILL: why, and what it costs"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"title": "FILL: step or fix",
|
|
59
|
+
"text": "FILL: one line"
|
|
60
|
+
}
|
|
61
|
+
],
|
|
62
|
+
"details": [
|
|
63
|
+
{
|
|
64
|
+
"label": "FILL: a number",
|
|
65
|
+
"value": "FILL: value"
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
],
|
|
70
|
+
"decisions": [
|
|
71
|
+
{
|
|
72
|
+
"key": "FILL: short-id",
|
|
73
|
+
"question": "FILL: one question, 12 words or fewer?",
|
|
74
|
+
"why": "FILL: why it needs an answer now",
|
|
75
|
+
"options": [
|
|
76
|
+
{
|
|
77
|
+
"value": "a",
|
|
78
|
+
"label": "FILL: first choice",
|
|
79
|
+
"outcome": "FILL: what happens if picked"
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"value": "b",
|
|
83
|
+
"label": "FILL: second choice",
|
|
84
|
+
"outcome": "FILL: what happens if picked"
|
|
85
|
+
}
|
|
86
|
+
],
|
|
87
|
+
"recommend": "a",
|
|
88
|
+
"reason": "FILL: why you would pick the first",
|
|
89
|
+
"quiet": "FILL: what you do if the person stays quiet"
|
|
90
|
+
}
|
|
91
|
+
],
|
|
92
|
+
"next": {
|
|
93
|
+
"heading": "FILL: the next step, in one line",
|
|
94
|
+
"lines": [
|
|
95
|
+
"FILL: what happens after the person reads or answers",
|
|
96
|
+
"FILL: where the proof will land"
|
|
97
|
+
]
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
{
|
|
2
|
+
"contract": 1,
|
|
3
|
+
"kind": "report",
|
|
4
|
+
"schema": 3,
|
|
5
|
+
"key": "FILL: short-id",
|
|
6
|
+
"title": "FILL: the answer in one line",
|
|
7
|
+
"filed": "FILL: UTC time",
|
|
8
|
+
"about": "FILL: why it matters, in 2 sentences",
|
|
9
|
+
"group": "FILL: section name",
|
|
10
|
+
"understood": [
|
|
11
|
+
{
|
|
12
|
+
"label": "You asked",
|
|
13
|
+
"text": "FILL: the request in your own words"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"label": "Done when",
|
|
17
|
+
"text": "FILL: how the person will know it is finished"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"visual": {
|
|
21
|
+
"type": "bars",
|
|
22
|
+
"heading": "FILL: the one point the picture makes, in one line",
|
|
23
|
+
"text": "FILL: what the bars measure and where the number comes from",
|
|
24
|
+
"unit": "min",
|
|
25
|
+
"rows": [
|
|
26
|
+
{
|
|
27
|
+
"label": "FILL: first thing",
|
|
28
|
+
"value": 4.5,
|
|
29
|
+
"note": "FILL: tag",
|
|
30
|
+
"tone": "bad"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"label": "FILL: second thing",
|
|
34
|
+
"value": 2.8,
|
|
35
|
+
"note": "FILL: tag",
|
|
36
|
+
"tone": "ok"
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
"details": [
|
|
40
|
+
{
|
|
41
|
+
"label": "FILL: a number",
|
|
42
|
+
"value": "FILL: its value, unit and when it was measured"
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
"sections": [
|
|
47
|
+
{
|
|
48
|
+
"id": "found",
|
|
49
|
+
"stage": "found",
|
|
50
|
+
"heading": "FILL: what you found, as a statement",
|
|
51
|
+
"body": "FILL: one or two lines of lead",
|
|
52
|
+
"items": [
|
|
53
|
+
{
|
|
54
|
+
"title": "FILL: one finding",
|
|
55
|
+
"text": "FILL: one line",
|
|
56
|
+
"more": "FILL: the detail that opens when it is tapped",
|
|
57
|
+
"tone": "bad"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"title": "FILL: another finding",
|
|
61
|
+
"text": "FILL: one line",
|
|
62
|
+
"more": "FILL: the detail",
|
|
63
|
+
"tone": "ok"
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"id": "steps",
|
|
69
|
+
"stage": "recommend",
|
|
70
|
+
"heading": "FILL: what you recommend, as a statement",
|
|
71
|
+
"body": "FILL: one or two lines of lead",
|
|
72
|
+
"items": [
|
|
73
|
+
{
|
|
74
|
+
"title": "FILL: step or fix",
|
|
75
|
+
"text": "FILL: one line",
|
|
76
|
+
"more": "FILL: why, and what it costs"
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"title": "FILL: step or fix",
|
|
80
|
+
"text": "FILL: one line"
|
|
81
|
+
}
|
|
82
|
+
],
|
|
83
|
+
"details": [
|
|
84
|
+
{
|
|
85
|
+
"label": "FILL: a number",
|
|
86
|
+
"value": "FILL: value"
|
|
87
|
+
}
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
],
|
|
91
|
+
"next": {
|
|
92
|
+
"heading": "FILL: the next step, in one line",
|
|
93
|
+
"lines": [
|
|
94
|
+
"FILL: what happens after the person reads or answers",
|
|
95
|
+
"FILL: where the proof will land"
|
|
96
|
+
]
|
|
97
|
+
}
|
|
98
|
+
}
|