diff --git a/webclient/src/styles/app.scss b/webclient/src/styles/app.scss
index 323e57a..94d59c4 100644
--- a/webclient/src/styles/app.scss
+++ b/webclient/src/styles/app.scss
@@ -1,1932 +1,5 @@
-@use 'kit-deps' as *;
-
-// ─── Breakpoints ───────────────────────────────────────────────────────────
-$bp-mobile: 768px;
-// Drawer mode starts strictly *below* 1280px: use an .02px-exclusive bound
-// so a viewport of exactly 1280px gets the desktop sidebar, not the drawer.
-$bp-sidebar-drawer: 1279.98px;
-
-// ─── Scrollbars ────────────────────────────────────────────────────────────
-// The kit paints thick square webkit bars globally (kit.scss). The app
-// overrides them thinner and rounded, consistently for every scroll area
-// (tabs strip, settings region, message list, tables, code blocks).
-// Firefox gets the same look via scrollbar-width/color, which have no
-// per-element control.
-* {
- scrollbar-width: thin;
- scrollbar-color: $color-grey transparent;
-}
-
-*::-webkit-scrollbar {
- width: 8px;
- height: 8px;
-}
-
-*::-webkit-scrollbar-track {
- background: transparent;
-}
-
-*::-webkit-scrollbar-thumb {
- width: 8px;
- background: $color-grey;
- border-radius: 9999px;
- cursor: default;
-}
-
-*::-webkit-scrollbar-thumb:hover {
- background: $color-text-dark;
-}
-
-*::-webkit-scrollbar-corner {
- background: transparent;
-}
-
-*::-webkit-scrollbar-button {
- display: none;
-}
-
-// ─── Selection policy (PWA) ────────────────────────────────────────────────
-// Service UI is chrome, not content: long-pressing a control drags in the
-// browser text-selection sheet ("Search with Google") over the controls.
-// Keep selection OFF for chrome and ON for message content / text controls.
-.chat-header,
-.context-bar,
-.profile-badge,
-.msg-meta-row,
-.recall-banner,
-.app-sidebar,
-.input-bar,
-.tabs-list {
- user-select: none;
- -webkit-touch-callout: none;
-
- // Anything the user actually types in stays selectable + editable-safe.
- input,
- textarea {
- user-select: auto;
- -webkit-touch-callout: default;
- }
-}
-
-// Focus zoom: mobile browsers zoom the viewport when an input with a
-// font-size < 16px gets focus. Text entry stays at 16px on phones;
-// checkboxes/radios are excluded — they never trigger the zoom.
-@media (max-width: 767px) {
- textarea,
- input:not([type='checkbox']):not([type='radio']):not([type='range']) {
- font-size: 16px !important;
- }
-}
-
-// ─── App shell ─────────────────────────────────────────────────────────────
-$sidebar-width: 380px;
-
-html, body {
- height: 100%;
- // The document itself must never scroll: in a PWA the visual viewport
- // wanders (keyboard, system panels) and a document taller than the screen
- // lets the pull-to-refresh/gesture scroll carry fixed headers away.
- // Everything scrolls inside #app's regions instead.
- overflow: hidden;
- overscroll-behavior: none;
-}
-
-// #app is the Vue mount point — full viewport flex container.
-// % height (not 100vh/dvh): relative to the layout viewport, which stays
-// stable in a standalone PWA where vh/dvh drift with keyboard and system bars.
-#app {
- display: flex;
- height: 100%;
- overflow: hidden;
-}
-
-// ─── Main content area (sibling of sidebar) ────────────────────────────────
-.app-main {
- flex: 1 1 0; // flex-basis:0 prevents content from dictating height
- min-width: 0;
- min-height: 0;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- background: $surface-page;
-}
-
-// ─── Sidebar ───────────────────────────────────────────────────────────────
-.app-sidebar {
- width: $sidebar-width;
- min-width: $sidebar-width;
- max-width: $sidebar-width;
- display: flex;
- flex-direction: column;
- background: $surface-panel;
- border-right: $border-width-base $border-style-base $border-color-muted;
- overflow: hidden;
- flex-shrink: 0;
- transition:
- width 0.22s ease,
- min-width 0.22s ease,
- max-width 0.22s ease,
- border-color 0.22s ease,
- transform 0.28s ease;
-
- &.is-hidden-by-artifacts {
- width: 0;
- min-width: 0;
- max-width: 0;
- border-right-color: transparent;
- }
-
- @media (max-width: $bp-sidebar-drawer) {
- position: fixed;
- inset: 0;
- z-index: 100;
- width: min(#{$sidebar-width}, 92vw) !important;
- min-width: min(#{$sidebar-width}, 92vw) !important;
- transform: translateX(-100%);
- transition: transform 0.28s ease;
-
- &.is-mobile-open {
- transform: translateX(0);
- }
-
- &.is-hidden-by-artifacts {
- width: min(#{$sidebar-width}, 92vw) !important;
- min-width: min(#{$sidebar-width}, 92vw) !important;
- max-width: min(#{$sidebar-width}, 92vw) !important;
- border-right-color: $border-color-muted;
- }
- }
-
- @media (max-width: 980px) {
- width: 100vw !important;
- min-width: 100vw !important;
- max-width: 100vw !important;
-
- &.is-hidden-by-artifacts {
- width: 100vw !important;
- min-width: 100vw !important;
- max-width: 100vw !important;
- }
- }
-}
-
-// Logo row: logo first, New Chat aligned into the same line (the old profile
-// select was removed — the profile chips above the list own that job now).
-.sidebar-header {
- padding: $space-md;
- border-bottom: $border-width-base $border-style-base $border-color-muted;
- display: flex;
- flex-direction: column;
- gap: $space-sm;
-}
-
-.sidebar-logo-row {
- display: flex;
- align-items: center;
- gap: $space-sm;
-}
-
-.sidebar-logo {
- display: flex;
- align-items: center;
- gap: $space-sm;
- text-decoration: none;
- // Takes the free width so it stays a single row with the New Chat button
- flex: 1;
- min-width: 0;
-
- img {
- width: 42px;
- height: 42px;
- }
-
- span {
- font-size: 16px;
- font-weight: 700;
- color: $color-text-light;
- }
-}
-
-.sidebar-sessions {
- flex: 1;
- overflow: hidden;
- display: flex;
- flex-direction: column;
-}
-
-.sessions-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: $space-sm $space-md;
- gap: $space-sm;
-}
-
-.sessions-label {
- font-size: 11px;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- color: $color-text-dark;
-}
-
-.sessions-refresh-btn {
- font-size: 14px;
- color: $color-text-dark;
- transition: color 0.15s, transform 0.15s;
-
- &:hover { color: $color-text-light; }
- &.is-spinning i { animation: spin 0.8s linear infinite; }
-}
-
-@keyframes spin {
- to { transform: rotate(360deg); }
-}
-
-// ─── Session item ──────────────────────────────────────────────────────────
-.session-item {
- display: flex;
- align-items: flex-start;
- gap: $space-sm;
- padding: $space-sm $space-md;
- cursor: pointer;
- border-radius: 0;
- transition: background $motion-fast $motion-ease;
- position: relative;
-
- &:hover {
- background: $surface-panel-muted;
-
- .session-actions {
- opacity: 1;
- }
- }
-
- // Touch devices: always show actions
- @media (hover: none) {
- .session-actions {
- opacity: 1;
- }
- }
-
- &.is-active {
- background: $surface-panel-strong;
- border-left: $border-width-accent $border-style-base $color-secondary;
- padding-left: calc(#{$space-md} - #{$border-width-accent});
- }
-
- &.is-pinned {
- background: rgba($color-warning, 0.07);
-
- .session-name {
- color: $color-warning;
- }
- }
-
- &.has-recall {
- .session-actions {
- opacity: 1;
- }
- }
-
- &.is-special {
- opacity: 0.72;
-
- &:hover {
- opacity: 1;
- }
-
- .session-name {
- font-weight: 400;
- }
- }
-}
-
-.session-icons {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 2px;
- flex-shrink: 0;
- margin-top: 2px;
- width: 14px;
-
- .session-pin-icon,
- .session-recall-icon,
- .session-special-icon {
- color: $color-text-dark;
- font-size: $icon-size-xs;
- opacity: 0;
- transition: opacity $motion-fast $motion-ease;
- }
-
- .session-pin-icon {
- color: $color-warning;
- }
-
- .session-recall-icon {
- color: var(--gn-color-secondary);
- }
-
- .session-special-icon {
- color: var(--gn-color-secondary);
- }
-}
-
-.is-pinned .session-icons .session-pin-icon,
-.has-recall .session-icons .session-recall-icon,
-.is-special .session-icons .session-special-icon {
- opacity: 1;
-}
-
-.session-info {
- flex: 1;
- overflow: hidden;
-}
-
-.session-name {
- font-size: 13px;
- color: $color-text-light;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- line-height: 1.4;
-}
-
-.session-time {
- font-size: 11px;
- color: $color-text-dark;
- line-height: 1.4;
- margin-top: 2px;
-}
-
-.session-preview {
- font-size: 12px;
- color: $color-text-light;
- display: -webkit-box;
- -webkit-line-clamp: 3;
- -webkit-box-orient: vertical;
- overflow: hidden;
- line-height: 1.4;
- margin-top: 3px;
-}
-
-.session-actions {
- display: flex;
- gap: 2px;
- opacity: 0;
- transition: opacity $motion-fast $motion-ease;
- flex-shrink: 0;
- align-self: flex-start;
- margin-top: 1px;
-
- .btn-icon {
- width: 26px;
- height: 26px;
- font-size: $icon-size-xs;
- }
-}
-
-// ─── Chat area (ChatArea.vue root) ─────────────────────────────────────────
-.chat-area {
- flex: 1 1 0;
- min-height: 0;
- min-width: 0;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- background: $surface-page;
-}
-
-// ─── Chat header ───────────────────────────────────────────────────────────
-.chat-header {
- display: flex;
- align-items: center;
- gap: $space-sm;
- padding: $space-sm $space-md;
- border-bottom: $border-width-base $border-style-base $border-color-muted;
- background: $surface-panel;
- flex-shrink: 0;
- min-height: 54px;
-}
-
-.chat-header-info {
- flex: 1;
- display: flex;
- align-items: center;
- gap: $space-sm;
- overflow: hidden;
- min-width: 0;
-
- @media (max-width: $bp-mobile) {
- flex-direction: column;
- align-items: flex-start;
- gap: 2px;
- }
-}
-
-.chat-header-title {
- font-size: 14px;
- font-weight: 600;
- color: $color-text-light;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- flex-shrink: 1;
- min-width: 0;
- cursor: text;
-}
-
-// Rename: replaces the title, stretches across the header-info width
-.chat-header-rename {
- flex: 1;
- min-width: 0;
- height: 32px;
- padding: 0 10px;
- font-size: 14px;
- font-weight: 600;
- font-family: inherit;
- color: $color-text-light;
- background: $surface-panel-strong;
- border: 1px solid $color-secondary;
- border-radius: $border-radius-sm;
- outline: none;
-}
-
-.profile-badge {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- flex-shrink: 0;
- white-space: nowrap;
-}
-
-.btn-sidebar-toggle {
- display: none;
- @media (max-width: $bp-sidebar-drawer) {
- display: flex;
- }
-}
-
-// ─── Context bar (token indicator) ─────────────────────────────────────────
-.context-bar {
- display: flex;
- align-items: center;
- flex-shrink: 0;
- cursor: default;
-}
-
-// ─── Profile badge ─────────────────────────────────────────────────────────
-// ─── Message list ──────────────────────────────────────────────────────────
-// Message list: the virtual scroller (MessageList.vue) carries its own
-// scoped layout; nothing global left here.
-
-// ─── Messages ──────────────────────────────────────────────────────────────
-.msg-user {
- display: flex;
- justify-content: flex-end;
- gap: $space-sm;
-}
-
-.msg-user-bubble {
- background: $color-secondary;
- color: $color-black;
- padding: $space-sm $space-md;
- border-radius: $border-radius-md;
- font-size: 14px;
- line-height: 1.6;
- word-wrap: break-word;
- width: 100%;
-
- .msg-user-quote {
- margin: 0 0 $space-xs 0;
- padding: 2px 0 2px $space-sm;
- border-left: 3px solid rgba($color-black, 0.35);
- opacity: 0.7;
- font-size: 13px;
- }
-}
-
-.msg-user--recall .msg-user-bubble {
- background: color-mix(in srgb, var(--gn-color-secondary) 12%, transparent);
- border: 1px solid color-mix(in srgb, var(--gn-color-secondary) 25%, transparent);
-
- .msg-user-text {
- color: $color-text-light;
- }
-}
-
-.msg-recall-badge {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- font-size: 11px;
- color: var(--gn-color-secondary);
- margin-bottom: 4px;
- opacity: 0.85;
-}
-
-// Sent while a turn was running: shown at once, executed when that turn ends.
-// Muted rather than highlighted — it is the user's own message, not news.
-.msg-user--queued .msg-user-bubble {
- opacity: 0.75;
-}
-
-// Neutral, not accented. The badge sits on the user's blue bubble, which is
-// itself faded to 0.75 for a queued message; the accent orange it used to take
-// (#FF9E64) measured ~1.9:1 there and read as a warning sign rather than as
-// "this message is waiting". The palette's own grey stays readable against that
-// blue and reads as secondary text, where the darker neutrals (#1F2335, the
-// bubble's own near-black text) look like the message itself.
-.msg-queued-badge {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- font-size: 11px;
- font-weight: 500;
- color: $color-grey;
- margin-bottom: 4px;
-}
-
-.msg-user-attachments {
- display: flex;
- flex-wrap: wrap;
- gap: $space-xs;
- margin-top: $space-xs;
-}
-
-.msg-user-image {
- width: 80px;
- height: 80px;
- object-fit: cover;
- border-radius: $border-radius-md;
- cursor: pointer;
- transition: opacity 0.15s;
-
- &:hover {
- opacity: 0.75;
- }
-}
-
-.msg-user-file-pill {
- display: inline-flex;
- align-items: center;
- gap: $space-xs;
- padding: 2px $space-sm;
- background: rgba($color-black, 0.3);
- border-radius: $border-radius-pill;
- font-size: 12px;
-}
-
-.msg-time {
- font-size: 11px;
- color: $color-text-dark;
- line-height: 1;
- margin-top: 4px;
-}
-
-// Assistant message metadata row (elapsed · tools · tokens · time)
-.msg-meta-row {
- display: flex;
- align-items: center;
- gap: $space-sm;
- margin-top: 2px;
- min-width: 0;
-}
-
-.msg-meta-item {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- font-size: 11px;
- color: $color-text-dark;
- white-space: nowrap;
-
- i { font-size: 12px; opacity: 0.7; }
-}
-
-.msg-meta-time {
- // grouped with other meta items — no special positioning
-}
-
-.msg-user-wrap {
- display: flex;
- flex-direction: column;
- align-items: flex-end;
- max-width: 70%;
-
- @media (max-width: $bp-mobile) {
- max-width: 90%;
- }
-}
-
-.msg-time--user {
-}
-
-.msg-user-footer {
- display: flex;
- align-items: center;
- gap: $space-xs;
- align-self: flex-end;
-}
-
-.msg-copy-btn {
- width: 22px;
- height: 22px;
- min-width: 22px;
- padding: 0;
- font-size: 12px;
- opacity: 0.5;
- transition: opacity 0.15s;
-
- &:hover { opacity: 1; }
-}
-
-.msg-meta-row > .msg-copy-btn {
- margin-left: auto;
-}
-
-.msg-meta-row .msg-rate-btn {
- width: 22px;
- height: 22px;
- min-width: 22px;
- padding: 0;
- font-size: 13px;
-}
-
-.msg-assistant {
- display: flex;
- flex-direction: column;
- gap: $space-sm;
- max-width: 100%;
-}
-
-.planning-indicator {
- display: flex;
- align-items: center;
- gap: $space-sm;
- font-size: 13px;
- font-style: italic;
- color: $color-text-dark;
- padding: $space-xs 0;
-}
-
-.planning-indicator.is-executing {
- color: $color-text-medium;
- font-style: normal;
-}
-
-.planning-label {
- opacity: 0.75;
- transition: opacity 0.2s;
-}
-
-.msg-assistant-avatar {
- width: 28px;
- height: 28px;
- border-radius: 50%;
- flex-shrink: 0;
-}
-
-.msg-assistant-content {
- font-size: 14px;
- line-height: 1.7;
- color: $color-text-light;
-
- // Flex container so image paragraphs can form a grid
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
-
- // Markdown block elements take full width by default
- & > p, & > ul, & > ol, & > blockquote, & > pre, & > .table-wrap,
- & > .code-block, & > h1, & > h2, & > h3, & > h4, & > hr {
- flex: 1 1 100%;
- margin: 0;
- }
-
- // Paragraphs that contain only a markdown image link → 3 per row
- & > p:has(> .msg-md-image-link:only-child) {
- flex: 0 0 calc((100% - 2 * 8px) / 3);
- }
-
- h1, h2, h3, h4 { color: $color-text-light; }
- ul, ol { padding-left: $space-lg; }
- li { margin-bottom: $space-sm; }
- blockquote {
- padding-left: $space-md;
- border-left: $border-width-accent $border-style-base $color-secondary;
- color: $color-text-medium;
- }
- a { color: $color-secondary; }
- strong { color: $color-text-light; }
- hr { border: none; border-top: 1px solid $border-color-muted; }
-
- // Markdown images: adaptive square grid, click opens lightbox
- .msg-md-image-link {
- display: block;
- width: 100%;
- text-decoration: none;
- border: 2px solid rgba($color-text-light, 0.12);
- border-radius: $border-radius-sm;
- overflow: hidden;
- transition: border-color 0.2s ease;
- cursor: zoom-in;
-
- &:hover {
- border-color: $color-secondary;
- }
- }
-
- .msg-md-image {
- display: block;
- width: 100%;
- aspect-ratio: 1 / 1;
- object-fit: cover;
- border-radius: 2px;
- }
-
- .msg-md-image-link.is-broken {
- cursor: default;
- border-color: rgba($color-error, 0.4);
- background: rgba($color-error, 0.06);
- display: flex;
- align-items: center;
- justify-content: center;
- aspect-ratio: 1 / 1;
-
- &::before {
- content: '🖼️';
- font-size: 32px;
- opacity: 0.4;
- }
-
- &:hover {
- border-color: rgba($color-error, 0.4);
- }
-
- .msg-md-image {
- display: none;
- }
- }
-
- // Markdown tables: scrollable wrapper for mobile
- .table-wrap {
- overflow-x: auto;
- -webkit-overflow-scrolling: touch;
- margin: $space-md 0;
- }
-
- // Markdown tables live as bare
inside .table-wrap (the pipe
- // sanitizer never adds a class) — target that shape, not .table.
- .table-wrap table {
- margin: 0;
- border: $border-width-base $border-style-base $border-color-muted;
- border-radius: $border-radius-sm;
-
- th,
- td {
- padding: $space-sm $space-md;
- font-size: 13px;
- vertical-align: middle;
- text-align: left;
- border-bottom: $border-width-base $border-style-base $border-color-muted;
- }
-
- th {
- font-weight: 600;
- color: $color-text-medium;
- background: $surface-panel-muted;
- }
-
- tr:last-child th,
- tr:last-child td {
- border-bottom: none;
- }
- }
-
- // Code inline
- :not(pre) > code {
- font-family: "IBM Plex Mono", monospace;
- font-size: 13px;
- background: $surface-panel-strong;
- padding: 1px 5px;
- color: $color-cyan;
- }
-
- // Code blocks — .code-block wraps header + pre
- .code-block {
- margin: $space-md 0;
- background: $surface-panel;
- border: $border-width-base $border-style-base $border-color-muted;
- border-radius: $border-radius-sm;
- overflow: hidden;
-
- .code-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: $space-xs $space-md;
- background: $surface-panel-strong;
- border-bottom: $border-width-base $border-style-base $border-color-muted;
- font-size: 12px;
- color: $color-text-dark;
- font-family: "IBM Plex Mono", monospace;
- }
-
- pre {
- margin: 0;
- background: $surface-panel !important;
-
- code {
- font-family: "IBM Plex Mono", monospace;
- font-size: 13px;
- display: block;
- padding: $space-md;
- overflow-x: auto;
- }
- }
- }
-}
-
-// ─── Thinking card ─────────────────────────────────────────────────────────
-.thinking-card {
- summary {
- list-style: none;
- display: flex;
- align-items: center;
- gap: $space-sm;
- padding: $space-xs $space-md;
- cursor: pointer;
- color: $color-text-dark;
- font-size: 12px;
- font-style: italic;
- background: $surface-panel-muted;
- border: $border-width-base $border-style-base $border-color-muted;
- border-radius: $border-radius-md;
- transition: background $motion-fast $motion-ease;
- user-select: none;
-
- &::-webkit-details-marker { display: none; }
- &:hover { background: $surface-panel-strong; }
- }
-
- &[open] summary {
- border-bottom-color: transparent;
- /* Keep only the top corners rounded — the full rounding let the page
- background peek through the two corners where the summary meets the
- straight-topped body (the "gap" when the card is expanded). */
- border-radius: $border-radius-md $border-radius-md 0 0;
- }
-
- .thinking-chevron {
- margin-left: auto;
- font-size: $icon-size-sm;
- color: $color-text-dark;
- flex-shrink: 0;
- transition: transform $motion-fast $motion-ease;
- }
-
- &[open] .thinking-chevron {
- transform: rotate(180deg);
- }
-
- .thinking-body {
- padding: $space-sm $space-md;
- font-size: 12px;
- font-style: italic;
- color: $color-text-medium;
- line-height: 1.5;
- background: $surface-panel-muted;
- border: $border-width-base $border-style-base $border-color-muted;
- border-top: none;
- border-radius: 0 0 $border-radius-md $border-radius-md;
- white-space: pre-wrap;
- word-break: break-word;
- /* Long reasoning chains don't grow the whole chat — scroll inside */
- max-height: 800px;
- overflow-y: auto;
- }
-}
-
-// ─── Plan card ─────────────────────────────────────────────────────────────
-.plan-card {
- border: $border-width-base $border-style-base $color-secondary;
- border-left-width: $border-width-accent;
- border-left-color: $color-secondary;
- border-radius: $border-radius-md;
- overflow: hidden;
- font-size: 12px;
- margin-bottom: 8px;
-
- summary {
- list-style: none;
- display: flex;
- align-items: center;
- gap: $space-sm;
- padding: $space-xs $space-md;
- cursor: pointer;
- background: rgba($color-secondary, 0.08);
- color: $color-secondary;
- font-size: 12px;
- user-select: none;
-
- &::-webkit-details-marker { display: none; }
- &:hover { background: rgba($color-secondary, 0.14); }
-
- .plan-chevron {
- margin-left: auto;
- font-size: $icon-size-sm;
- color: rgba($color-secondary, 0.6);
- transition: transform $motion-fast $motion-ease;
- flex-shrink: 0;
- }
- }
-
- &[open] .plan-chevron {
- transform: rotate(180deg);
- }
-
- .plan-body {
- padding: $space-sm $space-md;
- font-size: 12px;
- color: $color-text-medium;
- line-height: 1.5;
- background: rgba($color-secondary, 0.04);
- border-top: $border-width-base $border-style-base rgba($color-secondary, 0.3);
-
- p { margin: 0 0 $space-sm; &:last-child { margin-bottom: 0; } }
- ul, ol { padding-left: $space-lg; margin-bottom: $space-sm; }
- li { margin-bottom: $space-xs; }
- strong { color: $color-text-light; }
- }
-}
-
-// ─── Tool card ─────────────────────────────────────────────────────────────
-.tool-card {
- border: $border-width-base $border-style-base $border-color-muted;
- border-left-width: $border-width-accent;
- border-radius: $border-radius-md;
- margin-bottom: 8px;
- border-left-color: $color-secondary;
- overflow: hidden;
- font-size: 13px;
-
- &.is-success { border-left-color: $color-success; }
- &.is-error { border-left-color: $color-error; }
-
- > summary {
- list-style: none;
- display: flex;
- align-items: center;
- gap: $space-sm;
- padding: $space-xs $space-md;
- cursor: pointer;
- background: $surface-panel-muted;
- color: $color-text-medium;
- user-select: none;
-
- &::-webkit-details-marker { display: none; }
- &:hover { background: $surface-panel-strong; }
-
- .tool-chevron {
- font-size: $icon-size-sm;
- color: $color-text-dark;
- flex-shrink: 0;
- transition: transform $motion-fast $motion-ease;
- }
- }
-
- &[open] > summary .tool-chevron {
- transform: rotate(180deg);
- }
-
- .tool-name {
- flex: 1;
- font-family: "IBM Plex Mono", monospace;
- font-size: 12px;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .tool-running-time {
- font-family: "IBM Plex Mono", monospace;
- font-size: 11px;
- color: $color-text-dark;
- white-space: nowrap;
- }
-
- .tool-status-icon {
- display: flex;
- align-items: center;
- justify-content: center;
- width: $icon-size-sm;
- height: $icon-size-sm;
- font-size: $icon-size-sm;
- flex-shrink: 0;
- }
-}
-
-.tool-card-body {
- background: $surface-panel-muted;
- border-top: $border-width-base $border-style-base $border-color-muted;
-}
-
-.tool-running-banner {
- display: flex;
- align-items: center;
- gap: $space-sm;
- padding: $space-sm $space-md;
- color: $color-text-medium;
- background: rgba($color-secondary, 0.08);
- border-bottom: 1px solid $border-color-muted;
- font-size: 12px;
-}
-
-.tool-section {
- padding: $space-sm $space-md;
-
- & + & {
- border-top: 1px solid $border-color-muted;
- }
-}
-
-.tool-section-label {
- font-size: 11px;
- text-transform: uppercase;
- letter-spacing: 0.06em;
- color: $color-text-dark;
- margin-bottom: $space-xs;
-}
-
-.tool-code {
- font-family: "IBM Plex Mono", monospace;
- font-size: 12px;
- color: $color-text-medium;
- white-space: pre-wrap;
- word-break: break-word;
-}
-
-.tool-args {
- summary {
- list-style: none;
- display: flex;
- align-items: center;
- gap: $space-sm;
- padding: $space-xs $space-md;
- cursor: pointer;
- user-select: none;
- font-size: 12px;
- color: $color-text-medium;
- background: $surface-panel-muted;
-
- &::-webkit-details-marker { display: none; }
- &:hover { background: $surface-panel-strong; }
-
- .tool-chevron {
- margin-left: auto;
- font-size: $icon-size-sm;
- color: $color-text-dark;
- transition: transform $motion-fast $motion-ease;
- }
- }
-
- &[open] > summary .tool-chevron {
- transform: rotate(180deg);
- }
-}
-
-.tool-cards-summary {
- display: inline-flex;
- align-items: center;
- gap: $space-sm;
- padding: $space-xs 0;
-}
-
-// A new tool card has to grow the list, not displace it: without the height the
-// card below lands a full card lower in the space of one frame. The height comes
-// from a grid row, not from a max-height or a measured pixel value — `0fr → 1fr`
-// interpolates in every engine and resolves to whatever the card turns out to
-// measure, so a live tool card, a plan block and the one-line "N tools" badge all
-// grow by the right amount, and a card dropped off the front shrinks away again.
-.tool-fade-item {
- display: grid;
- grid-template-rows: 1fr;
- transition: none;
-
- // A grid item's automatic minimum size would hold the row at the content
- // height and leave 0fr nothing to shrink; the overflow does the clipping.
- > * {
- min-height: 0;
- overflow: hidden;
- }
-}
-
-.tool-fade-enter-active,
-.tool-fade-leave-active {
- transition:
- opacity $motion-base $motion-ease,
- transform $motion-base $motion-ease,
- grid-template-rows $motion-slow $motion-ease;
-}
-
-.tool-fade-enter-from,
-.tool-fade-leave-to {
- opacity: 0;
- transform: translateY(-4px);
- grid-template-rows: 0fr;
-}
-
-.tool-result {
- font-size: 12px;
- line-height: 1.5;
- color: $color-text-medium;
-
- // Markdown content inside tool results uses normal font
- h1, h2, h3, h4 { color: $color-text-light; margin: $space-sm 0; }
- p { margin: $space-xs 0; }
- ul, ol { padding-left: $space-lg; margin: $space-xs 0; }
- li { margin-bottom: 2px; }
- blockquote {
- padding-left: $space-sm;
- border-left: $border-width-accent $border-style-base $color-secondary;
- color: $color-text-medium;
- margin: $space-xs 0;
- }
- code {
- font-family: "IBM Plex Mono", monospace;
- background: $surface-panel-strong;
- padding: 1px 4px;
- color: $color-cyan;
- font-size: 11px;
- }
- pre {
- margin: $space-xs 0;
- background: $surface-panel;
- border: $border-width-base $border-style-base $border-color-muted;
- overflow: hidden;
- code {
- display: block;
- padding: $space-sm;
- overflow-x: auto;
- }
- }
-}
-
-.result-line {
- font-family: "IBM Plex Mono", monospace;
- padding: 1px 0;
- white-space: pre-wrap;
- word-break: break-word;
-}
-
-// Diff output (filesystem diff/edit_lines/smart_edit) — rendered line-by-line
-// with a tight line-height so consecutive diff lines don't show the wide
-// vertical gaps the generic result-line divs (line-height:1.5 + padding:1px 0)
-// produce.
-.result-line.diff-summary,
-.result-line.diff-hunk,
-.result-line.diff-add,
-.result-line.diff-del,
-.result-line.diff-ctx {
- padding: 0;
- line-height: 1.3;
-}
-// Fallback colouring for whole lines without a server-prefixed number
-// (hunk/file headers, or older callers).
-.result-line.diff-summary { color: $color-text-medium; }
-.result-line.diff-hunk { color: $color-text-medium; }
-.result-line.diff-add { color: $color-success; }
-.result-line.diff-del { color: $color-error; }
-.result-line.diff-ctx { color: inherit; }
-
-// Numbered diff lines (the common case — the server prefixes "{marker} {num}│
-// {content}"): only the {num} {marker} anchor takes the marker colour; the
-// content reads neutral. Matches the TUI renderer (renderers/diff.py).
-.diff-num-marker.diff-add { color: $color-success; }
-.diff-num-marker.diff-del { color: $color-error; }
-.diff-num-marker.diff-ctx { color: inherit; }
-.diff-content { color: inherit; }
-
-// Filesystem `read` output: header plaque (path accent, tail dim), optional
-// ⚠ warning, then numbered body — the line number is the accent colour
-// (navigation anchor), the content neutral. Matches the TUI _render_read.
-.read-header-path { color: $color-accent; }
-.read-header-rest { color: $color-text-medium; }
-.read-warning { color: $color-warning; }
-.read-num { color: $color-accent; }
-.read-content { color: inherit; }
-
-.json-object,
-.json-array {
- font-family: "IBM Plex Mono", monospace;
- display: flex;
- flex-direction: column;
- gap: 1px;
- padding: $space-xs 0;
-}
-
-.json-item {
- display: flex;
- align-items: baseline;
- gap: $space-xs;
- padding: 1px 0;
-}
-
-.json-key {
- color: $color-text-light;
- font-weight: 500;
- flex-shrink: 0;
-}
-
-.json-sep {
- color: $color-text-dark;
-}
-
-.json-value {
- color: $color-text-medium;
- word-break: break-word;
- white-space: pre-wrap;
-}
-
-.json-string { color: $color-success; }
-.json-number { color: $color-warning; }
-.json-bool { color: $color-primary; }
-.json-null { color: $color-text-dark; font-style: italic; }
-.json-empty { color: $color-text-dark; font-style: italic; }
-
-// ─── Subagent steps ────────────────────────────────────────────────────────
-.subagent-steps {
- display: flex;
- flex-direction: column;
- gap: $space-xs;
- padding: $space-xs $space-md $space-md;
- background: $surface-panel-muted;
-}
-
-.subagent-planning-indicator {
- display: flex;
- align-items: center;
- gap: $space-sm;
- padding: $space-xs $space-md;
- font-size: 12px;
- font-style: italic;
- color: $color-text-dark;
- background: $surface-panel-muted;
-}
-
-// Background-task progress line inside a tool card (task_update events)
-.task-update-step {
- display: flex;
- align-items: baseline;
- gap: $space-sm;
- padding: $space-xs $space-md;
- font-size: 12px;
- color: $color-text-medium;
- background: $surface-panel-muted;
-
- i { font-size: 14px; color: $color-warning; flex: none; }
- .task-step-tool { color: $color-text-light; font-weight: 500; }
- .task-step-status { color: $color-text-dark; font-style: italic; }
- .task-step-preview {
- color: $color-text-medium;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- &.completed i { color: $color-success; }
- &.completed .task-step-status { color: $color-success; font-style: normal; }
- &.failed i, &.cancelled i { color: $color-error; }
- &.failed .task-step-status, &.cancelled .task-step-status { color: $color-error; font-style: normal; }
-
- .task-spin { animation: task-spin 1s linear infinite; }
-}
-
-@keyframes task-spin {
- from { transform: rotate(0deg); }
- to { transform: rotate(360deg); }
-}
-
-.subagent-step {
- border: $border-width-base $border-style-base $border-color-muted;
- border-left-width: $border-width-accent;
- border-left-color: $color-secondary;
- border-radius: $border-radius-md;
- overflow: hidden;
- margin-bottom: 8px;
- font-size: 12px;
-
- &.is-success { border-left-color: $color-success; }
- &.is-error { border-left-color: $color-error; }
-
- summary {
- list-style: none;
- display: flex;
- align-items: center;
- gap: $space-sm;
- padding: $space-xs $space-md;
- cursor: pointer;
- background: $surface-panel-muted;
- color: $color-text-medium;
- user-select: none;
-
- &::-webkit-details-marker { display: none; }
- &:hover { background: $surface-panel-strong; }
- }
-
- .tool-name {
- flex: 1;
- font-family: "IBM Plex Mono", monospace;
- font-size: 12px;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .tool-running-time {
- font-family: "IBM Plex Mono", monospace;
- font-size: 11px;
- color: $color-text-dark;
- white-space: nowrap;
- }
-
- .tool-status-icon {
- display: flex;
- align-items: center;
- justify-content: center;
- width: $icon-size-sm;
- height: $icon-size-sm;
- font-size: $icon-size-sm;
- flex-shrink: 0;
- }
-
- .tool-chevron {
- font-size: $icon-size-sm;
- color: $color-text-dark;
- flex-shrink: 0;
- transition: transform $motion-fast $motion-ease;
- }
-
- &[open] > summary .tool-chevron {
- transform: rotate(180deg);
- }
-}
-
-// ─── Summary / compression notice ──────────────────────────────────────────
-.summary-card {
- border: $border-width-base $border-style-base $border-color-muted;
- border-left-width: $border-width-accent;
- border-left-color: $color-info;
- border-radius: $border-radius-md;
- overflow: hidden;
- font-size: 13px;
-
- summary {
- list-style: none;
- display: flex;
- align-items: center;
- gap: $space-sm;
- padding: $space-sm $space-md;
- cursor: pointer;
- background: rgba($color-info, 0.08);
- color: $color-info;
- user-select: none;
- &::-webkit-details-marker { display: none; }
- }
-}
-
-.compression-notice {
- font-size: 12px;
-
- summary {
- list-style: none;
- text-align: center;
- padding: $space-sm;
- color: $color-text-dark;
- display: flex;
- align-items: center;
- justify-content: center;
- gap: $space-sm;
- cursor: pointer;
- user-select: none;
- &::-webkit-details-marker { display: none; }
-
- &::before, &::after {
- content: '';
- flex: 1;
- height: 1px;
- background: $border-color-muted;
- }
- }
-
- .compression-counts {
- opacity: 0.7;
- }
-
- .compression-chevron {
- font-size: $icon-size-sm;
- transition: transform $motion-fast $motion-ease;
- }
-
- &[open] .compression-chevron {
- transform: rotate(180deg);
- }
-
- .compression-body {
- padding: $space-md;
- font-size: 13px;
- color: $color-text-medium;
- line-height: 1.6;
- background: $surface-panel-muted;
- border: $border-width-base $border-style-base $border-color-muted;
- }
-}
-
-// ─── Input bar ─────────────────────────────────────────────────────────────
-.input-bar {
- flex-shrink: 0;
- padding: 8px 15px;
- background: $surface-page;
- border-top: $border-width-base $border-style-base $border-color-muted;
-
- @media (max-width: 767px) {
- padding: 0; /* empty bar is flush; the attachment strip below owns the gaps */
- }
-}
-
-.input-dropzone {
- position: relative;
-
- &.is-dragging::after {
- content: 'Drop files here';
- position: absolute;
- inset: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- background: rgba($color-secondary, 0.12);
- border: 2px dashed $color-secondary;
- font-size: 14px;
- color: $color-secondary;
- pointer-events: none;
- z-index: 10;
- }
-}
-
-.file-preview-strip {
- display: flex;
- flex-wrap: wrap;
- gap: $space-xs;
- margin-bottom: $space-xs;
- max-width: 920px;
- margin-left: auto;
- margin-right: auto;
-
- @media (max-width: 767px) {
- /* The strip, not the bar, provides the top gap on full-bleed mobile */
- padding-top: $space-sm;
- }
-}
-
-.file-preview-image {
- position: relative;
- width: 60px;
- height: 60px;
-
- img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- border: $border-width-base $border-style-base $border-color-muted;
- }
-}
-
-.file-preview-pill.file-preview-pill {
- /* GnChip root: filenames must not inherit the kit chip's uppercase */
- text-transform: none;
- max-width: 180px;
-
- /* Inside the kit's inline-flex chip the name span won't shrink below its
- content unless min-width is lifted (default `auto`), so the ellipsis
- only kicks in with an explicit min-width: 0 */
- .file-preview-name {
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-}
-
-/* Thumb stays app-owned (no kit image-preview component) — kit surface look */
-.file-preview-image {
- border: $border-width-base $border-style-base $border-color-muted;
- border-radius: $border-radius-sm;
- overflow: hidden;
- background: $surface-panel-muted;
-}
-
-.file-preview-remove {
- background: none;
- border: none;
- cursor: pointer;
- color: $color-text-dark;
- padding: 0;
- display: flex;
- align-items: center;
- font-size: $icon-size-xs;
- transition: color $motion-fast $motion-ease;
- flex-shrink: 0;
-
- &:hover { color: $color-error; }
-}
-
-.upload-progress {
- height: 2px;
- background: $surface-panel-strong;
- margin-bottom: $space-xs;
- overflow: hidden;
-}
-
-.upload-progress-fill {
- height: 100%;
- background: $color-secondary;
- transition: width $motion-base $motion-ease;
-}
-
-// Indeterminate variant: fetch() gives no progress fraction, so the fill
-// slides across instead of sitting at a fake fixed percentage.
-.upload-progress.is-indeterminate {
- .upload-progress-fill {
- width: 30%;
- transition: none;
- animation: upload-slide 1.1s ease-in-out infinite;
- }
-}
-
-@keyframes upload-slide {
- 0% { transform: translateX(-110%); }
- 100% { transform: translateX(440%); }
-}
-
-.input-row {
- display: flex;
- align-items: center;
- gap: $space-sm;
- background: $surface-panel-muted;
- border: $border-width-base $border-style-base $color-text-light;
- border-radius: $border-radius-sm;
- padding: $space-sm;
- max-width: 920px;
- margin: 0 auto;
-
- &:hover {
- border-bottom-color: $color-text-dark;
- }
-
- &:focus-within {
- border-color: $color-electric-blue;
- background: transparent;
- }
-
- @media (max-width: 767px) {
- border: none;
- border-radius: 0;
- max-width: none;
- padding: $space-sm $space-md;
- }
-}
-
-.input-textarea {
- flex: 1;
- background: none;
- border: none;
- outline: none;
- resize: none;
- color: $color-text-light;
- font-size: 14px;
- line-height: 1.5;
- font-family: inherit;
- min-height: 24px;
- max-height: 144px;
- overflow-y: auto;
- padding: 2px 0;
-
- &::placeholder { color: $color-text-dark; }
-}
-
-// ─── Welcome screen ────────────────────────────────────────────────────────
-.welcome-screen {
- flex: 1;
- position: relative;
- display: flex;
- flex-direction: column;
- align-items: center;
- // `safe center` is not cosmetic: with a plain `center`, content taller than
- // the pane overflows past the flex start edge and cannot be scrolled back to.
- // The profile grid grows with the profile list, so this pane must be able to
- // scroll — on a phone it is taller than the viewport with more than a few.
- justify-content: safe center;
- overflow-y: auto;
- padding: $space-xl;
- gap: $space-xl;
- text-align: center;
-
- @media (max-width: 768px) {
- padding: 15px;
- gap: $space-md;
- justify-content: flex-start;
- padding-top: 52px;
- }
-}
-
-.welcome-logo {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: $space-sm;
-
- img { width: 64px; height: 64px; }
-
- h1 {
- font-size: 30px; // kit h1 (typography tokens are not exported by kit-deps)
- font-weight: 600;
- color: $color-text-light;
- margin: 0;
- }
-
- p {
- font-size: var(--gn-font-size-base);
- color: $color-text-medium;
- margin: 0;
- }
-
- @media (max-width: 768px) {
- flex-direction: row;
- flex-wrap: wrap;
- justify-content: center;
- gap: $space-sm;
-
- img { width: 36px; height: 36px; }
-
- h1 { font-size: 22px; }
-
- p {
- font-size: 13px;
- flex-basis: 100%;
- }
- }
-}
-
-.welcome-profiles {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- // `1fr` rows all take the height of the tallest one, so a card whose name
- // wraps or whose description runs long does not stand out. A fixed pixel
- // height would not survive that: "Server Administrator" wraps on a phone.
- grid-auto-rows: 1fr;
- // The pane scrolls, so the grid must not be squeezed to fit it.
- flex-shrink: 0;
- gap: $space-md;
- width: min(100%, 880px);
-
- @media (max-width: 768px) {
- grid-template-columns: repeat(2, 1fr);
- gap: 10px;
- width: 100%;
- max-width: 100%;
- }
-}
-
-.welcome-profile-card {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: $space-sm;
- padding: $space-md;
- background: $surface-panel-muted;
- border: $border-width-base $border-style-base $border-color-muted;
- border-radius: $border-radius-md;
- cursor: pointer;
- transition: border-color $motion-fast $motion-ease, background $motion-fast $motion-ease;
- width: 100%;
- text-align: center;
-
- &:hover {
- border-color: $color-secondary;
- background: $surface-panel-strong;
- }
-
- &.is-creating {
- opacity: 0.6;
- pointer-events: none;
- }
-
- .profile-name {
- font-size: 14px;
- font-weight: 600;
- color: $color-text-light;
- }
-
- .profile-desc {
- font-size: 12px;
- color: $color-text-dark;
- line-height: 1.4;
- // No line clamp: a description the user cannot finish reading is worse
- // than a taller card, and `grid-auto-rows: 1fr` keeps the row even anyway.
- }
-
- @media (max-width: 768px) {
- width: auto;
- padding: 10px;
- gap: 6px;
-
- .profile-name { font-size: 13px; }
- .profile-desc { font-size: 11px; }
- }
-}
-
-// ─── Empty state ───────────────────────────────────────────────────────────
-// Centers the GnEmptyState card inside the chat area.
-.empty-chat {
- flex: 1;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
-}
-
-// ─── Spinner ───────────────────────────────────────────────────────────────
-.spinner {
- display: inline-block;
- width: 14px;
- height: 14px;
- border: 2px solid $border-color-muted;
- border-top-color: $color-secondary;
- border-radius: 50%;
- animation: spin 0.6s linear infinite;
- flex-shrink: 0;
-}
-
-@keyframes spin {
- to { transform: rotate(360deg); }
-}
-
-// ─── Animations ────────────────────────────────────────────────────────────
-.fade-enter-active,
-.fade-leave-active {
- transition: opacity $motion-fast $motion-ease;
-}
-.fade-enter-from,
-.fade-leave-to {
- opacity: 0;
-}
-
-.msg-enter {
- animation: msgEnter $motion-base $motion-ease both;
-}
-
-@keyframes msgEnter {
- from { opacity: 0; transform: translateY(8px); }
- to { opacity: 1; transform: translateY(0); }
-}
-
-.slide-left-enter-active,
-.slide-left-leave-active {
- transition: transform $motion-slow $motion-ease, opacity $motion-slow $motion-ease;
-}
-.slide-left-enter-from {
- transform: translateX(-100%);
- opacity: 0;
-}
-.slide-left-leave-to {
- transform: translateX(-100%);
- opacity: 0;
-}
-
-// ─── Confirm dialog transition ─────────────────────────────────────────────
-.modal-enter-active,
-.modal-leave-active {
- transition: opacity $motion-slow $motion-ease;
-
- .modal-panel {
- transition: opacity $motion-slow $motion-ease, margin-top $motion-slow $motion-ease;
- }
-}
-
-.modal-enter-from,
-.modal-leave-to {
- opacity: 0;
-
- .modal-panel {
- opacity: 0;
- margin-top: 200px;
- }
-}
-
-// ─── Disclosure chevrons ───────────────────────────────────────────────────
-
-// Kit 1.0 dropped the hover icon rotation entirely; app-owned
-// tool/thinking cards keep the open-state chevron flip.
-.tool-card[open] > summary .tool-chevron,
-.thinking-card[open] > summary .tool-chevron {
- transform: rotate(180deg) !important;
-}
-
-// ─── Syntax highlighting ───────────────────────────────────────────────────
-
-// highlight.js emits `.hljs-*` classes but ships no theme of its own — without
-// these rules every highlighted block renders in one flat text colour, which
-// is what an un-styled `hljs` looks like. One palette, declared once, so the
-// message cards, the artifact panel and the content lightbox all agree.
-// Colours are kit palette tokens, picked for the dark default theme.
-.hljs {
- background: transparent;
- color: inherit;
-}
-
-.hljs-comment,
-.hljs-quote {
- color: $color-text-dark;
- font-style: italic;
-}
-
-.hljs-keyword,
-.hljs-selector-tag,
-.hljs-literal,
-.hljs-doctag,
-.hljs-formula {
- color: $color-purple;
-}
-
-.hljs-string,
-.hljs-regexp,
-.hljs-addition,
-.hljs-char.escape_ {
- color: $color-neon-green;
-}
-
-.hljs-number,
-.hljs-symbol,
-.hljs-bullet,
-.hljs-link {
- color: $color-orange;
-}
-
-.hljs-title,
-.hljs-title.class_,
-.hljs-title.function_,
-.hljs-section,
-.hljs-name,
-.hljs-selector-id,
-.hljs-selector-class {
- color: $color-electric-blue;
-}
-
-.hljs-attr,
-.hljs-attribute,
-.hljs-variable,
-.hljs-template-variable,
-.hljs-property {
- color: $color-cyan;
-}
-
-.hljs-type,
-.hljs-built_in,
-.hljs-class .hljs-title,
-.hljs-params,
-.hljs-operator,
-.hljs-punctuation {
- color: $color-neon-yellow;
-}
-
-.hljs-meta,
-.hljs-meta .hljs-keyword {
- color: $color-teal;
-}
-
-.hljs-deletion {
- color: $color-error;
-}
-
-.hljs-emphasis {
- font-style: italic;
-}
-
-.hljs-strong {
- font-weight: 600;
-}
-
-// ─── Search result message flash ────────────────────────────────────────────
-.msg-flash {
- animation: msgFlash 2.5s ease-out;
-}
-
-@keyframes msgFlash {
- 0% { filter: brightness(1); }
- 15% { filter: brightness(1.4); background-color: rgba($color-secondary, 0.12); }
- 40% { filter: brightness(1.4); background-color: rgba($color-secondary, 0.12); }
- 100% { filter: brightness(1); }
-}
+// App-wide styles, split by area. Order is the cascade order.
+@use 'partials/base';
+@use 'partials/shell';
+@use 'partials/chat';
+@use 'partials/composer';
diff --git a/webclient/src/styles/partials/_base.scss b/webclient/src/styles/partials/_base.scss
new file mode 100644
index 0000000..1dd9d9f
--- /dev/null
+++ b/webclient/src/styles/partials/_base.scss
@@ -0,0 +1,74 @@
+@use 'kit-deps' as *;
+@use 'tokens' as *;
+
+// ─── Scrollbars ────────────────────────────────────────────────────────────
+// The kit paints thick square webkit bars globally (kit.scss). The app
+// overrides them thinner and rounded, consistently for every scroll area
+// (tabs strip, settings region, message list, tables, code blocks).
+// Firefox gets the same look via scrollbar-width/color, which have no
+// per-element control.
+* {
+ scrollbar-width: thin;
+ scrollbar-color: $color-grey transparent;
+}
+
+*::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+*::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+*::-webkit-scrollbar-thumb {
+ width: 8px;
+ background: $color-grey;
+ border-radius: 9999px;
+ cursor: default;
+}
+
+*::-webkit-scrollbar-thumb:hover {
+ background: $color-text-dark;
+}
+
+*::-webkit-scrollbar-corner {
+ background: transparent;
+}
+
+*::-webkit-scrollbar-button {
+ display: none;
+}
+
+// ─── Selection policy (PWA) ────────────────────────────────────────────────
+// Service UI is chrome, not content: long-pressing a control drags in the
+// browser text-selection sheet ("Search with Google") over the controls.
+// Keep selection OFF for chrome and ON for message content / text controls.
+.chat-header,
+.context-bar,
+.profile-badge,
+.msg-meta-row,
+.recall-banner,
+.app-sidebar,
+.input-bar,
+.tabs-list {
+ user-select: none;
+ -webkit-touch-callout: none;
+
+ // Anything the user actually types in stays selectable + editable-safe.
+ input,
+ textarea {
+ user-select: auto;
+ -webkit-touch-callout: default;
+ }
+}
+
+// Focus zoom: mobile browsers zoom the viewport when an input with a
+// font-size < 16px gets focus. Text entry stays at 16px on phones;
+// checkboxes/radios are excluded — they never trigger the zoom.
+@media (max-width: 767px) {
+ textarea,
+ input:not([type='checkbox']):not([type='radio']):not([type='range']) {
+ font-size: 16px !important;
+ }
+}
diff --git a/webclient/src/styles/partials/_chat.scss b/webclient/src/styles/partials/_chat.scss
new file mode 100644
index 0000000..8766f1c
--- /dev/null
+++ b/webclient/src/styles/partials/_chat.scss
@@ -0,0 +1,1061 @@
+@use 'kit-deps' as *;
+@use 'tokens' as *;
+
+// ─── Chat area (ChatArea.vue root) ─────────────────────────────────────────
+.chat-area {
+ flex: 1 1 0;
+ min-height: 0;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ background: $surface-page;
+}
+
+// ─── Chat header ───────────────────────────────────────────────────────────
+.chat-header {
+ display: flex;
+ align-items: center;
+ gap: $space-sm;
+ padding: $space-sm $space-md;
+ border-bottom: $border-width-base $border-style-base $border-color-muted;
+ background: $surface-panel;
+ flex-shrink: 0;
+ min-height: 54px;
+}
+
+.chat-header-info {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: $space-sm;
+ overflow: hidden;
+ min-width: 0;
+
+ @media (max-width: $bp-mobile) {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 2px;
+ }
+}
+
+.chat-header-title {
+ font-size: 14px;
+ font-weight: 600;
+ color: $color-text-light;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex-shrink: 1;
+ min-width: 0;
+ cursor: text;
+}
+
+// Rename: replaces the title, stretches across the header-info width
+.chat-header-rename {
+ flex: 1;
+ min-width: 0;
+ height: 32px;
+ padding: 0 10px;
+ font-size: 14px;
+ font-weight: 600;
+ font-family: inherit;
+ color: $color-text-light;
+ background: $surface-panel-strong;
+ border: 1px solid $color-secondary;
+ border-radius: $border-radius-sm;
+ outline: none;
+}
+
+.profile-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+ white-space: nowrap;
+}
+
+.btn-sidebar-toggle {
+ display: none;
+ @media (max-width: $bp-sidebar-drawer) {
+ display: flex;
+ }
+}
+
+// ─── Context bar (token indicator) ─────────────────────────────────────────
+.context-bar {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ cursor: default;
+}
+
+// ─── Profile badge ─────────────────────────────────────────────────────────
+// ─── Message list ──────────────────────────────────────────────────────────
+// Message list: the virtual scroller (MessageList.vue) carries its own
+// scoped layout; nothing global left here.
+
+// ─── Messages ──────────────────────────────────────────────────────────────
+.msg-user {
+ display: flex;
+ justify-content: flex-end;
+ gap: $space-sm;
+}
+
+.msg-user-bubble {
+ background: $color-secondary;
+ color: $color-black;
+ padding: $space-sm $space-md;
+ border-radius: $border-radius-md;
+ font-size: 14px;
+ line-height: 1.6;
+ word-wrap: break-word;
+ width: 100%;
+
+ .msg-user-quote {
+ margin: 0 0 $space-xs 0;
+ padding: 2px 0 2px $space-sm;
+ border-left: 3px solid rgba($color-black, 0.35);
+ opacity: 0.7;
+ font-size: 13px;
+ }
+}
+
+.msg-user--recall .msg-user-bubble {
+ background: color-mix(in srgb, var(--gn-color-secondary) 12%, transparent);
+ border: 1px solid color-mix(in srgb, var(--gn-color-secondary) 25%, transparent);
+
+ .msg-user-text {
+ color: $color-text-light;
+ }
+}
+
+.msg-recall-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 11px;
+ color: var(--gn-color-secondary);
+ margin-bottom: 4px;
+ opacity: 0.85;
+}
+
+// Sent while a turn was running: shown at once, executed when that turn ends.
+// Muted rather than highlighted — it is the user's own message, not news.
+.msg-user--queued .msg-user-bubble {
+ opacity: 0.75;
+}
+
+// Neutral, not accented. The badge sits on the user's blue bubble, which is
+// itself faded to 0.75 for a queued message; the accent orange it used to take
+// (#FF9E64) measured ~1.9:1 there and read as a warning sign rather than as
+// "this message is waiting". The palette's own grey stays readable against that
+// blue and reads as secondary text, where the darker neutrals (#1F2335, the
+// bubble's own near-black text) look like the message itself.
+.msg-queued-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 11px;
+ font-weight: 500;
+ color: $color-grey;
+ margin-bottom: 4px;
+}
+
+.msg-user-attachments {
+ display: flex;
+ flex-wrap: wrap;
+ gap: $space-xs;
+ margin-top: $space-xs;
+}
+
+.msg-user-image {
+ width: 80px;
+ height: 80px;
+ object-fit: cover;
+ border-radius: $border-radius-md;
+ cursor: pointer;
+ transition: opacity 0.15s;
+
+ &:hover {
+ opacity: 0.75;
+ }
+}
+
+.msg-user-file-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: $space-xs;
+ padding: 2px $space-sm;
+ background: rgba($color-black, 0.3);
+ border-radius: $border-radius-pill;
+ font-size: 12px;
+}
+
+.msg-time {
+ font-size: 11px;
+ color: $color-text-dark;
+ line-height: 1;
+ margin-top: 4px;
+}
+
+// Assistant message metadata row (elapsed · tools · tokens · time)
+.msg-meta-row {
+ display: flex;
+ align-items: center;
+ gap: $space-sm;
+ margin-top: 2px;
+ min-width: 0;
+}
+
+.msg-meta-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 11px;
+ color: $color-text-dark;
+ white-space: nowrap;
+
+ i { font-size: 12px; opacity: 0.7; }
+}
+
+.msg-meta-time {
+ // grouped with other meta items — no special positioning
+}
+
+.msg-user-wrap {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ max-width: 70%;
+
+ @media (max-width: $bp-mobile) {
+ max-width: 90%;
+ }
+}
+
+.msg-time--user {
+}
+
+.msg-user-footer {
+ display: flex;
+ align-items: center;
+ gap: $space-xs;
+ align-self: flex-end;
+}
+
+.msg-copy-btn {
+ width: 22px;
+ height: 22px;
+ min-width: 22px;
+ padding: 0;
+ font-size: 12px;
+ opacity: 0.5;
+ transition: opacity 0.15s;
+
+ &:hover { opacity: 1; }
+}
+
+.msg-meta-row > .msg-copy-btn {
+ margin-left: auto;
+}
+
+.msg-meta-row .msg-rate-btn {
+ width: 22px;
+ height: 22px;
+ min-width: 22px;
+ padding: 0;
+ font-size: 13px;
+}
+
+.msg-assistant {
+ display: flex;
+ flex-direction: column;
+ gap: $space-sm;
+ max-width: 100%;
+}
+
+.planning-indicator {
+ display: flex;
+ align-items: center;
+ gap: $space-sm;
+ font-size: 13px;
+ font-style: italic;
+ color: $color-text-dark;
+ padding: $space-xs 0;
+}
+
+.planning-indicator.is-executing {
+ color: $color-text-medium;
+ font-style: normal;
+}
+
+.planning-label {
+ opacity: 0.75;
+ transition: opacity 0.2s;
+}
+
+.msg-assistant-avatar {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.msg-assistant-content {
+ font-size: 14px;
+ line-height: 1.7;
+ color: $color-text-light;
+
+ // Flex container so image paragraphs can form a grid
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+
+ // Markdown block elements take full width by default
+ & > p, & > ul, & > ol, & > blockquote, & > pre, & > .table-wrap,
+ & > .code-block, & > h1, & > h2, & > h3, & > h4, & > hr {
+ flex: 1 1 100%;
+ margin: 0;
+ }
+
+ // Paragraphs that contain only a markdown image link → 3 per row
+ & > p:has(> .msg-md-image-link:only-child) {
+ flex: 0 0 calc((100% - 2 * 8px) / 3);
+ }
+
+ h1, h2, h3, h4 { color: $color-text-light; }
+ ul, ol { padding-left: $space-lg; }
+ li { margin-bottom: $space-sm; }
+ blockquote {
+ padding-left: $space-md;
+ border-left: $border-width-accent $border-style-base $color-secondary;
+ color: $color-text-medium;
+ }
+ a { color: $color-secondary; }
+ strong { color: $color-text-light; }
+ hr { border: none; border-top: 1px solid $border-color-muted; }
+
+ // Markdown images: adaptive square grid, click opens lightbox
+ .msg-md-image-link {
+ display: block;
+ width: 100%;
+ text-decoration: none;
+ border: 2px solid rgba($color-text-light, 0.12);
+ border-radius: $border-radius-sm;
+ overflow: hidden;
+ transition: border-color 0.2s ease;
+ cursor: zoom-in;
+
+ &:hover {
+ border-color: $color-secondary;
+ }
+ }
+
+ .msg-md-image {
+ display: block;
+ width: 100%;
+ aspect-ratio: 1 / 1;
+ object-fit: cover;
+ border-radius: 2px;
+ }
+
+ .msg-md-image-link.is-broken {
+ cursor: default;
+ border-color: rgba($color-error, 0.4);
+ background: rgba($color-error, 0.06);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ aspect-ratio: 1 / 1;
+
+ &::before {
+ content: '🖼️';
+ font-size: 32px;
+ opacity: 0.4;
+ }
+
+ &:hover {
+ border-color: rgba($color-error, 0.4);
+ }
+
+ .msg-md-image {
+ display: none;
+ }
+ }
+
+ // Markdown tables: scrollable wrapper for mobile
+ .table-wrap {
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ margin: $space-md 0;
+ }
+
+ // Markdown tables live as bare inside .table-wrap (the pipe
+ // sanitizer never adds a class) — target that shape, not .table.
+ .table-wrap table {
+ margin: 0;
+ border: $border-width-base $border-style-base $border-color-muted;
+ border-radius: $border-radius-sm;
+
+ th,
+ td {
+ padding: $space-sm $space-md;
+ font-size: 13px;
+ vertical-align: middle;
+ text-align: left;
+ border-bottom: $border-width-base $border-style-base $border-color-muted;
+ }
+
+ th {
+ font-weight: 600;
+ color: $color-text-medium;
+ background: $surface-panel-muted;
+ }
+
+ tr:last-child th,
+ tr:last-child td {
+ border-bottom: none;
+ }
+ }
+
+ // Code inline
+ :not(pre) > code {
+ font-family: "IBM Plex Mono", monospace;
+ font-size: 13px;
+ background: $surface-panel-strong;
+ padding: 1px 5px;
+ color: $color-cyan;
+ }
+
+ // Code blocks — .code-block wraps header + pre
+ .code-block {
+ margin: $space-md 0;
+ background: $surface-panel;
+ border: $border-width-base $border-style-base $border-color-muted;
+ border-radius: $border-radius-sm;
+ overflow: hidden;
+
+ .code-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: $space-xs $space-md;
+ background: $surface-panel-strong;
+ border-bottom: $border-width-base $border-style-base $border-color-muted;
+ font-size: 12px;
+ color: $color-text-dark;
+ font-family: "IBM Plex Mono", monospace;
+ }
+
+ pre {
+ margin: 0;
+ background: $surface-panel !important;
+
+ code {
+ font-family: "IBM Plex Mono", monospace;
+ font-size: 13px;
+ display: block;
+ padding: $space-md;
+ overflow-x: auto;
+ }
+ }
+ }
+}
+
+// ─── Thinking card ─────────────────────────────────────────────────────────
+.thinking-card {
+ summary {
+ list-style: none;
+ display: flex;
+ align-items: center;
+ gap: $space-sm;
+ padding: $space-xs $space-md;
+ cursor: pointer;
+ color: $color-text-dark;
+ font-size: 12px;
+ font-style: italic;
+ background: $surface-panel-muted;
+ border: $border-width-base $border-style-base $border-color-muted;
+ border-radius: $border-radius-md;
+ transition: background $motion-fast $motion-ease;
+ user-select: none;
+
+ &::-webkit-details-marker { display: none; }
+ &:hover { background: $surface-panel-strong; }
+ }
+
+ &[open] summary {
+ border-bottom-color: transparent;
+ /* Keep only the top corners rounded — the full rounding let the page
+ background peek through the two corners where the summary meets the
+ straight-topped body (the "gap" when the card is expanded). */
+ border-radius: $border-radius-md $border-radius-md 0 0;
+ }
+
+ .thinking-chevron {
+ margin-left: auto;
+ font-size: $icon-size-sm;
+ color: $color-text-dark;
+ flex-shrink: 0;
+ transition: transform $motion-fast $motion-ease;
+ }
+
+ &[open] .thinking-chevron {
+ transform: rotate(180deg);
+ }
+
+ .thinking-body {
+ padding: $space-sm $space-md;
+ font-size: 12px;
+ font-style: italic;
+ color: $color-text-medium;
+ line-height: 1.5;
+ background: $surface-panel-muted;
+ border: $border-width-base $border-style-base $border-color-muted;
+ border-top: none;
+ border-radius: 0 0 $border-radius-md $border-radius-md;
+ white-space: pre-wrap;
+ word-break: break-word;
+ /* Long reasoning chains don't grow the whole chat — scroll inside */
+ max-height: 800px;
+ overflow-y: auto;
+ }
+}
+
+// ─── Plan card ─────────────────────────────────────────────────────────────
+.plan-card {
+ border: $border-width-base $border-style-base $color-secondary;
+ border-left-width: $border-width-accent;
+ border-left-color: $color-secondary;
+ border-radius: $border-radius-md;
+ overflow: hidden;
+ font-size: 12px;
+ margin-bottom: 8px;
+
+ summary {
+ list-style: none;
+ display: flex;
+ align-items: center;
+ gap: $space-sm;
+ padding: $space-xs $space-md;
+ cursor: pointer;
+ background: rgba($color-secondary, 0.08);
+ color: $color-secondary;
+ font-size: 12px;
+ user-select: none;
+
+ &::-webkit-details-marker { display: none; }
+ &:hover { background: rgba($color-secondary, 0.14); }
+
+ .plan-chevron {
+ margin-left: auto;
+ font-size: $icon-size-sm;
+ color: rgba($color-secondary, 0.6);
+ transition: transform $motion-fast $motion-ease;
+ flex-shrink: 0;
+ }
+ }
+
+ &[open] .plan-chevron {
+ transform: rotate(180deg);
+ }
+
+ .plan-body {
+ padding: $space-sm $space-md;
+ font-size: 12px;
+ color: $color-text-medium;
+ line-height: 1.5;
+ background: rgba($color-secondary, 0.04);
+ border-top: $border-width-base $border-style-base rgba($color-secondary, 0.3);
+
+ p { margin: 0 0 $space-sm; &:last-child { margin-bottom: 0; } }
+ ul, ol { padding-left: $space-lg; margin-bottom: $space-sm; }
+ li { margin-bottom: $space-xs; }
+ strong { color: $color-text-light; }
+ }
+}
+
+// ─── Tool card ─────────────────────────────────────────────────────────────
+.tool-card {
+ border: $border-width-base $border-style-base $border-color-muted;
+ border-left-width: $border-width-accent;
+ border-radius: $border-radius-md;
+ margin-bottom: 8px;
+ border-left-color: $color-secondary;
+ overflow: hidden;
+ font-size: 13px;
+
+ &.is-success { border-left-color: $color-success; }
+ &.is-error { border-left-color: $color-error; }
+
+ > summary {
+ list-style: none;
+ display: flex;
+ align-items: center;
+ gap: $space-sm;
+ padding: $space-xs $space-md;
+ cursor: pointer;
+ background: $surface-panel-muted;
+ color: $color-text-medium;
+ user-select: none;
+
+ &::-webkit-details-marker { display: none; }
+ &:hover { background: $surface-panel-strong; }
+
+ .tool-chevron {
+ font-size: $icon-size-sm;
+ color: $color-text-dark;
+ flex-shrink: 0;
+ transition: transform $motion-fast $motion-ease;
+ }
+ }
+
+ &[open] > summary .tool-chevron {
+ transform: rotate(180deg);
+ }
+
+ .tool-name {
+ flex: 1;
+ font-family: "IBM Plex Mono", monospace;
+ font-size: 12px;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .tool-running-time {
+ font-family: "IBM Plex Mono", monospace;
+ font-size: 11px;
+ color: $color-text-dark;
+ white-space: nowrap;
+ }
+
+ .tool-status-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: $icon-size-sm;
+ height: $icon-size-sm;
+ font-size: $icon-size-sm;
+ flex-shrink: 0;
+ }
+}
+
+.tool-card-body {
+ background: $surface-panel-muted;
+ border-top: $border-width-base $border-style-base $border-color-muted;
+}
+
+.tool-running-banner {
+ display: flex;
+ align-items: center;
+ gap: $space-sm;
+ padding: $space-sm $space-md;
+ color: $color-text-medium;
+ background: rgba($color-secondary, 0.08);
+ border-bottom: 1px solid $border-color-muted;
+ font-size: 12px;
+}
+
+.tool-section {
+ padding: $space-sm $space-md;
+
+ & + & {
+ border-top: 1px solid $border-color-muted;
+ }
+}
+
+.tool-section-label {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: $color-text-dark;
+ margin-bottom: $space-xs;
+}
+
+.tool-code {
+ font-family: "IBM Plex Mono", monospace;
+ font-size: 12px;
+ color: $color-text-medium;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.tool-args {
+ summary {
+ list-style: none;
+ display: flex;
+ align-items: center;
+ gap: $space-sm;
+ padding: $space-xs $space-md;
+ cursor: pointer;
+ user-select: none;
+ font-size: 12px;
+ color: $color-text-medium;
+ background: $surface-panel-muted;
+
+ &::-webkit-details-marker { display: none; }
+ &:hover { background: $surface-panel-strong; }
+
+ .tool-chevron {
+ margin-left: auto;
+ font-size: $icon-size-sm;
+ color: $color-text-dark;
+ transition: transform $motion-fast $motion-ease;
+ }
+ }
+
+ &[open] > summary .tool-chevron {
+ transform: rotate(180deg);
+ }
+}
+
+.tool-cards-summary {
+ display: inline-flex;
+ align-items: center;
+ gap: $space-sm;
+ padding: $space-xs 0;
+}
+
+// A new tool card has to grow the list, not displace it: without the height the
+// card below lands a full card lower in the space of one frame. The height comes
+// from a grid row, not from a max-height or a measured pixel value — `0fr → 1fr`
+// interpolates in every engine and resolves to whatever the card turns out to
+// measure, so a live tool card, a plan block and the one-line "N tools" badge all
+// grow by the right amount, and a card dropped off the front shrinks away again.
+.tool-fade-item {
+ display: grid;
+ grid-template-rows: 1fr;
+ transition: none;
+
+ // A grid item's automatic minimum size would hold the row at the content
+ // height and leave 0fr nothing to shrink; the overflow does the clipping.
+ > * {
+ min-height: 0;
+ overflow: hidden;
+ }
+}
+
+.tool-fade-enter-active,
+.tool-fade-leave-active {
+ transition:
+ opacity $motion-base $motion-ease,
+ transform $motion-base $motion-ease,
+ grid-template-rows $motion-slow $motion-ease;
+}
+
+.tool-fade-enter-from,
+.tool-fade-leave-to {
+ opacity: 0;
+ transform: translateY(-4px);
+ grid-template-rows: 0fr;
+}
+
+.tool-result {
+ font-size: 12px;
+ line-height: 1.5;
+ color: $color-text-medium;
+
+ // Markdown content inside tool results uses normal font
+ h1, h2, h3, h4 { color: $color-text-light; margin: $space-sm 0; }
+ p { margin: $space-xs 0; }
+ ul, ol { padding-left: $space-lg; margin: $space-xs 0; }
+ li { margin-bottom: 2px; }
+ blockquote {
+ padding-left: $space-sm;
+ border-left: $border-width-accent $border-style-base $color-secondary;
+ color: $color-text-medium;
+ margin: $space-xs 0;
+ }
+ code {
+ font-family: "IBM Plex Mono", monospace;
+ background: $surface-panel-strong;
+ padding: 1px 4px;
+ color: $color-cyan;
+ font-size: 11px;
+ }
+ pre {
+ margin: $space-xs 0;
+ background: $surface-panel;
+ border: $border-width-base $border-style-base $border-color-muted;
+ overflow: hidden;
+ code {
+ display: block;
+ padding: $space-sm;
+ overflow-x: auto;
+ }
+ }
+}
+
+.result-line {
+ font-family: "IBM Plex Mono", monospace;
+ padding: 1px 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+// Diff output (filesystem diff/edit_lines/smart_edit) — rendered line-by-line
+// with a tight line-height so consecutive diff lines don't show the wide
+// vertical gaps the generic result-line divs (line-height:1.5 + padding:1px 0)
+// produce.
+.result-line.diff-summary,
+.result-line.diff-hunk,
+.result-line.diff-add,
+.result-line.diff-del,
+.result-line.diff-ctx {
+ padding: 0;
+ line-height: 1.3;
+}
+// Fallback colouring for whole lines without a server-prefixed number
+// (hunk/file headers, or older callers).
+.result-line.diff-summary { color: $color-text-medium; }
+.result-line.diff-hunk { color: $color-text-medium; }
+.result-line.diff-add { color: $color-success; }
+.result-line.diff-del { color: $color-error; }
+.result-line.diff-ctx { color: inherit; }
+
+// Numbered diff lines (the common case — the server prefixes "{marker} {num}│
+// {content}"): only the {num} {marker} anchor takes the marker colour; the
+// content reads neutral. Matches the TUI renderer (renderers/diff.py).
+.diff-num-marker.diff-add { color: $color-success; }
+.diff-num-marker.diff-del { color: $color-error; }
+.diff-num-marker.diff-ctx { color: inherit; }
+.diff-content { color: inherit; }
+
+// Filesystem `read` output: header plaque (path accent, tail dim), optional
+// ⚠ warning, then numbered body — the line number is the accent colour
+// (navigation anchor), the content neutral. Matches the TUI _render_read.
+.read-header-path { color: $color-accent; }
+.read-header-rest { color: $color-text-medium; }
+.read-warning { color: $color-warning; }
+.read-num { color: $color-accent; }
+.read-content { color: inherit; }
+
+.json-object,
+.json-array {
+ font-family: "IBM Plex Mono", monospace;
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ padding: $space-xs 0;
+}
+
+.json-item {
+ display: flex;
+ align-items: baseline;
+ gap: $space-xs;
+ padding: 1px 0;
+}
+
+.json-key {
+ color: $color-text-light;
+ font-weight: 500;
+ flex-shrink: 0;
+}
+
+.json-sep {
+ color: $color-text-dark;
+}
+
+.json-value {
+ color: $color-text-medium;
+ word-break: break-word;
+ white-space: pre-wrap;
+}
+
+.json-string { color: $color-success; }
+.json-number { color: $color-warning; }
+.json-bool { color: $color-primary; }
+.json-null { color: $color-text-dark; font-style: italic; }
+.json-empty { color: $color-text-dark; font-style: italic; }
+
+// ─── Subagent steps ────────────────────────────────────────────────────────
+.subagent-steps {
+ display: flex;
+ flex-direction: column;
+ gap: $space-xs;
+ padding: $space-xs $space-md $space-md;
+ background: $surface-panel-muted;
+}
+
+.subagent-planning-indicator {
+ display: flex;
+ align-items: center;
+ gap: $space-sm;
+ padding: $space-xs $space-md;
+ font-size: 12px;
+ font-style: italic;
+ color: $color-text-dark;
+ background: $surface-panel-muted;
+}
+
+// Background-task progress line inside a tool card (task_update events)
+.task-update-step {
+ display: flex;
+ align-items: baseline;
+ gap: $space-sm;
+ padding: $space-xs $space-md;
+ font-size: 12px;
+ color: $color-text-medium;
+ background: $surface-panel-muted;
+
+ i { font-size: 14px; color: $color-warning; flex: none; }
+ .task-step-tool { color: $color-text-light; font-weight: 500; }
+ .task-step-status { color: $color-text-dark; font-style: italic; }
+ .task-step-preview {
+ color: $color-text-medium;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &.completed i { color: $color-success; }
+ &.completed .task-step-status { color: $color-success; font-style: normal; }
+ &.failed i, &.cancelled i { color: $color-error; }
+ &.failed .task-step-status, &.cancelled .task-step-status { color: $color-error; font-style: normal; }
+
+ .task-spin { animation: task-spin 1s linear infinite; }
+}
+
+@keyframes task-spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+
+.subagent-step {
+ border: $border-width-base $border-style-base $border-color-muted;
+ border-left-width: $border-width-accent;
+ border-left-color: $color-secondary;
+ border-radius: $border-radius-md;
+ overflow: hidden;
+ margin-bottom: 8px;
+ font-size: 12px;
+
+ &.is-success { border-left-color: $color-success; }
+ &.is-error { border-left-color: $color-error; }
+
+ summary {
+ list-style: none;
+ display: flex;
+ align-items: center;
+ gap: $space-sm;
+ padding: $space-xs $space-md;
+ cursor: pointer;
+ background: $surface-panel-muted;
+ color: $color-text-medium;
+ user-select: none;
+
+ &::-webkit-details-marker { display: none; }
+ &:hover { background: $surface-panel-strong; }
+ }
+
+ .tool-name {
+ flex: 1;
+ font-family: "IBM Plex Mono", monospace;
+ font-size: 12px;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .tool-running-time {
+ font-family: "IBM Plex Mono", monospace;
+ font-size: 11px;
+ color: $color-text-dark;
+ white-space: nowrap;
+ }
+
+ .tool-status-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: $icon-size-sm;
+ height: $icon-size-sm;
+ font-size: $icon-size-sm;
+ flex-shrink: 0;
+ }
+
+ .tool-chevron {
+ font-size: $icon-size-sm;
+ color: $color-text-dark;
+ flex-shrink: 0;
+ transition: transform $motion-fast $motion-ease;
+ }
+
+ &[open] > summary .tool-chevron {
+ transform: rotate(180deg);
+ }
+}
+
+// ─── Summary / compression notice ──────────────────────────────────────────
+.summary-card {
+ border: $border-width-base $border-style-base $border-color-muted;
+ border-left-width: $border-width-accent;
+ border-left-color: $color-info;
+ border-radius: $border-radius-md;
+ overflow: hidden;
+ font-size: 13px;
+
+ summary {
+ list-style: none;
+ display: flex;
+ align-items: center;
+ gap: $space-sm;
+ padding: $space-sm $space-md;
+ cursor: pointer;
+ background: rgba($color-info, 0.08);
+ color: $color-info;
+ user-select: none;
+ &::-webkit-details-marker { display: none; }
+ }
+}
+
+.compression-notice {
+ font-size: 12px;
+
+ summary {
+ list-style: none;
+ text-align: center;
+ padding: $space-sm;
+ color: $color-text-dark;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: $space-sm;
+ cursor: pointer;
+ user-select: none;
+ &::-webkit-details-marker { display: none; }
+
+ &::before, &::after {
+ content: '';
+ flex: 1;
+ height: 1px;
+ background: $border-color-muted;
+ }
+ }
+
+ .compression-counts {
+ opacity: 0.7;
+ }
+
+ .compression-chevron {
+ font-size: $icon-size-sm;
+ transition: transform $motion-fast $motion-ease;
+ }
+
+ &[open] .compression-chevron {
+ transform: rotate(180deg);
+ }
+
+ .compression-body {
+ padding: $space-md;
+ font-size: 13px;
+ color: $color-text-medium;
+ line-height: 1.6;
+ background: $surface-panel-muted;
+ border: $border-width-base $border-style-base $border-color-muted;
+ }
+}
diff --git a/webclient/src/styles/partials/_composer.scss b/webclient/src/styles/partials/_composer.scss
new file mode 100644
index 0000000..21c3d0c
--- /dev/null
+++ b/webclient/src/styles/partials/_composer.scss
@@ -0,0 +1,488 @@
+@use 'kit-deps' as *;
+@use 'tokens' as *;
+
+// ─── Input bar ─────────────────────────────────────────────────────────────
+.input-bar {
+ flex-shrink: 0;
+ padding: 8px 15px;
+ background: $surface-page;
+ border-top: $border-width-base $border-style-base $border-color-muted;
+
+ @media (max-width: 767px) {
+ padding: 0; /* empty bar is flush; the attachment strip below owns the gaps */
+ }
+}
+
+.input-dropzone {
+ position: relative;
+
+ &.is-dragging::after {
+ content: 'Drop files here';
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba($color-secondary, 0.12);
+ border: 2px dashed $color-secondary;
+ font-size: 14px;
+ color: $color-secondary;
+ pointer-events: none;
+ z-index: 10;
+ }
+}
+
+.file-preview-strip {
+ display: flex;
+ flex-wrap: wrap;
+ gap: $space-xs;
+ margin-bottom: $space-xs;
+ max-width: 920px;
+ margin-left: auto;
+ margin-right: auto;
+
+ @media (max-width: 767px) {
+ /* The strip, not the bar, provides the top gap on full-bleed mobile */
+ padding-top: $space-sm;
+ }
+}
+
+.file-preview-image {
+ position: relative;
+ width: 60px;
+ height: 60px;
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border: $border-width-base $border-style-base $border-color-muted;
+ }
+}
+
+.file-preview-pill.file-preview-pill {
+ /* GnChip root: filenames must not inherit the kit chip's uppercase */
+ text-transform: none;
+ max-width: 180px;
+
+ /* Inside the kit's inline-flex chip the name span won't shrink below its
+ content unless min-width is lifted (default `auto`), so the ellipsis
+ only kicks in with an explicit min-width: 0 */
+ .file-preview-name {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+}
+
+/* Thumb stays app-owned (no kit image-preview component) — kit surface look */
+.file-preview-image {
+ border: $border-width-base $border-style-base $border-color-muted;
+ border-radius: $border-radius-sm;
+ overflow: hidden;
+ background: $surface-panel-muted;
+}
+
+.file-preview-remove {
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: $color-text-dark;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ font-size: $icon-size-xs;
+ transition: color $motion-fast $motion-ease;
+ flex-shrink: 0;
+
+ &:hover { color: $color-error; }
+}
+
+.upload-progress {
+ height: 2px;
+ background: $surface-panel-strong;
+ margin-bottom: $space-xs;
+ overflow: hidden;
+}
+
+.upload-progress-fill {
+ height: 100%;
+ background: $color-secondary;
+ transition: width $motion-base $motion-ease;
+}
+
+// Indeterminate variant: fetch() gives no progress fraction, so the fill
+// slides across instead of sitting at a fake fixed percentage.
+.upload-progress.is-indeterminate {
+ .upload-progress-fill {
+ width: 30%;
+ transition: none;
+ animation: upload-slide 1.1s ease-in-out infinite;
+ }
+}
+
+@keyframes upload-slide {
+ 0% { transform: translateX(-110%); }
+ 100% { transform: translateX(440%); }
+}
+
+.input-row {
+ display: flex;
+ align-items: center;
+ gap: $space-sm;
+ background: $surface-panel-muted;
+ border: $border-width-base $border-style-base $color-text-light;
+ border-radius: $border-radius-sm;
+ padding: $space-sm;
+ max-width: 920px;
+ margin: 0 auto;
+
+ &:hover {
+ border-bottom-color: $color-text-dark;
+ }
+
+ &:focus-within {
+ border-color: $color-electric-blue;
+ background: transparent;
+ }
+
+ @media (max-width: 767px) {
+ border: none;
+ border-radius: 0;
+ max-width: none;
+ padding: $space-sm $space-md;
+ }
+}
+
+.input-textarea {
+ flex: 1;
+ background: none;
+ border: none;
+ outline: none;
+ resize: none;
+ color: $color-text-light;
+ font-size: 14px;
+ line-height: 1.5;
+ font-family: inherit;
+ min-height: 24px;
+ max-height: 144px;
+ overflow-y: auto;
+ padding: 2px 0;
+
+ &::placeholder { color: $color-text-dark; }
+}
+
+// ─── Welcome screen ────────────────────────────────────────────────────────
+.welcome-screen {
+ flex: 1;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ // `safe center` is not cosmetic: with a plain `center`, content taller than
+ // the pane overflows past the flex start edge and cannot be scrolled back to.
+ // The profile grid grows with the profile list, so this pane must be able to
+ // scroll — on a phone it is taller than the viewport with more than a few.
+ justify-content: safe center;
+ overflow-y: auto;
+ padding: $space-xl;
+ gap: $space-xl;
+ text-align: center;
+
+ @media (max-width: 768px) {
+ padding: 15px;
+ gap: $space-md;
+ justify-content: flex-start;
+ padding-top: 52px;
+ }
+}
+
+.welcome-logo {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: $space-sm;
+
+ img { width: 64px; height: 64px; }
+
+ h1 {
+ font-size: 30px; // kit h1 (typography tokens are not exported by kit-deps)
+ font-weight: 600;
+ color: $color-text-light;
+ margin: 0;
+ }
+
+ p {
+ font-size: var(--gn-font-size-base);
+ color: $color-text-medium;
+ margin: 0;
+ }
+
+ @media (max-width: 768px) {
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: $space-sm;
+
+ img { width: 36px; height: 36px; }
+
+ h1 { font-size: 22px; }
+
+ p {
+ font-size: 13px;
+ flex-basis: 100%;
+ }
+ }
+}
+
+.welcome-profiles {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ // `1fr` rows all take the height of the tallest one, so a card whose name
+ // wraps or whose description runs long does not stand out. A fixed pixel
+ // height would not survive that: "Server Administrator" wraps on a phone.
+ grid-auto-rows: 1fr;
+ // The pane scrolls, so the grid must not be squeezed to fit it.
+ flex-shrink: 0;
+ gap: $space-md;
+ width: min(100%, 880px);
+
+ @media (max-width: 768px) {
+ grid-template-columns: repeat(2, 1fr);
+ gap: 10px;
+ width: 100%;
+ max-width: 100%;
+ }
+}
+
+.welcome-profile-card {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: $space-sm;
+ padding: $space-md;
+ background: $surface-panel-muted;
+ border: $border-width-base $border-style-base $border-color-muted;
+ border-radius: $border-radius-md;
+ cursor: pointer;
+ transition: border-color $motion-fast $motion-ease, background $motion-fast $motion-ease;
+ width: 100%;
+ text-align: center;
+
+ &:hover {
+ border-color: $color-secondary;
+ background: $surface-panel-strong;
+ }
+
+ &.is-creating {
+ opacity: 0.6;
+ pointer-events: none;
+ }
+
+ .profile-name {
+ font-size: 14px;
+ font-weight: 600;
+ color: $color-text-light;
+ }
+
+ .profile-desc {
+ font-size: 12px;
+ color: $color-text-dark;
+ line-height: 1.4;
+ // No line clamp: a description the user cannot finish reading is worse
+ // than a taller card, and `grid-auto-rows: 1fr` keeps the row even anyway.
+ }
+
+ @media (max-width: 768px) {
+ width: auto;
+ padding: 10px;
+ gap: 6px;
+
+ .profile-name { font-size: 13px; }
+ .profile-desc { font-size: 11px; }
+ }
+}
+
+// ─── Empty state ───────────────────────────────────────────────────────────
+// Centers the GnEmptyState card inside the chat area.
+.empty-chat {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+// ─── Spinner ───────────────────────────────────────────────────────────────
+.spinner {
+ display: inline-block;
+ width: 14px;
+ height: 14px;
+ border: 2px solid $border-color-muted;
+ border-top-color: $color-secondary;
+ border-radius: 50%;
+ animation: spin 0.6s linear infinite;
+ flex-shrink: 0;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+// ─── Animations ────────────────────────────────────────────────────────────
+.fade-enter-active,
+.fade-leave-active {
+ transition: opacity $motion-fast $motion-ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+ opacity: 0;
+}
+
+.msg-enter {
+ animation: msgEnter $motion-base $motion-ease both;
+}
+
+@keyframes msgEnter {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.slide-left-enter-active,
+.slide-left-leave-active {
+ transition: transform $motion-slow $motion-ease, opacity $motion-slow $motion-ease;
+}
+.slide-left-enter-from {
+ transform: translateX(-100%);
+ opacity: 0;
+}
+.slide-left-leave-to {
+ transform: translateX(-100%);
+ opacity: 0;
+}
+
+// ─── Confirm dialog transition ─────────────────────────────────────────────
+.modal-enter-active,
+.modal-leave-active {
+ transition: opacity $motion-slow $motion-ease;
+
+ .modal-panel {
+ transition: opacity $motion-slow $motion-ease, margin-top $motion-slow $motion-ease;
+ }
+}
+
+.modal-enter-from,
+.modal-leave-to {
+ opacity: 0;
+
+ .modal-panel {
+ opacity: 0;
+ margin-top: 200px;
+ }
+}
+
+// ─── Disclosure chevrons ───────────────────────────────────────────────────
+
+// Kit 1.0 dropped the hover icon rotation entirely; app-owned
+// tool/thinking cards keep the open-state chevron flip.
+.tool-card[open] > summary .tool-chevron,
+.thinking-card[open] > summary .tool-chevron {
+ transform: rotate(180deg) !important;
+}
+
+// ─── Syntax highlighting ───────────────────────────────────────────────────
+
+// highlight.js emits `.hljs-*` classes but ships no theme of its own — without
+// these rules every highlighted block renders in one flat text colour, which
+// is what an un-styled `hljs` looks like. One palette, declared once, so the
+// message cards, the artifact panel and the content lightbox all agree.
+// Colours are kit palette tokens, picked for the dark default theme.
+.hljs {
+ background: transparent;
+ color: inherit;
+}
+
+.hljs-comment,
+.hljs-quote {
+ color: $color-text-dark;
+ font-style: italic;
+}
+
+.hljs-keyword,
+.hljs-selector-tag,
+.hljs-literal,
+.hljs-doctag,
+.hljs-formula {
+ color: $color-purple;
+}
+
+.hljs-string,
+.hljs-regexp,
+.hljs-addition,
+.hljs-char.escape_ {
+ color: $color-neon-green;
+}
+
+.hljs-number,
+.hljs-symbol,
+.hljs-bullet,
+.hljs-link {
+ color: $color-orange;
+}
+
+.hljs-title,
+.hljs-title.class_,
+.hljs-title.function_,
+.hljs-section,
+.hljs-name,
+.hljs-selector-id,
+.hljs-selector-class {
+ color: $color-electric-blue;
+}
+
+.hljs-attr,
+.hljs-attribute,
+.hljs-variable,
+.hljs-template-variable,
+.hljs-property {
+ color: $color-cyan;
+}
+
+.hljs-type,
+.hljs-built_in,
+.hljs-class .hljs-title,
+.hljs-params,
+.hljs-operator,
+.hljs-punctuation {
+ color: $color-neon-yellow;
+}
+
+.hljs-meta,
+.hljs-meta .hljs-keyword {
+ color: $color-teal;
+}
+
+.hljs-deletion {
+ color: $color-error;
+}
+
+.hljs-emphasis {
+ font-style: italic;
+}
+
+.hljs-strong {
+ font-weight: 600;
+}
+
+// ─── Search result message flash ────────────────────────────────────────────
+.msg-flash {
+ animation: msgFlash 2.5s ease-out;
+}
+
+@keyframes msgFlash {
+ 0% { filter: brightness(1); }
+ 15% { filter: brightness(1.4); background-color: rgba($color-secondary, 0.12); }
+ 40% { filter: brightness(1.4); background-color: rgba($color-secondary, 0.12); }
+ 100% { filter: brightness(1); }
+}
diff --git a/webclient/src/styles/partials/_shell.scss b/webclient/src/styles/partials/_shell.scss
new file mode 100644
index 0000000..b72733a
--- /dev/null
+++ b/webclient/src/styles/partials/_shell.scss
@@ -0,0 +1,310 @@
+@use 'kit-deps' as *;
+@use 'tokens' as *;
+
+// ─── App shell ─────────────────────────────────────────────────────────────
+$sidebar-width: 380px;
+
+html, body {
+ height: 100%;
+ // The document itself must never scroll: in a PWA the visual viewport
+ // wanders (keyboard, system panels) and a document taller than the screen
+ // lets the pull-to-refresh/gesture scroll carry fixed headers away.
+ // Everything scrolls inside #app's regions instead.
+ overflow: hidden;
+ overscroll-behavior: none;
+}
+
+// #app is the Vue mount point — full viewport flex container.
+// % height (not 100vh/dvh): relative to the layout viewport, which stays
+// stable in a standalone PWA where vh/dvh drift with keyboard and system bars.
+#app {
+ display: flex;
+ height: 100%;
+ overflow: hidden;
+}
+
+// ─── Main content area (sibling of sidebar) ────────────────────────────────
+.app-main {
+ flex: 1 1 0; // flex-basis:0 prevents content from dictating height
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ background: $surface-page;
+}
+
+// ─── Sidebar ───────────────────────────────────────────────────────────────
+.app-sidebar {
+ width: $sidebar-width;
+ min-width: $sidebar-width;
+ max-width: $sidebar-width;
+ display: flex;
+ flex-direction: column;
+ background: $surface-panel;
+ border-right: $border-width-base $border-style-base $border-color-muted;
+ overflow: hidden;
+ flex-shrink: 0;
+ transition:
+ width 0.22s ease,
+ min-width 0.22s ease,
+ max-width 0.22s ease,
+ border-color 0.22s ease,
+ transform 0.28s ease;
+
+ &.is-hidden-by-artifacts {
+ width: 0;
+ min-width: 0;
+ max-width: 0;
+ border-right-color: transparent;
+ }
+
+ @media (max-width: $bp-sidebar-drawer) {
+ position: fixed;
+ inset: 0;
+ z-index: 100;
+ width: min(#{$sidebar-width}, 92vw) !important;
+ min-width: min(#{$sidebar-width}, 92vw) !important;
+ transform: translateX(-100%);
+ transition: transform 0.28s ease;
+
+ &.is-mobile-open {
+ transform: translateX(0);
+ }
+
+ &.is-hidden-by-artifacts {
+ width: min(#{$sidebar-width}, 92vw) !important;
+ min-width: min(#{$sidebar-width}, 92vw) !important;
+ max-width: min(#{$sidebar-width}, 92vw) !important;
+ border-right-color: $border-color-muted;
+ }
+ }
+
+ @media (max-width: 980px) {
+ width: 100vw !important;
+ min-width: 100vw !important;
+ max-width: 100vw !important;
+
+ &.is-hidden-by-artifacts {
+ width: 100vw !important;
+ min-width: 100vw !important;
+ max-width: 100vw !important;
+ }
+ }
+}
+
+// Logo row: logo first, New Chat aligned into the same line (the old profile
+// select was removed — the profile chips above the list own that job now).
+.sidebar-header {
+ padding: $space-md;
+ border-bottom: $border-width-base $border-style-base $border-color-muted;
+ display: flex;
+ flex-direction: column;
+ gap: $space-sm;
+}
+
+.sidebar-logo-row {
+ display: flex;
+ align-items: center;
+ gap: $space-sm;
+}
+
+.sidebar-logo {
+ display: flex;
+ align-items: center;
+ gap: $space-sm;
+ text-decoration: none;
+ // Takes the free width so it stays a single row with the New Chat button
+ flex: 1;
+ min-width: 0;
+
+ img {
+ width: 42px;
+ height: 42px;
+ }
+
+ span {
+ font-size: 16px;
+ font-weight: 700;
+ color: $color-text-light;
+ }
+}
+
+.sidebar-sessions {
+ flex: 1;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.sessions-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: $space-sm $space-md;
+ gap: $space-sm;
+}
+
+.sessions-label {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: $color-text-dark;
+}
+
+.sessions-refresh-btn {
+ font-size: 14px;
+ color: $color-text-dark;
+ transition: color 0.15s, transform 0.15s;
+
+ &:hover { color: $color-text-light; }
+ &.is-spinning i { animation: spin 0.8s linear infinite; }
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+// ─── Session item ──────────────────────────────────────────────────────────
+.session-item {
+ display: flex;
+ align-items: flex-start;
+ gap: $space-sm;
+ padding: $space-sm $space-md;
+ cursor: pointer;
+ border-radius: 0;
+ transition: background $motion-fast $motion-ease;
+ position: relative;
+
+ &:hover {
+ background: $surface-panel-muted;
+
+ .session-actions {
+ opacity: 1;
+ }
+ }
+
+ // Touch devices: always show actions
+ @media (hover: none) {
+ .session-actions {
+ opacity: 1;
+ }
+ }
+
+ &.is-active {
+ background: $surface-panel-strong;
+ border-left: $border-width-accent $border-style-base $color-secondary;
+ padding-left: calc(#{$space-md} - #{$border-width-accent});
+ }
+
+ &.is-pinned {
+ background: rgba($color-warning, 0.07);
+
+ .session-name {
+ color: $color-warning;
+ }
+ }
+
+ &.has-recall {
+ .session-actions {
+ opacity: 1;
+ }
+ }
+
+ &.is-special {
+ opacity: 0.72;
+
+ &:hover {
+ opacity: 1;
+ }
+
+ .session-name {
+ font-weight: 400;
+ }
+ }
+}
+
+.session-icons {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ flex-shrink: 0;
+ margin-top: 2px;
+ width: 14px;
+
+ .session-pin-icon,
+ .session-recall-icon,
+ .session-special-icon {
+ color: $color-text-dark;
+ font-size: $icon-size-xs;
+ opacity: 0;
+ transition: opacity $motion-fast $motion-ease;
+ }
+
+ .session-pin-icon {
+ color: $color-warning;
+ }
+
+ .session-recall-icon {
+ color: var(--gn-color-secondary);
+ }
+
+ .session-special-icon {
+ color: var(--gn-color-secondary);
+ }
+}
+
+.is-pinned .session-icons .session-pin-icon,
+.has-recall .session-icons .session-recall-icon,
+.is-special .session-icons .session-special-icon {
+ opacity: 1;
+}
+
+.session-info {
+ flex: 1;
+ overflow: hidden;
+}
+
+.session-name {
+ font-size: 13px;
+ color: $color-text-light;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.4;
+}
+
+.session-time {
+ font-size: 11px;
+ color: $color-text-dark;
+ line-height: 1.4;
+ margin-top: 2px;
+}
+
+.session-preview {
+ font-size: 12px;
+ color: $color-text-light;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ line-height: 1.4;
+ margin-top: 3px;
+}
+
+.session-actions {
+ display: flex;
+ gap: 2px;
+ opacity: 0;
+ transition: opacity $motion-fast $motion-ease;
+ flex-shrink: 0;
+ align-self: flex-start;
+ margin-top: 1px;
+
+ .btn-icon {
+ width: 26px;
+ height: 26px;
+ font-size: $icon-size-xs;
+ }
+}
diff --git a/webclient/src/styles/partials/_tokens.scss b/webclient/src/styles/partials/_tokens.scss
new file mode 100644
index 0000000..35e351a
--- /dev/null
+++ b/webclient/src/styles/partials/_tokens.scss
@@ -0,0 +1,7 @@
+@use 'kit-deps' as *;
+
+// ─── Breakpoints ───────────────────────────────────────────────────────────
+$bp-mobile: 768px;
+// Drawer mode starts strictly *below* 1280px: use an .02px-exclusive bound
+// so a viewport of exactly 1280px gets the desktop sidebar, not the drawer.
+$bp-sidebar-drawer: 1279.98px;