phase: 104_chip_sizing_question_cap
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:
@@ -1,106 +0,0 @@
|
||||
# Phase 104 — Suggestion chips stay single-line (hover reveals the full text) + the composer enforces the 4,000-char question cap
|
||||
|
||||
**Source:** Owner request (chat, 2026-09-12) — "…tweak the size of the suggestion chips on the chat page. Some users submit truly massive queries and the 'chips' become more like 'chonks'. Hovering over the chips should still show the full message so users still have a way to see it. In fact, I would suggest you put a character cap on the chat submission box, to prevent ultra-long context overflowing queries. Users should still be able to paste code example of a few dozen lines, but nothing much longer than that."
|
||||
**Story:** n/a (owner request — extends the suggestion-chips component contract of `05_story_suggestion_chips` / `80_history_suggestion_chips`; the question-cap backstop pattern of `83_chat_save_payload_limits`).
|
||||
**Context:** `frontend/assets/styles.css` — the base `.suggestion-chip` rule (~L1225: the pill, `min-height: 44px`, text WRAPS inside today — a 400-char question becomes a tall multi-line block), the `.maybe-try .suggestion-chip` override (~L702: `min-width: 0; max-width: 100%` — the phase-07 overflow fix), the mobile row (~L4331: `.suggestions { flex-wrap: nowrap; overflow-x: auto; }` + `.suggestion-chip { flex: 0 0 auto; }`). `frontend/assets/app.js` — `renderChips` (~L1003-1040: the shared chip component, onboarding row + "Maybe try" row; NO `title`/`aria-label` today), the source-chip `title` + `aria-label`-when-truncated pattern (~L1331-1338 — the house precedent to copy), `autoGrow` (~L1258, 192px max), the four `input.value` mutation sites (the `input` listener ~L2484, `submitSuggestion` ~L1013, the `handleSend` clear ~L2118, the `startNewChat` clear ~L2015), the out-of-turn `showErrorBanner` precedent (`saveAsDoc` ~L731). `frontend/index.html` — the composer (~L271-285: `#message-input` textarea with NO maxlength; the `.chat-bottom` sticky unit wraps the chat-actions row + the form, ~L205-215); the theme inputs' `maxlength`-mirrors-the-server comment precedent (~L1054). `app/schemas.py` — `ChatRequest.message: Field(min_length=1, max_length=4000)` (L75 — the server backstop ALREADY in force: a >4000-char question 422s today with zero UI feedback — the banner just shows "Brain's API answered with HTTP 422."); `HistoryTurn.text` stays 32_000 (HISTORY turns may be long answers — only the CURRENT question is capped at 4000). `tests/unit/test_schemas.py` — no 4000/4001 `message` boundary pin today. `tests/unit/test_pinned_composer.py` — pins `.chat-bottom` as the LAST CHILD of `.chat-shell` and the `.composer` sticky CSS (a new child INSIDE `.chat-bottom` breaks neither).
|
||||
|
||||
## Objective
|
||||
Two coupled fixes on the chat page. (1) Long questions no longer balloon the suggestion chips into multi-line "chonks": every chip is single-line, ellipsized at the row edge, and the FULL text is one hover away (native `title` tooltip) plus the accessible name. (2) The composer makes the question-length cap VISIBLE — the server already hard-caps the current question at 4,000 chars (`ChatRequest.message`); the textarea gains `maxlength="4000"`, a counter appears near the cap, and a guard covers the one path that bypasses `maxlength` (the chip one-tap fill) — so no user ever meets the 422 blind.
|
||||
|
||||
## Owner decisions (chat, 2026-09-12 — recorded per AGENTS.md rule 3)
|
||||
- **A1 — single-line chips, never chonks:** chips never wrap — `white-space: nowrap`, ellipsized at the row edge, never taller than the one-line 44px pill, at every viewport width.
|
||||
- **A2 — hover shows the full text:** "Hovering over the chips should still show the full message so users still have a way to see it" — a native `title` tooltip carrying the FULL text on every chip (the house source-chip pattern), plus `aria-label` = full text when the chip is visually truncated (screen readers).
|
||||
- **A3 — the question cap is 4,000 chars, mirroring the existing server cap:** the cap must allow "code example of a few dozen lines, but nothing much longer" — 4,000 chars ≈ a 50-line block at 80 chars/line, and the server ALREADY rejects >4,000 (`ChatRequest.message max_length=4000`, pre-existing, untouched). **NO schema change:** the server cap stays the backstop (this phase pins it at the boundary, task 03); the UI becomes the visible contract (`maxlength` + counter + guard).
|
||||
- **A4 — counter behavior:** hidden while the RAW length < 3,200 (80% of the cap — no noise on normal use); shows `len/4000` from 3,200; at/over the cap shows `len/4000 — character limit` in the `--err-*` semantic family (B3: the COPY change carries the state — text + color, never color alone; the executor verifies + records the AA ratio of the chosen `--err-*` pairing in the CSS comment). Count the RAW value (no trim): raw ≤ 4,000 ⟹ trimmed ≤ 4,000, so a raw count is a safe superset of what the server validates.
|
||||
- **A5 — the over-cap guard:** `maxlength` constrains typing + pastes, but a programmatic `input.value = …` bypasses it — the one reachable path is `submitSuggestion` (a chip >4,000 chars; possible only via an admin-authored `BOR_SUGGESTIONS` seed — history chips are ≤4,000 by construction, having passed the same cap when asked). `handleSend` guards: trimmed text > the cap → the out-of-turn error banner (the `saveAsDoc` precedent), NO turn, the input KEEPS the text (the user trims it) — never stale (PLAN §7.4).
|
||||
- **A6 — shared page:** `frontend/assets/shared.js` renders "Maybe try" chips as plain non-interactive spans (`pointer-events: none`, scoped `.shared-shell` — owner-locked phase 51: a guest tapping a chip has nowhere to go; a tooltip could never show) — UNTOUCHED; the CSS sizing applies to those pills automatically (shared stylesheet).
|
||||
|
||||
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||
|
||||
### Chip sizing (task 01) — `frontend/assets/styles.css`
|
||||
- The base `.suggestion-chip` rule gains: `white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; min-width: 0;`
|
||||
- `overflow: hidden` (≠ visible) zeroes the flex item's automatic minimum size, so `max-width: 100%` actually binds: in the desktop wrap row (`.suggestions`, the 46rem column) a long chip clips at the column edge; in the ≤640px row (`flex-wrap: nowrap; overflow-x: auto`) it clips at the VISIBLE width and the row scrolls (the phase-07 contract).
|
||||
- One line per chip at every width — the 44px `min-height` pill stays a pill.
|
||||
- The `.maybe-try .suggestion-chip` override (`min-width: 0; max-width: 100%`) is fully subsumed by the new base rule → **delete it**, folding its phase-07 overflow-fix provenance into the base rule's comment (house: provenance lives with the contract).
|
||||
- The mobile rule `.suggestion-chip { flex: 0 0 auto; }` (≤640px block) stays.
|
||||
- No other CSS in this task.
|
||||
|
||||
### Tooltip + a11y (task 02) — `frontend/assets/app.js` `renderChips`
|
||||
- After `btn.textContent = text;`: `btn.title = text;` — the FULL text, always (A2; the house source-chip pattern, ~L1331).
|
||||
- After `container.appendChild(btn)`: `if (btn.scrollWidth > btn.clientWidth) btn.setAttribute("aria-label", text);` — the source-chip truncation pattern (~L1336-1338): the screen-reader name is the full text when the visible text is clipped; attribute absent when not clipped (textContent already carries the full text).
|
||||
- No other `renderChips` change (one-tap submit, `role="listitem"`, the container contract).
|
||||
|
||||
### The composer cap (task 03) — `frontend/index.html` + `frontend/assets/app.js`
|
||||
- `index.html`:
|
||||
- `#message-input` gains `maxlength="4000"`, with the provenance comment (house pattern — the theme inputs' "maxlength=300 mirrors the server's 300-char"): "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)".
|
||||
- The counter element, INSIDE `.chat-bottom` between the chat-actions row and `<form class="composer">` (a new child of the sticky unit — the sticky contract + `test_pinned_composer.py`'s last-child-of-`.chat-shell` pin are untouched):
|
||||
```html
|
||||
<p class="char-count" id="char-count" hidden></p>
|
||||
```
|
||||
with the phase-104 comment: hidden until 80% of the cap (app.js); the `.is-max` state uses the `--err-*` family + a copy change (B3); NOT a live region — per-keystroke feedback is decorative, the over-cap failure announces through the error banner (`role=alert`).
|
||||
- `app.js`:
|
||||
- Constants next to `autoGrow` (provenance comment: mirrors the schema cap; the threshold is 80% — owner 2026-09-12 A4):
|
||||
```js
|
||||
const MAX_QUESTION_CHARS = 4000;
|
||||
const CHAR_COUNT_SHOW_AT = 3200;
|
||||
```
|
||||
- Element grabber alongside the existing ones (`~L301`): `const charCountEl = document.querySelector("#char-count");`
|
||||
- `updateCharCount()` (next to `autoGrow`): `len = input.value.length` (RAW); `len < CHAR_COUNT_SHOW_AT` → `hidden = true` + drop `.is-max`; else unhide, `textContent = "${len}/${MAX_QUESTION_CHARS}"` and, when `len >= MAX_QUESTION_CHARS`, append ` — character limit` + add `.is-max`. Over-cap (the chip path) shows the honest `len` (e.g. `5123/4000 — character limit`).
|
||||
- Call `updateCharCount()` at the EXACT four `input.value` mutation sites (each already calls `autoGrow()` there): the `input` listener, `submitSuggestion`, the `handleSend` clear, the `startNewChat` clear.
|
||||
- `handleSend` guard, immediately AFTER `if (!text || sendBtn.disabled) return;` (BEFORE the clear — the input keeps the text):
|
||||
```js
|
||||
if (text.length > MAX_QUESTION_CHARS) {
|
||||
showErrorBanner("Questions are limited to 4,000 characters — trim the question and try again.");
|
||||
return;
|
||||
}
|
||||
```
|
||||
with the A5 comment (maxlength caps typing + pastes; the programmatic chip fill bypasses it — this guard is the never-stale backstop).
|
||||
- `styles.css` (this task): `.char-count` — `margin: 0; text-align: right; font-size: 0.75rem; line-height: 1.2; color: var(--ink-soft);` (the counter sits on the app background behind `.chat-bottom` — the executor verifies the chosen pairing is ≥4.5:1 there and records the ratio in the comment, house style); `.char-count.is-max { color: var(--err-ink); }` (verify + record the ratio on the same background; the copy change already carries the state — B3).
|
||||
- `tests/unit/test_schemas.py`: the `ChatRequest.message` boundary pin — exactly 4,000 chars validates; 4,001 → a pydantic `ValidationError` naming `message` (the A2/A3 backstop — the cap the UI now mirrors).
|
||||
|
||||
### Unit source pins (house pattern — one new file, `tests/unit/test_chip_sizing_question_cap.py`, extended per task)
|
||||
- Task 01 (CSS): the `.suggestion-chip` rule block contains `white-space: nowrap`, `overflow: hidden`, `text-overflow: ellipsis`, `max-width: 100%`, `min-width: 0`; `css.count(".maybe-try .suggestion-chip") == 0` (the subsumed override is gone); the ≤640px block keeps `.suggestion-chip { flex: 0 0 auto; }`.
|
||||
- Task 02 (renderChips): the `renderChips` block contains `btn.title = text` and the `scrollWidth > btn.clientWidth` → `setAttribute("aria-label"` pattern.
|
||||
- Task 03: `index.html` — the `#message-input` block carries `maxlength="4000"`; `#char-count` exists, is `hidden` by default, and sits inside `.chat-bottom` before `#composer` (source order). `app.js` — `MAX_QUESTION_CHARS = 4000` + `CHAR_COUNT_SHOW_AT = 3200`; the guard `text.length > MAX_QUESTION_CHARS` + the banner copy "4,000 characters"; `updateCharCount()` defined AND called at all four mutation sites (pin each site's context). **Single-source cross-file pin:** the HTML `maxlength` value == the JS `MAX_QUESTION_CHARS` value (regex-parse both files and compare — the cap lives in one place conceptually).
|
||||
|
||||
### E2E (task 04) — new dedicated suite `tests/e2e/test_chip_sizing_question_cap.py`
|
||||
House scaffolding (DB up, mock LLM, the fixture-KB module import, admin login — the chips are `require_user`, the phase-80 autouse `saved_chats` truncate):
|
||||
1. **Truncated chip + tooltip (A1/A2 core):** save via the API a chat whose FIRST user question is LONG (300+ chars — a readable repeated phrase) with a short follow-up; reload → exactly ONE onboarding chip (phase-103 opener semantics):
|
||||
- computed style `white-space: nowrap`, `overflow: hidden`, `text-overflow: ellipsis`;
|
||||
- `scrollWidth > clientWidth` (visually clipped);
|
||||
- single line: `44 <= clientHeight <= 60` (a one-line pill is the 44px min-height; a wrapped two-liner is ≥ ~76px);
|
||||
- `title` attribute == the full long text; `aria-label` == the full long text.
|
||||
Fresh-DB contrast pin: a short SEED chip has `title` set and NO `aria-label` (not truncated).
|
||||
2. **Counter threshold (A4):** `#char-count` hidden at boot; 100 chars typed → still hidden; exactly 3,500 chars in the box (a dispatched `input` event) → visible, text `3500/4000`, NO `.is-max`.
|
||||
3. **Hard cap through the input path (A3):** `keyboard.insert_text("x" * 6000)` (CDP `Input.insertText` = the paste path — `maxlength` applies) → the textarea holds EXACTLY 4,000 chars; the counter reads `4000/4000 — character limit` + `.is-max`. Submit → the 4,000-char question passes the server cap (NO 422 error state) → the mock answer streams to `done` → the input is cleared and the counter hidden again.
|
||||
4. **The over-cap guard (A5):** `page.evaluate` sets `#message-input.value = "x".repeat(5000)` + dispatches an `input` event (the programmatic path `maxlength` cannot stop) → counter `5000/4000 — character limit` + `.is-max` → click Send → the error banner shows the "4,000 characters" copy; NO brain bubble appended; the input STILL holds the 5,000 chars (kept for trimming — never stale).
|
||||
5. **Short-flow regression:** a short question submits cleanly; the counter never becomes visible.
|
||||
|
||||
**Regressions (run in isolation, must stay green):** `tests/e2e/test_suggestion_chips.py` (the chip contract — phase 103's rewrite), `tests/e2e/test_pinned_composer.py` (the sticky cluster now hosts the counter), `tests/e2e/test_responsive_polish.py` (the mobile chip row + the chip AA-contrast pairs — the chip colors are unchanged), `tests/e2e/test_chat_history.py` (the send flow). Unit: `tests/unit/test_pinned_composer.py` (the `.chat-bottom` pins — untouched structure) + any `tests/unit/` source pin that conflicts with the added `app.js` lines (the executor runs `uv run pytest tests/unit/ -q` and fixes only genuine conflicts — the additions live INSIDE existing functions, so substring/context pins should survive).
|
||||
|
||||
## Dependencies
|
||||
- `103_suggestions_session_openers` (todo) — the onboarding chips become session openers; this phase's long-chip E2E state (a 300+ char opener chip) builds on that contract and runs AFTER it. Queue order only at the code level (different files), but the E2E fixtures assume the phase-103 semantics.
|
||||
- `80_history_suggestion_chips` (complete) — the chip component + the E2E fixture pattern.
|
||||
- `83_chat_save_payload_limits` (complete) — the boundary-cap pin pattern (`tests/unit/test_schemas.py`).
|
||||
|
||||
## Tasks
|
||||
1. `01_chip_ellipsis_css.md` — the base chip rule (single-line ellipsis) + the subsumed-override deletion + the CSS unit pins.
|
||||
2. `02_chip_tooltip_aria.md` — `renderChips` full-text `title` + `aria-label`-when-truncated + the unit pins.
|
||||
3. `03_composer_question_cap.md` — `maxlength` + the counter (HTML/JS/CSS) + the `handleSend` guard + the unit pins + the `test_schemas.py` boundary pin.
|
||||
4. `04_e2e_suite_commit.md` — the dedicated E2E suite + regression E2Es + full gate + atomic commit.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit — `tests/unit/test_chip_sizing_question_cap.py` (new; per-task pins above) + the `ChatRequest.message` 4,000/4,001 boundary in `tests/unit/test_schemas.py`.
|
||||
- E2E (mandatory, A16) — `tests/e2e/test_chip_sizing_question_cap.py` green in isolation (the five states above); the four named regression suites green in isolation.
|
||||
- Coverage: **>90%** on `app/` (`uv run pytest --cov=app --cov-report=term-missing`) — the `app/` delta is nil-to-trivial (no `app/` code changes; the gate stays green).
|
||||
- Lint/types: `uv run ruff check . && uv run pyright`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] A 300+ char onboarding chip renders single-line (`clientHeight` ≤ 60px, ellipsized, `scrollWidth > clientWidth`) with `title` + `aria-label` == the full text (E2E).
|
||||
- [ ] `#message-input` hard-caps at 4,000 through the paste path; submitting at the cap streams a mock answer (no 422); the counter is hidden again after send (E2E).
|
||||
- [ ] The over-cap guard: a programmatic 5,000-char fill + Send → the cap banner, no turn, the text kept (E2E).
|
||||
- [ ] The `ChatRequest.message` 4,000/4,001 boundary is pinned in `tests/unit/test_schemas.py`; the HTML `maxlength` == the JS constant (cross-file pin).
|
||||
- [ ] `uv run pytest tests/e2e/test_chip_sizing_question_cap.py -v --no-cov` green in isolation; `test_suggestion_chips.py`, `test_pinned_composer.py`, `test_responsive_polish.py`, `test_chat_history.py` green in isolation.
|
||||
- [ ] `uv run pytest` green; coverage >90%; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] `git diff --stat` limited to `frontend/`, the new unit file, `tests/unit/test_schemas.py`, the new E2E file, phase files (NO `app/` code diff, no migration, no `shared.js` diff).
|
||||
- [ ] One atomic `--no-gpg-sign` Conventional-Commits commit (e.g. `feat(chat): single-line suggestion chips with full-text tooltips + the visible 4,000-char question cap`); phase dir moved to `.agents/phases/complete/`.
|
||||
@@ -1,33 +0,0 @@
|
||||
# Task 01 — Single-line ellipsized chips: the base `.suggestion-chip` rule + the subsumed override deletion
|
||||
|
||||
**Phase:** `104_chip_sizing_question_cap` · **Story:** n/a (owner request)
|
||||
|
||||
## Objective
|
||||
A suggestion chip is ONE line at every viewport width — long text ellipsizes at the row edge instead of wrapping the pill into a multi-line "chonk" (owner A1).
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/styles.css` — the base `.suggestion-chip` rule (~L1225) gains four declarations (keep the existing ones — font/weight/color/background/border/radius/padding/min-height/transition):
|
||||
```css
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
```
|
||||
Extend the rule's comment (house provenance style): phase 104 (owner 2026-09-12) — chips never wrap; `overflow: hidden` zeroes the flex item's automatic minimum size so `max-width: 100%` binds (desktop wrap row: 100% = the 46rem column; the ≤640px row: 100% = the visible width, the row scrolls — the phase-07 overflow contract now lives here).
|
||||
2. DELETE the `.maybe-try .suggestion-chip { min-width: 0; max-width: 100%; }` override (~L702) — fully subsumed by the new base rule; fold its phase-07 provenance sentence into the base rule's comment (the step-1 comment) so the history is not lost.
|
||||
3. Leave the ≤640px block's `.suggestion-chip { flex: 0 0 auto; }` (~L4331) and `.suggestions` row rule untouched.
|
||||
4. `tests/unit/test_chip_sizing_question_cap.py` — CREATE the phase's unit-pins file (module docstring: pins the phase-104 chip-sizing contract in the static frontend — the house source-pin pattern) with the CSS pins:
|
||||
- the `.suggestion-chip` rule block contains `white-space: nowrap`, `overflow: hidden`, `text-overflow: ellipsis`, `max-width: 100%`, `min-width: 0` (reuse the `_rule`-style block extraction from `tests/unit/test_pinned_composer.py` / `test_wide_column_css.py` — read one and match its helpers);
|
||||
- `css.count(".maybe-try .suggestion-chip") == 0` (the subsumed override is gone);
|
||||
- the ≤640px mobile block still contains `.suggestion-chip { flex: 0 0 auto; }`.
|
||||
5. Run `uv run pytest tests/unit/ -q` — green (the new file + no existing pin broken).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the new pins ARE this task's test layer (they guard the CSS bytes — the house source-pin pattern).
|
||||
- Coverage: **>90%** on `app/` (untouched — the full-suite gate runs at the phase's end).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `.suggestion-chip` is single-line + ellipsized + capped at the row edge (the five declarations pinned); the `.maybe-try .suggestion-chip` override deleted with its provenance folded into the base comment
|
||||
- [ ] `tests/unit/test_chip_sizing_question_cap.py` created and green; `uv run pytest tests/unit/ -q` green
|
||||
- [ ] no other file changed (JS/HTML/E2E are later tasks)
|
||||
@@ -1,30 +0,0 @@
|
||||
# Task 02 — Full-text hover tooltip + accessible name on every suggestion chip
|
||||
|
||||
**Phase:** `104_chip_sizing_question_cap` · **Story:** n/a (owner request)
|
||||
|
||||
## Objective
|
||||
The full text of a (possibly ellipsized) chip is always recoverable: a native `title` tooltip on hover (owner A2) and the full text as the accessible name when the visible text is clipped (the house source-chip pattern).
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/app.js` — `renderChips` (~L1024-1040), inside the per-chip loop:
|
||||
- after `btn.textContent = text;` add `btn.title = text;` — the FULL text, always (the hover contract; the source-chip precedent is `chip.title = label` at ~L1331).
|
||||
- after `container.appendChild(btn);` add the truncation-aware accessible name (the source-chip precedent at ~L1336-1338):
|
||||
```js
|
||||
if (btn.scrollWidth > btn.clientWidth) btn.setAttribute("aria-label", text);
|
||||
```
|
||||
(When NOT clipped the attribute stays absent — the `textContent` already carries the full text, so screen readers read it; the attribute is belt-and-suspenders for the clipped case, exactly like the source chips.)
|
||||
- One short comment on the pair: 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).
|
||||
- Touch NOTHING else in `renderChips` (one-tap submit via `submitSuggestion`, `role="listitem"`, the container-replace contract, the `onSelect` hook).
|
||||
2. `tests/unit/test_chip_sizing_question_cap.py` — extend with the `renderChips` pins (extract the `renderChips` function body from `app.js` the way the neighboring unit files do):
|
||||
- the body contains `btn.title = text`;
|
||||
- the body contains the `btn.scrollWidth > btn.clientWidth` guard setting `aria-label` (pin the `setAttribute("aria-label"` call inside that guard — a small slice of the function text, the house "pin the contract words" style).
|
||||
3. Run `uv run pytest tests/unit/ -q` — green (including the existing `tests/unit/test_shared_page.py` pin that `renderChips` does NOT leak into `shared.js` — `shared.js` is untouched by this task).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the new pins guard the `app.js` bytes; the full E2E hover/tooltip behavior is task 04.
|
||||
- Coverage: **>90%** on `app/` (untouched).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] every chip rendered by `renderChips` (onboarding row AND "Maybe try" row) carries `title` = the full text, and `aria-label` = the full text when clipped
|
||||
- [ ] the unit pins are green; `uv run pytest tests/unit/ -q` green
|
||||
- [ ] no other file changed
|
||||
@@ -1,91 +0,0 @@
|
||||
# Task 03 — The visible 4,000-char question cap: `maxlength` + counter + the over-cap guard + the server-boundary pin
|
||||
|
||||
**Phase:** `104_chip_sizing_question_cap` · **Story:** n/a (owner request)
|
||||
|
||||
## Objective
|
||||
The question-length cap the server ALREADY enforces (`ChatRequest.message max_length=4000` — a >4,000-char question 422s today with zero UI feedback) becomes VISIBLE in the composer: the textarea hard-caps input/paste, a counter appears near the cap, and a guard covers the one path that bypasses `maxlength` (the chip one-tap fill). The server cap is untouched and pinned at the boundary.
|
||||
|
||||
## Work
|
||||
1. `frontend/index.html`:
|
||||
- `#message-input` (~L273-279) gains `maxlength="4000"`, with the provenance comment (house pattern — the theme inputs' "maxlength=300 mirrors the server's 300-char", ~L1054): `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)`.
|
||||
- The counter element — INSIDE `.chat-bottom` (~L215), between the closing `</div>` of `.chat-actions` and the composer comment/`<form class="composer">`:
|
||||
```html
|
||||
<!-- 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>
|
||||
```
|
||||
(A new child of the sticky unit — the unit stays the LAST child of `.chat-shell` and the `.composer` sticky CSS is untouched, so `tests/unit/test_pinned_composer.py` + `tests/e2e/test_pinned_composer.py` stay green; a `hidden` `<p>` adds zero height.)
|
||||
2. `frontend/assets/app.js`:
|
||||
- Constants directly above `autoGrow` (~L1258) with the provenance comment (mirrors the schema cap — the executor must NOT change `app/schemas.py`; the threshold is 80% of the cap, owner A4):
|
||||
```js
|
||||
const MAX_QUESTION_CHARS = 4000;
|
||||
const CHAR_COUNT_SHOW_AT = 3200; // 80% of the cap — visible only when it matters
|
||||
```
|
||||
- Element grabber alongside the existing ones (next to `const suggestionsEl = …` ~L301): `const charCountEl = document.querySelector("#char-count");`
|
||||
- `updateCharCount()` next to `autoGrow`:
|
||||
```js
|
||||
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}`;
|
||||
}
|
||||
```
|
||||
(Over-cap — reachable only via the programmatic chip-fill path — shows the honest `len`, e.g. `5123/4000 — character limit`.)
|
||||
- Call `updateCharCount()` at the EXACT four `input.value` mutation sites (each already calls `autoGrow()` — add the call right after it, or fold both into the listener body):
|
||||
a. the `input` listener (~L2484: `input.addEventListener("input", autoGrow)` — e.g. `input.addEventListener("input", () => { autoGrow(); updateCharCount(); })`);
|
||||
b. `submitSuggestion` (~L1013-1015, after `input.value = text; autoGrow();`);
|
||||
c. `handleSend` (~L2118-2119, after the post-send clear `input.value = ""; autoGrow();`);
|
||||
d. `startNewChat` (~L2015-2016, after `input.value = ""; autoGrow();`).
|
||||
- The `handleSend` guard — immediately AFTER `if (!text || sendBtn.disabled) return;` (~L2114) and BEFORE the clear (the input keeps the text for trimming):
|
||||
```js
|
||||
// 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.
|
||||
if (text.length > MAX_QUESTION_CHARS) {
|
||||
showErrorBanner("Questions are limited to 4,000 characters — trim the question and try again.");
|
||||
return;
|
||||
}
|
||||
```
|
||||
(Out-of-turn banner = the `saveAsDoc` precedent, ~L731; the banner is cleared by the next user action — the existing `clearErrorBanner` call sites.)
|
||||
3. `frontend/assets/styles.css` — the counter rules (near the composer styles, ~L1242+):
|
||||
```css
|
||||
/* Phase 104: the question-length counter — right-aligned above the
|
||||
composer, hidden until 80% of the 4,000-char cap (app.js).
|
||||
[executor: verify + record the ratio] --ink-soft on the app
|
||||
background behind .chat-bottom is ≥4.5:1 (WCAG AA). */
|
||||
.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); }
|
||||
```
|
||||
Verify BOTH pairings against the actual background the counter sits on (the app `--bg` behind `.chat-bottom` — the chat column area) and record each ratio in the comment (house style). The `.is-max` state pairs the color with the "— character limit" COPY change (B3 — never color alone).
|
||||
4. `tests/unit/test_chip_sizing_question_cap.py` — extend with:
|
||||
- `index.html` pins: the `#message-input` textarea block carries `maxlength="4000"`; `#char-count` exists, is `hidden` by default, and appears INSIDE `.chat-bottom` before `#composer` (source order).
|
||||
- `app.js` pins: `MAX_QUESTION_CHARS = 4000` and `CHAR_COUNT_SHOW_AT = 3200`; the guard `text.length > MAX_QUESTION_CHARS` with the banner copy "4,000 characters"; `updateCharCount` defined AND its call present in each of the four mutation-site contexts (the listener, `submitSuggestion`, `handleSend`, `startNewChat` — pin each site's slice).
|
||||
- **Single-source cross-file pin:** regex-parse the HTML `maxlength="(\d+)"` on the `#message-input` block and the JS `MAX_QUESTION_CHARS = (\d+)` and assert they are EQUAL (the cap lives in one place conceptually — the schema is the source, both mirror it).
|
||||
5. `tests/unit/test_schemas.py` — the `ChatRequest.message` boundary pin (currently unpinned): exactly 4,000 chars validates; 4,001 → a pydantic `ValidationError` naming `message` (the backstop the UI now mirrors — the phase-83 boundary-pin pattern).
|
||||
6. Run `uv run pytest tests/unit/ -q` — green.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the new pins (HTML/JS/CSS bytes + the cross-file constant match + the schema boundary) ARE this task's test layer; the behavioral E2E is task 04.
|
||||
- Coverage: **>90%** on `app/` (no `app/` code changes — `app/schemas.py` is untouched, only test-pinned).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `#message-input` has `maxlength="4000"` + the provenance comment; the counter element sits in `.chat-bottom` above the composer, hidden by default
|
||||
- [ ] `updateCharCount` fires at all four mutation sites; the `handleSend` guard (banner + no turn + no clear) is in place; the counter CSS is AA-verified with recorded ratios
|
||||
- [ ] The HTML `maxlength` == the JS `MAX_QUESTION_CHARS` (cross-file pin); the 4,000/4,001 `ChatRequest.message` boundary is pinned in `test_schemas.py`
|
||||
- [ ] `uv run pytest tests/unit/ -q` green; no `app/` file changed
|
||||
@@ -1,41 +0,0 @@
|
||||
# Task 04 — The dedicated E2E suite + regression E2Es + full gate + atomic commit
|
||||
|
||||
**Phase:** `104_chip_sizing_question_cap` · **Story:** n/a (owner request)
|
||||
|
||||
## Objective
|
||||
The browser proves the whole contract — single-line ellipsized chips with full-text tooltips, the hard 4,000-char cap through the paste path, the counter states, and the over-cap guard — then the phase closes with every gate green and one atomic commit.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_chip_sizing_question_cap.py` — NEW dedicated suite (house scaffolding: module docstring stating the phase-104 contract + the run-in-isolation command; DB up `podman compose up -d db`; the deterministic mock LLM — the fixture-KB import pattern from a sibling chat suite, e.g. `tests/e2e/test_suggestion_chips.py`; admin login via `e2e.auth_helpers.login` — the chips are `require_user`; the phase-80 autouse `saved_chats` TRUNCATE fixture so each test starts from — and leaves — an empty deployment):
|
||||
- **`test_long_chip_is_single_line_ellipsized_with_full_text_tooltip`** (A1/A2 core): save via the API a chat whose FIRST user question is LONG (300+ chars — a readable repeated phrase, e.g. `"What are the correct arguments for " * 20 + "qwen on llama.cpp?"`) with one short follow-up turn; reload the chat page → exactly ONE onboarding chip (the phase-103 opener semantics — the follow-up never surfaces):
|
||||
- computed style: `white-space: nowrap`, `overflow: hidden`, `text-overflow: ellipsis`;
|
||||
- `scrollWidth > clientWidth` (visually clipped — 300+ chars of ~0.5rem/char far exceeds the 46rem column);
|
||||
- single line: `44 <= clientHeight <= 60` (a one-line pill sits at the 44px `min-height`; a wrapped two-liner is ≥ ~76px — the chonk);
|
||||
- `get_attribute("title")` == the full long text (the hover reveal);
|
||||
- `get_attribute("aria-label")` == the full long text (the clipped-case accessible name).
|
||||
- **`test_short_seed_chip_has_tooltip_but_no_aria_label`**: fresh DB (seed chips) → a short chip has `title` set AND no `aria-label` (not truncated — the attribute is absent by design).
|
||||
- **`test_counter_hidden_below_threshold_and_visible_above`** (A4): on the empty-state chat page, `#char-count` is hidden; type 100 chars → still hidden; put exactly 3,500 chars in `#message-input` (a dispatched `input` event — `locator.fill` does this) → `#char-count` visible, text `3500/4000`, NO `.is-max` class.
|
||||
- **`test_paste_path_hard_caps_at_the_cap_and_sends`** (A3): `page.keyboard.insert_text("x" * 6000)` (CDP `Input.insertText` = the paste path — `maxlength` applies) → `#message-input` holds EXACTLY 4,000 chars; the counter reads `4000/4000 — character limit` + `.is-max`. Click Send → NO 422 error state (the 4,000-char question passes the server cap) → the mock answer streams to `done` (the brain bubble + the "Deterministic mock answer for E2E" marker) → `#message-input` cleared and `#char-count` hidden again.
|
||||
- *Executor note:* if `insert_text` proves not to respect `maxlength` on the pinned Chromium build (it goes through the browser's input pipeline, like a paste — expect it to work), fall back to pinning the attribute (`maxlength == "4000"`) + a `fill`-based counter check, and record the deviation in the phase record — the guard test below still covers the bypass path.
|
||||
- **`test_over_cap_programmatic_fill_hits_the_guard`** (A5): `page.evaluate` sets `#message-input.value = "x".repeat(5000)` + dispatches an `input` event (the programmatic path `maxlength` cannot stop — the chip one-tap fill) → counter `5000/4000 — character limit` + `.is-max` → click Send → the error banner shows the "4,000 characters" cap copy; NO brain bubble appended; `#message-input` STILL holds the 5,000 chars (kept for trimming — never stale, PLAN §7.4).
|
||||
- **`test_short_flow_never_shows_the_counter`**: type a short question → submit → the mock answer lands; `#char-count` never becomes visible during the turn.
|
||||
2. Regression E2Es — run EACH in isolation (`--no-cov`, DB up), must stay green:
|
||||
- `tests/e2e/test_suggestion_chips.py` (the chip contract — phase 103's rewrite; the onboarding row this phase restyles),
|
||||
- `tests/e2e/test_pinned_composer.py` (the sticky cluster now hosts the counter),
|
||||
- `tests/e2e/test_responsive_polish.py` (the mobile chip row + the chip AA-contrast pairs — the chip colors are unchanged by this phase),
|
||||
- `tests/e2e/test_chat_history.py` (the send/save flow).
|
||||
Fix ONLY a regression whose assertion measured the OLD chip wrapping (e.g. a pin that asserted a multi-line chip height) — the asserted BEHAVIOR (a chip exists, is clickable, AA contrast) must survive; note any such fix in the commit message.
|
||||
3. Full gate: `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` **>90%** on `app/`; `uv run ruff check . && uv run pyright` clean.
|
||||
4. `git diff --stat` — limited to `frontend/`, `tests/unit/test_chip_sizing_question_cap.py`, `tests/unit/test_schemas.py`, `tests/e2e/test_chip_sizing_question_cap.py`, and the phase files (NO `app/` code diff, no migration, no `shared.js` diff, no `pyproject.toml`/`uv.lock`).
|
||||
5. Move the phase dir to `.agents/phases/complete/` and make ONE atomic `--no-gpg-sign` Conventional-Commits commit (e.g. `feat(chat): single-line suggestion chips with full-text tooltips + the visible 4,000-char question cap`).
|
||||
|
||||
## Testing & Quality
|
||||
- E2E: the new suite (five tests above) IS the phase's story suite — run in isolation per AGENTS.md rule 9; the four regression suites re-prove the untouched contracts (chip component, sticky composer, contrast, send flow).
|
||||
- Coverage: **>90%** on `app/` (the `app/` delta is nil — the gate must simply stay green).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_chip_sizing_question_cap.py -v --no-cov` green in isolation (DB up)
|
||||
- [ ] `test_suggestion_chips.py`, `test_pinned_composer.py`, `test_responsive_polish.py`, `test_chat_history.py` each green in isolation
|
||||
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean
|
||||
- [ ] `git diff --stat` scoped per Work step 4 (no `app/` code diff, no `shared.js` diff)
|
||||
- [ ] Phase dir moved to `.agents/phases/complete/`; one atomic `--no-gpg-sign` commit
|
||||
Reference in New Issue
Block a user