From dd104cfde442a6fff7143feffd1f9bbdc52586f9 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Sun, 30 Aug 2026 18:23:38 +0200 Subject: [PATCH] Give steering interactions a teal identity --- docs/ui-behavior.md | 8 ++--- src/codex/middle/ConversationCards.cpp | 49 ++++++++++++++++++++------ src/codex/ui/UiStyle.cpp | 8 +++-- tests/codex/ConversationCardsTest.cpp | 29 +++++++++++++++ ui-review/UX-DESIGN-DECISIONS.md | 7 ++++ web/src/app/App.tsx | 18 +++++----- web/src/styles.css | 6 +++- web/tests/card-copy.test.mjs | 12 +++++++ web/tests/responsive-layout.test.mjs | 5 +++ 9 files changed, 114 insertions(+), 28 deletions(-) diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index a5a1032..03aa300 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -134,10 +134,10 @@ explicit error state. A prompt that starts a turn is the outer soft-blue turn card. A prompt admitted through `turn.steer` appears immediately inside the active turn as an animated -blue You card; after acknowledgment the same widget becomes a softer blue -authoritative steering card. No optimistic card is exchanged for a second -widget, and the turn grows around it without changing existing nested card -identity. +teal `You · steering` card. After acknowledgment, the same widget becomes a +soft-teal inset steering card with the canonical teal border and title treatment. +No optimistic card is exchanged for a second widget, and the turn grows around +it without changing existing nested card identity. After acknowledgment, the authoritative outer You card uses a stronger static blue border while its turn remains active. It has no animation, glow, shading, diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index 426dc8b..c111a1b 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -992,6 +992,19 @@ class ConversationCard::Impl final { return true; } + void setNestedConversationCard(bool nested) { + owner->setProperty("nestedConversationCard", nested); + if (current.kind == CardKind::UserMessage || + current.kind == CardKind::LocalPrompt) + title->setText(nested ? QStringLiteral("You · steering") + : QStringLiteral("You")); + if (current.kind == CardKind::LocalPrompt) + refreshPendingPresentation(); + owner->style()->unpolish(owner); + owner->style()->polish(owner); + owner->update(); + } + void setNestedCards(const std::vector &cards) { const std::unordered_set retained(cards.begin(), cards.end()); @@ -1004,7 +1017,7 @@ class ConversationCard::Impl final { nestedLayout->removeWidget(card); card->setParent(owner->parentWidget()); card->setVisible(!explicitlyHidden); - card->setProperty("nestedConversationCard", false); + card->impl_->setNestedConversationCard(false); } for (std::size_t index = 0; index < cards.size(); ++index) { ConversationCard *card = cards[index]; @@ -1015,7 +1028,7 @@ class ConversationCard::Impl final { if (nestedLayout->indexOf(card) != position) nestedLayout->insertWidget(position, card); card->setVisible(!explicitlyHidden); - card->setProperty("nestedConversationCard", true); + card->impl_->setNestedConversationCard(true); } hasVisibleNestedCards = std::ranges::any_of(cards, [](const ConversationCard *card) { @@ -1301,8 +1314,10 @@ class ConversationCard::Impl final { const bool waiting = prompt->state == PromptState::Queued || prompt->state == PromptState::InFlight; const bool failed = prompt->state == PromptState::Failed; + const bool steering = owner->property("nestedConversationCard").toBool(); const QString foreground = waiting || transitioning - ? QStringLiteral("#536b8f") + ? steering ? QStringLiteral("#146f73") + : QStringLiteral("#536b8f") : failed ? QStringLiteral("#982f3d") : QStringLiteral("#1d2633"); const QString style = @@ -1446,14 +1461,21 @@ void ConversationCard::paintEvent(QPaintEvent *event) { prompt->state == PromptState::InFlight; const bool transitioning = acceptedTransitionActive(*prompt, now); const bool failed = prompt->state == PromptState::Failed; + const bool steering = property("nestedConversationCard").toBool(); const QColor background = waiting || transitioning - ? QColor(QStringLiteral("#dbe7f8")) + ? QColor(steering ? QStringLiteral("#d9efef") + : QStringLiteral("#dbe7f8")) : failed ? QColor(QStringLiteral("#fff0f2")) - : QColor(QStringLiteral("#eaf2ff")); + : QColor(steering + ? QStringLiteral("#eefafa") + : QStringLiteral("#eaf2ff")); const QColor border = waiting || transitioning - ? QColor(QStringLiteral("#9eb9df")) + ? QColor(steering ? QStringLiteral("#78bdc0") + : QStringLiteral("#9eb9df")) : failed ? QColor(QStringLiteral("#efb8c0")) - : QColor(QStringLiteral("#bfd3f9")); + : QColor(steering + ? QStringLiteral("#9fd7d8") + : QStringLiteral("#bfd3f9")); painter.setBrush(background); painter.setPen(QPen(border, 1.0)); painter.drawRoundedRect(bounds, 8.0, 8.0); @@ -1473,9 +1495,12 @@ void ConversationCard::paintEvent(QPaintEvent *event) { const qreal center = bounds.left() + position * bounds.width(); const qreal radius = std::max(28.0, bounds.width() * 0.24); QLinearGradient sweep(center - radius, 0.0, center + radius, 0.0); - sweep.setColorAt(0.0, QColor(47, 111, 235, 0)); - sweep.setColorAt(0.5, QColor(117, 160, 239, 105)); - sweep.setColorAt(1.0, QColor(47, 111, 235, 0)); + sweep.setColorAt(0.0, steering ? QColor(22, 123, 128, 0) + : QColor(47, 111, 235, 0)); + sweep.setColorAt(0.5, steering ? QColor(92, 180, 184, 105) + : QColor(117, 160, 239, 105)); + sweep.setColorAt(1.0, steering ? QColor(22, 123, 128, 0) + : QColor(47, 111, 235, 0)); QPainterPath clip; clip.addRoundedRect(bounds, 8.0, 8.0); painter.save(); @@ -1484,7 +1509,9 @@ void ConversationCard::paintEvent(QPaintEvent *event) { painter.restore(); painter.setBrush(Qt::NoBrush); - painter.setPen(QPen(QColor(QStringLiteral("#79a0d7")), 1.5)); + painter.setPen(QPen(QColor(steering ? QStringLiteral("#5caeb1") + : QStringLiteral("#79a0d7")), + 1.5)); painter.drawRoundedRect(bounds, 8.0, 8.0); } diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index c423ea8..5a95f98 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -153,8 +153,9 @@ QString applicationStyleSheet() { QPushButton[kind="history"]:hover { background: #d8e7ff; border-color: #9ebcf3; } QPushButton[kind="request"] { background: #fff6df; border-color: #e5c77d; color: #8a5208; } QPushButton[kind="request"]:hover { background: #ffefc4; border-color: #d5ad50; } - QPushButton[kind="steer"] { background: #ffffff; border-color: #2f6feb; color: #2f6feb; } - QPushButton[kind="steer"]:hover { background: #e5eeff; border-color: #285fca; color: #285fca; } + QPushButton[kind="steer"] { background: #167b80; border-color: #167b80; color: white; } + QPushButton[kind="steer"]:hover { background: #126b70; border-color: #126b70; color: white; } + QPushButton[kind="steer"]:pressed { background: #0f595d; border-color: #0f595d; color: white; } QPushButton[kind="cancel"] { background: #eef1f5; border-color: #c8d0dc; color: #475467; } QPushButton[kind="cancel"]:hover { background: #e3e8ef; border-color: #aeb8c6; } QPushButton[kind="subtle"], QToolButton[kind="subtle"] { @@ -240,7 +241,8 @@ QString applicationStyleSheet() { QFrame[kind="raised"][tone="warning"] { background: #fff6df; border-color: #e5c77d; } QFrame[messageRole="user"] { background: #eaf2ff; border: 1px solid #bfd3f9; border-radius: 8px; } QFrame[messageRole="user"] QLabel[kind="title"] { color: #285fca; } - QFrame[messageRole="user"][nestedConversationCard="true"] { background: #f3f7ff; border-color: #cfddf4; } + QFrame[messageRole="user"][nestedConversationCard="true"] { background: #eefafa; border-color: #9fd7d8; } + QFrame[messageRole="user"][nestedConversationCard="true"] QLabel[kind="title"] { color: #146f73; } QFrame[messageRole="agent"][messagePhase="final"] { background: #f4f0ff; border: 1px solid #d4c5f2; border-radius: 8px; } QFrame[messageRole="agent"][messagePhase="final"] QLabel[kind="title"] { color: #53389e; } QFrame[messageRole="agent"][messagePhase="update"] { background: #ffffff; border: 1px solid #d7dee8; border-radius: 8px; } diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index 2dda2b1..ace5b38 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -269,6 +269,28 @@ ConversationCard *card(ConversationView &view, const std::string &key) { return nullptr; } +QString cardTitle(const ConversationCard *card) { + if (!card) + return {}; + const auto labels = card->findChildren(); + const auto title = std::ranges::find_if(labels, [](QLabel *label) { + return label->property("kind").toString() == QStringLiteral("title"); + }); + return title == labels.end() ? QString{} : (*title)->text(); +} + +QColor cardTitleColor(const ConversationCard *card) { + if (!card) + return {}; + const auto labels = card->findChildren(); + const auto title = std::ranges::find_if(labels, [](QLabel *label) { + return label->property("kind").toString() == QStringLiteral("title"); + }); + return title == labels.end() + ? QColor{} + : (*title)->palette().color(QPalette::WindowText); +} + std::vector visualCardKeys(ConversationView &view) { std::vector cards; for (QWidget *widget : view.findChildren()) @@ -1426,6 +1448,9 @@ bool testCardFoldingGeometryAndRetention() { result &= expect(steeringCard && userCard->isAncestorOf(steeringCard) && steeringCard->property("nestedConversationCard").toBool() && + cardTitle(steeringCard) == QStringLiteral("You · steering") && + cardTitleColor(steeringCard) == + QColor(QStringLiteral("#146f73")) && steeringAnimation && steeringAnimation->isActive(), "a pending steering You card is nested and keeps its animation"); @@ -1441,6 +1466,10 @@ bool testCardFoldingGeometryAndRetention() { expect(authoritativeSteering == steeringCard && userCard->isAncestorOf(authoritativeSteering) && authoritativeSteering->cardKind() == CardKind::UserMessage && + cardTitle(authoritativeSteering) == + QStringLiteral("You · steering") && + authoritativeSteering->palette().color( + QPalette::Window) == QColor(QStringLiteral("#eefafa")) && steeringAnimation && !steeringAnimation->isActive(), "steering acknowledgement morphs the same nested card"); diff --git a/ui-review/UX-DESIGN-DECISIONS.md b/ui-review/UX-DESIGN-DECISIONS.md index f2d04cb..97c9f49 100644 --- a/ui-review/UX-DESIGN-DECISIONS.md +++ b/ui-review/UX-DESIGN-DECISIONS.md @@ -48,6 +48,13 @@ are not mapped to the canonical application scale. | Orange | `#a85d0c` | `#8e4d09` | `#743e07` | `#fff6df` | `#e5c77d` | `#8a5208` | | Red | `#c43d4d` | `#aa3342` | `#8f2b38` | `#fff0f2` | `#efb8c0` | `#982f3d` | | Violet | `#6941c6` | `#5b37ad` | `#4b2e90` | `#f4f0ff` | `#d4c5f2` | `#53389e` | +| Teal | `#167b80` | `#126b70` | `#0f595d` | `#eefafa` | `#9fd7d8` | `#146f73` | + +Teal is a secondary interaction and identity family, not a status color. It +connects the Steer action with the resulting steering user message and +distinguishes that message from its blue owning turn without implying success, +warning, or failure. Existing state families remain authoritative everywhere +else. Neutral separators and borders use three canonical intensity steps: diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index 738965a..cfb6ac3 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -423,15 +423,15 @@ function ScrollableCode({text, className, label}: {text: string; className: stri }}>{text}; } -export function Card({card, active, collapsed, onToggle, onCopy, nested, turnContainer = false}: {card: VisibleCardData; active: boolean; collapsed: boolean; onToggle: () => void; onCopy: (content: CardCopyContent) => void; nested?: ReactNode; turnContainer?: boolean}) { +export function Card({card, active, collapsed, onToggle, onCopy, nested, turnContainer = false, nestedCard = false}: {card: VisibleCardData; active: boolean; collapsed: boolean; onToggle: () => void; onCopy: (content: CardCopyContent) => void; nested?: ReactNode; turnContainer?: boolean; nestedCard?: boolean}) { let title = humanize(card.kind); let body: ReactNode; let phaseClass = ""; if (card.kind === "userMessage") { - const data = card.payload as UserMessageData; title = "You"; + const data = card.payload as UserMessageData; title = nestedCard ? "You · steering" : "You"; body = <>; } else if (card.kind === "localPrompt") { - const data = card.payload as LocalPromptData; title = data.state === "failed" ? "Not sent" : "You"; + const data = card.payload as LocalPromptData; title = data.state === "failed" ? "Not sent" : nestedCard ? "You · steering" : "You"; body = <>
{data.prompt}
{data.error &&
{data.error}
}; } else if (card.kind === "agentMessage") { const data = card.payload as AgentMessageData; title = "Codex"; phaseClass = data.finalAnswer ? "final" : "update"; @@ -469,7 +469,7 @@ export function Card({card, active, collapsed, onToggle, onCopy, nested, turnCon const activeTurn = active && turnContainer && card.kind === "userMessage"; const activeWork = (card.kind === "commandExecution" || card.kind === "imageGeneration") && ["active", "inProgress", "running", "started"].includes((card.payload as CommandExecutionData | ImageGenerationData).status); - return
+ return
{title}{card.itemId}{copyContent.text && }{foldable && }
{!collapsed && <>{body}{nested &&
{nested}
}}
; } @@ -635,9 +635,9 @@ function Conversation({session, revision, paneControls}: {session: BrowserFronte const visibleSections = conversation.sections .map(section => ({...section, cards: section.cards.filter(cardVisible)})) .filter(section => section.cards.length > 0); - const renderCard = (card: VisibleCardData, nested?: ReactNode, turnContainer = false) => { + const renderCard = (card: VisibleCardData, nested?: ReactNode, turnContainer = false, nestedCard = false) => { const key = stableKey(card.key); const collapsed = cardCollapsed(card, key); - return toggleCard(key, collapsed)} onCopy={copyCard} nested={nested} turnContainer={turnContainer} />; + return toggleCard(key, collapsed)} onCopy={copyCard} nested={nested} turnContainer={turnContainer} nestedCard={nestedCard} />; }; return
Conversation @@ -665,7 +665,7 @@ function Conversation({session, revision, paneControls}: {session: BrowserFronte const rootKey = section.rootCardKey ? stableKey(section.rootCardKey) : ""; const prompt = rootKey === "" ? undefined : section.cards.find(card => stableKey(card.key) === rootKey); const nestedCards = prompt ? section.cards.filter(card => card !== prompt) : []; - const nested = nestedCards.length > 0 ? nestedCards.map(card => renderCard(card)) : undefined; + const nested = nestedCards.length > 0 ? nestedCards.map(card => renderCard(card, undefined, false, true)) : undefined; return
{prompt ? renderCard(prompt, nested, true) : section.cards.map(card => renderCard(card))}
; @@ -737,8 +737,8 @@ function Composer({session, active, draftKey, drafts, options}: {session: Browse })) { event.preventDefault(); event.currentTarget.form?.requestSubmit(); } }} placeholder={active ? "Message Codex…" : "Select or create a thread"} rows={1} />
Enter to send · Shift+Enter for a new line - {running ? - : }
+ + {running && }
; } diff --git a/web/src/styles.css b/web/src/styles.css index 9e93d1a..6694e81 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -95,7 +95,9 @@ h1, h2, h3, p { margin: 0; } .conversation-card.collapsed > header { margin-bottom: 0; } .conversation-card.userMessage, .conversation-card.localPrompt { background: #eaf2ff; border-color: #bfd3f9; } .conversation-card.userMessage > header span, .conversation-card.localPrompt > header span { color: #285fca; } -.turn-nested > .conversation-card.userMessage { background: #f3f7ff; border-color: #cfddf4; } +.turn-nested > .conversation-card.userMessage.steering { background: #eefafa; border-color: #9fd7d8; } +.turn-nested > .conversation-card.steering > header span { color: #146f73; } +.turn-nested > .conversation-card.localPrompt.steering { background: linear-gradient(100deg, #eefafa, #d9efef, #eefafa); background-size: 200% 100%; border-color: #78bdc0; } .conversation-card.localPrompt { background: linear-gradient(100deg, #eaf2ff, #dbe7f8, #eaf2ff); background-size: 200% 100%; animation: awaiting 1.7s linear infinite; } .conversation-card.userMessage.turn-container.active-turn { border-color: #6f98e8; } .conversation-card.active-work { border: 1.5px solid #98a2b3; } @@ -132,8 +134,10 @@ h1, h2, h3, p { margin: 0; } .composer textarea:disabled { background: #f4f6f9; } .composer-actions { display: flex; align-items: center; justify-content: space-between; padding: 7px 9px 9px 14px; } .composer-actions span { color: #667085; font-size: 9px; } +.composer-submit-actions { display: flex; align-items: center; gap: 8px; } .send-button, .stop-button { border: 0; border-radius: 8px; padding: 7px 13px; cursor: pointer; } .send-button { background: #315ccf; color: #fff; }.send-button:disabled { opacity: .4; cursor: default; } +.send-button.steer { background: #167b80; color: #fff; }.send-button.steer:hover { background: #126b70; }.send-button.steer:active { background: #0f595d; } .stop-button { color: #b93647; background: #fcecef; } .settings-panel { margin-bottom: 7px; border: 1px solid #d8dfe9; border-radius: 10px; background: #f8fafc; overflow: hidden; } .settings-toggle { display: flex; justify-content: space-between; width: 100%; padding: 7px 10px; border: 0; background: transparent; color: #536075; font-size: 10px; cursor: pointer; }.settings-toggle > span { color: #667085; } diff --git a/web/tests/card-copy.test.mjs b/web/tests/card-copy.test.mjs index 9b13b77..88c24d1 100644 --- a/web/tests/card-copy.test.mjs +++ b/web/tests/card-copy.test.mjs @@ -77,6 +77,18 @@ test("only an authoritative running-turn You container is emphasized", () => { assert.doesNotMatch(finished, /active-turn/u); }); +test("nested user messages expose the steering identity", () => { + const user = itemCard("userMessage", "steering", { + text: "Additional direction", imagePaths: [], + }); + const markup = renderToStaticMarkup(createElement(Card, { + card: user, active: true, collapsed: false, nestedCard: true, + onToggle() {}, + })); + assert.match(markup, /conversation-card userMessage[^"]*steering/u); + assert.match(markup, /You · steering/u); +}); + test("message attachments retain one horizontal ribbon in source order", () => { const user = itemCard("userMessage", "prompt", { text: "Prompt", imagePaths: ["/tmp/one.png", "/tmp/two.png", "/tmp/three.png"], diff --git a/web/tests/responsive-layout.test.mjs b/web/tests/responsive-layout.test.mjs index 2f4331b..d9a60ba 100644 --- a/web/tests/responsive-layout.test.mjs +++ b/web/tests/responsive-layout.test.mjs @@ -85,6 +85,11 @@ test("responsive CSS keeps the desktop grid and removes the old document-width f assert.match(css, /@media \(max-width:\s*760px\)[\s\S]*grid-template-columns:\s*minmax\(0, 1fr\)/u); assert.match(css, /@media \(max-width:\s*760px\)[\s\S]*\.top-bar\s*\{[^}]*flex-wrap:\s*wrap/u); assert.match(css, /\.conversation-heading \.conversation-activity\s*\{[^}]*color:\s*#1d2633/u); + assert.match(css, /\.conversation-card\.userMessage\.steering\s*\{[^}]*background:\s*#eefafa;[^}]*border-color:\s*#9fd7d8/u); + assert.match(css, /\.conversation-card\.localPrompt\.steering\s*\{[^}]*#eefafa[^}]*#d9efef[^}]*border-color:\s*#78bdc0/u); + assert.match(css, /\.send-button\.steer\s*\{[^}]*background:\s*#167b80[^}]*color:\s*#fff/u); + assert.match(css, /\.send-button\.steer:hover\s*\{[^}]*background:\s*#126b70/u); + assert.match(css, /\.send-button\.steer:active\s*\{[^}]*background:\s*#0f595d/u); assert.match(css, /\.responsive-drawer\s*\{[^}]*position:\s*fixed/u); assert.match(css, /\.drawer-backdrop\s*\{[^}]*position:\s*fixed/u); assert.match(css, /button:focus-visible[\s\S]*outline:\s*2px solid #6f98e8/u);