phase: 104_chip_sizing_question_cap
Build and Push Containers / build-and-push-app (push) Successful in 1m48s
Build and Push Containers / build-and-push-db (push) Successful in 12s

All completion criteria verified. Final report:

**Phase 104 — final verification pass: all green**
- Verified (no code changes needed): single-line ellipsized `.suggestion-chip` CSS + deleted `.maybe-try` override, `renderChips` full-text `title` + `aria-label`-when-clipped, `maxlength="4000"` + `#char-count` counter + `handleSend` over-cap guard, unit pins file, schemas boundary pins, dedicated E2E suite.
- E2E (isolation): `uv run pytest tests/e2e/test_chip_sizing_question_cap.py -v --no-cov` → **6 passed**; regressions: `test_suggestion_chips.py` 8 passed, `test_pinned_composer.py` 4 passed, `test_responsive_polish.py` 7 passed, `test_chat_history.py` 5 passed.
- `uv run pytest` → **2102 passed**; `--cov=app` → **99%** (>90%); `uv run ruff check . && uv run pyright` → clean, 0 errors.
- Criteria: chip E2E (single-line, clipped, title+aria-label full text) ✅; paste caps at exactly 4,000, send streams, counter hides ✅; programmatic 5,000-char fill → banner, no turn, text kept ✅; 4,000/4,001 boundary pinned + HTML maxlength == JS constant cross-file pin ✅.
- Diff scope: `frontend/`, new unit file, `tests/unit/test_schemas.py`, new E2E file, phase files — **no `app/` diff, no migration, no `shared.js` diff**.
- Deviations: 4 regression test files touched — 2 genuine DOM-pin conflicts from the new `#char-count` child (explicitly anticipated by the overview) + 3 documented **pre-existing E2E flake fixes** (smooth-scroll race, tab-walk heuristic, 10 ms timeout), each verified pre-existing on the pre-phase-104 tree.
- No commit made (harness commits per the execution protocol override).
- Next pending phase: `98_sync_summary_visibility`.
This commit is contained in:
2026-09-12 19:45:00 -04:00
parent 1f1c01c9f7
commit ecc921098a
31 changed files with 1874 additions and 36 deletions
+54 -1
View File
@@ -299,6 +299,7 @@ bindSharedHeaderControls();
const messagesEl = document.querySelector("#messages");
const emptyState = document.querySelector("#empty-state");
const suggestionsEl = document.querySelector("#suggestions");
const charCountEl = document.querySelector("#char-count"); // phase 104: the question-length counter (ships hidden)
const composer = document.querySelector("#composer");
const input = document.querySelector("#message-input");
const sendBtn = document.querySelector("#send-btn");
@@ -1012,6 +1013,7 @@ function appendTruncatedNote(wrap, argument, charsShown, charsTotal) {
function submitSuggestion(text) {
input.value = text;
autoGrow();
updateCharCount(); // phase 104: the chip fill bypasses maxlength — count what landed
input.focus();
composer.requestSubmit();
}
@@ -1028,11 +1030,16 @@ function renderChips(container, items, { onSelect } = {}) {
btn.className = "suggestion-chip";
btn.setAttribute("role", "listitem");
btn.textContent = text;
// phase 104 (owner 2026-09-12): the single-line chip clips long
// questions — `title` is the hover reveal, `aria-label` the
// clipped-case accessible name (the source-chip pattern).
btn.title = text;
btn.addEventListener("click", () => {
submitSuggestion(text);
if (onSelect) onSelect(text, btn);
});
container.appendChild(btn);
if (btn.scrollWidth > btn.clientWidth) btn.setAttribute("aria-label", text);
}
return container;
}
@@ -1255,11 +1262,42 @@ export function setUiState(state, errorDetail = "") {
if (state === UI_STATE.error) showErrorBanner(errorDetail);
}
/* Phase 104 (owner 2026-09-12, A3/A4): the visible question-length cap.
MAX_QUESTION_CHARS mirrors ChatRequest.message max_length=4000
(app/schemas.py — the source of truth; the server 422s beyond it) and
must stay equal to the #message-input maxlength (cross-file pin in
tests/unit/test_chip_sizing_question_cap.py). The counter shows only
from 80% of the cap — no noise on normal use (owner A4). */
const MAX_QUESTION_CHARS = 4000;
const CHAR_COUNT_SHOW_AT = 3200; // 80% of the cap — visible only when it matters
function autoGrow() {
input.style.height = "auto";
input.style.height = `${Math.min(input.scrollHeight, 192)}px`;
}
/* Phase 104 (owner 2026-09-12, A4): the counter state — hidden below the
80% threshold, plain `len/4000` above it, and `len/4000 — character
limit` + the .is-max (–err-*) treatment at/over the cap. The over-cap
reading is the HONEST length (the programmatic chip-fill path can
exceed maxlength — e.g. `5123/4000 — character limit`). */
function updateCharCount() {
// RAW length (no trim): raw ≤ cap ⟹ trimmed ≤ cap, so the raw
// count is a safe superset of what the server validates.
const len = input.value.length;
if (len < CHAR_COUNT_SHOW_AT) {
charCountEl.hidden = true;
charCountEl.classList.remove("is-max");
return;
}
charCountEl.hidden = false;
const atMax = len >= MAX_QUESTION_CHARS;
charCountEl.classList.toggle("is-max", atMax);
charCountEl.textContent = atMax
? `${len}/${MAX_QUESTION_CHARS} — character limit`
: `${len}/${MAX_QUESTION_CHARS}`;
}
/* ---------- chat turn (SSE streaming, PLAN §4) ---------- */
/* Cancel a response body without leaking an unhandled rejection:
@@ -2014,6 +2052,7 @@ function startNewChat() {
setUiState(UI_STATE.idle);
input.value = "";
autoGrow();
updateCharCount(); // phase 104: the cleared composer hides the counter again
input.focus();
sendStatus.textContent = "New chat started — previous conversation cleared.";
}
@@ -2111,12 +2150,21 @@ async function handleSend(e) {
}
const text = input.value.trim();
if (!text || sendBtn.disabled) return;
// Phase 104 (owner 2026-09-12, A5): maxlength caps typing + pastes, but
// a programmatic fill (the chip one-tap path) bypasses it — this guard
// is the never-stale backstop (PLAN §7.4): no turn, no clear, the user
// trims the kept text (out-of-turn banner, the saveAsDoc precedent).
if (text.length > MAX_QUESTION_CHARS) {
showErrorBanner("Questions are limited to 4,000 characters — trim the question and try again.");
return;
}
// Phase 49: the user append + persistence save point 1 moved into
// runTurn with the rest of the turn — the `reask` flag skips them on
// the redo-in-place retry path (the question is already in the DOM +
// conversation); handleSend keeps only the form-level pre-work.
input.value = "";
autoGrow();
updateCharCount(); // phase 104: the sent question clears the counter with the input
clearErrorBanner();
await runTurn(text, { reask: false });
}
@@ -2481,7 +2529,12 @@ async function runTurn(text, { reask = false } = {}) {
}
}
input.addEventListener("input", autoGrow);
// Phase 104: every input-path change (keystroke, paste — maxlength
// caps both at 4,000) re-runs the counter alongside the auto-grow.
input.addEventListener("input", () => {
autoGrow();
updateCharCount();
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
+31 -8
View File
@@ -703,14 +703,6 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
gap: 0.45rem;
padding-inline: 0.25rem;
}
/* As flex items these chips must be allowed to shrink (min-width:auto
would let a long title-derived chip exceed the column on phones —
phase 07 overflow fix); the label text then wraps inside the pill. */
.maybe-try .suggestion-chip {
min-width: 0;
max-width: 100%;
}
/* ---------- Steering notes (phase 15) ---------- */
/* "Tune" button in the meta row of every completed brain bubble: ghost
pill, ≥44px, right-aligned after the source chips. ink-soft on surface
@@ -1230,6 +1222,21 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
margin: 0;
padding: 0;
}
/* The shared chip pill — both chip rows (the onboarding row in the
empty state and the "Maybe try" row under a deflected bubble) reuse
it. Phase 104 (owner 2026-09-12, A1): a chip is ONE line at every
viewport width — `white-space: nowrap` stops a 400-char question
from wrapping the pill into a multi-line "chonk"; `overflow: hidden`
zeroes the flex item's automatic minimum size (min-width:auto would
pin the item to the full text width), so `max-width: 100%` binds and
`text-overflow: ellipsis` clips the text at the row edge — in the
desktop wrap row 100% is the chat column, in the ≤640px row
(nowrap + overflow-x: auto) 100% is the VISIBLE width and the row
scrolls. That clipping is the phase-07 overflow fix the old
"Maybe try" chip override used to carry (min-width:0 + max-width:
100%) — now fully subsumed here and deleted, provenance folded into
this comment so the history lives with the contract. The full text
stays one hover away (the title tooltip, phase 104 task 02). */
.suggestion-chip {
font: inherit;
font-size: 0.92rem;
@@ -1242,6 +1249,11 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
min-height: 44px;
cursor: pointer;
transition: background 0.15s ease, transform 0.05s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
min-width: 0;
}
.suggestion-chip:hover { background: var(--brand-soft); }
.suggestion-chip:active { transform: scale(0.98); }
@@ -1321,6 +1333,17 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
#view-chat:not(.chat-booted) .composer {
position: static;
}
/* Phase 104 (owner 2026-09-12, A4): the question-length counter —
right-aligned above the composer, a child of the .chat-bottom sticky
unit (between the actions row and the form), hidden until 80% of the
4,000-char cap (app.js updateCharCount). It sits on the APP
background behind the transparent .chat-bottom unit, so both pairings
are verified against --bg: --ink-soft on --bg = 8.6:1 (≥4.5:1, WCAG
AA) and the .is-max state's --err-ink on --bg = 10.4:1 (AA). The
.is-max state ALSO changes the copy ("— character limit") — text +
color, never color alone (B3). */
.char-count { margin: 0; text-align: right; font-size: 0.75rem; line-height: 1.2; color: var(--ink-soft); }
.char-count.is-max { color: var(--err-ink); }
.composer {
display: flex;
align-items: flex-end;
+13
View File
@@ -252,6 +252,15 @@
</button>
</div>
<!-- Phase 104 (owner 2026-09-12): the question-length counter —
hidden until 80% of the 4,000-char cap (app.js
updateCharCount); the .is-max state uses the --err-* family
PLUS a copy change (B3: text + color, never color alone). NOT
a live region: per-keystroke feedback is decorative — the
over-cap failure path announces through the error banner
(role=alert). -->
<p class="char-count" id="char-count" hidden></p>
<!-- Composer (phase 48, 2026-08-29, TODO.md L3): one button, two
roles — #send-btn reads "Send" when idle and morphs into the
enabled "Stop" control (.is-stop, rose treatment) while a turn
@@ -270,12 +279,16 @@
precedent as the tuning form's noValidate). -->
<form class="composer" id="composer" novalidate>
<label class="visually-hidden" for="message-input">Ask Brain of Reese a question</label>
<!-- maxlength=4000 mirrors ChatRequest.message max_length=4000
(app/schemas.py) — the server 422s beyond; the #char-count
line makes the cap visible (app.js updateCharCount). -->
<textarea
id="message-input"
name="message"
rows="1"
placeholder="Ask me anything…"
autocomplete="off"
maxlength="4000"
></textarea>
<button type="submit" class="send-btn" id="send-btn">
<span class="spinner" aria-hidden="true" hidden></span>