Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,38 @@ follow [SemVer](https://semver.org/).

## [Unreleased]

### Fixed
- **Multi-line item `detail` kept its shape on both rendering surfaces**
(round table `q-forms-hunk-review-001`, 2026-08-28). A `detail`
carrying more than one line — a diff hunk, a log excerpt — was
corrupted on the way out:
- **Widget**: it rendered as an inline `<span>` inside a flex row with
no `white-space` rule, so HTML folded its newlines and leading
indentation into single spaces and a diff arrived as one run-on
line. A multi-line detail now renders as an `ae-detail-block` —
full-width, `white-space:pre-wrap`, monospace. Fixed across
`triage`, `assumption_review`, `progress` and `confirm` through one
shared `widget._detail_html` helper and one `CSS_BASE` rule. A
single-line detail still renders as the same inline span it always
did (output byte-identical).
- **Markdown**: it was interpolated into the item's bullet line, so
every line the detail started with `-` — every removed line of a
diff — parsed as a NEW bullet, and a `suggested` suffix landed on
the detail's last line. A multi-line detail now renders as an
indented code block under the bullet, with the suffix left on the
bullet. The block is INDENTED rather than fenced because
`_defuse_fences` breaks every three-backtick run in author text to
keep the reply skeleton's boundaries; for the same reason an
author's own ` ```lang ` wrapper is stripped rather than rendered
as defused noise. Fixed for `triage`, `assumption_review` and
`confirm` consequences.

### Changed
- `FORM_THEME_CSS` budget raised 10 KB → 12 KB (chair-ruled
2026-08-28) for the shared `ae-detail-block` rule in `CSS_BASE`; a
trim to fit under 10 KB was offered and declined. Current size
10,263 B. The cap remains a design decision, not a ratchet.

## [0.8.0] — 2026-08-24

Per-stage form telemetry: the lifecycle is now measurable end to end
Expand Down
93 changes: 93 additions & 0 deletions docs/reports/roundtable/q-forms-hunk-review-001.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Round table — a ninth construct? (q-forms-hunk-review-001)

Curated stub — chair-promoted sections only. Full transcript:
`~/.attune/reports/roundtable/q-forms-hunk-review-001.md`
(machine-local, TTL-exempt). Board thread: `q-forms-hunk-review-001`
(2026-08-28, 1 round, 3/3 seats: Claude, Antigravity, Codex).

**Question.** Should attune-forms build a ninth construct
`hunk_review`, or is a per-hunk diff review just `triage` /
`assumption_review` with code-shaped items plus a presentation flag?
Measured cost of a construct: ~19 files, +865 (ranking, PR #24) to
+1219 (assumption_review, PR #25).

## Ruling: build nothing (3/3, board msgs 3/4/5)

No seat argued for building the construct. The candidate fails
admission test #4 (no committed consumer) and no seat could state a
construct-defining validator rule under test #2 that `triage` does not
already cover.

Two corrections the independent round made to the chair's held lean
(msg 2, deliberately not shown to the seats):

1. **The reuse target is `triage`, not `assumption_review`.** All
three seats rejected `assumption_review` independently. Its
vocabulary is fixed *because* the vocabulary is the construct
(D2-a), so `accept` / `edit` / `reject` over hunks is semantically
wrong even though the answer shape matches. `triage`'s dispositions
are author-named; that is the correct member.
2. **The interim costs zero files, not three.** A verdict-only hunk
board is expressible in shipped `triage` slots with no library
change at all.

## The zero-change encoding (msg 3, chair-promoted)

| hunk review needs | existing `triage` slot |
|---|---|
| stable hunk key | `triage_items[].id` = `path@base_sha:start-end` |
| what changed | `triage_items[].detail` = the unified diff, fenced ` ```diff ` |
| file / severity | `triage_items[].tag` |
| verdict vocabulary | `dispositions` = `["apply","revise","drop"]` (author-named) |
| agent's proposal | `suggested` (already rendered as a proposal) |
| answer | `{hunk id: verdict}` — already the shape |

Validated, on all four surfaces, for the price of a helper function in
the *consumer*. What is lost versus a purpose-built construct is
red/green diff rendering — a rendering gap, and rendering gaps do not
buy `QuestionType` members here (`list_style`, `progress_style` are the
standing precedent).

## Needs, classified (msgs 3/4/5, unanimous)

| Need | Kind | Verdict |
|---|---|---|
| file/line anchors | rendering + key stability | `id` already carries the key |
| `+`/`−` line semantics | rendering | flag |
| syntax highlighting | rendering (evaporates on 3 of 4 surfaces) | flag |
| hunk ordering | presentation; `ranking` is a different speech act | not a need |
| per-hunk replacement **code** | **answer-shape** | one field on `triage`, not a construct |
| partial-hunk / line-subset selection | **answer-shape**, genuinely new | **out of scope** — see below |

**Line-level selection is the boundary.** It turns the answer from
`{key: scalar}` into `{key: subset-of-a-variable-list}`, and there is
no honest `AskUserQuestion` control for "lines 3, 4, 7 of 12" short of
an option-per-line explosion — it fails admission test #3 on the
flattest surface. `git add -p` line staging belongs to a widget-only
staging UI, not to a four-surface grammar.

## Flip conditions (pre-recorded so the gate is a decision, not a drift)

The backlog item stays gated. It flips to **build** only when all three
hold:

1. A committed consumer is **already shipping** the `triage` encoding
above and can point at a wall.
2. The wall is **answer-shape, not paint**. If the wall is "I need to
hand back replacement code," the answer is an opt-in
`edit_dispositions: ["revise"]` on `triage` — lifting the
`assumption_review` edit lane (`{"edit": text}` + paired
`"<id>.<item>.text"` + the text-iff-edit fold, all already written
and paid for). That is one field, **not** a construct.
3. Someone can state, in one sentence, the validator rule that *is*
`hunk_review`. The table named exactly one candidate — anchor
integrity — and the chair did **not** promote it; it is recorded in
the machine-local transcript (msg 3) as the seat's own stated risk
of waiting, not as project doctrine.

## Open chair fact

All three seats converged on the same follow-up (msgs 6/7/8): does the
prospective consumer need the human to hand back **code**, or only a
**verdict**? Verdict-only is free today; code-back is one field. The
answer decides which of the flip conditions is even reachable.
64 changes: 52 additions & 12 deletions src/attune_forms/markdown_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,18 +136,62 @@ def _progress_lines(q: FormQuestion) -> list[str]:
return lines


#: Indent that makes a block inside a ``- `` bullet an INDENTED code
#: block: 2 columns of list continuation + the 4 CommonMark requires.
_DETAIL_INDENT = " " * 6


def _detail_block(detail: str) -> list[str]:
"""A MULTI-LINE item ``detail`` as an indented code block under its bullet.

Interpolating a multi-line detail into the bullet itself breaks out
of the list item: every line the detail starts with ``-`` — every
removed line of a diff — parses as a NEW bullet, and a trailing
``suggested`` suffix lands on the detail's last line.

The block is INDENTED rather than fenced because a fence cannot
survive this surface: :func:`_defuse_fences` breaks every run of
three backticks in author text so the trailing reply skeleton keeps
its boundaries. An indented block needs no backticks at all, so it
passes through untouched and its content stays literal.

For the same reason an author's own `````lang`` wrapper is
STRIPPED rather than kept: defused, it would render as visible
backtick-plus-zero-width noise around the block.
"""
lines = detail.strip("\n").splitlines()
if lines and lines[0].lstrip().startswith("```"):
lines = lines[1:]
if lines and lines[-1].strip().startswith("```"):
lines = lines[:-1]
return [f"{_DETAIL_INDENT}{line}".rstrip() for line in lines]


def _item_row(head: str, detail: str | None, suffix: str = "") -> list[str]:
"""One bullet row plus, for a multi-line ``detail``, its indented block.

A single-line detail stays inline after an em dash (unchanged
rendering); a multi-line one moves below the bullet so ``suffix``
(e.g. "→ suggested: `apply`") stays on the bullet instead of being
glued onto the detail's closing fence.
"""
multiline = bool(detail) and "\n" in (detail or "")
inline = f" — {detail}" if detail and not multiline else ""
rows = [f"{head}{inline}{suffix}"]
if multiline:
rows.extend(_detail_block(detail or ""))
return rows


def _triage_lines(q: FormQuestion) -> list[str]:
"""Item rows + the shared ruling vocabulary for a TRIAGE question."""
vocabulary = " / ".join(f"`{d}`" for d in q.dispositions or [])
lines = [f"Rule each item as one of: {vocabulary}"]
for key, item in expansion_items(q):
tag = f" `{item['tag']}`" if item.get("tag") else ""
detail = f" — {item['detail']}" if item.get("detail") else ""
line = f"- **{item.get('label', '')}**{tag}{detail}"
pick = suggested_pick(q, key)
if pick is not None:
line += f" → suggested: `{pick}`"
lines.append(line)
suffix = f" → suggested: `{pick}`" if pick is not None else ""
lines.extend(_item_row(f"- **{item.get('label', '')}**{tag}", item.get("detail"), suffix))
return lines


Expand All @@ -167,13 +211,10 @@ def _assumption_lines(q: FormQuestion) -> list[str]:
accept, edit, reject = ASSUMPTION_RULINGS
lines = [f"Rule each assumption: `{accept}` / `{edit}: <replacement text>` / `{reject}`"]
for key, item in expansion_items(q):
detail = f" — {item['detail']}" if item.get("detail") else ""
source = f" *(from {item['source']})*" if item.get("source") else ""
line = f"- **{item.get('label', '')}**{detail}{source}"
pick = suggested_pick(q, key)
if pick is not None:
line += f" → suggested: `{pick}`"
lines.append(line)
suffix = source + (f" → suggested: `{pick}`" if pick is not None else "")
lines.extend(_item_row(f"- **{item.get('label', '')}**", item.get("detail"), suffix))
return lines


Expand Down Expand Up @@ -225,8 +266,7 @@ def _control_lines(q: FormQuestion) -> list[str]:
lines = ["If approved:"]
for item in confirm_consequences(q):
tag = f" `{item['severity']}`" if item.get("severity") else ""
detail = f" — {item['detail']}" if item.get("detail") else ""
lines.append(f"- {item.get('label', '')}{tag}{detail}")
lines.extend(_item_row(f"- {item.get('label', '')}{tag}", item.get("detail")))
lines.append("")
lines.append("Answer one of: " + " / ".join(f"**{opt}**" for opt in q.options))
return lines
Expand Down
18 changes: 13 additions & 5 deletions src/attune_forms/theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,20 @@
stylesheet renders native on claude.ai (host tokens win,
light/dark follows the host) and standalone (fallbacks win).

Budget: ``FORM_THEME_CSS`` is capped at 10 KB raw by
Budget: ``FORM_THEME_CSS`` is capped at 12 KB raw by
``test_form_theme_budget`` (4 KB → 6 KB by chair ruling 2026-07-31;
6 KB → 8 KB ratified with the grammar-expansion merge 2026-08-14 —
TRIAGE + CONFIRM families + deliberation seat chips, 8,158 B; 8 KB →
10 KB ratified 2026-08-15 for the 0.6.0 constructs, ranking-construct
spec D2-a — the RANK family did not fit under 8 KB; a CSS
consolidation pass was offered and not chosen, so the cap is NOT a
ratchet: growth past it is a design decision, not a drift) — no fonts,
no icon fonts, no images, no @import.
ratchet: growth past it is a design decision, not a drift; 10 KB →
12 KB chair-ruled 2026-08-28 for the shared ``ae-detail-block`` rule
in CSS_BASE, which lets a MULTI-LINE item detail — a diff hunk, a log
excerpt — keep its newlines instead of collapsing inside a flex row,
across triage / assumption_review / progress / confirm at once; a
trim to fit under 10 KB was offered and declined) — no fonts, no icon
fonts, no images, no @import.

Copyright 2026 Smart-AI-Memory
Licensed under Apache 2.0
Expand Down Expand Up @@ -66,6 +71,9 @@
color:var(--text-accent,#a1571c); }
#attune-elicit-form .ae-field-missing { border-left:3px solid
var(--text-accent,#a1571c); padding-left:.6rem; }
#attune-elicit-form .ae-detail-block { flex-basis:100%; white-space:pre-wrap;
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
overflow-x:auto; }
"""

#: INPUT — text_input, textarea, number, date, boolean, non-list single_select.
Expand Down Expand Up @@ -123,7 +131,7 @@
CSS_PROGRESS = """#attune-elicit-form .ae-progress { display:flex; flex-direction:column; gap:.5rem; }
#attune-elicit-form .ae-prog-rows { display:flex; flex-direction:column; gap:.25rem; }
#attune-elicit-form .ae-prog-row { display:flex; align-items:baseline; gap:.5rem;
font-size:14px; color:var(--text-secondary,#5f5e59); }
flex-wrap:wrap; font-size:14px; color:var(--text-secondary,#5f5e59); }
#attune-elicit-form .ae-prog-icon { flex:none; font-weight:700; width:1.1em;
text-align:center; }
#attune-elicit-form .ae-prog-done .ae-prog-icon { color:var(--text-success,#3fb950); }
Expand Down Expand Up @@ -168,7 +176,7 @@
#attune-elicit-form .ae-gate-h { font-size:11px; font-weight:600;
text-transform:uppercase; letter-spacing:.03em; color:var(--text-accent,#a1571c); }
#attune-elicit-form .ae-gate-row { display:flex; align-items:baseline; gap:.5rem;
font-size:14px; color:var(--text-secondary,#5f5e59); }
flex-wrap:wrap; font-size:14px; color:var(--text-secondary,#5f5e59); }
#attune-elicit-form .ae-gate-label { color:var(--text-primary,#2c2c2a); }
#attune-elicit-form .ae-gate-detail { font-size:13px; color:var(--text-muted,#8a887f); }
#attune-elicit-form .ae-gate-tag { flex:none; font-size:10px; font-weight:600;
Expand Down
47 changes: 24 additions & 23 deletions src/attune_forms/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,7 @@ def _control_progress_report_html(q: FormQuestion) -> str:
if it.get("label") in q.options:
continue
tag = f'<span class="ae-prog-tag">{_esc(it.get("status", ""))}</span>'
detail = (
f'<span class="ae-prog-detail">{_esc(it["detail"])}</span>' if it.get("detail") else ""
)
detail = _detail_html(it["detail"], "ae-prog-detail") if it.get("detail") else ""
rows += (
f'<div class="ae-prog-row ae-prog-report">'
f'{tag}<span class="ae-prog-label">{_esc(it.get("label", ""))}</span>'
Expand Down Expand Up @@ -207,11 +205,7 @@ def _control_progress_html(q: FormQuestion) -> str:
)
for status_key, icon, sr in status_rows:
for it in by_status[status_key]:
detail = (
f'<span class="ae-prog-detail">{_esc(it["detail"])}</span>'
if it.get("detail")
else ""
)
detail = _detail_html(it["detail"], "ae-prog-detail") if it.get("detail") else ""
rows += (
f'<div class="ae-prog-row ae-prog-{status_key}">'
f'<span class="ae-prog-icon" aria-hidden="true">{icon}</span>'
Expand Down Expand Up @@ -276,6 +270,25 @@ def _control_deliberation_html(q: FormQuestion) -> str:
return f'<div class="ae-cards" role="radiogroup">{cards}</div>'


def _detail_html(text: str, cls: str) -> str:
"""An item ``detail`` as an inline span, or a BLOCK when multi-line.

A multi-line detail — a diff hunk, a log excerpt — rendered as an
inline span inside a flex row collapses: HTML folds its newlines and
leading indentation into single spaces, so a diff arrives as one
run-on line (found probing the triage encoding for hunk review,
round table q-forms-hunk-review-001). As an ``ae-detail-block`` it
takes its own full-width line with ``white-space:pre-wrap`` and
keeps the shape the author wrote.

Single-line details stay inline spans — the common case renders
exactly as before.
"""
if "\n" in text:
return f'<div class="{cls} ae-detail-block">{_esc(text)}</div>'
return f'<span class="{cls}">{_esc(text)}</span>'


def _control_triage_html(q: FormQuestion) -> str:
"""Render a TRIAGE control: one row per item, each row its own
disposition radiogroup.
Expand All @@ -290,11 +303,7 @@ def _control_triage_html(q: FormQuestion) -> str:
for idx, (key, item) in enumerate(expansion_items(q)):
label = item.get("label", "")
tag = f'<span class="ae-triage-tag">{_esc(item["tag"])}</span>' if item.get("tag") else ""
detail = (
f'<span class="ae-triage-detail">{_esc(item["detail"])}</span>'
if item.get("detail")
else ""
)
detail = _detail_html(item["detail"], "ae-triage-detail") if item.get("detail") else ""
pick = suggested_pick(q, key)
opts = ""
for disposition in q.dispositions or []:
Expand Down Expand Up @@ -330,11 +339,7 @@ def _control_confirm_html(q: FormQuestion) -> str:
if item.get("severity")
else ""
)
detail = (
f'<span class="ae-gate-detail">{_esc(item["detail"])}</span>'
if item.get("detail")
else ""
)
detail = _detail_html(item["detail"], "ae-gate-detail") if item.get("detail") else ""
rows += (
f'<div class="ae-gate-row">{tag}'
f'<span class="ae-gate-label">{_esc(item.get("label", ""))}</span>'
Expand Down Expand Up @@ -430,11 +435,7 @@ def _control_assumption_review_html(q: FormQuestion) -> str:
if item.get("source")
else ""
)
detail = (
f'<span class="ae-triage-detail">{_esc(item["detail"])}</span>'
if item.get("detail")
else ""
)
detail = _detail_html(item["detail"], "ae-triage-detail") if item.get("detail") else ""
pick = suggested_pick(q, key)
opts = ""
for ruling in ASSUMPTION_RULINGS:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_form_theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
#: merge, 8,158 B; 8 KB -> 10 KB ratified 2026-08-15, ranking-construct
#: decisions.md D2-a — a consolidation pass was offered and NOT chosen,
#: so the cap is not a ratchet: the next raise needs its own ruling).
_BUDGET_BYTES = 10240
_BUDGET_BYTES = 12288

#: ``var(--name)`` with NO fallback value — the pattern the theme
#: must never contain (host-token fallbacks are the design).
Expand Down
Loading
Loading