sign-off-axi 0.4.6 → 0.4.7

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
@@ -5,14 +5,15 @@ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
5
5
  import { spawn, spawnSync } from 'node:child_process';
6
6
  import { accessSync, closeSync, constants, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, watch, writeFileSync } from 'node:fs';
7
7
  import { lookup } from 'node:dns/promises';
8
- import { homedir, tmpdir, userInfo } from 'node:os';
8
+ import { availableParallelism, homedir, tmpdir, userInfo } from 'node:os';
9
+ import { Worker } from 'node:worker_threads';
9
10
  import { basename, delimiter, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
10
11
  import { parseArgs } from 'node:util';
11
12
  import { encode } from '@toon-format/toon';
12
13
  import { AxiError, runAxiCli } from 'axi-sdk-js';
13
14
  import { CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendAnswer, appendDismissed, appendSendBack, appendDesignAnswer, appendMessage, appendRound, awaitsVerdict, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
14
15
  import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, mediaFiles, parseMessage, reportPage } from './contract.js';
15
- import { faults } from './evidence-text.js';
16
+ import { faults, isStill } from './evidence-text.js';
16
17
  import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
17
18
  const FORMAT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/file-formats.md';
18
19
  const CONTRACT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/contract.md';
@@ -326,8 +327,10 @@ const ASK_FLAGS = ['why', 'outcome', 'recommend-reason', 'silent-by', 'merge'];
326
327
  const MANY = ['option', 'outcome', 'merge'];
327
328
  /** Board types that are contract kinds: a decision (the default type) is an ask. */
328
329
  const CONTRACT_TYPES = ['decision', 'ask', 'evidence', 'report', 'plan', 'design'];
329
- /** The stored item as the message it came from: the board's own `filed` stamp is not part of a message that names none. */
330
- const asSent = ({ filed: _filed, ...rest }) => rest;
330
+ /** The stored item as the message it came from: the board's own `filed` stamp is not part of a message that names none, and the notice record (`announced`) is the board's too. */
331
+ const asSent = ({ filed: _filed, announced: _announced, ...rest }) => rest;
332
+ /** A report names its own `filed` time, so only the notice record goes. */
333
+ const asReport = ({ announced: _announced, ...rest }) => rest;
331
334
  /** Items of a --from file that carry contract or kind are contract messages: each is checked against its kind's rules before anything is stored.
332
335
  * A new item of a contract kind must carry them (contract section 2). A --replace feed replaces the stored item, so an item under an existing key
333
336
  * that changed is checked again; an unchanged item, a board-format one and every item outside a --replace feed are not checked again. */
@@ -349,7 +352,7 @@ function contractItems(items, from, file, replace = false) {
349
352
  // item, a board-format one and every item outside a --replace feed are not checked again.
350
353
  const asMessage = (x) => {
351
354
  if (m?.kind !== 'evidence')
352
- return m?.kind === 'report' ? x : asSent(x);
355
+ return m?.kind === 'report' ? asReport(x) : asSent(x);
353
356
  const { type: _type, number: _number, media, evidence: run, ...message } = asSent(x);
354
357
  return { ...message, ...(media !== undefined && { media: Array.isArray(media) ? media.map((r) => r?.path) : media }), ...(run && { run }) };
355
358
  };
@@ -919,7 +922,7 @@ function postSignin(file, named, on, values, filer) {
919
922
  let status = same ? (done ? 'already done' : 'already waiting') : had ? 'new link' : 'posted';
920
923
  if (!same) {
921
924
  fits(d.title, d.key);
922
- const next = { ...item, filed: new Date().toISOString().replace(/\.\d+Z$/, 'Z') };
925
+ const next = { ...item, ...(had?.announced && { announced: had.announced }), filed: new Date().toISOString().replace(/\.\d+Z$/, 'Z') };
923
926
  if (had)
924
927
  items[at] = next;
925
928
  else
@@ -990,7 +993,9 @@ function replace(file, named, on, from, filer) {
990
993
  }
991
994
  }
992
995
  const old = existsSync(file) ? readItems(file) : undefined;
993
- let items = feed, extra = fed.extra, resulting = incoming;
996
+ // The notice record belongs to the card key, so a feed that rewrites a card keeps it: the receiver was told once.
997
+ const told = new Map((old?.items ?? []).filter((i) => i?.key && i.announced).map((i) => [i.key, i.announced]));
998
+ let items = feed.map((i) => (told.has(i?.key) ? { ...i, announced: told.get(i.key) } : i)), extra = fed.extra, resulting = incoming;
994
999
  if (filer) {
995
1000
  // The feed's items first: the board shows the file's last item first, so the other filers' items keep showing on top.
996
1001
  const fedKeys = new Set(feed.map((i) => i.key));
@@ -1067,7 +1072,7 @@ function postMedia(file, named, on, values, type, filer, message) {
1067
1072
  return path;
1068
1073
  });
1069
1074
  // A still that is not a picture of the product (text, colour bars or a placeholder) is measured before anything is copied.
1070
- const [unfit] = explainer ? [] : sources.flatMap((src) => /\.(png|jpe?g|webp)$/i.test(src) ? faults(src).map((f) => ({ src, f })) : []);
1075
+ const [unfit] = explainer ? [] : sources.flatMap((src) => isStill(src) ? faults(src).map((f) => ({ src, f })) : []);
1071
1076
  if (unfit)
1072
1077
  throw fail(UNFIT[unfit.f](tilde(unfit.src)), 'VALIDATION_ERROR', 'Post a real capture of the running product instead: a screenshot or recording of the screen the user sees');
1073
1078
  mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
@@ -1077,8 +1082,8 @@ function postMedia(file, named, on, values, type, filer, message) {
1077
1082
  const current = board(file), store = join(dirname(file), 'evidence'), taken = current.map((d) => Number(/^update-(\d+)$/.exec(d.key)?.[1] ?? 0));
1078
1083
  const own = message ? message.key : explainer && (values.key ?? slug(values.title)), had = message && own && items.find((x) => x?.key === own);
1079
1084
  if (had) {
1080
- // The stored item, read back as the message it came from: its run, and each media file by hash.
1081
- const asMessage = ({ type: _t, number: _n, filed: _f, evidence: run, media: kept, ...rest }) => canon({ ...rest, run, media: kept?.map((x) => x.sha256) });
1085
+ // The stored item, read back as the message it came from: its run, and each media file by hash. The notice record is the board's, not the message's.
1086
+ const asMessage = ({ type: _t, number: _n, filed: _f, announced: _a, evidence: run, media: kept, ...rest }) => canon({ ...rest, run, media: kept?.map((x) => x.sha256) });
1082
1087
  if (asMessage(had) !== canon({ ...message, media: sources.length ? sources.map((src) => sha256(readFileSync(src))) : undefined })) {
1083
1088
  throw fail(`key ${own} already holds a different message on ${tilde(file)}`, 'CONFLICT', 'Post it under a new key');
1084
1089
  }
@@ -1372,7 +1377,7 @@ function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false)
1372
1377
  // The same question from another filer, or at another time, is the same question: it keeps its first filer and time.
1373
1378
  const same = (x) => JSON.stringify({ ...x, answer: undefined, dismissed: undefined, filer: undefined, filed: undefined, options: [...x.options].sort((a, b) => a.value.localeCompare(b.value)) });
1374
1379
  // A message that names no filed time matches the one the board stamped.
1375
- const differs = () => exact ? canon({ ...items.find((x) => x?.key === d.key), ...(raw[i].filed === undefined && { filed: undefined }) }) !== canon(raw[i]) : same(had) !== same(d);
1380
+ const differs = () => exact ? canon({ ...items.find((x) => x?.key === d.key), ...(raw[i].filed === undefined && { filed: undefined }), announced: undefined }) !== canon(raw[i]) : same(had) !== same(d);
1376
1381
  if (had && differs())
1377
1382
  throw refuse(`already holds a different ${contract ? 'message' : 'question'} on ${tilde(file)}`);
1378
1383
  // Each new item records when it was raised, unless it names its own time.
@@ -1674,13 +1679,23 @@ function waitingKeys(file) {
1674
1679
  return undefined;
1675
1680
  }
1676
1681
  }
1677
- /** Announces each card that entered Waiting on you with a write. The card is already stored, so nothing here fails the command. */
1682
+ /** Announces each card that entered Waiting on you with a write. The card is already stored, so nothing here fails the command.
1683
+ * A card is announced once: the card keeps the record, so one that leaves Waiting and comes back (a new sign-in link) stays quiet,
1684
+ * the same silent return an Undo gets. */
1678
1685
  function notifyEntered(file, named, before) {
1679
1686
  try {
1680
1687
  const so = dirname(named), notify = before ? readSetup(so).notify : undefined;
1681
1688
  if (!before || !notify || (notify.token_env && !process.env[notify.token_env]))
1682
1689
  return;
1683
- for (const d of board(file, () => { }).filter((d) => waits(d) && !before.has(d.key)))
1690
+ const { items } = readItems(file), fresh = board(file, () => { }).filter((d) => waits(d) && !before.has(d.key) && !items.some((i) => i?.key === d.key && i.announced));
1691
+ if (!fresh.length)
1692
+ return;
1693
+ const at = new Date().toISOString().replace(/\.\d+Z$/, 'Z');
1694
+ for (const i of items)
1695
+ if (fresh.some((d) => d.key === i?.key))
1696
+ i.announced = at;
1697
+ writeItems(file, items);
1698
+ for (const d of fresh)
1684
1699
  sendNotice(so, notify, d);
1685
1700
  }
1686
1701
  catch { }
@@ -1994,6 +2009,7 @@ async function serve(args) {
1994
2009
  tokenOf(parsed.file, true);
1995
2010
  await sweep('startup');
1996
2011
  [file, evidenceRoot] = [parsed.file, parsed.values['evidence-root'] ? resolve(parsed.values['evidence-root']) : ''];
2012
+ guardReady = warmGuard();
1997
2013
  await new Promise((ok, no) => server.once('error', no).listen(port, bind, ok)).catch((e) => {
1998
2014
  throw fail(e.code === 'EADDRINUSE' ? `port ${port} is in use; pass --port` : e.code === 'EADDRNOTAVAIL' ? `${bind} is not an address of this machine; pass another --bind` : e.message, 'SERVE_FAILED', 'Run `sign-off-axi --help` for the flags, then start it again');
1999
2015
  });
@@ -2048,6 +2064,8 @@ async function exportBoard(args) {
2048
2064
  const parsed = parse('export', args, { out: { type: 'string' }, 'evidence-root': { type: 'string' } });
2049
2065
  ready(parsed.file, parsed.on, parsed.values['evidence-root']);
2050
2066
  [file, evidenceRoot] = [parsed.file, parsed.values['evidence-root'] ? resolve(parsed.values['evidence-root']) : ''];
2067
+ guardReady = warmGuard();
2068
+ await guardReady;
2051
2069
  const { file: _, messages: _messages, annotations: _annotations, ...frozen } = state(), files = {}, q = encodeURIComponent;
2052
2070
  let missing = 0;
2053
2071
  // The same URLs the page asks the server for, read through the same hash and path checks.
@@ -2094,18 +2112,75 @@ const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
2094
2112
  /** A card whose picture is a colour-bar test card or a placeholder is not shown: a card posted before the guard can hold one. Read by the
2095
2113
  * stored hash, so each picture is measured once. */
2096
2114
  const unfitMedia = new Map();
2097
- const fakeEvidence = (d) => d.type === 'evidence' && (d.media ?? []).some((m) => {
2098
- if (!unfitMedia.has(m.sha256)) {
2099
- let found;
2115
+ const fakeEvidence = (d) => d.type === 'evidence' && (d.media ?? []).some((m) => isStill(m.path) && unfitMedia.get(m.sha256) === true);
2116
+ /** Every still on the board, once, so a request never walks the media: the board's files are hash-checked and immutable. */
2117
+ const stillJobs = () => {
2118
+ const jobs = [], seen = new Set();
2119
+ for (const d of loadDecisions(file)) {
2120
+ if (d.type !== 'evidence')
2121
+ continue;
2122
+ for (const m of d.media ?? []) {
2123
+ if (!isStill(m.path) || seen.has(m.sha256) || unfitMedia.has(m.sha256))
2124
+ continue;
2125
+ seen.add(m.sha256);
2126
+ try {
2127
+ jobs.push({ path: under(dirname(file), m.path), sha256: m.sha256 });
2128
+ }
2129
+ catch { /* an absent file has no faults */ }
2130
+ }
2131
+ }
2132
+ return jobs;
2133
+ };
2134
+ /** Measures the board's stills in parallel and off the request path. A fresh board is therefore ready before it is asked for cards: the
2135
+ * page and media answer at once, and the guard's pass never blocks them. One follow-up pass takes whatever a lost worker never reported. */
2136
+ let guardReady = Promise.resolve();
2137
+ /** A board read waits this long for the warm pass, so a still that is slow or stuck never holds the cards. */
2138
+ const GUARD_WAIT = 500;
2139
+ /** A worker still measuring after this is stuck in a convert that outlived its own timeout: it is stopped and its stills stay unmeasured. */
2140
+ const WORKER_WAIT = 35_000;
2141
+ const warmGuard = () => new Promise((done) => {
2142
+ const pass = (retry) => {
2143
+ let jobs;
2100
2144
  try {
2101
- found = faults(under(dirname(file), m.path)).some((f) => f !== 'text');
2145
+ jobs = stillJobs();
2102
2146
  }
2103
2147
  catch {
2104
- return false;
2148
+ return done();
2149
+ }
2150
+ if (!jobs.length)
2151
+ return done();
2152
+ const workers = Math.max(1, Math.min(8, availableParallelism(), jobs.length));
2153
+ let left = workers;
2154
+ const ended = () => {
2155
+ if (--left)
2156
+ return;
2157
+ if (retry)
2158
+ pass(false);
2159
+ else
2160
+ done();
2161
+ };
2162
+ for (let i = 0; i < workers; i++) {
2163
+ let worker;
2164
+ try {
2165
+ worker = new Worker(new URL('./stills-worker.js', import.meta.url), { workerData: jobs.filter((_, j) => j % workers === i) });
2166
+ }
2167
+ catch {
2168
+ ended();
2169
+ continue;
2170
+ }
2171
+ let stop;
2172
+ const arm = () => {
2173
+ clearTimeout(stop);
2174
+ stop = setTimeout(() => { void worker.terminate(); }, WORKER_WAIT);
2175
+ stop.unref();
2176
+ };
2177
+ arm();
2178
+ worker.on('message', ([sha, unfit]) => { unfitMedia.set(sha, unfit); arm(); });
2179
+ worker.once('error', () => clearTimeout(stop));
2180
+ worker.once('exit', () => { clearTimeout(stop); ended(); });
2105
2181
  }
2106
- unfitMedia.set(m.sha256, found);
2107
- }
2108
- return unfitMedia.get(m.sha256);
2182
+ };
2183
+ pass(true);
2109
2184
  });
2110
2185
  const state = () => {
2111
2186
  reap();
@@ -2292,6 +2367,9 @@ async function route(req) {
2292
2367
  if (req.method === 'GET' && req.url === '/api/board')
2293
2368
  return [200, { file, pid: process.pid }];
2294
2369
  if (req.method === 'GET' && req.url === '/api/decisions') {
2370
+ let timer;
2371
+ await Promise.race([guardReady, new Promise((ok) => { timer = setTimeout(ok, GUARD_WAIT); })]);
2372
+ clearTimeout(timer);
2295
2373
  return [200, state()];
2296
2374
  }
2297
2375
  if (req.method !== 'POST' || !['/api/answers', '/api/rounds', '/api/messages', '/api/annotations', '/api/dismiss', '/api/signin'].includes(req.url ?? ''))
@@ -1,4 +1,4 @@
1
- import { spawnSync } from 'node:child_process';
1
+ import { spawn, spawnSync } from 'node:child_process';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { inflateSync } from 'node:zlib';
4
4
  /** A picture of text (a log, receipt or summary) is a few colours: paper, ink and their anti-aliased blends, with no large flat block of a
@@ -26,12 +26,7 @@ const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
26
26
  /** Adam7 interlace passes: x start, y start, x step, y step. A non-interlaced PNG is one pass over every pixel. */
27
27
  const ADAM7 = [[0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4], [0, 2, 2, 4], [1, 0, 2, 2], [0, 1, 1, 2]];
28
28
  const WHOLE = [[0, 0, 1, 1]];
29
- /** The share of pixels that are ink (a colour off the paper), of pixels off the paper-ink line, and of the largest flat area in any other colour,
30
- * or undefined when the file cannot be decoded. */
31
- export function palette(file) {
32
- const rgb = decode(file);
33
- if (!rgb)
34
- return undefined;
29
+ const shareOf = (rgb) => {
35
30
  const total = rgb.length / 3, bins = new Uint32Array(32768);
36
31
  const centre = (b) => [(b >> 10) * 8 + 4, ((b >> 5) & 31) * 8 + 4, (b & 31) * 8 + 4];
37
32
  for (let i = 0; i < rgb.length; i += 3)
@@ -71,11 +66,19 @@ export function palette(file) {
71
66
  off++;
72
67
  }
73
68
  return { ink: ink / total, off: off / total, solid, top, bars, barShare };
69
+ };
70
+ export function palette(file) {
71
+ const rgb = decode(file);
72
+ return rgb && shareOf(rgb);
74
73
  }
74
+ /** The still formats this guard measures. A recording is never measured: it is not a still, and decoding it would push a video through
75
+ * ImageMagick on every board load. */
76
+ const STILL = /\.(png|jpe?g|webp)$/i;
77
+ /** Whether the guard has a still to measure. Callers that scan a whole board use this to skip recordings, as post does. */
78
+ export const isStill = (file) => STILL.test(file);
75
79
  /** Why a still is not a picture of the product: text (a log, receipt or summary), colour bars (a test card) or a placeholder (a flat card
76
- * standing in for a picture). A file that cannot be decoded has no faults: it is not refused. */
77
- export function faults(file) {
78
- const share = palette(file);
80
+ * standing in for a picture). Anything that is not a still, and a file that cannot be decoded, has no faults: it is not refused. */
81
+ function faultsOf(share) {
79
82
  if (!share)
80
83
  return [];
81
84
  const text = share.ink >= INK_MIN && share.off <= OFF_LINE_MAX && share.solid <= SOLID_MAX;
@@ -85,6 +88,17 @@ export function faults(file) {
85
88
  ...(!text && share.top >= PLACEHOLDER_MIN ? ['placeholder'] : []),
86
89
  ];
87
90
  }
91
+ export function faults(file) {
92
+ if (!isStill(file))
93
+ return [];
94
+ return faultsOf(palette(file));
95
+ }
96
+ /** The same faults, decoded without blocking the caller's thread, so a hung child can be killed instead of holding a worker. */
97
+ export async function faultsAsync(file) {
98
+ if (!isStill(file))
99
+ return [];
100
+ return faultsOf(await paletteAsync(file));
101
+ }
88
102
  const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
89
103
  /** The picture as packed 8-bit RGB. PNG is decoded here; JPEG, WebP and any PNG this decoder refuses go through ImageMagick, when it is installed. */
90
104
  function decode(file) {
@@ -94,9 +108,51 @@ function decode(file) {
94
108
  if (own)
95
109
  return own;
96
110
  }
97
- const out = spawnSync('convert', [file, 'png24:-'], { maxBuffer: 1 << 30, timeout: 30_000 });
111
+ const out = spawnSync('convert', [file, 'png24:-'], { maxBuffer: 1 << 30, timeout: 30_000, killSignal: 'SIGKILL' });
98
112
  return out.status === 0 && out.stdout.subarray(0, 8).equals(PNG_SIGNATURE) ? pngRgb(out.stdout) : undefined;
99
113
  }
114
+ /** ImageMagick's output, bounded in time and size: the child, and anything it delegated to, is killed when either is passed. */
115
+ async function paletteAsync(file) {
116
+ const bytes = readFileSync(file);
117
+ if (bytes.subarray(0, 8).equals(PNG_SIGNATURE)) {
118
+ const own = pngRgb(bytes);
119
+ if (own)
120
+ return shareOf(own);
121
+ }
122
+ const out = await convert(file), rgb = out && out.subarray(0, 8).equals(PNG_SIGNATURE) ? pngRgb(out) : undefined;
123
+ return rgb && shareOf(rgb);
124
+ }
125
+ const CONVERT_WAIT = 30_000, CONVERT_BYTES = 1 << 30;
126
+ function convert(file) {
127
+ return new Promise((done) => {
128
+ const child = spawn('convert', [file, 'png24:-'], { detached: true, stdio: ['ignore', 'pipe', 'ignore'] });
129
+ const chunks = [];
130
+ let finished = false, code = null, bytes = 0;
131
+ const finish = (out) => { if (!finished) {
132
+ finished = true;
133
+ clearTimeout(timer);
134
+ done(out);
135
+ } };
136
+ const stop = () => {
137
+ finish(undefined);
138
+ try {
139
+ process.kill(-child.pid, 'SIGKILL');
140
+ }
141
+ catch {
142
+ child.kill('SIGKILL');
143
+ }
144
+ child.stdout.destroy();
145
+ };
146
+ const timer = setTimeout(stop, CONVERT_WAIT);
147
+ child.stdout.on('data', (b) => { bytes += b.length; if (bytes > CONVERT_BYTES)
148
+ stop();
149
+ else
150
+ chunks.push(b); });
151
+ child.on('error', () => finish(undefined));
152
+ child.on('exit', (c) => { code = c; });
153
+ child.on('close', () => finish(code === 0 ? Buffer.concat(chunks) : undefined));
154
+ });
155
+ }
100
156
  /** Packed 8-bit RGB of a PNG, every colour type and bit depth, interlaced or not. undefined for a PNG it does not read. */
101
157
  function pngRgb(buf) {
102
158
  try {
@@ -0,0 +1,12 @@
1
+ import { parentPort, workerData } from 'node:worker_threads';
2
+ import { faultsAsync } from './evidence-text.js';
3
+ /** One slice of a board's stills, measured off the server's thread so page and media requests keep answering. */
4
+ const jobs = workerData;
5
+ for (const { path, sha256 } of jobs) {
6
+ let unfit = false;
7
+ try {
8
+ unfit = (await faultsAsync(path)).some((f) => f !== 'text');
9
+ }
10
+ catch { /* an undecodable or unreadable still is not refused */ }
11
+ parentPort.postMessage([sha256, unfit]);
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sign-off-axi",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
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
@@ -409,7 +409,8 @@
409
409
  .design-preview-full { display: block; max-width: 56rem; margin: 0 auto; color: var(--accent); }
410
410
  @media (min-width: 60rem) { .design-preview-window { max-width: 24.375rem; } }
411
411
  .design-review label { display: grid; gap: 0.5rem; max-width: 56rem; margin: 1rem auto; color: var(--muted); }
412
- .design-review textarea { min-height: 6rem; padding: 0.75rem; background: var(--surface); }
412
+ /* The design note fills its column, so it lines up with the buttons under it at every width (the 70ch prose cap would narrow it at tablet width). */
413
+ .design-review textarea { max-width: none; min-height: 6rem; padding: 0.75rem; background: var(--surface); }
413
414
  .design-review .foot { max-width: 56rem; margin: 1rem auto; }
414
415
  .design-review .foot > * { flex: 1; }
415
416
  .design-review .foot button.open-link { background: var(--action); color: var(--on-accent); }
@@ -431,7 +432,7 @@
431
432
  .design-viewer-panel .foot { display: flex; gap: 0.75rem; margin: 0.5rem 0; }
432
433
  .design-viewer-panel .foot > * { flex: 1; }
433
434
  .design-viewer-panel label { display: grid; gap: 0.25rem; margin: 0.5rem 0; }
434
- .design-viewer-panel textarea { min-height: 3rem; max-height: 6rem; margin: 0; }
435
+ .design-viewer-panel textarea { max-width: none; min-height: 3rem; max-height: 6rem; margin: 0; }
435
436
  .design-viewer-panel .open-link { background: var(--action); color: var(--on-accent); }
436
437
  @media (min-width: 60rem) { .design-viewer header h2 { font-size: 2.125rem; } .design-viewer-body { grid-template: minmax(0, 1fr) / minmax(0, 1fr) 22.5rem; } .design-viewer-panel { padding: 1.25rem; } }
437
438
  @media (max-height: 550px) { .design-viewer-body { overflow: auto; grid-template-rows: minmax(12rem, 1fr) auto; } }