phase: 132_image_upload_feedback
All verification complete. Final report: **Phase 132 (image upload feedback) — final verification pass: 100% green** - **Verified** all 3 tasks' implementations in the working tree: `index.html` status part (hidden-by-default, arc + "Uploading image…", pinned order), `app.js` `enterUploadState`/`exitUploadState` (entered after `attachUpload` guard pre-await, exited on BOTH resolution paths, `setUiState` untouched, idle "Send" restore byte-identical), `styles.css` `.is-uploading` rules (house `spin` keyframes, reduced-motion block, existing `:disabled` Send language), unit pins, E2E suite with route-delayed upload window. - **Completion criteria** — (1) mid-flight spinner + "Uploading image…", remove + Send disabled with "Uploading…": ✅ E2E-asserted; (2) second Send press = no second upload (request count exactly 1): ✅; (3) success clears + turn proceeds (bubble carries image); failure clears + banner + text/attachment remain: ✅; (4) `uv run pytest tests/e2e/test_image_upload_feedback.py -v --no-cov`: ✅ 3 passed (18.5 s, in isolation); (5) no behavior change in completed phases: ✅ (full suite green, A5 path-never-bytes re-pinned in E2E). - **Full suite**: `uv run pytest --cov=app --cov-report=term-missing` → **3108 passed**, `app/` coverage **99%** (>90% gate); `uv run ruff check .` → clean; `uv run pyright` → 0 errors. - **Documentation**: README.md fully reviewed (setup, pages, all `.env.example` vars + every default vs `app/config.py`, tools incl. phase-131 `read(offset,limit)`, image/PDF/sync/deploy sections) and AGENTS.md fully reviewed (rules, quick-reference commands, gitignore/gpg claims) — **verified consistent; no changes needed**. Phase-132 README bullet already in the working tree; CSS `:root` contrast-note corrections (15.3:1 / 8.2:1) re-verified mathematically correct (comments only). - **Decisions**: none — all task work was already implemented and correct; no fixes required. - **Next pending phase**: `133_pdf_screenshot_fallback`.
This commit is contained in:
@@ -314,6 +314,10 @@ const attachBtn = document.querySelector("#attach-btn");
|
||||
const attachFile = document.querySelector("#attach-file");
|
||||
const attachPreview = document.querySelector("#attach-preview");
|
||||
const attachRemove = document.querySelector("#attach-remove");
|
||||
// Phase 132 (task 01, TODO L8): the strip's uploading status part —
|
||||
// ships hidden; enterUploadState / exitUploadState (below) are its
|
||||
// sole `hidden` writers (shown iff the image upload is in flight).
|
||||
const attachStatus = document.querySelector(".attach-preview-status");
|
||||
const banner = document.querySelector("#kb-banner");
|
||||
const bannerText = document.querySelector("#kb-banner-text");
|
||||
const versionEl = document.querySelector("#app-version");
|
||||
@@ -2757,6 +2761,49 @@ function clearAttachedImage() {
|
||||
if (attachPreview) attachPreview.hidden = true;
|
||||
}
|
||||
|
||||
/* Phase 132 (task 01, TODO L8; A5, owner-confirmed 2026-09-27): the
|
||||
* PRE-turn uploading state — the visible proof that the attached
|
||||
* image is moving while POST /api/chat-images is in flight. The
|
||||
* affordance is BOTH halves at once: the preview strip shows the
|
||||
* spinner + "Uploading image…" (the status part unhidden, the
|
||||
* .is-uploading class for task 02's CSS) with its remove X disabled
|
||||
* (the attachment is mid-flight — it can't be removed out from under
|
||||
* the upload), AND the Send button is DISABLED with the "Uploading…"
|
||||
* label — the user-facing half of the attachUpload double-fire guard
|
||||
* (a second press does nothing: the top-of-handleSend
|
||||
* `sendBtn.disabled` guard AND the `attachUpload` guard both no-op).
|
||||
* exitUploadState is the mirror image: the strip returns to its
|
||||
* normal preview (thumbnail + filename stay visible), the remove X
|
||||
* re-enables, and the button restores EXACTLY the idle look —
|
||||
* enabled + "Send" (byte-identical to setUiState's idle label),
|
||||
* restored BEFORE the success path calls runTurn (whose
|
||||
* setUiState("thinking") then takes the button over as usual) and
|
||||
* before the failure path returns (banner shown, input text intact,
|
||||
* the attachment still attached — the user can retry or remove).
|
||||
* setUiState / uiState / the turn loader are deliberately UNTOUCHED:
|
||||
* the upload is PRE-turn (uiState stays idle; the turn's
|
||||
* thinking/streaming feedback starts only when runTurn does).
|
||||
* Both are idempotent and null-guarded on the attach family (the
|
||||
* house pattern — a New chat mid-upload hides the strip; the class
|
||||
* on a hidden element is harmless and still cleared at resolution);
|
||||
* the send refs are unconditional (the composer is required for the
|
||||
* chat view to exist at all). */
|
||||
function enterUploadState() {
|
||||
if (attachPreview) attachPreview.classList.add("is-uploading");
|
||||
if (attachStatus) attachStatus.hidden = false;
|
||||
if (attachRemove) attachRemove.disabled = true;
|
||||
sendBtn.disabled = true;
|
||||
sendLabel.textContent = "Uploading…";
|
||||
}
|
||||
|
||||
function exitUploadState() {
|
||||
if (attachPreview) attachPreview.classList.remove("is-uploading");
|
||||
if (attachStatus) attachStatus.hidden = true;
|
||||
if (attachRemove) attachRemove.disabled = false;
|
||||
sendBtn.disabled = false;
|
||||
sendLabel.textContent = "Send";
|
||||
}
|
||||
|
||||
async function handleSend(e) {
|
||||
e.preventDefault();
|
||||
// Phase 48: while a turn is in flight the Send button IS the Stop
|
||||
@@ -2787,8 +2834,20 @@ async function handleSend(e) {
|
||||
if (attachedImage) {
|
||||
if (attachUpload) return; // a second submit mid-upload: the first owns the send (never two uploads, never two turns)
|
||||
attachUpload = true;
|
||||
// Phase 132 (task 01, TODO L8): the uploading state is entered
|
||||
// exactly ONCE per upload — after the guard, before the await —
|
||||
// so the gap between the press and the resolution is never
|
||||
// frozen-looking (the strip's spinner + "Uploading image…", the
|
||||
// disabled "Uploading…" Send).
|
||||
enterUploadState();
|
||||
const path = await uploadAttachedImage(attachedImage.file);
|
||||
attachUpload = false; // the helper never throws (every failure path is a banner + null)
|
||||
// Phase 132: exited on BOTH resolution paths — the helper never
|
||||
// throws, so this line is reached on success AND failure. Before
|
||||
// the success path continues to runTurn (whose setUiState takes
|
||||
// the button over) and before the failure path returns below
|
||||
// (banner + editable composer + the attachment for the retry).
|
||||
exitUploadState();
|
||||
if (path === null) return; // A8: the send is blocked — the question stays
|
||||
image = {
|
||||
path, // the record + the /api/chat body (A5: the path, never base64)
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
:root {
|
||||
/* Palette — all text/background pairs meet WCAG 2.1 AA (>= 4.5:1) */
|
||||
--bg: #0f0a0a; /* page: ink on bg 16.7:1 */
|
||||
--surface: #1a0f0f; /* ink on surface 13.8:1 */
|
||||
--surface: #1a0f0f; /* ink on surface 15.3:1 */
|
||||
--ink: #f0e6e6;
|
||||
--ink-soft: #b8a8a8; /* 5.1:1 on --surface */
|
||||
--ink-soft: #b8a8a8; /* 8.2:1 on --surface */
|
||||
--line: #2d1a1a; /* decorative 1px borders */
|
||||
--grid-line: #4a2626; /* background grid texture — decorative, no
|
||||
contrast duty (like --line) */
|
||||
@@ -1745,6 +1745,56 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
}
|
||||
.attach-preview-remove svg { width: 18px; height: 18px; }
|
||||
.attach-preview-remove:hover { background: var(--err-bg); color: var(--err-ink); }
|
||||
/* Phase 132 (task 02, TODO L8): the strip's UPLOADING look — the
|
||||
visual half of the owner-confirmed double-spam block (task 01's JS
|
||||
state proves the image is moving while POST /api/chat-images is in
|
||||
flight; before this the strip looked frozen and the user spammed
|
||||
Send). The thumbnail DIMS to 0.5 (it is decorative — no contrast
|
||||
duty — and 0.5 is the house dim value the disabled remove button
|
||||
below); the filename HIDES with visibility (the flex: 1 slot keeps
|
||||
its measured width — the status takes the row's right hand WITHOUT
|
||||
reflowing the pinned 44px remove button); the house loading arc
|
||||
(the status part's svg — task 01's markup, currentColor = the
|
||||
status's --ink-soft) SPINS at the sync spinner's pace (the house
|
||||
`spin` keyframes at 1s linear — .sync-icon.is-spinning's in-flight
|
||||
pace; the .spinner's 0.8s is the busy-turn arc's). The status text
|
||||
is --ink-soft on the strip's --surface — the :root-pinned pair,
|
||||
8.2:1 computed against WCAG 2.1 (AA ≥ 4.5:1), at the strip's
|
||||
small text size (the name's 0.85rem). The remove button (task 01
|
||||
disables it mid-flight) dims with its disabled attribute — the
|
||||
attribute is the state, the 0.5 is its visual half; the Send rides
|
||||
the same house :disabled language (.send-btn:disabled's
|
||||
--brand-busy + not-allowed below — which reads as disabled already,
|
||||
so NO new color or weight was added, B4). prefers-reduced-motion stills the arc (the house block): the
|
||||
"Uploading image…" TEXT + the dimmed thumbnail carry the state
|
||||
without motion (B5 — never motion alone). Every value is a theme
|
||||
variable or a neutral number — the block ships no raw color. */
|
||||
.attach-preview.is-uploading .attach-preview-img {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.attach-preview.is-uploading .attach-preview-name {
|
||||
visibility: hidden; /* keep the flex: 1 slot; the status takes its right hand */
|
||||
}
|
||||
.attach-preview-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.85rem; /* the strip's small text — the name's size */
|
||||
min-width: 0; /* the row's clamp: at 320 px the text WRAPS (no clip) */
|
||||
}
|
||||
.attach-preview-status-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
animation: spin 1s linear infinite; /* the house spin keyframes (the sync pace) */
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.attach-preview-status-icon { animation: none; }
|
||||
}
|
||||
.attach-preview.is-uploading .attach-preview-remove {
|
||||
opacity: 0.5; /* the disabled attribute's visual half (task 01 sets it) */
|
||||
}
|
||||
.composer {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
|
||||
@@ -280,6 +280,22 @@
|
||||
<div class="attach-preview" id="attach-preview" hidden>
|
||||
<img class="attach-preview-img" alt="">
|
||||
<span class="attach-preview-name"></span>
|
||||
<!-- Phase 132 (task 01, TODO L8): the strip's UPLOADING
|
||||
status part — the house loading arc (the svg is
|
||||
decorative, aria-hidden) + the pinned "Uploading image…"
|
||||
text (it carries the meaning). SHIPS HIDDEN (the house
|
||||
rule) — app.js's enterUploadState / exitUploadState
|
||||
(handleSend's attachedImage branch) are the SOLE writers
|
||||
of its `hidden`: shown exactly while the POST
|
||||
/api/chat-images upload is in flight, hidden by
|
||||
construction at BOTH resolution points (success → the
|
||||
turn, failure → the banner + the retry). The thumbnail
|
||||
+ filename stay visible throughout — the user watches
|
||||
THEIR image uploading. -->
|
||||
<span class="attach-preview-status" hidden>
|
||||
<svg class="attach-preview-status-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-6.2-8.56"/></svg>
|
||||
<span class="attach-preview-status-text">Uploading image…</span>
|
||||
</span>
|
||||
<button type="button" class="attach-preview-remove" id="attach-remove" aria-label="Remove the attached image">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user