phase: 123_chat_image_questions
Build and Push Containers / build-and-push-app (push) Successful in 1m54s
Build and Push Containers / build-and-push-db (push) Failing after 13s

All gates green. Verification complete.

**Phase 123 — final verification pass (all 4 tasks already in `complete/`)**

- Verified the full implementation is in the working tree: `app/api/chat_images.py` (upload/serve pair), `ChatRequest.image`/`ChatMessage.image` (path-validated, omitted-when-None), toggle-off + stale-file hinted error frames, `build_user_content` multimodal build at both sites (chat.py deflected branch + `run_agent`), config-gated composer attach/preview/upload-then-send, restore + shared rendering, CSP `img-src 'self' data:` carve-out, mock-LLM capture buffer.
- `uv run pytest` → **2796 passed**, exit 0 (unit + integration).
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (29/4615 missed; phase-123 modules 99–100%).
- `uv run pytest tests/e2e/test_chat_image_questions.py -v --no-cov` → **5 passed** in isolation.
- `uv run ruff check . && uv run pyright` → clean (0 errors).

**Completion criteria:** (1) attach→send→multimodal text+image to the model, bubble/reload/shared all render it, saved chat stores the PATH with `"base64" not in json.dumps(stored)` — **verified** (E2E tests 1–4 + integration round-trip); (2) `BOR_IMAGES=false` — control hidden, exact hinted error frame, zero model calls / no query_log row — **verified** (E2E test 5 + integration); (3) text-only byte-identical (`content` stays a plain `str`) — **verified** (unit + integration); (4) all gates green — **verified**; (5) commit + phase move — left to the harness per pipeline rules (no `git add`/`commit` run).

No defects found; no live-infrastructure changes (repo + local dev DB only). **Next pending phase: none** — 123 is the last phase in `todo/`.
This commit is contained in:
2026-09-25 05:19:18 -04:00
parent a19d78d284
commit bef24e05e2
48 changed files with 3910 additions and 71 deletions
+262 -10
View File
@@ -306,6 +306,14 @@ const sendBtn = document.querySelector("#send-btn");
const sendLabel = document.querySelector("#send-label");
const sendStatus = document.querySelector("#send-status");
const turnLoader = document.querySelector("#turn-loader"); // phase 109 (D16): the persistent in-turn loader — ships hidden; setUiState is its sole visibility owner
// Phase 123 (task 02, TODO L6): the attach control family — the
// paperclip button (hidden until the boot /api/config says
// `images: true`), its hidden file-input backend, the preview strip
// (hidden until a pick), and the strip's remove button.
const attachBtn = document.querySelector("#attach-btn");
const attachFile = document.querySelector("#attach-file");
const attachPreview = document.querySelector("#attach-preview");
const attachRemove = document.querySelector("#attach-remove");
const banner = document.querySelector("#kb-banner");
const bannerText = document.querySelector("#kb-banner-text");
const versionEl = document.querySelector("#app-version");
@@ -927,8 +935,17 @@ const USER_AVATAR =
* scrolls only when the caller passes `scroll = true` — the user submit
* (reveal my message) and the phase-14 restore landing. The streaming
* path (thinking / tool / delta) creates bubbles with the default
* (scroll = false): the page never follows a turn. */
function addMessage(who, html, scroll = false) {
* (scroll = false): the page never follows a turn.
*
* Phase 123 (task 02, TODO L6): the optional `image` argument —
* { src, alt } for a USER bubble carrying the question's attached
* image (attachedImage → the live data URL; task 03's restore → the
* stored path). The attachment is part of the question, so the img
* lands at the TOP of the bubble (above the text) through
* attachBubbleImage — ONE renderer for live + restore + shared. A
* null image (every text-only message, every brain message) leaves
* the bubble byte-identical to pre-phase. */
function addMessage(who, html, scroll = false, image = null) {
if (emptyState) emptyState.hidden = true;
const wrap = document.createElement("div");
wrap.className = `msg ${who}`;
@@ -937,11 +954,45 @@ function addMessage(who, html, scroll = false) {
<div class="msg-body">
<div class="bubble">${html}</div>
</div>`;
if (who === "user" && image) {
attachBubbleImage(wrap.querySelector(".bubble"), image.src, image.alt);
}
messagesEl.appendChild(wrap);
if (scroll) scrollReveal(wrap);
return wrap;
}
/* Phase 123 (task 02, TODO L6): the question's image in a user bubble
* — the ONE renderer (task 03 reuses it for the restore and the shared
* page): the img is built createElement-style (no HTML strings, the
* house rule), capped height + full-width safe (a tall portrait must
* not blow the chat column — .msg-image in styles.css), lazy-loaded
* (the restore's stored paths re-fetch on demand), alt = the
* accessible name (the filename live, task 03's restore choice). The
* image PREPENDS the text: the attachment is part of the question. */
function attachBubbleImage(bubble, src, alt) {
const img = document.createElement("img");
img.className = "msg-image";
img.src = src;
img.alt = alt || "attached image";
img.loading = "lazy";
// Phase 123 (task 03, TODO L6): the load failure — the STORED file
// was deleted out-of-band (the record keeps its path, the render
// degrades): the img is replaced IN PLACE by the small "image
// unavailable" line (never a broken-image icon). In practice only
// the restore's stored path can 404 (a live data URL is inline); the
// shared page carries its own copy of the same degradation (the
// per-page duplication house style).
img.onerror = () => {
const note = document.createElement("span");
note.className = "msg-image-unavailable";
note.textContent = "image unavailable";
img.replaceWith(note);
};
bubble.prepend(img);
return img;
}
function addTyping() {
removeTyping(); // idempotent: at most one indicator at a time
if (emptyState) emptyState.hidden = true;
@@ -1268,6 +1319,26 @@ let leavePartialIndex = -1; // index of this turn's pagehide partial (-1 = none)
let turnAbort = null; // AbortController of the in-flight turn (null idle)
let stoppedByUser = false; // the Stop button took this turn (not the guard)
/* Phase 123 (task 02, TODO L6; locked A5): the composer's ATTACHED
* image — the { file, name, dataUrl } triple, held until send. The
* bytes NEVER touch the chat payload: the send flow uploads the File
* to POST /api/chat-images and the record + the request body carry the
* returned STORED PATH (A5: never base64). The data URL feeds two
* local things only — the preview thumbnail and the LIVE user bubble
* (no fetch needed); the restore (task 03) re-renders from the stored
* path instead. null = no attachment — the text-only path, byte-
* identical to pre-phase (request body, record, bubble). */
let attachedImage = null;
let attachUpload = false; // phase 123: one upload at a time (double-fire guard — the upload is the first await in handleSend; a second submit mid-upload is a no-op, the first owns the send)
/* The six extensions the upload endpoint accepts (phase 122's image
* set, one list — the server re-validates on the upload; this pre-check
* only keeps a bad pick from opening a state change + a wasted
* round-trip, and it checks the file NAME's extension: the accept
* attribute is advisory, and a drag-pasted or renamed file can carry
* any extension the server will 422 anyway). */
const ATTACHABLE_IMAGE_EXTENSIONS = ["bmp", "gif", "jpeg", "jpg", "png", "webp"];
function stopThinkingClock() {
if (thinkingClock) {
clearInterval(thinkingClock);
@@ -1788,7 +1859,22 @@ function renderStoredMessage(m) {
// the default SCROLL (smooth; "auto" under prefers-reduced-motion)
// instead of the old forced "auto" — noted per the phase-42 task.
if (m.who === "user") {
addMessage("user", renderMarkdown(m.text), true);
const wrap = addMessage("user", renderMarkdown(m.text), true);
// Phase 123 (task 03, TODO L6): the restored record may carry the
// question's attached image — `m.image`, the STORED PATH (A5:
// never base64; a pre-phase / text-only record has no key at all,
// so it renders byte-identically — no img). It lands through the
// SAME one bubble-image renderer the live send uses
// (attachBubbleImage — the live bubble passed the data URL, the
// restore passes the stored path; the helper takes any src): the
// img at the top of the user bubble, above the text. A load
// failure degrades inside the helper (deleted out-of-band file →
// the small "image unavailable" line, never a broken icon). A
// re-ask (retryLastTurn) re-sends prev.text only (locked A7) —
// this bubble's restored attachment is untouched by the redo.
if (typeof m.image === "string" && m.image) {
attachBubbleImage(wrap.querySelector(".bubble"), m.image, m.text || "attached image");
}
return;
}
const wrap = addMessage("brain", renderMarkdown(m.text), true);
@@ -2355,6 +2441,7 @@ function startNewChat() {
input.value = "";
autoGrow();
updateCharCount(); // phase 104: the cleared composer hides the counter again
clearAttachedImage(); // phase 123: the attachment is composer draft state — it resets with the conversation
input.focus();
sendStatus.textContent = "New chat started — previous conversation cleared.";
}
@@ -2460,6 +2547,70 @@ function retryLastTurn(wrap) {
return runTurn(text, { reask: true });
}
/* Phase 123 (task 02, TODO L6): the send flow's UPLOAD STEP (locked
* A8) — the attached File goes to POST /api/chat-images as multipart
* (the session cookie rides the browser; the endpoint is user-gated
* like the turn it feeds) and the returned STORED path is what the
* record + the /api/chat body carry (A5: never base64). ANY failure —
* 413 over the cap, 422 a bad extension (a renamed file the client
* pre-check missed), 5xx, or a network drop — settles the
* phase-114-style OUT-OF-TURN banner with the server's detail and
* returns null: the send is BLOCKED (the question is never sent without
* the image the user attached — the typed text stays, the attachment
* stays for the retry). */
async function uploadAttachedImage(file) {
let res;
try {
const form = new FormData();
form.append("file", file);
res = await fetch("/api/chat-images", { method: "POST", body: form });
} catch {
// Network drop before the server answered — no detail to show.
showErrorBanner("Couldn't attach the image — try again.");
return null;
}
let detail = "";
let path = null;
try {
const body = await res.json();
if (typeof body?.detail === "string") detail = body.detail;
if (typeof body?.path === "string") path = body.path;
} catch { /* non-JSON error body — the status line stands in */ }
if (!res.ok || !path) {
showErrorBanner(
detail
? `Couldn't attach the image — ${detail}.`
: "Couldn't attach the image — try again."
);
return null;
}
return path;
}
/* Phase 123 (task 02, TODO L6): the preview strip — revealed with the
* attached image's data-URL thumbnail + filename (the thumbnail is
* decorative, alt="" in the static markup — the filename beside it is
* the readable label), and cleared with the attachment (the remove button,
* the send, or a New chat). The strip's markup is static (hidden by
* default); only the thumbnail's src + the name's textContent move
* here (the createElement/textContent house rule — no HTML strings).
* Idempotent: a fresh pick re-renders the same strip in place. */
function showAttachPreview() {
if (!attachedImage || !attachPreview) return;
attachPreview.querySelector("img").src = attachedImage.dataUrl;
attachPreview.querySelector(".attach-preview-name").textContent = attachedImage.name;
attachPreview.hidden = false;
}
/* Phase 123 (task 02, TODO L6): clear the attachment + hide the strip
* (idempotent — calling it with nothing attached is a no-op). The
* strip must not linger into a turn, and a New chat resets the
* composer's draft (the question text + its attachment) together. */
function clearAttachedImage() {
attachedImage = null;
if (attachPreview) attachPreview.hidden = true;
}
async function handleSend(e) {
e.preventDefault();
// Phase 48: while a turn is in flight the Send button IS the Stop
@@ -2479,6 +2630,26 @@ async function handleSend(e) {
showErrorBanner("Questions are limited to 4,000 characters — trim the question and try again.");
return;
}
// Phase 123 (task 02, TODO L6; locked A8): an attached image uploads
// FIRST — BEFORE the input is cleared, so a failed upload BLOCKS the
// send and the typed question stays exactly where the user left it
// (the question is never sent without the image the user attached;
// the banner says what failed, the attachment stays for the retry).
// The returned STORED path (never the bytes, A5) then rides runTurn
// into the record + the request body.
let image = null; // { path, src, alt } | null — null = text-only send
if (attachedImage) {
if (attachUpload) return; // a second submit mid-upload: the first owns the send (never two uploads, never two turns)
attachUpload = true;
const path = await uploadAttachedImage(attachedImage.file);
attachUpload = false; // the helper never throws (every failure path is a banner + null)
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)
src: attachedImage.dataUrl, // the live bubble (no fetch); restore uses the path
alt: attachedImage.name, // the filename (task 03's restore picks its own alt)
};
}
// 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 +
@@ -2487,7 +2658,7 @@ async function handleSend(e) {
autoGrow();
updateCharCount(); // phase 104: the sent question clears the counter with the input
clearErrorBanner();
await runTurn(text, { reask: false });
await runTurn(text, { reask: false, image });
}
/* Phase 120 (TODO.md L3–4, locked A1): the single funnel for every
@@ -2561,17 +2732,38 @@ function finalizeFailedTurn(detail, { acc, thinking, tools, wrap, leavePartialIn
* finally settle moved here verbatim, and the turn-local resets (acc,
* thinkingAcc, sawThinking, sawDone, toolAcc, stoppedByUser, turnAbort)
* stay turn-scoped exactly as phase 48 left them. */
async function runTurn(text, { reask = false } = {}) {
async function runTurn(text, { reask = false, image = null } = {}) {
if (!reask) {
addMessage("user", renderMarkdown(text), true); // reveal my message (owner-kept)
// Phase 123 (task 02, TODO L6): the attached image rides the user
// bubble (the data URL live — the stored path is the fallback, so
// any future caller passing only a path still renders) and the
// STORED RECORD (the path, A5: never base64). A null image (every
// text-only send, every re-ask — A7: a redo re-sends the text
// only) leaves the bubble and the record byte-identical to
// pre-phase.
addMessage(
"user",
renderMarkdown(text),
true, // reveal my message (owner-kept)
image ? { src: image.src || image.path, alt: image.alt } : null
);
// Persistence save point 1: the question is stored the moment it is
// sent, so a failed/interrupted turn never loses it.
conversation.push({ who: "user", text });
// sent, so a failed/interrupted turn never loses it. The `image`
// key (the stored path) joins the `bor.chat.v1` record only when an
// attachment exists (A5 — the phase-14 shape gains one optional key;
// a text-only record is byte-identical to pre-phase).
conversation.push(
image ? { who: "user", text, image: image.path } : { who: "user", text }
);
saveConversation();
// Phase 55 (A2): the auto-save rides the save point — an unlinked
// conversation creates its row here (auto-title, server-side), a
// linked one refreshes. Fire-and-forget: it never blocks the turn.
persistConversation();
// Phase 123 (task 02): the strip must not linger into the turn —
// cleared AFTER the bubble is rendered (the bubble already holds
// the image; the record holds the path; a failed turn keeps both).
clearAttachedImage();
}
let wrap = null;
@@ -2632,10 +2824,19 @@ async function runTurn(text, { reask = false } = {}) {
text: m.text,
thinking: m.who === "brain" ? m.thinking || undefined : undefined,
}));
// Phase 123 (task 02, TODO L6): the attached image's STORED path
// rides the body top-level (A5: the path, never base64; the server
// builds the multimodal content from the stored bytes). Added only
// when present — a text-only body omits the `image` key entirely
// (byte-identical to pre-phase). HISTORY entries stay {who, text,
// thinking}: prior turns' images are never replayed (locked A7 —
// the image is turn-local to the original send).
const payload = { message: text, history };
if (image) payload.image = image.path;
res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text, history }),
body: JSON.stringify(payload),
signal: turnAbort.signal, // phase 48: the Stop button aborts the fetch
});
if (!res.ok || !res.body) {
@@ -3008,6 +3209,48 @@ input.addEventListener("keydown", (e) => {
});
composer.addEventListener("submit", handleSend);
/* Phase 123 (task 02, TODO L6): the attach flow. The button (revealed
* at boot ONLY when the config flag says images on) opens the hidden
* file input; a pick is validated CLIENT-side against the six
* extensions (the server re-validates on upload — a bad pick gets the
* out-of-turn banner and NO state change: a previous attachment, if
* any, survives), then the { file, name, dataUrl } triple lives in
* attachedImage until the send flow uploads it. The remove button clears
* the state + hides the strip; a fresh pick replaces the triple in
* place (the strip re-renders through showAttachPreview). */
attachBtn?.addEventListener("click", () => attachFile?.click());
attachRemove?.addEventListener("click", () => {
clearAttachedImage();
attachBtn?.focus(); // back to the trigger (reachable only while the strip is visible — which means the button is too)
});
attachFile?.addEventListener("change", () => {
const file = attachFile.files && attachFile.files[0];
attachFile.value = ""; // the same file re-picked must fire change again
if (!file) return;
// The file NAME's extension (lowercased) is the client pre-check —
// the accept attribute is advisory (a renamed file can carry any
// extension); the upload endpoint is the authority (422).
const dot = file.name.lastIndexOf(".");
const ext = dot >= 0 ? file.name.slice(dot + 1).toLowerCase() : "";
if (!ATTACHABLE_IMAGE_EXTENSIONS.includes(ext)) {
showErrorBanner(
"Only PNG, JPEG, WebP, GIF, and BMP images can be attached."
);
return; // no state change — a previous attachment survives
}
const reader = new FileReader();
reader.onload = () => {
attachedImage = { file, name: file.name, dataUrl: String(reader.result) };
showAttachPreview();
};
reader.onerror = () => {
// The bytes never became readable (the file evicted mid-pick) —
// the same copy as an upload failure; no state change.
showErrorBanner("Couldn't attach the image — try again.");
};
reader.readAsDataURL(file);
});
/* Phase 55 (owner-locked A2, 2026-08-31): the phase-50 Save binding is
* GONE with the pill — there is no Save control; persistConversation()
* auto-saves headless at the save points (fire-and-forget, quiet on
@@ -3086,8 +3329,17 @@ window.addEventListener("pagehide", () => {
// proven), so a restored conversation of a configured admin gets the
// "Save as doc" button exactly once: no flash, no re-render, no
// second fetch (the brand fetch IS the config fetch).
await (window.BOR_CONFIG_PROMISE ?? Promise.resolve());
const bootConfig = await (window.BOR_CONFIG_PROMISE ?? Promise.resolve());
docsRepoConfigured = window.BOR_DOCS_REPO_CONFIGURED === true;
// Phase 123 (task 02, TODO L6): the attach control reveals ONLY when
// the SAME settled config says images: true (the brand boot's ONE
// /api/config request — no second round-trip). Off (the default) or
// an unanswered fetch → the button stays hidden for good: the
// flag-off DOM is byte-identical to pre-phase (A5's default-off
// contract), and a degraded boot degrades quietly (the loadHealth
// house style — the page never breaks, the affordance is simply
// absent; the API contract still enforces the toggle server-side).
if (attachBtn) attachBtn.hidden = bootConfig?.images !== true;
applyAuthState(); // chat page: the auth pair (idempotent with header.js)
// Phase 55 (task 03): no Share-reveal step — the pill is static,
// always-visible markup (visible to every visitor, phase 51 contract).
+44 -2
View File
@@ -318,9 +318,42 @@ function addStoppedNote(wrap) {
meta.appendChild(note);
}
/* The question's attached image (phase 123, task 03, TODO L6) — the
* local copy of the chat page's attachBubbleImage (the per-page
* duplication house style: this file keeps its own small copies of
* the chat page's message-fragment builders). The SAME .msg-image
* treatment the chat page uses — styles.css is shared by both pages,
* so the rule needs no second copy: the img at the TOP of the user
* bubble (the attachment is part of the question), lazy, alt = the
* record's text or the fallback. The load failure degrades IDENTICALLY
* to the chat page: the img is replaced by the small "image
* unavailable" line (the stored file was deleted out-of-band — the
* record keeps its path, the render degrades; never a broken icon).
* The serve route is public (the token is the shared chat's
* credential, like saved-chat content), so the img loads for guests
* exactly as it does for the owner. */
function addBubbleImage(wrap, src, alt) {
const bubble = wrap?.querySelector?.(".bubble");
if (!bubble) return;
const img = document.createElement("img");
img.className = "msg-image";
img.src = src;
img.alt = alt || "attached image";
img.loading = "lazy";
img.onerror = () => {
const note = document.createElement("span");
note.className = "msg-image-unavailable";
note.textContent = "image unavailable";
img.replaceWith(note);
};
bubble.prepend(img);
}
/* One stored record through the SAME .msg structure the chat page
* uses (pixel-parity with the chat page's restore path): user → the
* .msg.user bubble; brain → the .msg.brain bubble with the optional
* .msg.user bubble (with the question's attached image when the
* record carries the stored path — phase 123, task 03); brain → the
* .msg.brain bubble with the optional
* thinking block (restored COLLAPSED — phase 17), the tool lines,
* the deflection treatment + the plain-text "Maybe try" chips, the
* plain-text source chips, and the stopped note. NO interactive
@@ -331,7 +364,16 @@ function addStoppedNote(wrap) {
* unchanged. */
function renderSharedMessage(m) {
if (m.who === "user") {
addSharedMessage("user", renderMarkdown(m.text));
const wrap = addSharedMessage("user", renderMarkdown(m.text));
// Phase 123 (task 03, TODO L6): the question's attached image —
// the record's `m.image` carries the STORED PATH (A5: never
// base64; a pre-phase record has no key at all, so it renders
// byte-identically — no img). The public image route makes the
// shared view faithful: the SAME bubble treatment, alt, and
// load-failure degradation as the chat page's restore.
if (typeof m.image === "string" && m.image) {
addBubbleImage(wrap, m.image, m.text || "attached image");
}
return;
}
const wrap = addSharedMessage("brain", renderMarkdown(m.text));
+117
View File
@@ -551,6 +551,42 @@ body::before {
}
.msg.user .bubble code { background: color-mix(in srgb, var(--bg) 16%, transparent); }
/* Phase 123 (task 02, TODO L6): the question's IMAGE in the user
bubble — the attachment is PART of the question, so it sits at the
TOP of the bubble (above the text). Capped height (a tall portrait
must not blow the chat column) + full-width safe; the theme's
bordered-image treatment (the .source-image-img language) — the
border gives the bytes a boundary against the brand bubble fill. */
.msg-image {
display: block;
width: auto;
height: auto;
max-width: 100%;
max-height: 240px;
object-fit: contain;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
margin-bottom: 0.45rem;
}
/* Phase 123 (task 03, TODO L6): the question's image is UNAVAILABLE —
the stored file was deleted out-of-band (the record keeps its
path, the render degrades): the small muted line takes the img's
place at the top of the user bubble on BOTH pages (the chat
restore and the shared view render the same record shape through
the same .msg/.bubble structure, and share this stylesheet). It
inherits the bubble's text color — the user bubble's text already
passes 4.5:1 (PLAN §7.2), so the note does too; the smaller size +
italic make it read as a note, never a broken-image icon. The
margin-bottom mirrors .msg-image's, so the text below keeps its
spacing either way. */
.msg-image-unavailable {
display: block;
margin-bottom: 0.45rem;
font-size: 0.75rem;
font-style: italic;
}
.msg.brain .bubble { border-bottom-left-radius: 4px; }
.msg.brain.is-deflected .bubble {
background: var(--accent-bg);
@@ -1581,6 +1617,62 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
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); }
/* Phase 123 (task 02, TODO L6): the ATTACH PREVIEW STRIP — the
selected image ABOVE the input row (thumbnail ≤48px + the filename
+ the remove button): a surface card in the .chat-bottom stack, between
the char-count line and the composer. The name is ellipsized
(AA-safe --ink on --surface = 13.8:1) — a long filename never widens
the strip; the thumbnail is a fixed 48px cover box (the preview
crops, the bubble shows the whole image); the remove button keeps the
44px touch
target (PLAN §7.1) with a destructive hover (--err-ink on --err-bg =
9.3:1 — text + color, never color alone, B3). Hidden by default
(the global [hidden] rule) — revealed only while a file is attached,
cleared with the send so the strip never lingers into a turn. */
.attach-preview {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 0.45rem;
padding: 0.35rem 0.5rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
.attach-preview-img {
flex: none;
width: 48px;
height: 48px;
object-fit: cover;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--bg);
}
.attach-preview-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.85rem;
color: var(--ink);
}
.attach-preview-remove {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 44px;
min-height: 44px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-soft);
cursor: pointer;
padding: 0;
}
.attach-preview-remove svg { width: 18px; height: 18px; }
.attach-preview-remove:hover { background: var(--err-bg); color: var(--err-ink); }
.composer {
display: flex;
align-items: flex-end;
@@ -1605,6 +1697,31 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
background: transparent;
}
.composer textarea::placeholder { color: var(--ink-soft); }
/* Phase 123 (task 02, TODO L6): the composer's ATTACH CONTROL — the
paperclip glyph button LEFT of the input. Hidden in the markup until
app.js reveals it from the config's images flag (the global [hidden]
rule keeps it out of the flag-off DOM — A5's default-off contract).
A neutral cut of the .send-btn family: the same 44px hit target +
radius + the global :focus-visible ring (PLAN §7.2); surface fill
with a muted hover step (the icon: --ink-soft on --surface = 5.1:1,
hover --brand-ink on --brand-soft = 12.4:1 — both past the 3:1
non-text floor). */
.attach-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 44px;
min-height: 44px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--ink-soft);
cursor: pointer;
padding: 0;
}
.attach-btn svg { width: 20px; height: 20px; }
.attach-btn:hover { background: var(--brand-soft); border-color: var(--brand-soft); color: var(--brand-ink); }
.send-btn {
display: inline-flex;
align-items: center;