sign-off-axi 0.4.1 → 0.4.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/dist/cli.js CHANGED
@@ -2400,6 +2400,8 @@ const server = createServer(async (req, res) => {
2400
2400
  if (given !== null)
2401
2401
  return res.writeHead(303, { location: '/', 'set-cookie': `${cookie}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000` }).end();
2402
2402
  }
2403
+ if (req.method === 'GET' && req.url === '/favicon.ico')
2404
+ return res.writeHead(204).end();
2403
2405
  if (req.method === 'GET' && req.url === '/') {
2404
2406
  return res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': "default-src 'self' 'unsafe-inline'" }).end(page);
2405
2407
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sign-off-axi",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "One review board for evidence, quick yes/no decisions and retro changes.",
5
5
  "type": "module",
6
6
  "bin": { "sign-off-axi": "dist/cli.js" },
package/web/index.html CHANGED
@@ -149,6 +149,58 @@
149
149
  #reader .close { display: inline-flex; flex: none; align-items: center; min-height: 3rem; margin-left: -0.5rem; padding: 0 0.5rem; border-radius: 0.625rem; color: var(--accent); font-weight: 650; text-decoration: none; }
150
150
  #reader h1 { margin: 0; min-width: 0; overflow: hidden; font-size: 1rem; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
151
151
  #reader iframe { flex: 1; width: 100%; border: 0; background: var(--bg); }
152
+ #reader h1:not(:focus-visible) { outline: none; }
153
+ #reader h1:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
154
+ .i-plan { --area: var(--c-waiting); }
155
+ #reader .plan-scroll { flex: 1; min-height: 0; overflow: auto; padding: 1.25rem 1.25rem 9rem; }
156
+ .plan-layout { max-width: 72rem; margin: auto; display: grid; gap: 2rem; }
157
+ .plan-main { min-width: 0; }
158
+ .plan-main > h2 { margin: 2rem 0 0.75rem; }
159
+ .plan-main > h2:first-of-type { font-size: 3.5rem; line-height: 1.15; margin: 0.5rem 0; }
160
+ .plan-main > p { margin: 0.5rem 0 1.5rem; }
161
+ .plan-summary { display: flex; gap: 0.75rem; color: var(--muted); font-size: 1.125rem; line-height: 1.5; margin: 1.5rem 0 2rem; }
162
+ #reader .plan-view { width: 100%; height: 22rem; flex: none; border-radius: 1rem; }
163
+ .plan-section, .plan-decision, .plan-explain { background: var(--surface); border-radius: 1.125rem; padding: 1rem; margin: 1rem 0; scroll-margin-top: 1rem; }
164
+ .plan-section h3, .plan-decision h3 { font-size: 1.375rem; line-height: 1.3; margin: 0 0 0.75rem; }
165
+ .plan-section p { white-space: pre-line; }
166
+ .plan-verdicts { display: flex; gap: 0.625rem; }
167
+ .plan-verdicts button { flex: 1; border: 0; border-radius: 980px; background: var(--sunk); font-weight: 600; }
168
+ .plan-verdicts button[aria-pressed="true"] { box-shadow: inset 0 0 0 1.5px currentColor; background: var(--surface); }
169
+ .plan-verdicts button[aria-pressed="true"]::before, .plan-decision button[aria-pressed="true"]::before { content: none; }
170
+ .plan-verdicts .approve[aria-pressed="true"] { color: var(--ok); }
171
+ .plan-verdicts .change[aria-pressed="true"] { color: var(--warn); }
172
+ .plan-section label { display: grid; gap: 0.5rem; margin-top: 1rem; color: var(--muted); }
173
+ .plan-section label[hidden] { display: none; }
174
+ .plan-section textarea { width: 100%; min-height: 7rem; padding: 0.75rem; background: var(--sunk); }
175
+ .plan-decision .choices { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.625rem; }
176
+ .plan-decision .choices button { text-align: left; border-radius: 0.875rem; padding: 0.875rem; gap: 0.25rem; }
177
+ .plan-decision .choices button .tag { margin: 0; text-transform: none; letter-spacing: normal; color: var(--accent); }
178
+ .plan-decision .choices button .tag::before { content: none; }
179
+ .plan-decision .choices button[aria-pressed="true"] { background: color-mix(in srgb, var(--accent) 12%, var(--surface)); color: var(--text); border: 2px solid var(--accent); }
180
+ .plan-decision .tag { display: block; color: var(--accent); font-size: 0.875rem; }
181
+ .plan-explain .foot { justify-content: space-between; }
182
+ .plan-send { position: absolute; bottom: 0; left: 0; right: 0; padding: 0.625rem 1rem calc(0.75rem + env(safe-area-inset-bottom)); background: var(--bar); border-top: 1px solid var(--line); }
183
+ .plan-send p { margin: 0 0 0.5rem; }
184
+ .plan-send button { width: 100%; }
185
+ .plan-next-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 0.5rem; }
186
+ .plan-next { width: auto !important; padding: 0; border: 0; background: none; color: var(--accent); font-weight: 600; }
187
+ .plan-send .err:empty { display: none; }
188
+ .plan-progress button { width: 100%; padding: 0.5rem 0; display: flex; justify-content: space-between; gap: 1rem; background: none; text-align: left; border-radius: 0.5rem; font-size: 1rem; }
189
+ .plan-progress span { color: var(--muted); }
190
+ @media (max-width: 59.999rem) { #reader .plan-scroll { margin-bottom: calc(9rem + env(safe-area-inset-bottom)); } #reader .plan-scroll.sent { margin-bottom: 0; } .plan-send h2, .plan-progress { display: none; } .plan-send p { font-size: 1rem; } }
191
+ #reader .plan-sent .back { display: inline-flex; }
192
+ .plan-sent { text-align: center; padding-top: 2rem; }
193
+ .plan-sent h2 { color: var(--ok); font-size: 2.125rem; }
194
+ .plan-sent .icon { margin: auto; background: var(--ok); }
195
+ .plan-layout.sent { grid-template-columns: minmax(0, 42.5rem); justify-content: center; }
196
+ .plan-send[hidden] { display: none; }
197
+ @media (min-width: 60rem) {
198
+ #reader .plan-scroll { padding: 2rem 3rem; }
199
+ .plan-layout { grid-template-columns: minmax(0, 42.5rem) minmax(15rem, 1fr); }
200
+ .plan-send { position: sticky; align-self: start; top: 0; border: 0; border-radius: 1.125rem; padding: 1.25rem; }
201
+ .plan-next { display: none; }
202
+ .plan-section, .plan-decision { padding: 1.5rem; }
203
+ }
152
204
  body.reading { overflow: hidden; }
153
205
  .foot { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 1rem; }
154
206
  .foot .meta { margin: 0 auto 0 0; }
@@ -572,7 +624,7 @@
572
624
  const update = d.type === 'evidence', card = d.evidenceCard, result = RESULT[card?.result];
573
625
  const brow = [update && area(d) !== 'waiting' ? `Update ${d.number}` : kind(d), result ? el('span', { class: `st ${result[0]}` }, result[1]) : '',
574
626
  update && d.filed ? (isToday(d.filed) ? el('span', { class: 'st new' }, 'New') : day(d.filed)) : '', seen].filter(Boolean);
575
- const lead = el('div', { class: 'lead' }, x ? xBtn(d) : '', brow.length ? el('p', { class: 'eyebrow' }, ...brow) : '', el('h3', { dir: 'auto' }, name));
627
+ const lead = el('div', { class: 'lead' }, x ? xBtn(d) : '', brow.length ? el('p', { class: 'eyebrow' }, ...(d.type === 'plan' ? [icon('plan')] : []), ...brow) : '', el('h3', { dir: 'auto' }, name));
576
628
  if (d.type === 'signin') return signin(d, lead, saved, err);
577
629
  if (d.type === 'plan') return plan(d, lead);
578
630
  const quiet = !ro && d.options.find((o) => o.value === d.default_if_silent);
@@ -869,6 +921,7 @@
869
921
  evidence: 'M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1zM9 12l2 2 4-4',
870
922
  reports: 'M12 7v14M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z',
871
923
  answered: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM9 12l2 2 4-4',
924
+ plan: 'M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7zM14 2v6h6M8 13h8M8 17h8M8 9h2',
872
925
  you: 'M12 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM4.5 20.5c1.2-3.6 4-5.5 7.5-5.5s6.3 1.9 7.5 5.5' };
873
926
  // The drawings are fixed strings from this page, never text from the board file.
874
927
  const icon = (a) => { const s = el('span', { class: `icon i-${a}`, 'aria-hidden': 'true' }); s.innerHTML = `<svg viewBox="0 0 24 24"><path d="${ICONS[a]}"/></svg>`; return s; };
@@ -932,7 +985,7 @@
932
985
  /** A short row: a picture (or the area's mark) and the title; an answered item has its answer and time under the title. */
933
986
  const compact = (d, a) => {
934
987
  const s = state.answers[d.key];
935
- return [thumb(a, pic(d)), el('span', { class: 't' }, el('strong', { dir: 'auto' }, title(d)), s
988
+ return [thumb(d.type === 'plan' ? 'plan' : a, pic(d)), el('span', { class: 't' }, el('strong', { dir: 'auto' }, title(d)), s
936
989
  ? el('span', { class: 'ans' }, el('b', { dir: 'auto' }, s.answer === 'text' ? 'Your reply' : s.label), el('time', {}, isToday(s.at) ? hhmm(s.at) : day(s.at)))
937
990
  : el('span', { class: 'meta', dir: 'auto' }, sub(d)))];
938
991
  };
@@ -1013,6 +1066,95 @@
1013
1066
  return box;
1014
1067
  }
1015
1068
 
1069
+ /** Plan answers stay on the trusted board, never in the agent's sandboxed page. Drafts survive refresh until the server saves them. */
1070
+ function planReview(d, url) {
1071
+ const saved = answerOf(d.key), ro = !!saved || state.readonly || !!FROZEN, draftKey = `plan:${d.key}`;
1072
+ let draft;
1073
+ try { draft = JSON.parse(drafts.get(draftKey) ?? '{}'); } catch { draft = {}; }
1074
+ const notes = new Map((saved?.notes ?? draft.notes ?? []).filter((n) => d.sections.some((s) => s.id === n.section)).map((n) => [n.section, n]));
1075
+ const picks = new Map(Object.entries(saved?.decisions ?? draft.decisions ?? {}).filter(([key, value]) => d.decisions.some((q) => q.key === key && q.options.some((o) => o.value === value))));
1076
+ const remember = () => { drafts.set(draftKey, JSON.stringify({ notes: [...notes.values()], decisions: Object.fromEntries(picks) })); keep(); };
1077
+ const main = el('div', { class: 'plan-main' }), panel = el('aside', { class: 'plan-send' });
1078
+ const status = el('p', { role: 'status' }), err = el('p', { class: 'err', role: 'alert' }), progress = el('div', { class: 'plan-progress' });
1079
+ const send = el('button', { class: 'open-link' }, 'Send back'), next = el('button', { class: 'plan-next' }, 'Go to next');
1080
+ const complete = (s) => { const n = notes.get(s.id); return n?.verdict === 'approve' || (n?.verdict === 'change' && n.note?.trim()); };
1081
+ next.onclick = () => {
1082
+ const section = d.sections.find((s) => !complete(s)), decision = d.decisions.find((q) => !q.options.some((o) => o.value === picks.get(q.key)));
1083
+ document.getElementById(section ? `plan-section-${section.id}` : `plan-decision-${decision?.key}`)?.scrollIntoView({ block: 'start' });
1084
+ };
1085
+ const update = () => {
1086
+ const left = d.sections.filter((s) => !complete(s)).length, decisions = d.decisions.filter((q) => !q.options.some((o) => o.value === picks.get(q.key))).length;
1087
+ status.textContent = saved ? 'Sent back. Your answers are saved.' : left || decisions ? `${left} section${left === 1 ? '' : 's'} and ${decisions} decision${decisions === 1 ? '' : 's'} left` : 'Ready. Every part has an answer.';
1088
+ send.disabled = ro || !!left || !!decisions;
1089
+ next.hidden = !left && !decisions;
1090
+ progress.replaceChildren(...d.sections.map((s) => {
1091
+ const verdict = notes.get(s.id)?.verdict, b = el('button', {}, s.heading, el('span', {}, verdict === 'approve' ? 'Approved' : verdict === 'change' ? 'Change' : 'To review'));
1092
+ b.onclick = () => document.getElementById(`plan-section-${s.id}`).scrollIntoView(); return b;
1093
+ }), d.decisions.length ? el('p', { class: 'meta' }, `${d.decisions.length - decisions} of ${d.decisions.length} decisions answered`) : '');
1094
+ };
1095
+ if (saved) {
1096
+ main.append(el('div', { class: 'plan-sent', role: 'status' }, icon('answered'), el('h2', {}, 'Sent back'), el('p', {}, 'Your section notes and decisions are saved for the agent.'),
1097
+ el('a', { class: 'back', href: '#answered' }, 'See it in Answered')));
1098
+ }
1099
+ main.append(el('p', { class: 'eyebrow' }, icon('plan'), 'Plan'), el('h2', { dir: 'auto' }, d.title), el('p', { dir: 'auto' }, d.about ?? ''));
1100
+ if (!saved) main.append(el('iframe', { class: 'plan-view', src: url, title: `Plan: ${d.title}`, sandbox: 'allow-scripts' }),
1101
+ el('p', { class: 'plan-summary' }, icon('plan'), `${d.sections.length} sections to approve or change, and ${d.decisions.length} decisions to make. Nothing is built until you send back.`));
1102
+ main.append(el('h2', {}, saved ? 'What you sent' : 'The plan'), saved ? '' : el('p', { class: 'meta' }, 'Approve or change each part. A change needs a note.'));
1103
+ d.sections.forEach((s, i) => {
1104
+ const card = el('section', { class: 'plan-section', id: `plan-section-${s.id}`, 'aria-label': s.heading });
1105
+ const approve = el('button', { class: 'approve', 'aria-label': `Approve, ${s.heading}` }, 'Approve');
1106
+ const change = el('button', { class: 'change', 'aria-label': `Change, ${s.heading}` }, 'Change');
1107
+ const field = el('textarea', { id: `plan-note-${s.id}`, maxlength: '500', placeholder: 'Write your note for the agent' });
1108
+ field.value = notes.get(s.id)?.note ?? '';
1109
+ field.disabled = ro;
1110
+ const note = el('label', { for: field.id }, `What should change in ${s.heading.toLowerCase()}?`, field);
1111
+ const draw = () => {
1112
+ const verdict = notes.get(s.id)?.verdict;
1113
+ card.dataset.verdict = verdict ?? '';
1114
+ approve.setAttribute('aria-pressed', verdict === 'approve');
1115
+ approve.textContent = verdict === 'approve' ? '✓ Approved' : 'Approve';
1116
+ change.setAttribute('aria-pressed', verdict === 'change');
1117
+ approve.disabled = change.disabled = ro;
1118
+ note.hidden = verdict !== 'change';
1119
+ update();
1120
+ };
1121
+ [approve, change].forEach((b, n) => b.onclick = () => {
1122
+ notes.set(s.id, { section: s.id, verdict: n ? 'change' : 'approve', ...(n && { note: field.value }) });
1123
+ remember(); draw(); if (n) field.focus();
1124
+ });
1125
+ field.oninput = () => { notes.set(s.id, { section: s.id, verdict: 'change', note: field.value }); remember(); update(); };
1126
+ card.append(el('h3', { dir: 'auto' }, `${i + 1}. ${s.heading}`), el('p', { dir: 'auto' }, s.body), el('div', { class: 'plan-verdicts', role: 'group', 'aria-label': s.heading }, approve, change), note);
1127
+ main.append(card); draw();
1128
+ });
1129
+ if (d.decisions.length) main.append(el('h2', {}, 'Decisions for you'));
1130
+ d.decisions.forEach((q) => {
1131
+ const choices = el('div', { class: 'choices', role: 'group', 'aria-label': q.question });
1132
+ q.options.forEach((o) => {
1133
+ const b = el('button', { 'aria-pressed': picks.get(q.key) === o.value, ...(ro && { disabled: '' }) }, el('strong', {}, o.label),
1134
+ o.value === q.recommend ? el('span', { class: 'tag' }, 'Recommended') : '', el('span', { class: 'out', dir: 'auto' }, o.outcome));
1135
+ b.onclick = () => { picks.set(q.key, o.value); remember(); [...choices.children].forEach((b, i) => b.setAttribute('aria-pressed', q.options[i].value === o.value)); update(); };
1136
+ choices.append(b);
1137
+ });
1138
+ main.append(el('section', { class: 'plan-decision', id: `plan-decision-${q.key}` }, el('h3', { dir: 'auto' }, q.question), el('p', { class: 'meta', dir: 'auto' }, q.why), choices));
1139
+ });
1140
+ if (d.steps?.length && !saved) {
1141
+ let step = 0;
1142
+ const text = el('p', { 'aria-live': 'polite' }), prev = el('button', { 'aria-label': 'Previous step' }, '‹ Previous'), next = el('button', { 'aria-label': 'Next step' }, 'Next ›');
1143
+ const draw = () => { text.textContent = `Step ${step + 1} of ${d.steps.length}: ${d.steps[step].title}`; prev.disabled = !step; next.disabled = step === d.steps.length - 1; };
1144
+ prev.onclick = () => { step--; draw(); }; next.onclick = () => { step++; draw(); }; draw();
1145
+ main.append(el('section', { class: 'plan-explain' }, el('h3', {}, 'How it works'), text, el('div', { class: 'foot' }, prev, next), el('p', { class: 'meta' }, 'The direction page above explains these steps.')));
1146
+ }
1147
+ send.onclick = () => {
1148
+ send.disabled = true; err.textContent = '';
1149
+ call('/api/answers', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key,
1150
+ notes: d.sections.map((s) => notes.get(s.id)), decisions: Object.fromEntries(picks) }) }, () => {
1151
+ drafts.delete(draftKey); keep(); location.hash = `#answered/${d.key}`;
1152
+ }).catch((e) => { err.textContent = `Couldn't send back: ${e.message}`; update(); });
1153
+ };
1154
+ panel.append(el('h2', {}, 'Your answers'), progress, el('div', { class: 'plan-next-row' }, status, next), send, err); panel.hidden = !!saved; update();
1155
+ return el('div', { class: `plan-scroll${saved ? ' sent' : ''}` }, el('div', { class: `plan-layout${saved ? ' sent' : ''}` }, main, panel));
1156
+ }
1157
+
1016
1158
  /** 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.
1017
1159
  * The frame is made again only for another page, so a redraw never resets a demo halfway through. */
1018
1160
  const reader = document.getElementById('reader');
@@ -1021,10 +1163,13 @@
1021
1163
  for (const n of [document.querySelector('main'), document.getElementById('nav'), document.getElementById('tabs')]) n.inert = !!d;
1022
1164
  reader.hidden = !d;
1023
1165
  const url = d ? src(mockUrl(d.key, '', d.view)) : '';
1024
- if (reader.dataset.url === url) return d && (reader.querySelector('h1').textContent = d.title);
1166
+ const answer = d?.type === 'plan' ? JSON.stringify(state.answers[d.key] ?? null) : '';
1167
+ const plan = d?.type === 'plan' ? JSON.stringify(d) : '';
1168
+ if (reader.dataset.url === url && reader.dataset.answer === answer && reader.dataset.plan === plan) return d && (reader.querySelector('h1').textContent = d.title);
1169
+ Object.assign(reader.dataset, { answer, plan });
1025
1170
  Object.assign(reader.dataset, { url, key: d?.key ?? '', from });
1026
1171
  reader.replaceChildren(...(d ? [el('header', {}, el('a', { class: 'close', href: `#${from}` }, `‹ ${from === 'reports' ? 'Reports' : AREAS[from]}`), el('h1', { id: 'reader-title', tabindex: '-1', dir: 'auto' }, d.title)),
1027
- el('iframe', { src: url, title: `${d.type === 'plan' ? 'Plan' : 'Report'}: ${d.title}`, sandbox: 'allow-scripts' })] : []));
1172
+ d.type === 'plan' ? planReview(d, url) : el('iframe', { src: url, title: `Report: ${d.title}`, sandbox: 'allow-scripts' })] : []));
1028
1173
  }
1029
1174
  addEventListener('keydown', (e) => { if (e.key === 'Escape' && !reader.hidden) location.hash = `#${reader.dataset.from}`; });
1030
1175
 
@@ -1176,7 +1321,8 @@
1176
1321
  // An interactive page may post its own height, `{ height: <px> }`, and its frame then fits it with no blank below.
1177
1322
  // Only a frame's own message sizes that frame, and never past 10000px.
1178
1323
  addEventListener('message', (e) => {
1179
- const frame = [...document.querySelectorAll('.rung iframe')].find((f) => f.contentWindow === e.source), h = e.data?.height;
1324
+ const frame = [...document.querySelectorAll('.rung iframe, iframe.plan-view')].find((f) => f.contentWindow === e.source), h = e.data?.height;
1325
+ if (frame?.classList.contains('plan-view') && e.data?.['sign-off'] === 'ready') frame.contentWindow.postMessage({ 'sign-off': 'size' }, '*');
1180
1326
  if (frame && Number.isFinite(h) && h > 0) frame.style.height = `${Math.ceil(Math.min(h, 10_000))}px`;
1181
1327
  });
1182
1328
  // A design mock page may point (web/point.js): the board keeps the last element named per mock, and turns pointing on for one mock frame.
package/web/point.js CHANGED
@@ -31,6 +31,10 @@
31
31
  };
32
32
  addEventListener('message', (m) => {
33
33
  if (m.source === parent && m.data?.['sign-off'] === 'point') on = m.data.on === true;
34
+ if (m.source === parent && m.data?.['sign-off'] === 'size') {
35
+ const size = () => parent.postMessage({ height: document.documentElement.scrollHeight }, '*');
36
+ new ResizeObserver(size).observe(document.body); size();
37
+ }
34
38
  });
35
39
  for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) addEventListener(type, (m) => {
36
40
  if (!on) return;