web+ios: the inset composer label — Eleven's questions label the message box (#715)
A scripted question can now carry a label ('Your name:') that renders as a
small accent pill INSIDE the composer — the Mail/Messages 'To:' pattern — with
the placeholder demoted to an example ('e.g. Finn Wolfhard'). The ask stays
visible after typing starts, unlike a placeholder.
Web: composerLabels map beside the hint maps (same lifecycle: set by the
question's notice, cleared on send); the pill is absolutely positioned inside
the input's box, text indented past it by measure; solid accent + accent-ink
(the one pairing every theme guarantees AA contrast for); reduced-motion
respected; the textarea's aria-label carries the words. Native: FlowSayExtra
gains label, the model publishes composerLabel with the hint's reset rules,
and ElevenChatView renders the twin pill inside the field's border (view split
into sub-expressions — the type-checker choked on the single expression).
Flows: name/space-name/email steps gain labels + example hints. Twins
regenerated; flow tests updated (18/18); verified visually light+dark via the
local Playwright rig.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
13 files changed,
+169
−40
changelog/2026-07-11-composer-field-label.md+1 −0ios/LChatCore/Sources/LChatCore/ElevenChatView.swift+57 −20ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift+11 −0ios/LChatCore/Sources/LChatCore/FlowEngine.swift+4 −0ios/LChatCore/Sources/LChatCore/Flows/setup.json+6 −3ios/LChatCore/Sources/LChatCore/Flows/welcome.json+2 −1test/flow.test.mjs+7 −2web/static/apps/chat.js+35 −4web/static/apps/eleven.js+7 −6web/static/apps/flow.js+2 −0web/static/apps/setup.flow.js+5 −3web/static/apps/welcome.flow.js+2 −1web/static/style.css+30 −0
diff --git a/changelog/2026-07-11-composer-field-label.md b/changelog/2026-07-11-composer-field-label.md| new file mode 100644 |
| index 0000000..7109573 |
| --- /dev/null |
| +++ b/changelog/2026-07-11-composer-field-label.md |
| @@ -0,0 +1 @@ |
| +🤖 When Eleven asks you for something, the message box now shows what it's for. A small label sits right inside the box — "Your name:", "Space name:" — with an example alongside, so you always know what to type even after you've started. |
diff --git a/ios/LChatCore/Sources/LChatCore/ElevenChatView.swift b/ios/LChatCore/Sources/LChatCore/ElevenChatView.swift| index 061cd21..64deb39 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ElevenChatView.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ElevenChatView.swift |
| @@ -98,28 +98,12 @@ public struct ElevenChatView: View { |
| // The Mac feed composer's pill recipe (NativeFeedView.composerRow), shared |
| // here by both platforms: a plain field in a bordered pill on the panel |
| - // background, with the same send disc. |
| + // background, with the same send disc. A scripted question's inset field |
| + // label ("Your name:") sits INSIDE the pill before the text — the twin of |
| + // the web composer's .composer-label. |
| private var composer: some View { |
| HStack(alignment: .bottom, spacing: 8) { |
| - TextField(model.composerHint ?? "Message Eleven", text: $input, axis: .vertical) |
| - .textFieldStyle(.plain) |
| - .font(.system(size: fontSize)) |
| - .lineLimit(1...9) |
| - .focused($focused) |
| - .padding(.horizontal, 14) |
| - .padding(.vertical, 9) |
| - .background(RoundedRectangle(cornerRadius: 19, style: .continuous) |
| - .fill(.background)) |
| - .overlay(RoundedRectangle(cornerRadius: 19, style: .continuous) |
| - .strokeBorder(.quaternary, lineWidth: 1)) |
| - .onSubmit(send) |
| - // A prompt change under an ACTIVE field editor wedges the |
| - // AppKit-backed field (caret blinks, keys ignored). Recreate |
| - // the field on each hint and hand focus straight back. |
| - .id("composer-" + (model.composerHint ?? "")) |
| - .onChange(of: model.composerHint) { |
| - DispatchQueue.main.async { focused = true } |
| - } |
| + fieldPill |
| Button(action: send) { |
| Image(systemName: "arrow.up.circle.fill") |
| .font(.system(size: 28)) |
| @@ -137,6 +121,59 @@ public struct ElevenChatView: View { |
| .padding(.vertical, 8) |
| } |
| + // The bordered pill: optional inset label + the field (split out — one |
| + // expression here sends the type-checker into the weeds). |
| + private var fieldPill: some View { |
| + HStack(alignment: .firstTextBaseline, spacing: 8) { |
| + if let label = model.composerLabel { |
| + insetLabel(label) |
| + } |
| + composerField |
| + } |
| + .padding(.horizontal, 14) |
| + .padding(.vertical, 9) |
| + .background(RoundedRectangle(cornerRadius: 19, style: .continuous) |
| + .fill(.background)) |
| + .overlay(RoundedRectangle(cornerRadius: 19, style: .continuous) |
| + .strokeBorder(.quaternary, lineWidth: 1)) |
| + } |
| + |
| + private func insetLabel(_ label: String) -> some View { |
| + Text(label) |
| + .font(.system(size: 12, weight: .semibold)) |
| + .lineLimit(1) |
| + .padding(.horizontal, 9) |
| + .padding(.vertical, 2) |
| + .background(Capsule().fill(theme.accent.resolved(scheme))) |
| + .foregroundStyle(.white) |
| + .accessibilityHidden(true) // the field's label carries the words |
| + } |
| + |
| + private var fieldAccessibilityLabel: String { |
| + guard let label = model.composerLabel else { return "Message" } |
| + return label.trimmingCharacters(in: CharacterSet(charactersIn: ":: ")) |
| + } |
| + |
| + private var composerField: some View { |
| + TextField(model.composerHint ?? "Message Eleven", text: $input, axis: .vertical) |
| + .textFieldStyle(.plain) |
| + .font(.system(size: fontSize)) |
| + .lineLimit(1...9) |
| + .focused($focused) |
| + .onSubmit(send) |
| + // A prompt change under an ACTIVE field editor wedges the |
| + // AppKit-backed field (caret blinks, keys ignored). Recreate |
| + // the field on each hint/label and hand focus straight back. |
| + .id("composer-" + (model.composerHint ?? "") + "|" + (model.composerLabel ?? "")) |
| + .onChange(of: model.composerHint) { |
| + DispatchQueue.main.async { focused = true } |
| + } |
| + .onChange(of: model.composerLabel) { |
| + DispatchQueue.main.async { focused = true } |
| + } |
| + .accessibilityLabel(fieldAccessibilityLabel) |
| + } |
| + |
| private func send() { |
| let text = input |
| input = "" |
diff --git a/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift b/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift| index 334b7b8..e29a2c1 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift |
| @@ -34,6 +34,9 @@ public final class ElevenConversationModel: ObservableObject { |
| @Published public private(set) var rows: [Row] = [] |
| @Published public private(set) var typing = false |
| @Published public var composerHint: String? |
| + /// The composer's inset field label ("Your name:") — rides say extras like |
| + /// the hint and follows the same reset rules. |
| + @Published public var composerLabel: String? |
| @Published public private(set) var ready = false |
| public let instance: SavedInstance |
| @@ -204,6 +207,7 @@ public final class ElevenConversationModel: ObservableObject { |
| if let handler = answerHandler { |
| answerHandler = nil |
| composerHint = nil |
| + composerLabel = nil |
| handler(FlowAnswer(text: trimmed)) |
| } |
| } |
| @@ -275,6 +279,12 @@ extension ElevenConversationModel: FlowIO { |
| } else if !extra.local { |
| composerHint = nil |
| } |
| + // The inset field label follows the same lifecycle as the hint. |
| + if extra.label != nil { |
| + composerLabel = extra.label |
| + } else if !extra.local { |
| + composerLabel = nil |
| + } |
| } |
| private func attachChips(_ chips: [FlowChip]) { |
| @@ -291,6 +301,7 @@ extension ElevenConversationModel: FlowIO { |
| public func cancelAnswer() { |
| answerHandler = nil |
| composerHint = nil |
| + composerLabel = nil |
| } |
| public func record(step: String, label: String, value: String) async { |
diff --git a/ios/LChatCore/Sources/LChatCore/FlowEngine.swift b/ios/LChatCore/Sources/LChatCore/FlowEngine.swift| index ae40ca2..ccc496a 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/FlowEngine.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/FlowEngine.swift |
| @@ -201,6 +201,8 @@ public struct FlowSayExtra: Sendable { |
| public var key: String? |
| public var hint: String? |
| public var attachHint: String? |
| + /// The composer's inset field label ("Your name:") — the "To:" pattern. |
| + public var label: String? |
| public var local = false |
| public var imgToken: String? |
| public var imgAlt = "" |
| @@ -581,6 +583,7 @@ public final class FlowEngine { |
| if let key = b["key"].stringValue { extra.key = template(key) } |
| extra.hint = b["hint"].stringValue |
| extra.attachHint = b["attachHint"].stringValue |
| + extra.label = b["label"].stringValue |
| extra.local = b["local"].truthy |
| if let img = b["img"].stringValue { |
| extra.imgToken = img |
| @@ -625,6 +628,7 @@ public final class FlowEngine { |
| if let src = step["bubbles"].arrayValue.last { |
| extra.hint = src["hint"].stringValue |
| extra.attachHint = src["attachHint"].stringValue |
| + extra.label = src["label"].stringValue |
| } |
| await io.say(text, chips: buildChips(stepId: stepId, go: go), extra: extra) |
| armInput(stepId: stepId) |
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/setup.json b/ios/LChatCore/Sources/LChatCore/Flows/setup.json| index 8b6944b..feca77d 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Flows/setup.json |
| +++ b/ios/LChatCore/Sources/LChatCore/Flows/setup.json |
| @@ -41,7 +41,8 @@ |
| "bubbles": [ |
| { |
| "text": "First — what should everyone here call you? Reply with your name.", |
| - "hint": "Type your name" |
| + "label": "Your name:", |
| + "hint": "e.g. Finn Wolfhard" |
| } |
| ], |
| "input": { |
| @@ -83,7 +84,8 @@ |
| "then": "It's currently “{space.name}” — keep that, or reply with a new name.", |
| "else": "What should we call it? Reply with a name." |
| }, |
| - "hint": "Type a name for this space" |
| + "label": "Space name:", |
| + "hint": "e.g. The Book Club" |
| } |
| ], |
| "chips": [ |
| @@ -136,7 +138,8 @@ |
| }, |
| { |
| "text": "How can people here reach you outside the chat? Reply with an email address, or skip it.", |
| - "hint": "Type a contact email" |
| + "label": "Contact email:", |
| + "hint": "e.g. finn@example.com" |
| } |
| ], |
| "chips": [ |
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/welcome.json b/ios/LChatCore/Sources/LChatCore/Flows/welcome.json| index 1cc8562..54f4f4e 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Flows/welcome.json |
| +++ b/ios/LChatCore/Sources/LChatCore/Flows/welcome.json |
| @@ -164,7 +164,8 @@ |
| { |
| "text": "{ack?}First things first — what should folks here call you? Reply with your name.", |
| "key": "q-name", |
| - "hint": "Type your name" |
| + "label": "Your name:", |
| + "hint": "e.g. Finn Wolfhard" |
| } |
| ], |
| "chips": [ |
diff --git a/test/flow.test.mjs b/test/flow.test.mjs| index d1cec94..cd26c4b 100644 |
| --- a/test/flow.test.mjs |
| +++ b/test/flow.test.mjs |
| @@ -146,7 +146,8 @@ await test("setup: the full first-run transcript, happy path with detours", asyn |
| // Greeting + name question (fresh run: not resumed, no "Welcome back"). |
| assert.match(h.transcript[0].text, /^Hi — I'm Eleven! 👋/); |
| - assert.equal(h.transcript[1].extra.hint, "Type your name"); |
| + assert.equal(h.transcript[1].extra.label, "Your name:"); |
| + assert.equal(h.transcript[1].extra.hint, "e.g. Finn Wolfhard"); |
| assert.equal(h.transcript.some((t) => /Welcome back/.test(t.text)), false); |
| // Name: 40 chars caps to 32 (the profile-rename cap). |
| @@ -473,7 +474,11 @@ await test("welcome: native embed — passkey step skipped, access secured by th |
| // and the conversation goes straight from the greeting to the name. |
| assert.equal(h.transcript.some((t) => /Sign in with Eleven and your access is safe/.test(t.text)), false); |
| assert.deepEqual(h.records[0], { step: "access", label: "Your access is already secured by your Eleven sign-in", value: "native" }); |
| - assert.ok(h.transcript.find((t) => t.extra.key === "q-name")); |
| + const nameQ = h.transcript.find((t) => t.extra.key === "q-name"); |
| + assert.ok(nameQ); |
| + // The question carries the composer's inset field label + an example hint. |
| + assert.equal(nameQ.extra.label, "Your name:"); |
| + assert.equal(nameQ.extra.hint, "e.g. Finn Wolfhard"); |
| // Run it to the end: finish persists its completion marker (what the native |
| // embed gates on — no localStorage marker there) and never nags about the |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index 940e029..7282042 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -108,13 +108,17 @@ export function setNoticeTyping(threadId, name, on) { |
| // it appears immediately. Anchored just above the current newest message so it |
| // sits where you typed it and never jumps around as the conversation continues. |
| // The composer placeholder follows the conversation: a scripted question can |
| -// carry a `hint` ("Type your name") that replaces "Message" the moment the |
| +// carry a `hint` ("e.g. Finn Wolfhard") that replaces "Message" the moment the |
| // question renders, or an `attachHint` ("Optional: describe your photo") that |
| // applies only while attachments are staged — a photo question shouldn't |
| -// relabel the box before there's a photo. Sending resets to "Message" |
| -// immediately; the next question sets its own. Keyed like refreshFeed. |
| +// relabel the box before there's a photo. A `label` ("Your name:") renders as |
| +// an inset field label INSIDE the input — the Mail/Messages "To:" pattern — |
| +// so the ask survives even when the placeholder is hidden by typed text. |
| +// Sending resets everything to "Message"; the next question sets its own. |
| +// Keyed like refreshFeed. |
| const composerHints = new Map(); |
| const composerAttachHints = new Map(); |
| +const composerLabels = new Map(); |
| const refreshComposerHint = new Map(); |
| function applyComposerHint(threadId) { |
| const fn = refreshComposerHint.get(threadId); |
| @@ -123,6 +127,7 @@ function applyComposerHint(threadId) { |
| function clearComposerHint(threadId) { |
| composerHints.set(threadId, ""); |
| composerAttachHints.set(threadId, ""); |
| + composerLabels.set(threadId, ""); |
| applyComposerHint(threadId); |
| } |
| @@ -179,6 +184,7 @@ export function feedGlideToBottom(feed, jump) { |
| function addLocalNotice(ctx, spec) { |
| composerHints.set(ctx.threadId, spec.hint || ""); |
| composerAttachHints.set(ctx.threadId, spec.attachHint || ""); |
| + composerLabels.set(ctx.threadId, spec.label || ""); |
| applyComposerHint(ctx.threadId); |
| // Hint-only (a persisted question with no chips): the placeholder was the |
| // whole point — don't add an empty row to the feed. |
| @@ -2539,6 +2545,13 @@ function buildSkeleton(el, ctx) { |
| fileInput.hidden = true; |
| const field = document.createElement("div"); |
| field.className = "composer-field"; |
| + // The inset field label ("Your name:") — sits inside the input's box, pinned |
| + // to the first line, with the text indented past it. Decorative to a screen |
| + // reader (the textarea's aria-label carries the same words). |
| + const fieldLabel = document.createElement("span"); |
| + fieldLabel.className = "composer-label"; |
| + fieldLabel.setAttribute("aria-hidden", "true"); |
| + fieldLabel.hidden = true; |
| const input = document.createElement("textarea"); |
| // When plain text runs the app's default command (typing into To-do adds a |
| // to-do — see defaultCommandFor), the placeholder must SAY so: the implicit |
| @@ -2548,10 +2561,25 @@ function buildSkeleton(el, ctx) { |
| const dc = defaultCmd(); |
| return (dc && dc.app.composerPlaceholder) || "Message"; |
| }; |
| + const applyFieldLabel = () => { |
| + const label = composerLabels.get(ctx.threadId) || ""; |
| + fieldLabel.textContent = label; |
| + fieldLabel.hidden = !label; |
| + input.setAttribute("aria-label", label ? label.replace(/[::]\s*$/, "") : "Message"); |
| + if (!label) { |
| + input.style.paddingLeft = ""; |
| + return; |
| + } |
| + // Indent the text past the pill — measured after layout so the width is real. |
| + requestAnimationFrame(() => { |
| + if (!fieldLabel.hidden) input.style.paddingLeft = `${fieldLabel.offsetWidth + 12 + 8}px`; |
| + }); |
| + }; |
| input.placeholder = composerHints.get(ctx.threadId) || basePlaceholder(); |
| refreshComposerHint.set(ctx.threadId, () => { |
| const attach = pending.length ? composerAttachHints.get(ctx.threadId) : ""; |
| input.placeholder = attach || composerHints.get(ctx.threadId) || basePlaceholder(); |
| + applyFieldLabel(); |
| }); |
| input.rows = 1; |
| input.autocomplete = "off"; |
| @@ -2562,7 +2590,7 @@ function buildSkeleton(el, ctx) { |
| send.hidden = true; |
| send.innerHTML = |
| '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"></line><polyline points="6 11 12 5 18 11"></polyline></svg>'; |
| - field.append(input, send); |
| + field.append(fieldLabel, input, send); |
| compose.append(attach, fileInput, field); |
| // A default-command thread takes text only — with no one to read a file, an |
| // attachment is a dead end, so the affordance goes (stageFiles gates paste and |
| @@ -2614,6 +2642,9 @@ function buildSkeleton(el, ctx) { |
| let pending = []; // staged attachments: { att | null (uploading), name, isImage } |
| let sendQueued = false; // Enter landed mid-upload — auto-send once the uploads finish |
| let staging = 0; // stageFiles batches in flight — a queued send waits for the whole batch |
| + // A remount mid-conversation must restore the inset label; it needs layout |
| + // (the width measure) and `pending` above, so it runs here, post-append. |
| + applyComposerHint(ctx.threadId); |
| const refreshBanners = () => { |
| replyBanner.textContent = ""; |
diff --git a/web/static/apps/eleven.js b/web/static/apps/eleven.js| index 801bcbb..2a2cb18 100644 |
| --- a/web/static/apps/eleven.js |
| +++ b/web/static/apps/eleven.js |
| @@ -38,8 +38,9 @@ export function ask(env, go, onText) { |
| // the reply's length (with jitter, first message quickest). Each line is |
| // persisted via a `sayType` event keyed by its text; the app's reducer records |
| // the key in state.said and its chatMessage projects the text as a bubble. |
| -// extra: { hint, attachHint } ride the chips notice (composer placeholder); |
| -// { local: true } keeps a line client-only (e.g. an image preview). |
| +// extra: { hint, attachHint, label } ride the chips notice (composer |
| +// placeholder + the inset field label); { local: true } keeps a line |
| +// client-only (e.g. an image preview). |
| export function makeSay(appName, sayType) { |
| let queue = Promise.resolve(); |
| let first = true; |
| @@ -96,8 +97,8 @@ export function makeSay(appName, sayType) { |
| return; |
| } |
| } |
| - if ((actions && actions.length) || opts.hint || opts.attachHint) { |
| - env.respond({ actions, hint: opts.hint, attachHint: opts.attachHint }); |
| + if ((actions && actions.length) || opts.hint || opts.attachHint || opts.label) { |
| + env.respond({ actions, hint: opts.hint, attachHint: opts.attachHint, label: opts.label }); |
| } |
| }); |
| return queue; |
| @@ -149,8 +150,8 @@ export function makeLiveSay(live) { |
| return; |
| } |
| } |
| - if ((actions && actions.length) || opts.hint || opts.attachHint) { |
| - env.respond({ actions, hint: opts.hint, attachHint: opts.attachHint }); |
| + if ((actions && actions.length) || opts.hint || opts.attachHint || opts.label) { |
| + env.respond({ actions, hint: opts.hint, attachHint: opts.attachHint, label: opts.label }); |
| } |
| }); |
| return queue; |
diff --git a/web/static/apps/flow.js b/web/static/apps/flow.js| index 1be33f8..71387ad 100644 |
| --- a/web/static/apps/flow.js |
| +++ b/web/static/apps/flow.js |
| @@ -311,6 +311,7 @@ export function runFlow(flow, env, io) { |
| if (b.key) extra.key = template(env, b.key); |
| if (b.hint) extra.hint = b.hint; |
| if (b.attachHint) extra.attachHint = b.attachHint; |
| + if (b.label) extra.label = b.label; // the composer's inset field label |
| if (b.local) extra.local = true; |
| if (b.img) { |
| // imgSource may be async (welcome's profile photo decrypts to an |
| @@ -362,6 +363,7 @@ export function runFlow(flow, env, io) { |
| const src = stepCtx.step.bubbles && stepCtx.step.bubbles[stepCtx.step.bubbles.length - 1]; |
| if (src && src.hint) extra.hint = src.hint; |
| if (src && src.attachHint) extra.attachHint = src.attachHint; |
| + if (src && src.label) extra.label = src.label; |
| io.say(env, text, orNull(buildChips(stepCtx, stepCtx.go)), extra); |
| armInput(stepCtx); |
| } |
diff --git a/web/static/apps/setup.flow.js b/web/static/apps/setup.flow.js| index cc74523..3b0aeeb 100644 |
| --- a/web/static/apps/setup.flow.js |
| +++ b/web/static/apps/setup.flow.js |
| @@ -32,7 +32,7 @@ export default { |
| id: "name", |
| skipIf: ["done", "bridge.myName"], |
| bubbles: [ |
| - { text: "First — what should everyone here call you? Reply with your name.", hint: "Type your name" }, |
| + { text: "First — what should everyone here call you? Reply with your name.", label: "Your name:", hint: "e.g. Finn Wolfhard" }, |
| ], |
| input: { kind: "text", required: true, maxLen: 32, clean: "name" }, |
| effect: { verb: "setMyName", args: ["$answer"] }, |
| @@ -58,7 +58,8 @@ export default { |
| then: "It's currently “{space.name}” — keep that, or reply with a new name.", |
| else: "What should we call it? Reply with a name.", |
| }, |
| - hint: "Type a name for this space", |
| + label: "Space name:", |
| + hint: "e.g. The Book Club", |
| }, |
| ], |
| chips: [{ if: "space.nameSet", label: "Keep “{space.name}”", spent: "Kept", goto: "email" }], |
| @@ -86,7 +87,8 @@ export default { |
| }, |
| { |
| text: "How can people here reach you outside the chat? Reply with an email address, or skip it.", |
| - hint: "Type a contact email", |
| + label: "Contact email:", |
| + hint: "e.g. finn@example.com", |
| }, |
| ], |
| // The skip RECORDS the step: an unrecorded skip replays on every resume — |
diff --git a/web/static/apps/welcome.flow.js b/web/static/apps/welcome.flow.js| index 558539c..57899e0 100644 |
| --- a/web/static/apps/welcome.flow.js |
| +++ b/web/static/apps/welcome.flow.js |
| @@ -124,7 +124,8 @@ export default { |
| { |
| text: "{ack?}First things first — what should folks here call you? Reply with your name.", |
| key: "q-name", |
| - hint: "Type your name", |
| + label: "Your name:", |
| + hint: "e.g. Finn Wolfhard", |
| }, |
| ], |
| chips: [ |
diff --git a/web/static/style.css b/web/static/style.css| index b903e2b..9681a1e 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -1516,6 +1516,36 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| flex: 1; |
| display: flex; |
| } |
| +/* The inset field label ("Your name:") — the Mail/Messages "To:" pattern: a |
| + quiet accent pill pinned INSIDE the input on its first line; chat.js indents |
| + the textarea's text past it. Absolute (over the textarea, which owns the box |
| + visuals) and inert to the pointer; top 9px centres it in the 40px rest |
| + height and keeps it on line one as the box grows. */ |
| +.composer-label { |
| + position: absolute; |
| + left: 12px; |
| + top: 9px; |
| + padding: 1px 9px; |
| + border-radius: 999px; |
| + /* The theme's designed on-accent pairing — the one combination every theme |
| + guarantees AA contrast for (a tinted-text pill fails on amber). */ |
| + background: var(--accent); |
| + color: var(--accent-ink); |
| + font-size: 12.5px; |
| + font-weight: 600; |
| + line-height: 20px; |
| + white-space: nowrap; |
| + pointer-events: none; |
| + opacity: 1; |
| + transition: opacity 120ms ease; |
| +} |
| +.composer-label[hidden] { |
| + display: block; /* keep it measurable/animatable; hidden = faded out */ |
| + opacity: 0; |
| +} |
| +@media (prefers-reduced-motion: reduce) { |
| + .composer-label { transition: none; } |
| +} |
| .composer textarea { |
| flex: 1; |
| resize: none; |