diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index 90fd474..86392c4 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -126,8 +126,11 @@ Generated-image items show the app-server-saved image as a bounded thumbnail. Selecting it opens the shared non-modal image viewer; encoded image data is never displayed as generic activity text. -Process cards remain neutral so they support rather than dominate the user and -Codex conversation. Status text alone uses canonical semantic state colors. +User messages use the canonical soft-violet identity surface. Final Codex +messages use the canonical soft-blue identity surface, while interim Codex +updates remain neutral and identify their phase in the header. Process cards +also remain neutral so they support rather than dominate the primary exchange. +Status text alone uses canonical semantic state colors. Every conversation card with visible detail uses the same keyboard-focusable disclosure chevron: down when expanded and left when collapsed. Title-only diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index ce4052a..3b7615f 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -783,6 +783,13 @@ class ConversationCard::Impl final { } void updateComposition(const AgentMessageData &message) { + const QString messagePhase = + message.finalAnswer ? QStringLiteral("final") : QStringLiteral("update"); + if (owner->property("messagePhase").toString() != messagePhase) { + owner->setProperty("messagePhase", messagePhase); + owner->style()->unpolish(owner); + owner->style()->polish(owner); + } phase->setText(message.finalAnswer ? QStringLiteral("final answer") : QStringLiteral("update")); const QString phaseStatus = message.finalAnswer @@ -974,10 +981,8 @@ class ConversationCard::Impl final { const bool waiting = prompt->state == PromptState::Queued || prompt->state == PromptState::InFlight; const bool failed = prompt->state == PromptState::Failed; - const QString foreground = waiting || transitioning - ? QStringLiteral("#536b8f") - : failed ? QStringLiteral("#982f3d") - : QStringLiteral("#1d2633"); + const QString foreground = failed ? QStringLiteral("#982f3d") + : QStringLiteral("#53389e"); const QString style = QStringLiteral("background:transparent;color:%1;").arg(foreground); bool changed = false; @@ -1084,13 +1089,11 @@ void ConversationCard::paintEvent(QPaintEvent *event) { const bool transitioning = acceptedTransitionActive(*prompt, now); const bool failed = prompt->state == PromptState::Failed; const QColor background = waiting || transitioning - ? QColor(QStringLiteral("#dbe7f8")) + ? QColor(QStringLiteral("#eee8fb")) : failed ? QColor(QStringLiteral("#fff0f2")) - : QColor(QStringLiteral("#eaf2ff")); - const QColor border = waiting || transitioning - ? QColor(QStringLiteral("#9eb9df")) - : failed ? QColor(QStringLiteral("#efb8c0")) - : QColor(QStringLiteral("#bfd3f9")); + : QColor(QStringLiteral("#f4f0ff")); + const QColor border = failed ? QColor(QStringLiteral("#efb8c0")) + : QColor(QStringLiteral("#d4c5f2")); painter.setBrush(background); painter.setPen(QPen(border, 1.0)); painter.drawRoundedRect(bounds, 8.0, 8.0); @@ -1110,9 +1113,9 @@ 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, QColor(105, 65, 198, 0)); + sweep.setColorAt(0.5, QColor(155, 128, 214, 105)); + sweep.setColorAt(1.0, QColor(105, 65, 198, 0)); QPainterPath clip; clip.addRoundedRect(bounds, 8.0, 8.0); painter.save(); @@ -1121,7 +1124,7 @@ void ConversationCard::paintEvent(QPaintEvent *event) { painter.restore(); painter.setBrush(Qt::NoBrush); - painter.setPen(QPen(QColor(QStringLiteral("#79a0d7")), 1.5)); + painter.setPen(QPen(QColor(QStringLiteral("#b49bdf")), 1.5)); painter.drawRoundedRect(bounds, 8.0, 8.0); } diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index 1b15d60..7a6ef1e 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -204,8 +204,11 @@ QString applicationStyleSheet() { QFrame[kind="panel"] { background: #ffffff; } QFrame[kind="raised"] { background: #ffffff; border: 1px solid #d7dee8; border-radius: 10px; } QFrame[kind="raised"][tone="warning"] { background: #fff6df; border-color: #e5c77d; } - QFrame[messageRole="user"] { background: #eaf2ff; border: 1px solid #bfd3f9; border-radius: 8px; } - QFrame[messageRole="agent"] { background: #ffffff; border: 0; border-radius: 8px; } + QFrame[messageRole="user"] { background: #f4f0ff; border: 1px solid #d4c5f2; border-radius: 8px; } + QFrame[messageRole="user"] QLabel[kind="title"] { color: #53389e; } + QFrame[messageRole="agent"][messagePhase="final"] { background: #eaf2ff; border: 1px solid #bfd3f9; border-radius: 8px; } + QFrame[messageRole="agent"][messagePhase="final"] QLabel[kind="title"] { color: #285fca; } + QFrame[messageRole="agent"][messagePhase="update"] { background: #ffffff; border: 1px solid #d7dee8; border-radius: 8px; } QFrame[kind="summary"] { background: #f8fafc; border: 1px solid #d7dee8; border-radius: 7px; } QFrame[kind="standardDivider"] { background: #d7dee8; border: none; } QFrame[kind="greenBadge"] { background: #e9f7f0; border: 1px solid #a9d8c1; border-radius: 6px; } diff --git a/src/codex/ui/UiStyle.h b/src/codex/ui/UiStyle.h index af980bc..0ead6a2 100644 --- a/src/codex/ui/UiStyle.h +++ b/src/codex/ui/UiStyle.h @@ -48,6 +48,11 @@ inline constexpr auto redSurface = "#fff0f2"; inline constexpr auto redBorder = "#efb8c0"; inline constexpr auto redText = "#982f3d"; inline constexpr auto purple = "#6941c6"; +inline constexpr auto purpleHover = "#5b37ad"; +inline constexpr auto purplePressed = "#4b2e90"; +inline constexpr auto purpleSurface = "#f4f0ff"; +inline constexpr auto purpleBorder = "#d4c5f2"; +inline constexpr auto purpleText = "#53389e"; QString applicationStyleSheet(); QString humanizeLabel(QString value); diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index dd4ea90..a1ed8e4 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -5,6 +5,7 @@ #include "codex/ui/UiStyle.h" #include +#include #include #include #include @@ -179,6 +180,62 @@ ConversationSnapshot conversation(const std::string &threadId, int count) { return result; } +bool testMessageIdentityPalette() { + const QString originalStyleSheet = qApp->styleSheet(); + qApp->setStyleSheet(codexui::UiStyle::applicationStyleSheet()); + ConversationCard user(VisibleCardData{ + AuthoritativeItemKey{"identity-palette", "turn", "user"}, + CardKind::UserMessage, + "identity-palette", + "turn", + "user", + UserMessageData{QStringLiteral("Prompt"), {}}}); + ConversationCard update(VisibleCardData{ + AuthoritativeItemKey{"identity-palette", "turn", "update"}, + CardKind::AgentMessage, + "identity-palette", + "turn", + "update", + AgentMessageData{QStringLiteral("Working"), false}}); + ConversationCard final(VisibleCardData{ + AuthoritativeItemKey{"identity-palette", "turn", "final"}, + CardKind::AgentMessage, + "identity-palette", + "turn", + "final", + AgentMessageData{QStringLiteral("Response"), true}}); + for (ConversationCard *card : {&user, &update, &final}) { + card->resize(600, card->sizeHint().height()); + card->show(); + } + spin(); + + const auto titleColor = [](ConversationCard &card) { + for (QLabel *label : card.findChildren()) + if (label->property("kind").toString() == QStringLiteral("title")) + return label->palette().color(QPalette::WindowText); + return QColor{}; + }; + const auto surfaceColor = [](ConversationCard &card) { + const QImage rendered = card.grab().toImage(); + return rendered.pixelColor(rendered.width() - 10, + rendered.height() - 10); + }; + const bool result = expect( + titleColor(user) == + QColor(QString::fromLatin1(codexui::UiStyle::purpleText)) && + surfaceColor(user) == + QColor(QString::fromLatin1(codexui::UiStyle::purpleSurface)) && + surfaceColor(update) == + QColor(QString::fromLatin1(codexui::UiStyle::panel)) && + titleColor(final) == + QColor(QString::fromLatin1(codexui::UiStyle::blueHover)) && + surfaceColor(final) == QColor(QStringLiteral("#eaf2ff")), + "You is violet, interim Codex is neutral, and final Codex is blue"); + qApp->setStyleSheet(originalStyleSheet); + return result; +} + ConversationCard *card(ConversationView &view, const std::string &key) { for (QWidget *widget : view.findChildren()) { auto *candidate = dynamic_cast(widget); @@ -1503,7 +1560,13 @@ bool testPendingPromptAnimation() { const QImage second = card.grab().toImage(); bool result = expect(first != second, - "an unacknowledged prompt visibly animates its blue sweep"); + "an unacknowledged prompt visibly animates its violet sweep"); + result &= expect( + first.pixelColor(10, first.height() - 10).blue() > + first.pixelColor(10, first.height() - 10).green() && + first.pixelColor(10, first.height() - 10).red() > + first.pixelColor(10, first.height() - 10).green(), + "the temporary You card stays in the violet identity family"); auto &accepted = std::get(pending.payload); accepted.state = PromptState::Accepted; @@ -1742,7 +1805,8 @@ bool testGeneratedImagePresentationAndGenericBound() { int main(int argc, char **argv) { QApplication application(argc, argv); using namespace codexui::codex::middle; - bool result = testStructuralOrderAndIdentity(); + bool result = testMessageIdentityPalette(); + result &= testStructuralOrderAndIdentity(); result &= testFollowPauseAndStableAnchor(); result &= testPausedExpandedCommandStaysPainted(); result &= testThreadLocalScrollAndComposerExtent(); diff --git a/ui-review/UX-DESIGN-DECISIONS.md b/ui-review/UX-DESIGN-DECISIONS.md index 9234623..2ccc738 100644 --- a/ui-review/UX-DESIGN-DECISIONS.md +++ b/ui-review/UX-DESIGN-DECISIONS.md @@ -10,8 +10,10 @@ This document records the implemented CodexUI visual and interaction contract. white-text contrast. - Hover, focus, selection, disabled, warning, error, pending, and active states remain visually distinct. -- User messages are blue-tinted cards. Codex narrative is visually lighter. - Commands, tool activity, files, and collaboration activity use raised cards. +- User messages use the violet identity family. Final Codex narrative uses the + canonical blue identity family; interim Codex updates remain neutral and are + identified by their phase label. Commands, tool activity, files, and + collaboration activity also use neutral raised cards. - Scrollbars use one compact application style across conversation, nested output, State, Protocol, and Inspector surfaces. - The three primary panels use one prominent neutral 24 px header row: @@ -45,6 +47,7 @@ are not mapped to the canonical application scale. | Green | `#18865e` | `#14734f` | `#105f41` | `#e9f7f0` | `#a9d8c1` | `#176b45` | | Orange | `#a85d0c` | `#8e4d09` | `#743e07` | `#fff6df` | `#e5c77d` | `#8a5208` | | Red | `#c43d4d` | `#aa3342` | `#8f2b38` | `#fff0f2` | `#efb8c0` | `#982f3d` | +| Violet | `#6941c6` | `#5b37ad` | `#4b2e90` | `#f4f0ff` | `#d4c5f2` | `#53389e` | Neutral separators and borders use three canonical intensity steps: diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index e513c05..fb0f94d 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -93,6 +93,7 @@ function SafeMarkdown({text}: {text: string}) { function Card({card, active, collapsed, onToggle}: {card: VisibleCardData; active: boolean; collapsed: boolean; onToggle: () => void}) { let title = humanize(card.kind); let body: ReactNode; + let phaseClass = ""; if (card.kind === "userMessage") { const data = card.payload as UserMessageData; title = "You"; body = <>
{data.text}
{data.imagePaths.map(path => {path})}; @@ -100,7 +101,7 @@ function Card({card, active, collapsed, onToggle}: {card: VisibleCardData; activ const data = card.payload as LocalPromptData; title = data.state === "failed" ? "Not sent" : "You"; body = <>
{data.prompt}
{data.error &&
{data.error}
}; } else if (card.kind === "agentMessage") { - const data = card.payload as AgentMessageData; title = data.finalAnswer ? "Codex" : "Agent message"; + const data = card.payload as AgentMessageData; title = "Codex"; phaseClass = data.finalAnswer ? "final" : "update"; body = ; } else if (card.kind === "reasoning") { const data = card.payload as ReasoningData; title = "Reasoning"; @@ -128,7 +129,7 @@ function Card({card, active, collapsed, onToggle}: {card: VisibleCardData; activ } const foldable = ["agentMessage", "commandExecution", "agentActivity", "reasoning", "fileChanges", "genericActivity"].includes(card.kind) && !(card.kind === "reasoning" && !(card.payload as ReasoningData).summary); - return
+ return
{title}{card.itemId}{foldable && }
{!collapsed && body}
; } diff --git a/web/src/styles.css b/web/src/styles.css index dbd0e7c..0b65ced 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -64,10 +64,13 @@ h1, h2, h3, p { margin: 0; } .conversation-card > header small { font-size: 9px; opacity: .55; } .card-meta { display: flex; align-items: center; gap: 6px; }.card-meta button { width: 20px; height: 20px; padding: 0; border: 1px solid #dce2eb; border-radius: 5px; background: #fff; color: #778194; cursor: pointer; } .conversation-card.collapsed > header { margin-bottom: 0; } -.conversation-card.userMessage, .conversation-card.localPrompt { margin-left: 12%; background: #e7efff; border-color: #c9d8fb; } -.conversation-card.localPrompt { background: linear-gradient(100deg, #f0f4fb, #e6edfa, #f0f4fb); background-size: 200% 100%; animation: awaiting 1.7s linear infinite; } +.conversation-card.userMessage, .conversation-card.localPrompt { margin-left: 12%; background: #f4f0ff; border-color: #d4c5f2; } +.conversation-card.userMessage > header span, .conversation-card.localPrompt > header span { color: #53389e; } +.conversation-card.localPrompt { background: linear-gradient(100deg, #f4f0ff, #e8def8, #f4f0ff); background-size: 200% 100%; animation: awaiting 1.7s linear infinite; } .conversation-card.reasoning { border-left: 3px solid #7896df; } -.conversation-card.agentMessage { border-left: 3px solid #315ccf; } +.conversation-card.agentMessage.update { background: #fff; border-color: #dce2eb; } +.conversation-card.agentMessage.final { background: #eaf2ff; border-color: #bfd3f9; } +.conversation-card.agentMessage.final > header span { color: #285fca; } .conversation-card.fileChanges { border-left: 3px solid #28a56c; } @keyframes awaiting { to { background-position: -200% 0; } } .card-text { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 14px; line-height: 1.55; }