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;