@lovek4d/kickflip 0.7.1 → 0.7.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.
@@ -1,4 +1,4 @@
1
- var h=Object.defineProperty;var o=(e,i)=>h(e,"name",{value:i,configurable:!0});import l from"node:fs";import d from"node:path";import{execFileSync as c}from"node:child_process";const p="# Prepare .kickflip/build\n\nThe hub builds and serves a card's version of this repo by running one script, `.kickflip/build <file>/<id> <worktree>`, in the card's worktree. This board has none on main yet.\n\nContract (docs/adr/2026-11-01-1846-a-boards-build-script-serves-or-builds-each-card-and-main.md):\n- Print exactly `port <n>` on stdout to serve a website on that port.\n- Or exit 0 with no port for an app build, publishing the install with `board.mjs item set --install --install-sha`.\n- The file is a regular file in the repo, executable, writable by no one but its owner.\n\nWhat this card does:\n- Look at the repo for what it builds: a website or server, an iOS or Android app, a CLI, a library with nothing to serve.\n- If it finds one type, write `.kickflip/build` for it.\n- If it finds several, ask the owner which the board should serve, write one `.kickflip/build-<target>` for each type the owner wants (for example `build-web`, `build-ios`), and set this board's Build target in Board Settings to the one picked.\n- If nothing here builds, say so and write nothing.\n",u=`# Write CONTEXT.md and the first ADR
1
+ var l=Object.defineProperty;var r=(e,t)=>l(e,"name",{value:t,configurable:!0});import c from"node:fs";import h from"node:path";import{execFileSync as p}from"node:child_process";const u="# Prepare .kickflip/build\n\nThe hub builds and serves a card's version of this repo by running one script, `.kickflip/build <file>/<id> <worktree>`, in the card's worktree. This board has none on main yet.\n\nContract (docs/adr/2026-11-01-1846-a-boards-build-script-serves-or-builds-each-card-and-main.md):\n- Print exactly `port <n>` on stdout to serve a website on that port.\n- Or exit 0 with no port for an app build, publishing the install with `board.mjs item set --install --install-sha`.\n- The file is a regular file in the repo, executable, writable by no one but its owner.\n\nWhat this card does:\n- Look at the repo for what it builds: a website or server, an iOS or Android app, a CLI, a library with nothing to serve.\n- If it finds one type, write `.kickflip/build` for it.\n- If it finds several, ask the owner which the board should serve, write one `.kickflip/build-<target>` for each type the owner wants (for example `build-web`, `build-ios`), and set this board's Build target in Board Settings to the one picked.\n- If nothing here builds, say so and write nothing.\n",w=`# Write CONTEXT.md and the first ADR
2
2
 
3
3
  Cards in this repo are grilled and built against its own words and decisions. This card writes them down.
4
4
 
@@ -7,5 +7,5 @@ What this card does:
7
7
  - Write the first ADR in docs/adr/, named docs/adr/YYYY-MM-DD-HHMM-<slug>.md, for the biggest decision the repo's shape shows: what was chosen and what was considered.
8
8
  - Ask the owner what the project is for and which words they use for its parts; their wording goes in verbatim.
9
9
  - If the repo already has a CONTEXT.md or ADRs, extend them rather than start again.
10
- `,b=[{id:"onboard-build",title:"Prepare .kickflip/build",notes:p,needsNoBuild:!0},{id:"onboard-context",title:"Write the repo's CONTEXT.md glossary and first ADR",notes:u}];function T(e,i){try{return c("git",["-C",e,"ls-tree",`${i}:.kickflip`],{encoding:"utf8",stdio:["ignore","pipe","ignore"],timeout:3e3}).split(`
11
- `).some(r=>/^100755 blob \S+\tbuild(-.+)?$/.test(r))}catch{return!1}}o(T,"hasBuildScript");function y(e,{prefix:i="INT",hasBuild:s,now:r=new Date}){return b.filter(t=>!(t.needsNoBuild&&s)).map((t,a)=>{const n=`${t.id}-notes.md`;return l.writeFileSync(d.join(d.dirname(e),n),t.notes),{id:t.id,title:t.title,created:new Date(r.getTime()+a).toISOString(),phase:"grilling",ticket:`${i}-${a+1}`,plan:n}})}o(y,"onboardingItems");export{T as hasBuildScript,y as onboardingItems};
10
+ `,f=[{title:"Prepare .kickflip/build",notes:u,needsNoBuild:!0},{title:"Write the repo's CONTEXT.md glossary and first ADR",notes:w}];function T(e,t){try{return p("git",["-C",e,"ls-tree",`${t}:.kickflip`],{encoding:"utf8",stdio:["ignore","pipe","ignore"],timeout:3e3}).split(`
11
+ `).some(s=>/^100755 blob \S+\tbuild(-.+)?$/.test(s))}catch{return!1}}r(T,"hasBuildScript");function y(e,{hasBuild:t,now:o=new Date}){return f.filter(i=>!(i.needsNoBuild&&t)).map((i,a)=>{const n=`T-${a+1}`,d=`${n}-notes.md`;return c.writeFileSync(h.join(h.dirname(e),d),i.notes),{id:n,title:i.title,created:new Date(o.getTime()+a).toISOString(),phase:"grilling",plan:d}})}r(y,"onboardingItems");export{T as hasBuildScript,y as onboardingItems};
@@ -307,7 +307,7 @@ BoardView.part((B) => {
307
307
  // Every prototype round is a question on its card, so this button reaches the prototypes too, Done cards included
308
308
  // (docs/adr/2026-11-09-1100-needs-you-button-reaches-the-prototypes.md).
309
309
  const ACTION_BUTTONS = {
310
- [BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">Needs you</a>`,
310
+ [BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">Questions</a>`,
311
311
  };
312
312
  const actionButton = (it) => ACTION_BUTTONS[it.actionButton]?.(it) || '';
313
313
 
@@ -1,4 +1,4 @@
1
- // The board view's lanes and the Auto Review lane leading them: each lane's header and cards, Done's and Asks' Show more,
1
+ // The board view's lanes and the Auto Review lane leading them: each lane's header and cards, Done's Show more,
2
2
  // Done's finished-item-file folds, and Todo's wave bands with their Implement and card order.
3
3
  // Needs app-core.js and app-cards.js.
4
4
  BoardView.part((B) => {
@@ -9,9 +9,8 @@ BoardView.part((B) => {
9
9
  { id: 'progress', name: 'In Progress', open: true },
10
10
  { id: 'todo', name: 'Todo', open: true },
11
11
  { id: 'done', name: 'Done', open: false, capped: true },
12
- { id: 'asks', name: 'Asks', open: false, capped: true },
13
12
  ];
14
- // docs/adr/2026-11-03-0259-answered-asks-have-their-own-section-below-done.md
13
+ // docs/adr/2026-11-09-1430-answered-asks-fold-into-the-one-done-list.md
15
14
  // A capped lane shows STEP cards, and each Show more tap adds STEP more, kept in memory per lane. A card the owner opened
16
15
  // this visit stays shown past the cap; one open from before a reload does not (state.restored). Show more says how many are left (docs/adr/2026-10-29-1639-lists-never-cut-silently.md).
17
16
  // older is how many cards wait past `cards` in the archive; the tap that would show past `cards` fetches them first.
@@ -21,9 +21,12 @@ BoardView.part((B) => {
21
21
  let log = null;
22
22
  let stopReading = null;
23
23
 
24
- // A row of the transcript (transcript.mjs); a call with its output opens it on a tap of its line.
25
- function rowHtml(r, i) {
24
+ // A row of the transcript (transcript.mjs); a call with its output opens it on a tap of its line. What Claude says,
25
+ // its text rows and its result, is markdown in compact styles, linking the board's refs
26
+ // (docs/adr/2026-11-09-1453-orchestrator-messages-replies-and-transcript-text-are-markdown.md).
27
+ function rowHtml(r, i, links) {
26
28
  const tag = r.label ? `<span class="reader-tag">${esc(r.label)}</span>` : '';
29
+ if (r.kind.split(' ')[0] === 'text') return `<div class="reader-row ${r.kind} md">${tag}${markdown(r.body, links)}</div>`;
27
30
  if (r.out === undefined) return `<div class="reader-row ${r.kind}">${tag}${esc(r.body)}</div>`;
28
31
  const on = log.opened.has(i);
29
32
  return `<div class="reader-row tool call" data-call="${i}"><span class="reader-caret">${on ? '▾' : '▸'}</span>${tag}${esc(r.body)}</div>${on ? `<div class="reader-out">${esc(r.out)}</div>` : ''}`;
@@ -42,10 +45,11 @@ BoardView.part((B) => {
42
45
  }
43
46
  function drawLog() {
44
47
  const { rows, stream, raw, text } = log;
48
+ const links = linkContext(state.board?.items || [], null);
45
49
  body.innerHTML = !rows.length ? '<p class="muted reader-empty">The log is empty so far.</p>'
46
50
  : !stream ? `<pre class="reader-text">${esc(rows.map((r) => `${r.body}\n`).join(''))}</pre>`
47
51
  : raw ? (text === null ? '<p class="muted">Loading…</p>' : `<pre class="reader-raw">${esc(text)}</pre>`)
48
- : rows.map(rowHtml).join('');
52
+ : rows.map((r, i) => rowHtml(r, i, links)).join('');
49
53
  drawTools();
50
54
  drawMore();
51
55
  }
@@ -128,7 +128,7 @@ window.BoardView = (function () {
128
128
  board: null,
129
129
  fresh: null,
130
130
  openItems: new Set(store.get('openItems', [])),
131
- // The cards open when the page loaded: Done and Asks hide those past their cap until the owner opens one this visit.
131
+ // The cards open when the page loaded: Done hides those past their cap until the owner opens one this visit.
132
132
  restored: new Set(store.get('openItems', [])),
133
133
  threadCache: new Map(),
134
134
  edits: new Map(),
@@ -12,6 +12,7 @@
12
12
  // painted also gets a bubbling attach-change event with detail {id}
13
13
  window.BoardAttach = (function () {
14
14
  const LIMIT = 20 * 1024 * 1024;
15
+ const OVER = 'Over 20 MB';
15
16
  const state = new Map();
16
17
  const listeners = [];
17
18
  let seq = 0;
@@ -32,10 +33,17 @@ window.BoardAttach = (function () {
32
33
  const paintAll = () => { for (const c of document.querySelectorAll('[data-composer]')) paint(c.dataset.composer, siteOf(c)); };
33
34
 
34
35
  function upload(site, id, file) {
35
- const p = { id: 'f' + ++seq, name: file.name || 'file', type: file.type || 'application/octet-stream', status: 'uploading', progress: 0 };
36
+ const p = { id: 'f' + ++seq, name: file.name || 'file', type: file.type || 'application/octet-stream', status: 'uploading', progress: 0, source: file };
36
37
  if (/^image\//.test(p.type)) p.preview = URL.createObjectURL(file);
37
38
  state.set(keyOf(site, id), [...list(site, id), p]);
38
39
  if (file.size > LIMIT) { p.status = 'error'; p.error = 'Over 20 MB'; paint(id, site); return; }
40
+ send(site, id, p);
41
+ }
42
+
43
+ // Sends p's File and paints it uploading; a retry from an error chip runs it again.
44
+ function send(site, id, p) {
45
+ const file = p.source;
46
+ p.status = 'uploading'; p.progress = 0; delete p.error;
39
47
  paint(id, site);
40
48
  const xhr = new XMLHttpRequest();
41
49
  p.xhr = xhr;
@@ -54,6 +62,11 @@ window.BoardAttach = (function () {
54
62
  xhr.send(file);
55
63
  }
56
64
 
65
+ function retry(site, id, fileId) {
66
+ const p = list(site, id).find((x) => x.id === fileId);
67
+ if (p && p.status === 'error' && p.error !== OVER) send(site, id, p);
68
+ }
69
+
57
70
  function remove(site, id, fileId) {
58
71
  const p = list(site, id).find((x) => x.id === fileId);
59
72
  if (!p) return;
@@ -107,7 +120,10 @@ window.BoardAttach = (function () {
107
120
  document.addEventListener('click', (ev) => {
108
121
  const b = ev.target.closest && ev.target.closest('[data-unattach]');
109
122
  const c = b && b.closest('[data-composer]');
110
- if (c) { ev.preventDefault(); remove(siteOf(c), c.dataset.composer, b.dataset.unattach); }
123
+ if (c) { ev.preventDefault(); remove(siteOf(c), c.dataset.composer, b.dataset.unattach); return; }
124
+ const r = ev.target.closest && ev.target.closest('[data-retry]');
125
+ const rc = r && r.closest('[data-composer]');
126
+ if (rc) { ev.preventDefault(); retry(siteOf(rc), rc.dataset.composer, r.dataset.retry); }
111
127
  });
112
128
 
113
129
  return {
@@ -10,9 +10,9 @@
10
10
  // remote, it can't be picked and says why (docs/adr/2026-11-02-1850-a-board-can-work-on-any-git-remote.md).
11
11
  // With keyState(), Hub's /_hub/api/settings `linear`, as Board Settings passes it, the source row reads its live state
12
12
  // as a status word (docs/adr/2026-11-04-0227-every-new-card-gets-a-ticket-in-the-boards-current-source.md).
13
- function ticketsControl(root, { value = { source: 'internal', prefix: 'INT' }, repo = () => '', title = () => '', linear = null, loadLinear, isNew = false, change = () => {}, createProject, keyState } = {}) {
13
+ function ticketsControl(root, { value = { source: 'none' }, repo = () => '', title = () => '', linear = null, loadLinear, isNew = false, change = () => {}, createProject, keyState } = {}) {
14
14
  const { esc } = BoardUI;
15
- const draft = { prefix: 'INT', ...value };
15
+ const draft = { ...value };
16
16
  let creating = false, createError = '', destroyed = false, asking = false, noGithub = false, key = null;
17
17
  const works = () => Boolean(linear?.set && !linear.refused);
18
18
  const keyWorks = () => Boolean(key?.set && !key.refused);
@@ -31,12 +31,12 @@
31
31
  const workspace = () => linear?.url_key ?? linear?.workspace ?? draft.workspace;
32
32
  const newName = () => (draft.newName ?? title()).trim();
33
33
  const ready = () => works() && !linear.error;
34
- const read = () => draft.source === 'internal' ? { source: 'internal', prefix: draft.prefix || 'INT' }
34
+ const read = () => draft.source === 'none' ? { source: 'none' }
35
35
  : draft.source === 'github' ? { source: 'github', ...(draft.account ? { account: draft.account } : {}) }
36
36
  : !ready() ? { source: 'linear', workspace: draft.workspace, team: draft.team, project: draft.project }
37
37
  : draft.project === '__new' && isNew ? { source: 'linear', workspace: workspace(), team: draft.team, new_project: newName() }
38
38
  : { source: 'linear', workspace: workspace(), team: draft.team, project: draft.project };
39
- const valid = () => draft.source === 'internal' ? /^[A-Z]{2,6}$/.test(draft.prefix || 'INT')
39
+ const valid = () => draft.source === 'none' ? true
40
40
  : draft.source === 'github' ? Boolean(repo())
41
41
  : Boolean(ready() && draft.team && draft.project && (draft.project !== '__new' || (isNew && newName())));
42
42
  const pickDefaults = () => {
@@ -58,17 +58,17 @@
58
58
  const team = esc(draft.team || '');
59
59
  const note = draft.project === '__new'
60
60
  ? (isNew ? `<p class="muted small">Start creates the project in the ${team} team on Linear.</p>` : `<p class="muted small">Creates the project in the ${team} team on Linear.</p>`)
61
- : `<p class="muted small">A new card gets a ${team} issue in ${esc(project?.name || draft.project || '')}, in the ${esc(linear.workspace || workspace())} workspace from Hub’s key. Its badge opens it in Linear.</p>`;
61
+ : `<p class="muted small">A task gets a ${team} issue in ${esc(project?.name || draft.project || '')}, in the ${esc(linear.workspace || workspace())} workspace from Hub’s key, when you answer yes to its ticket question. Its badge opens it in Linear.</p>`;
62
62
  return `<div class="tk-grid"><label class="tk-field"><span>Team</span><select data-tk="team">${(linear.teams || []).map((t) => `<option value="${esc(t.key)}"${t.key === draft.team ? ' selected' : ''}>${esc(t.key)} · ${esc(t.name)}</option>`).join('')}</select></label><label class="tk-field"><span>Project</span><select data-tk="project">${(linear.projects || []).filter((p) => p.team === draft.team).map((p) => `<option value="${esc(p.id)}"${p.id === draft.project ? ' selected' : ''}>${esc(p.name)}</option>`).join('')}<option value="__new"${draft.project === '__new' ? ' selected' : ''}>New project…</option></select></label></div>${draft.project === '__new' ? `<div class="tk-new"><input data-tk="newName" value="${esc(draft.newName ?? title())}" aria-label="New Linear project name" placeholder="Project name" maxlength="80" autocomplete="off">${isNew ? '' : `<button type="button" data-tk-create${creating ? BoardUI.BUSY : ''}>Create</button>`}</div>` : ''}${createError ? `<p class="muted small" role="alert">${esc(createError)}</p>` : ''}${note}`;
63
63
  }
64
64
  function render() {
65
65
  const off = !repo();
66
- const segment = `<div class="tk-seg" role="group" aria-label="Ticket source">${[['linear', 'Linear'], ['github', 'GitHub issues'], ['internal', 'Internal']].map(([source, name]) => `<button type="button" data-tk-source="${source}" aria-pressed="${draft.source === source}"${source === 'github' && off ? ' aria-disabled="true"' : ''}>${name}</button>`).join('')}</div>`;
66
+ const segment = `<div class="tk-seg" role="group" aria-label="Ticket source">${[['linear', 'Linear'], ['github', 'GitHub issues'], ['none', 'None']].map(([source, name]) => `<button type="button" data-tk-source="${source}" aria-pressed="${draft.source === source}"${source === 'github' && off ? ' aria-disabled="true"' : ''}>${name}</button>`).join('')}</div>`;
67
67
  const why = `<p class="muted small" role="status">GitHub issues need a repo on GitHub, and ${isNew ? 'the repo chosen above' : 'this board’s repo'} isn’t on GitHub.</p>`;
68
68
  let detail;
69
69
  if (draft.source === 'github' && off) detail = why;
70
- else if (draft.source === 'internal') detail = `<label class="tk-field"><span>Prefix</span><input data-tk="prefix" value="${esc(draft.prefix)}" placeholder="INT" maxlength="6" pattern="[A-Z]{2,6}" title="2–6 uppercase letters" autocapitalize="characters" autocomplete="off" spellcheck="false"></label><p class="muted small" data-tk-example>Cards number ${esc(draft.prefix || 'INT')}-1, ${esc(draft.prefix || 'INT')}-2… on this board. The badge opens the card.</p>`;
71
- else if (draft.source === 'github') detail = `<div class="who tk-static"><span>Issues in ${esc(repo())}<span class="muted small sub">${isNew ? 'The repo chosen above' : 'The board’s repo, set above'}. A new card opens an issue there${draft.account ? ` as gh account ${esc(draft.account)}` : ''}; its badge reads #41.</span></span></div>`;
70
+ else if (draft.source === 'none') detail = '<p class="muted small">Every card is a task, numbered T-1, T-2… on this board, with no ticket. Its badge reads Task.</p>';
71
+ else if (draft.source === 'github') detail = `<div class="who tk-static"><span>Issues in ${esc(repo())}<span class="muted small sub">${isNew ? 'The repo chosen above' : 'The board’s repo, set above'}. A task opens an issue there${draft.account ? ` as gh account ${esc(draft.account)}` : ''} when you answer yes to its ticket question; its badge then reads #41.</span></span></div>`;
72
72
  else detail = linearDetail();
73
73
  root.innerHTML = `${status()}${segment}<div class="tk-detail">${noGithub && off && draft.source !== 'github' ? why : ''}${detail}</div>`;
74
74
  if (draft.source === 'linear' && ready()) {
@@ -126,11 +126,9 @@
126
126
  const input = (e) => {
127
127
  const field = e.target.dataset.tk;
128
128
  if (!field || (e.type === 'input' && e.target.tagName === 'SELECT')) return;
129
- draft[field] = field === 'prefix' ? e.target.value.toUpperCase() : e.target.value;
130
- if (field === 'prefix') e.target.value = draft.prefix;
129
+ draft[field] = e.target.value;
131
130
  if (field === 'team') draft.project = isNew ? '__new' : linear.projects?.find((p) => p.team === draft.team)?.id;
132
131
  if (field === 'team' || field === 'project') { createError = ''; render(); }
133
- if (field === 'prefix') root.querySelector('[data-tk-example]').textContent = `Cards number ${draft.prefix || 'INT'}-1, ${draft.prefix || 'INT'}-2… on this board. The badge opens the card.`;
134
132
  change(read(), valid(), { typing: e.type === 'input' });
135
133
  };
136
134
  root.addEventListener('click', click);
@@ -190,7 +188,7 @@
190
188
  el.setAttribute('class', 'sheet');
191
189
  el.setAttribute('aria-labelledby', 'newBoardTitle');
192
190
  const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
193
- const draft = { name: '', purpose: '', github: '', remote: '', branch: '', tickets: { source: 'internal', prefix: 'INT' } };
191
+ const draft = { name: '', purpose: '', github: '', remote: '', branch: '', tickets: { source: 'none' } };
194
192
  let tickets;
195
193
  let repos = [], signedIn = false, pasted, selected, edited = false, sending = false, closed = false, generation = 0, lookupTimer, linear;
196
194
  const controller = new AbortController();
@@ -76,7 +76,6 @@
76
76
  .hub-view .dur-custom input::-webkit-calendar-picker-indicator { position:absolute; inset:0; width:auto; height:auto; opacity:0; cursor:pointer; }
77
77
  .hub-view .dur-custom:focus-within { outline:2px solid var(--accent); outline-offset:2px; border-radius:7px; }
78
78
  .hub-view .dur-on .title { font-weight:600; white-space:normal; }
79
- .hub-view .awake-on { color:var(--color-notice); font-weight:600; }
80
79
  .hub-view code { font-family:var(--mono); font-size:.85em; white-space:nowrap; }
81
80
  .hub-view footer { color:var(--muted); font-size:12px; margin:1.8rem 2px 0; }
82
81
  .hub-view .host { font-family:var(--mono); font-size:11px; overflow-wrap:anywhere; }
@@ -95,8 +94,6 @@
95
94
  }
96
95
 
97
96
  .hub-view .hub-settings { margin-top: 24px; }
98
- .hub-view .hub-inline > div + div { margin-top: 16px; }
99
- .hub-view .hub-inline + .label { margin-top: 24px; }
100
97
  .hub-view .hub-setting { width: 100%; display: flex; align-items: center; gap: 12px; padding: 14px 16px; font: inherit; text-align: left; color: var(--text); cursor: pointer; border: 1px solid var(--line); border-radius: 12px; margin: 10px 0; background: var(--panel); }
101
98
  .hub-view .hub-setting .text { flex: 1; min-width: 0; }
102
99
  .hub-view .hub-setting .title { font-weight: 600; }
@@ -1,4 +1,4 @@
1
- // Hub's token slots: Linear's key and Slack's user token. Hub settings › Linear and › Slack mount them, and the Tickets
1
+ // Hub's token slots: Linear's key and Slack's user token. The Hub settings modals for Linear and Slack mount them, and the Tickets
2
2
  // control (dialogs.js) opens Linear's over Board settings or New board when Linear is tapped without a working key
3
3
  // (docs/adr/2026-11-02-1712-a-board-picks-linear-only-while-hubs-key-works.md). A token saves on paste, change or Enter
4
4
  // through POST /_hub/settings/<kind>, which checks it with the service; state is hub/views.mjs settingsView's `<kind>`.
@@ -21,7 +21,7 @@
21
21
  @media (hover: hover) { .sidebar .sb-close:hover { background: var(--chip); color: var(--text); } }
22
22
  .sidebar .sb-label { margin: 12px 8px 4px; font-size: 11.5px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
23
23
  .sidebar .sb-group { display: flex; flex-direction: column; gap: 2px; }
24
- .sidebar .sb-scroll { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding-bottom: max(12px, env(safe-area-inset-bottom)); display: flex; flex-direction: column; }
24
+ .sidebar .sb-scroll { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; overflow-wrap: anywhere; overscroll-behavior: contain; padding-bottom: max(12px, env(safe-area-inset-bottom)); display: flex; flex-direction: column; }
25
25
  .sidebar .sb-scroll > section { flex: none; }
26
26
  .sidebar .sb-utility { padding-top: 6px; }
27
27
  .sidebar .sb-boards { margin-top: auto; }
@@ -141,7 +141,7 @@ button.chip { border: 0; }
141
141
  .thread-more:active { background: var(--accent-soft); }
142
142
  .thread-more:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
143
143
  .thread-more .n { color: var(--muted); font-weight: 400; }
144
- /* [answered asks have their own section below Done, and Done and Asks show 10 cards at a time](docs/adr/2026-11-03-0259-answered-asks-have-their-own-section-below-done.md): Done's and Asks' Show more, the thread's look as a row of the lane's list */
144
+ /* [answered asks fold into the one Done list](docs/adr/2026-11-09-1430-answered-asks-fold-into-the-one-done-list.md): Done's Show more, the thread's look as a row of the lane's list */
145
145
  .list > .thread-more { width: 100%; margin: 0; border-top: 1px solid var(--line); border-radius: 0; }
146
146
 
147
147
  @media (max-width: 560px) {
@@ -303,7 +303,7 @@ main { padding-bottom: calc(110px + env(safe-area-inset-bottom)); }
303
303
  }
304
304
  /* The hub lives at the tailnet root, outside this site, so its link is root-absolute. */
305
305
  .brand { align-items: center; }
306
- .hub { display: inline-flex; flex: none; }
306
+ a.hub { display: inline-flex; flex: none; }
307
307
  .hub img { width: 32px; height: auto; display: block; image-rendering: pixelated; }
308
308
  @keyframes pulse { 50% { opacity: .35; } }
309
309
  /* A lane's folds are inset boxes in its list, so they read as part of that lane, not as more lanes. */
@@ -378,13 +378,18 @@ button.attach:disabled { opacity: .35; }
378
378
  /* A New card's "waiting", until the orchestrator picks it up: grey, its dot still. */
379
379
  .pickup-wait { color: var(--muted); font-size: 12px; font-weight: 600; }
380
380
  .pickup-wait .working-dot { background: currentColor; animation: none; }
381
- .msg.link { padding: 0 10px; margin: 4px 0; font-size: 13px; }
382
- .msg.builder-status { padding: 0 10px; margin: 4px 0; font-size: 12px; color: var(--color-notice); overflow-wrap: anywhere; }
381
+ /* Every status line shares this spacing and gutter: the mark sits in the first 23px, and wrapped text hangs under the
382
+ first line's text, never under the mark. A fold's summary hangs the same way inside its box. Each kind keeps only its colour. */
383
+ .status-line { padding: 0 10px; margin: 4px 0; font-size: 12px; overflow-wrap: anywhere; }
384
+ div.status-line { padding-left: 33px; text-indent: -23px; }
385
+ .status-line > summary { padding-left: 23px; text-indent: -23px; }
386
+ .status-line > ol { text-indent: 0; }
387
+ .msg.builder-status { color: var(--color-notice); }
383
388
  .msg.builder-status .working-dot { animation: none; }
384
- .msg.request { padding: 0 10px; margin: 4px 0; font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
389
+ .msg.request { color: var(--muted); }
385
390
  .msg.request.readied { color: var(--color-success); }
386
391
  .msg.request.hub.fault { color: var(--color-danger); }
387
- .msg.agent { padding: 0 10px; margin: 4px 0; font-size: 12px; font-weight: 600; overflow-wrap: anywhere; }
392
+ .msg.agent { font-weight: 600; }
388
393
  .msg.agent.started { color: var(--color-notice); } .msg.agent.finished { color: var(--color-success); }
389
394
  .msg.agent .muted { font-weight: 400; }
390
395
  /* A planner's line in the thread, dot included, is the owner's blue; its finish stays green
@@ -407,7 +412,6 @@ button.attach:disabled { opacity: .35; }
407
412
  .sub.agent.stale .working-dot { animation: none; }
408
413
  /* The one class every paused status takes, gray: the card and thread line, the header, the hub banner. */
409
414
  .paused { color: var(--muted); font-weight: 600; }
410
- .msg.calls { padding: 0 10px; margin: 4px 0; font-size: 12px; overflow-wrap: anywhere; }
411
415
  .msg.calls summary { cursor: pointer; color: var(--muted); list-style: none; }
412
416
  .msg.calls summary::-webkit-details-marker { display: none; }
413
417
  .msg.calls[open] summary .mark-icon { transform: rotate(90deg); }
@@ -641,14 +645,18 @@ main > * { transition: opacity .25s ease; }
641
645
  .view-questions .opt .rec { color: var(--accent); font-weight: 500; font-size: 12px; margin-left: 4px; }
642
646
  .view-questions .opt .det { color: var(--muted); font-size: 13px; }
643
647
  /* A question's context and an option's detail take markdown at body size: headings drawn as bold lines, blocks tight
644
- (docs/adr/2026-11-09-1500-a-questions-descriptions-are-markdown-and-its-text-is-one-brief-line.md). */
645
- :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det) { overflow-wrap: anywhere; }
646
- :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det) :is(p, ul, ol, blockquote, pre, table) { margin: 0 0 6px; }
647
- :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det) :is(h1, h2, h3, h4, h5, h6) { font-size: inherit; font-weight: 600; margin: 6px 0 2px; }
648
- :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det) :is(ul, ol) { padding-left: 18px; }
649
- :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det) :is(pre, code) { white-space: pre-wrap; font-size: 12px; }
650
- :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det) table { display: block; overflow-x: auto; }
651
- :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det) > :last-child { margin-bottom: 0; }
648
+ (docs/adr/2026-11-09-1500-a-questions-descriptions-are-markdown-and-its-text-is-one-brief-line.md). So does any .md
649
+ box: the orchestrator's thread messages, markdown replies and View transcript's text rows
650
+ (docs/adr/2026-11-09-1453-orchestrator-messages-replies-and-transcript-text-are-markdown.md), whose blocks break the
651
+ lines, not the text's own newlines. */
652
+ :is(.msg .body, .reader-row).md { white-space: normal; }
653
+ :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) { overflow-wrap: anywhere; }
654
+ :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) :is(p, ul, ol, blockquote, pre, table) { margin: 0 0 6px; }
655
+ :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) :is(h1, h2, h3, h4, h5, h6) { font-size: inherit; font-weight: 600; margin: 6px 0 2px; }
656
+ :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) :is(ul, ol) { padding-left: 18px; }
657
+ :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) :is(pre, code) { white-space: pre-wrap; font-size: 12px; }
658
+ :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) table { display: block; max-width: 100%; overflow-x: hidden; }
659
+ :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) > :last-child { margin-bottom: 0; }
652
660
  .view-questions .opt .suggested-why { margin-top: 4px; color: var(--color-info); font-size: 13px; line-height: 1.35; overflow-wrap: anywhere; }
653
661
  .view-questions .note { margin-top: 4px; }
654
662
  .view-questions .ans { background: var(--accent-soft); border-radius: 9px; padding: 7px 10px; margin-top: 8px; font-size: 13px; }
@@ -732,18 +740,18 @@ main > * { transition: opacity .25s ease; }
732
740
  .view-notes .plan li { margin: 3px 0; }
733
741
  .view-notes .plan li.nested { margin-left: 20px; }
734
742
  .view-notes .plan code { font-size: 12.5px; background: var(--chip); border-radius: 4px; padding: 0 3px; }
735
- .view-notes .plan pre { overflow-x: auto; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; }
736
- .view-notes .plan pre code { background: none; padding: 0; overflow-wrap: normal; }
743
+ .view-notes .plan pre { white-space: pre-wrap; overflow-x: hidden; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; }
744
+ .view-notes .plan pre code { background: none; padding: 0; }
737
745
  .view-notes .plan blockquote { border-left: 3px solid var(--line); padding-left: 10px; color: var(--muted); }
738
746
  .view-notes .plan hr { border: 0; border-top: 1px solid var(--line); margin: 16px 0; }
739
747
  .view-notes .plan a { color: var(--accent); }
740
- .view-notes .md-table { overflow-x: auto; margin: 10px 0; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); }
741
- .view-notes .md-table table { border-collapse: collapse; font-size: 13px; line-height: 1.4; }
742
- .view-notes .md-table th, .view-notes .md-table td { text-align: left; vertical-align: top; padding: 6px 8px; border-bottom: 1px solid var(--line); min-width: 5em; overflow-wrap: normal; }
748
+ .view-notes .md-table { overflow-x: hidden; margin: 10px 0; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); }
749
+ .view-notes .md-table table { width: 100%; border-collapse: collapse; font-size: 13px; line-height: 1.4; }
750
+ .view-notes .md-table th, .view-notes .md-table td { text-align: left; vertical-align: top; padding: 6px 8px; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
743
751
  .view-notes .md-table th { background: var(--bg); font-weight: 600; }
744
752
  .view-notes .md-table tr:last-child td { border-bottom: 0; }
745
- .view-notes .md-table .wide { min-width: 14em; }
746
- .view-notes .md-table .wider { min-width: 24em; }
753
+ /* The sidebar and every pane never scroll sideways: content wraps at the pane's width, overflow-x hidden is the last guard. */
754
+ .view-questions, .view-notes, .reader-sheet, .pane-viewer { overflow-x: hidden; }
747
755
  /* A framed page (view-frame.js) fills the space beside the header, and scrolls inside its frame. */
748
756
  .view-frame { display: flex; flex-direction: column; height: 100vh; height: 100dvh; }
749
757
  .view-frame > .top { flex: none; }
@@ -996,3 +1004,4 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
996
1004
  }
997
1005
  /* The Send bar is the last thing on its page: only the phone nav under it is left clear, so its background runs to the edge. */
998
1006
  body .view-questions main { padding-bottom: var(--app-bottom, 0px); }
1007
+ .pfile.retry { cursor: pointer; }
@@ -31,7 +31,10 @@
31
31
  return `${badge(look.tone, `${esc(name)}${look.mark}`, { href: pr.url, ...label })}${ci}${codex}`;
32
32
  }
33
33
  const linearChips = (it) => (it.linear || []).map((l) => badge('quiet', esc(l.id), { href: l.url, title: `${l.id} in Linear`, attrs: EXTERNAL })).join('');
34
- const ticketChip = (it) => !it.ticket ? '' : !it.ticket.url ? badge('quiet', esc(it.ticket.id), { title: `${it.ticket.id} · not from this board’s ticket source` }) : badge('quiet', esc(it.ticket.id), { href: it.ticket.url, title: `${it.ticket.id}${it.ticket.source === 'internal' ? ' · this board’s own ticket' : it.ticket.source === 'github' ? ' on GitHub' : ' in Linear'}`, attrs: it.ticket.source === 'internal' ? {} : EXTERNAL });
34
+ const ticketChip = (it) => !it.ticket ? '' : !it.ticket.url ? badge('quiet', esc(it.ticket.id), { title: `${it.ticket.id} · not from this board’s ticket source` }) : badge('quiet', esc(it.ticket.id), { href: it.ticket.url, title: `${it.ticket.id}${it.ticket.source === 'github' ? ' on GitHub' : ' in Linear'}`, attrs: EXTERNAL });
35
+ // docs/adr/2026-11-09-1500-a-task-is-a-card-with-no-ticket-keyed-t-n.md: a card with no ticket is a task, its T-<n>
36
+ // only in the badge's title.
37
+ const taskChip = (it) => badge('quiet', 'Task', { title: `${it.ref} · a task, no ticket` });
35
38
  const REVIEW = {
36
39
  approved: { tone: 'ready', mark: '✓', label: 'approved' },
37
40
  changes: { tone: 'danger', mark: '✗', label: 'asked for changes' },
@@ -325,8 +328,8 @@
325
328
  const PICKUP_WAIT = '<span class="pickup-wait"><span class="working-dot" aria-hidden="true"></span>waiting</span>';
326
329
  const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' ? thinkingLine() : ask.state === 'waiting' && PICKUP_WAIT].filter(Boolean).join(' · ');
327
330
  function workLines(it, { unread = 0, top = false } = {}) {
328
- const ticket = it.ticket ? ticketChip(it) : it.ticketSource === 'linear' ? linearChips(it) : it.internal ? badge('quiet', esc(it.ref), { title: 'Work on this site, not a product PR' }) : linearChips(it);
329
- // A work card always shows its ticket; an ask, which has none, shows New or Ask there (askChip).
331
+ const ticket = it.ticket ? ticketChip(it) : it.internal ? badge('quiet', esc(it.ref), { title: 'Work on this site, not a product PR' }) : it.linear?.length ? linearChips(it) : taskChip(it);
332
+ // A work card shows its ticket, or Task when it has none; an ask shows New or Ask there (askChip).
330
333
  const ids = it.kind === 'ask' ? askChip(it) : ticket;
331
334
  const chips = `${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
332
335
  // A handled ask's asked time sits under its title, since its Ask badge holds the ticket's spot
@@ -3,7 +3,7 @@
3
3
  // (docs/adr/2026-11-03-0223-each-lane-has-one-fixed-order.md). Each item carries its lane, isNew, bumpedAt, sortAt, order and key.
4
4
  // Needs nothing else: board/domain.mjs runs this file in a vm context.
5
5
  (function () {
6
- const LANE_ORDER = { review: 0, progress: 1, todo: 2, done: 3, asks: 4 };
6
+ const LANE_ORDER = { review: 0, progress: 1, todo: 2, done: 3 };
7
7
  // Every lane but Done lists bumped cards first, the earliest bump on top, a bumped New ask included
8
8
  // (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md,
9
9
  // docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md). Todo lists the rest oldest first by sortAt, a card with no
@@ -1,6 +1,6 @@
1
1
  // Owner questions, for Waiting on you (view-questions.js). Needs ui.js and ui-links.js.
2
2
  (function () {
3
- const { esc, escText, time, ago, replyBody, attachments, dropZone, protoButton, linkText, linkHtml, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
3
+ const { esc, escText, time, ago, replyBody, attachments, dropZone, protoButton, linkText, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
4
4
  // ui-markdown.js loads after this file, so markdown is looked up when a question draws.
5
5
  const markdown = (src, links) => globalThis.BoardUI.markdown(src, links);
6
6
  // A short id such as A or C2 leads the label; a word id such as "inline" would repeat it.
@@ -44,10 +44,12 @@
44
44
  : r.state === 'closed' ? badge('quiet', `Round ${r.n} closed · take ${esc(r.take)}`)
45
45
  : badge('normal', `Prototype round ${r.n}`);
46
46
  const RULE_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>';
47
+ // Each of an answer's replies, oldest first.
48
+ const repliesOf = (a, links) => (a.replies?.length ? a.replies : a.reply ? [a.reply] : []).map((r) => `<div class="rep${r.md ? ' md' : ''}"><div class="muted small">Claude</div>${replyBody(r, links)}</div>`).join('');
47
49
  function answerHistory(q, links = {}) {
48
50
  if (!q.answers.length) return '';
49
51
  const body = q.answers.map((a) => `<div class="ans"><div class="muted small">You · ${esc(time(a.ts))}</div>${escText(choiceLabel(q, a.choice))}${a.note ? `<div>${linkText(a.note, links)}</div>` : ''}${attachments(a.files)}</div>
50
- ${a.reply ? `<div class="rep"><div class="muted small">Claude</div>${linkHtml(replyBody(a.reply), links)}</div>` : ''}`).join('');
52
+ ${repliesOf(a, links)}`).join('');
51
53
  const last = q.answers[q.answers.length - 1];
52
54
  return q.state === 'open' || !last.reply ? body : `<details><summary>Your answer and Claude's reply</summary>${body}</details>`;
53
55
  }
@@ -99,7 +101,7 @@
99
101
  const outcome = q.resolved ? `${esc(q.resolved)}${/[.!?]$/.test(q.resolved) ? '' : '.'}` : '';
100
102
  const acted = q.resolved_at ? ` <span class="acted" title="${esc(time(q.resolved_at))}">acted on ${esc(ago(q.resolved_at))}</span>.` : '';
101
103
  const said = last && (last.note || last.reply || last.files?.length) ? `<div class="ans"><div class="muted small">You · ${esc(time(last.ts))}</div>${last.note ? `<div>${linkText(last.note, links)}</div>` : ''}${attachments(last.files)}</div>
102
- ${last.reply ? `<div class="rep"><div class="muted small">Claude</div>${linkHtml(replyBody(last.reply), links)}</div>` : ''}` : '';
104
+ ${repliesOf(last, links)}` : '';
103
105
  return `<section class="q resolved" id="${esc(q.id)}" data-q="${esc(q.id)}">
104
106
  <div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge('ready', '✓ Resolved', { button: true, title: 'Reopen this question', attrs: { type: 'button', 'data-reopen': q.id } })}</div>
105
107
  <h3>${linkText(q.question, links)}</h3>
@@ -129,7 +129,7 @@
129
129
  + '<p class="sc-foot">Press h twice anywhere to open this. Esc to close.</p></div>';
130
130
  }
131
131
 
132
- const ticketsLabel = (t) => (t.source === 'internal' ? `Internal · ${t.prefix}` : t.source === 'github' ? `GitHub issues${t.account ? ` · ${t.account}` : ''}` : `Linear · ${t.team}`);
132
+ const ticketsLabel = (t) => (t.source === 'github' ? `GitHub issues${t.account ? ` · ${t.account}` : ''}` : t.source === 'linear' ? `Linear · ${t.team}` : 'None');
133
133
  // A board's context window: its owner session's latest percent at the row's top right, red from 90%, dimmed once the
134
134
  // reading is older than ten minutes, with that age in its tooltip.
135
135
  const CTX_STALE_MIN = 10;
@@ -1,8 +1,13 @@
1
1
  // A card's thread: the orchestrator's lines (who claude, named Orchestrator), the board's status lines, its builders'
2
- // asks, the owner's messages and their replies. Needs ui.js, ui-links.js and ui-enums.js.
2
+ // asks, the owner's messages and their replies. Needs ui.js, ui-links.js, ui-enums.js and ui-markdown.js.
3
3
  (function () {
4
4
  const { esc, escText, time, etaLine, replyBody, attachments, dropZone, sendButton, editButton, withdrawButton, thinkingLine, linkText, linkHtml, anyLink, mark, statusMark, BUTTON } = globalThis.BoardUI;
5
5
  const VIA = { slack: ' · Slack', github: ' · GitHub' };
6
+ // ui-markdown.js loads after this file, so markdown is looked up when a thread draws.
7
+ const markdown = (src, links) => globalThis.BoardUI.markdown(src, links);
8
+ // The orchestrator's own messages are markdown in compact styles; the owner's, a shared person's and GitHub's or
9
+ // Slack's stay as written (docs/adr/2026-11-09-1453-orchestrator-messages-replies-and-transcript-text-are-markdown.md).
10
+ const drawsMarkdown = (m) => m.who === 'claude' && !m.html;
6
11
  // A link to a card on this board (#selectedCard=<key>) or on another board (/<route>/#selectedCard=<key>, an ask's move), or ''.
7
12
  function cardLink(link) {
8
13
  const key = /^(?:\/[\w-]+\/)?#(?:selectedCard|item)=([^&]+)/.exec(link)?.[1];
@@ -25,9 +30,9 @@
25
30
  const withReplies = (msgs) => {
26
31
  // A folded submit's answers each reply, quoting their own block.
27
32
  const out = msgs.flatMap((m, i) => [{ m, at: String(m.ts), i, part: 0 },
28
- ...(m.answers || [m]).filter((a) => a.eventId && a.reply).map((a) => ({ m: { ts: a.reply.at || m.ts, who: 'claude', kind: 'reply', reply: a.reply }, of: m, quoted: m.answers ? a.text : m.text, at: String(a.reply.at || m.ts), i, part: 1 }))]);
33
+ ...(m.answers || [m]).filter((a) => a.eventId && a.reply).flatMap((a) => (a.replies?.length ? a.replies : [a.reply]).map((reply, k) => ({ m: { ts: reply.at || m.ts, who: 'claude', kind: 'reply', reply }, of: m, quoted: m.answers ? a.text : m.text, later: k > 0, at: String(reply.at || m.ts), i, part: 1 })))]);
29
34
  out.sort((a, b) => a.at.localeCompare(b.at) || a.i - b.i || a.part - b.part);
30
- return out.map(({ m, of, quoted }, n) => (of ? { ...m, quote: out[n - 1]?.m === of && !(of.answers?.length > 1) ? '' : quoted || '' } : m));
35
+ return out.map(({ m, of, quoted, later }, n) => (of ? { ...m, quote: later || out[n - 1]?.m === of && !(of.answers?.length > 1) ? '' : quoted || '' } : m));
31
36
  };
32
37
  // A message being edited shows its edit box in place of its text; app-asks.js handles the form, and attach.js the
33
38
  // files it adds to the message, under composer id edit:<event id>.
@@ -35,26 +40,26 @@
35
40
  <div class="pending-files" data-files></div>
36
41
  <div class="send-actions"><button type="button" data-edit-cancel>Cancel</button>${sendButton('Save', 0)}</div></form>`;
37
42
  const line = (m, links, edits) => (m.kind === 'reply'
38
- ? `<div class="msg claude reply-msg"><div class="who">Orchestrator · ${esc(time(m.ts))}</div>${m.quote ? `<blockquote class="quote">${linkText(m.quote, links)}</blockquote>` : ''}<div class="body">${linkHtml(replyBody(m.reply), links)}</div></div>`
39
- : m.kind === 'ask-link' ? `<div class="msg link">${statusMark('ask-link')}<a href="${esc(m.link)}">${escText(m.text)} →</a></div>`
43
+ ? `<div class="msg claude reply-msg"><div class="who">Orchestrator · ${esc(time(m.ts))}</div>${m.quote ? `<blockquote class="quote">${linkText(m.quote, links)}</blockquote>` : ''}<div class="body${m.reply.md ? ' md' : ''}">${replyBody(m.reply, links)}</div></div>`
44
+ : m.kind === 'ask-link' ? `<div class="msg status-line link">${statusMark('ask-link')}<a href="${esc(m.link)}">${escText(m.text)} →</a></div>`
40
45
  // docs/adr/2026-11-05-1921-a-visual-is-a-page-an-agent-draws-for-a-thread.md: a visual's page opens full page.
41
- : m.kind === 'visual' ? `<div class="msg link visual">${statusMark('visual')}<a href="${esc(m.link)}">${escText(m.text)} →</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
46
+ : m.kind === 'visual' ? `<div class="msg status-line link visual">${statusMark('visual')}<a href="${esc(m.link)}">${escText(m.text)} →</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
42
47
  // A request line's mark is its words' icon, or m.mark's for a line whose words vary; a Full person's names them.
43
- : m.request && m.card ? `<div class="msg request">${statusMark(m.mark)}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
44
- : m.request ? `<div class="msg request${m.command === 'readied' ? ' readied' : ''}">${statusMark(m.mark || m.text)}${escText(m.text)} <span class="muted">· ${m.by ? `${esc(m.by)} · ` : ''}${esc(time(m.ts))}</span></div>`
48
+ : m.request && m.card ? `<div class="msg status-line request">${statusMark(m.mark)}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
49
+ : m.request ? `<div class="msg status-line request${m.command === 'readied' ? ' readied' : ''}">${statusMark(m.mark || m.text)}${escText(m.text)} <span class="muted">· ${m.by ? `${esc(m.by)} · ` : ''}${esc(time(m.ts))}</span></div>`
45
50
  // docs/adr/2026-11-09-1200-the-board-writes-status-lines-and-only-the-orchestrator-writes-messages.md: the board's
46
51
  // own line, its kind's mark and words, red for a fault.
47
- : m.kind === 'hub' ? `<div class="msg request hub${m.fault ? ' fault' : ''}">${statusMark(m.mark)}${m.link ? `<a href="${esc(m.link)}">${escText(m.text)}</a>` : escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
52
+ : m.kind === 'hub' ? `<div class="msg status-line request hub${m.fault ? ' fault' : ''}">${statusMark(m.mark)}${m.link ? `<a href="${esc(m.link)}">${escText(m.text)}</a>` : escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
48
53
  // docs/adr/2026-11-07-1400-a-card-stores-its-lane-and-facts-replace-statuses.md: a card's move between lanes.
49
- : m.kind === 'move' ? `<div class="msg request move">${statusMark('move')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
50
- : m.kind === 'questions' ? `<div class="msg builder-status planner new-questions">${statusMark('questions')}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
54
+ : m.kind === 'move' ? `<div class="msg status-line request move">${statusMark('move')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
55
+ : m.kind === 'questions' ? `<div class="msg status-line builder-status planner new-questions">${statusMark('questions')}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
51
56
  : m.folded ? foldedRun(m, links, edits)
52
- : m.kind === 'status' ? `<div class="msg builder-status${m.planner ? ' planner' : ''}">${mark('<span class="working-dot"></span>')}${linkText(m.text, links)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
53
- : m.kind === 'calls' ? `<details class="msg calls"><summary>${statusMark('judgment calls')}${escText(m.text)}'s judgment calls · ${m.calls.length} · ${m.verified ? `verified ${esc(time(m.verified.at))}` : 'not verified yet'}</summary><ol>${m.calls.map((c) => `<li>${linkText(c, links)}</li>`).join('')}</ol>${m.verified?.note ? `<div class="small">Verified: ${linkText(m.verified.note, links)}</div>` : ''}</details>`
54
- : m.kind === 'agent' ? `<div class="msg agent ${m.finished ? 'finished' : 'started'}${m.planner ? ' planner' : ''}">${mark('<span class="agent-dot"></span>')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}${m.eta ? ` · ${esc(etaLine(m.eta))}` : ''}</span></div>` : `<div class="msg ${m.who === 'love' ? 'love' : m.who === 'claude' ? 'claude' : 'other'}"${m.eventId ? ` data-event="${esc(m.eventId)}"` : ''}>
57
+ : m.kind === 'status' ? `<div class="msg status-line builder-status${m.planner ? ' planner' : ''}">${mark('<span class="working-dot"></span>')}${linkText(m.text, links)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
58
+ : m.kind === 'calls' ? `<details class="msg status-line calls"><summary>${statusMark('judgment calls')}${escText(m.text)}'s judgment calls · ${m.calls.length} · ${m.verified ? `verified ${esc(time(m.verified.at))}` : 'not verified yet'}</summary><ol>${m.calls.map((c) => `<li>${linkText(c, links)}</li>`).join('')}</ol>${m.verified?.note ? `<div class="small">Verified: ${linkText(m.verified.note, links)}</div>` : ''}</details>`
59
+ : m.kind === 'agent' ? `<div class="msg status-line agent ${m.finished ? 'finished' : 'started'}${m.planner ? ' planner' : ''}">${mark('<span class="agent-dot"></span>')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}${m.eta ? ` · ${esc(etaLine(m.eta))}` : ''}</span></div>` : `<div class="msg ${m.who === 'love' ? 'love' : m.who === 'claude' ? 'claude' : 'other'}"${m.eventId ? ` data-event="${esc(m.eventId)}"` : ''}>
55
60
  ${m.canDelete || m.canEdit ? `<div class="ask-actions">${m.canEdit ? editButton(m.eventId) : ''}${m.canDelete ? withdrawButton(m.eventId) : ''}</div>` : ''}
56
61
  <div class="who">${m.who === 'love' ? (m.by ? esc(m.by) : 'You') : m.who === 'claude' ? 'Orchestrator' : esc(m.who)}${VIA[m.via] || ''}${m.moved ? ' · Moved' : ''}${m.from ? ` · Moved from ${esc(m.from)}` : ''}${m.edited ? ' · Edited' : ''} · ${esc(time(m.ts))}</div>
57
- <div class="body"${edits.has(m.eventId) ? ' hidden' : ''}>${m.html ? linkHtml(m.html, links) : linkText(m.text, links)}</div>${edits.has(m.eventId) ? editForm(m.eventId, edits.get(m.eventId).text) : ''}${attachments(m.files)}
62
+ <div class="body${drawsMarkdown(m) ? ' md' : ''}"${edits.has(m.eventId) ? ' hidden' : ''}>${m.html ? linkHtml(m.html, links) : drawsMarkdown(m) ? markdown(m.text, links) : linkText(m.text, links)}</div>${edits.has(m.eventId) ? editForm(m.eventId, edits.get(m.eventId).text) : ''}${attachments(m.files)}
58
63
  ${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">View transcript</a></div>` : ''}
59
64
  ${m.link ? `<div class="small">${cardLink(m.link) || anyLink(m.link, m.link)}</div>` : ''}
60
65
  ${m.eventId ? msgFooter(m) : ''}
@@ -68,7 +73,7 @@
68
73
  const tail = m.folded.at(-1).finished ? 'finished' : 'running';
69
74
  const log = m.folded.find((f) => f.log)?.log;
70
75
  const logLine = log ? `<div class="small fold-log"><a href="log.html?file=${encodeURIComponent(log)}">View transcript</a></div>` : '';
71
- return `<details class="msg calls status-fold${m.planner ? ' planner' : ''}"><summary${tail === 'finished' ? ' class="finished"' : ''}>${statusMark('status lines')}${escText(m.agent)} · ${tail}</summary>${m.folded.map((f) => line(f, links, edits)).join('')}${logLine}</details>`;
76
+ return `<details class="msg status-line calls status-fold${m.planner ? ' planner' : ''}"><summary${tail === 'finished' ? ' class="finished"' : ''}>${statusMark('status lines')}${escText(m.agent)} · ${tail}</summary>${m.folded.map((f) => line(f, links, edits)).join('')}${logLine}</details>`;
72
77
  }
73
78
  function foldStatus(lines) {
74
79
  const runs = new Map();
@@ -35,7 +35,10 @@
35
35
  const day = d.toLocaleDateString([], { month: 'numeric', day: 'numeric', ...(now - d > 365 * 86400000 ? { year: 'numeric' } : {}) });
36
36
  return `${day}, ${at}`;
37
37
  }
38
- const replyBody = (reply) => reply.html ?? esc(JSON.stringify(reply.json));
38
+ // A reply as HTML with its refs linked: a markdown reply drawn by ui-markdown.js, which, like ui-links.js, loads after
39
+ // this file; an older HTML one as written. Its box takes class md for a markdown reply's compact styles.
40
+ const replyBody = (reply, links) => (reply.md ? globalThis.BoardUI.markdown(reply.md, links)
41
+ : globalThis.BoardUI.linkHtml(reply.html ?? esc(JSON.stringify(reply.json)), links));
39
42
 
40
43
  // Attachments are uploads from POST /api/upload: {name, url, path, type, size}. Only uploads/ URLs become links.
41
44
  const uploadUrl = (f) => (/^uploads\/[\w.-]+$/.test(f.url || '') ? f.url : null);
@@ -151,11 +154,16 @@
151
154
  return `<${tag} class="chip tone-${tone}"${extra}>${html}</${tag}>`;
152
155
  }
153
156
  // A composer's files before sending: {id, name, type, preview, status: uploading|done|error, progress (0–1), error}.
154
- const pendingFiles = (list) => list.map((p) => `<span class="pfile ${esc(p.status)}" title="${esc(p.name)}">
157
+ // An errored chip retries on click, except "Over 20 MB", which a retry can't change.
158
+ const pendingFiles = (list) => list.map((p) => {
159
+ const retry = p.status === 'error' && p.error !== 'Over 20 MB';
160
+ const title = retry ? `${p.name}: tap to retry` : p.status === 'error' ? `${p.name}: a retry can't succeed, remove it` : p.name;
161
+ return `<span class="pfile ${esc(p.status)}${retry ? ' retry' : ''}" title="${esc(title)}"${retry ? ` data-retry="${esc(p.id)}"` : ''}>
155
162
  ${isImage(p) && p.preview ? `<img src="${esc(p.preview)}" alt="${esc(p.name)}">` : `<span class="pname">${esc(p.name)}</span>`}
156
163
  ${p.status === 'uploading' ? `<span class="pbar" role="progressbar" aria-label="Uploading ${esc(p.name)}" aria-valuenow="${Math.round((p.progress || 0) * 100)}"><span style="width:${Math.max(4, Math.round((p.progress || 0) * 100))}%"></span></span>` : ''}
157
- ${p.status === 'error' ? `<span class="perr">${esc(p.error || 'Upload failed')}</span>` : ''}
158
- <button type="button" class="unattach" data-unattach="${esc(p.id)}" aria-label="Remove ${esc(p.name)}">✕</button></span>`).join('');
164
+ ${p.status === 'error' ? `<span class="perr">${esc(p.error || 'Upload failed')}${retry ? ' · tap to retry' : ''}</span>` : ''}
165
+ <button type="button" class="unattach" data-unattach="${esc(p.id)}" aria-label="Remove ${esc(p.name)}">✕</button></span>`;
166
+ }).join('');
159
167
 
160
168
  // Replacing innerHTML would close every <details> inside, collapsing what the reader had open, and replace the field
161
169
  // being typed in, dropping its text, cursor and focus, and on iOS the keyboard. So setHTML patches el in place: a