eleven / messenger Public

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>
Paul Campbell 23f2351ef2156037e2ed3c865f476fd8ca08e0f8 parent 8f2fd1b
13 files changed, +169 −40
  • changelog/2026-07-11-composer-field-label.md +1 −0
  • ios/LChatCore/Sources/LChatCore/ElevenChatView.swift +57 −20
  • ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift +11 −0
  • ios/LChatCore/Sources/LChatCore/FlowEngine.swift +4 −0
  • ios/LChatCore/Sources/LChatCore/Flows/setup.json +6 −3
  • ios/LChatCore/Sources/LChatCore/Flows/welcome.json +2 −1
  • test/flow.test.mjs +7 −2
  • web/static/apps/chat.js +35 −4
  • web/static/apps/eleven.js +7 −6
  • web/static/apps/flow.js +2 −0
  • web/static/apps/setup.flow.js +5 −3
  • web/static/apps/welcome.flow.js +2 −1
  • web/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;