diff --git a/desktop/renderer/css/composer.css b/desktop/renderer/css/composer.css index 7e24b775..d3eaacdc 100644 --- a/desktop/renderer/css/composer.css +++ b/desktop/renderer/css/composer.css @@ -1,184 +1,247 @@ /* ============================================================ - Composer dock — status strip, queue tray, voice strip, slash commands, the composer card, its control row (Backend · Provider · Model · Context · Mode), send and mic. Screens CM-01…07, CM-09…11. + Composer dock — Soft Tactile. Screens CM-01…07, CM-09…11, the dock in + every CH / MP screen. + Top to bottom inside `.composerwrap`: status line · queue tray · voice + strip · composer card; the slash list hangs above it all. Loaded after styles.css (tokens + kit). Colours come from tokens only. + Hooks the drivers read are kept by the renderer: #composer, #entry, + .composerwrap, .cfoot [data-sel-open], .cchip, .sendbtn(.locked/.stop), + .statusstrip, .slash/.slashrow, .micbtn, .qchip, .voicestrip. ============================================================ */ -/* composer */ -.composerwrap{flex:none;padding:0 var(--s6) var(--s5);max-width:720px;margin:0 auto;width:100%} -.statusstrip{height:32px;display:flex;align-items:center;gap:var(--s3);color:var(--text-secondary);font-size:13px} -.statusstrip.gated{color:var(--warn)} -.threedot{display:inline-flex;gap:3px} -.threedot i{width:4px;height:4px;border-radius:999px;background:var(--accent);animation:pulse 1.2s var(--ease) infinite} -.threedot i:nth-child(2){animation-delay:.15s} .threedot i:nth-child(3){animation-delay:.3s} -.qtray{display:flex;flex-direction:column;gap:var(--s2);padding-bottom:var(--s3)} -.qchip{height:28px;display:flex;align-items:center;gap:var(--s3);padding:0 var(--s3);border:1px dashed var(--line);border-radius:0;background:var(--bg-raised);font-size:13px;color:var(--text-secondary)} -.composer{border:1px solid var(--line);background:var(--bg-raised);border-radius:0;cursor:text;position:relative;overflow:hidden} -.composer.focus{border-color:var(--accent)} -.composer.running::before{content:"";position:absolute;inset:0 0 auto 0;height:2px;background:linear-gradient(90deg,transparent,var(--accent),transparent);animation:sweep 1.4s linear infinite} -@keyframes sweep{0%{transform:translateX(-100%)}100%{transform:translateX(100%)}} -.composer .field{display:flex;align-items:flex-end;gap:var(--s3);padding:12px 14px} -#entry{flex:1;min-width:0;background:none;border:none;outline:none;resize:none;font-size:15px;line-height:23px;height:23px;max-height:180px;overflow-y:auto;padding:0} -#entry::placeholder{color:var(--text-tertiary)} -.sendbtn{width:28px;height:28px;flex:none;border-radius:0;display:flex;align-items:center;justify-content:center;background:var(--accent);color:var(--on-fill)} -.sendbtn.mute{background:var(--bg-active);color:var(--text-disabled)} -.sendbtn.stop{background:transparent;border:1.5px solid var(--danger);color:var(--danger)} -.cfoot{height:28px;display:flex;align-items:center;gap:var(--s3);padding:0 10px 0 14px;box-shadow:inset 0 1px 0 var(--line-soft);margin:0 0 4px;overflow:hidden} -.cfoot > *{flex:none} -.cchip{white-space:nowrap} -.seg button{white-space:nowrap} -.cchip{height:22px;display:flex;align-items:center;gap:4px;padding:0 6px;border-radius:0;font-size:11px;color:var(--text-secondary)} -.cchip:hover{background:var(--bg-hover);color:var(--text-primary)} -.gauge{width:40px;height:4px;border-radius:0;background:var(--bg-sunken);overflow:hidden;flex:none} -.ctxbtn .gaugelb{font-size:11px} -.modechip{color:var(--accent-text);background:var(--accent-wash-soft)} -.modechip:hover{background:var(--accent-wash);color:var(--accent-text)} -.ctxbtn:hover .gauge i{background:var(--accent)} -.gauge i{display:block;height:100%;background:var(--text-tertiary)} +/* ---- The dock ---------------------------------------------------------- */ +.composerwrap{flex:none;position:relative;width:100%;max-width:760px;margin:0 auto; + padding:0 28px 14px;display:flex;flex-direction:column;gap:8px} -.slash{position:absolute;bottom:100%;left:0;margin-bottom:8px;width:520px;max-width:100%;background:var(--bg-overlay);border-radius:0;box-shadow:var(--shadow-overlay);overflow:hidden;z-index:15} -.slashrow{height:32px;display:grid;grid-template-columns:120px 1fr auto;gap:var(--s3);align-items:center;padding:0 var(--s4);width:100%} -.slashrow .cmd{font-family:var(--font-mono);font-size:13px;color:var(--accent-text)} -.slashrow .ds{font-size:11px;color:var(--text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.slashrow .hint{font-size:11px;color:var(--text-tertiary);font-family:var(--font-mono)} -.slashrow.on{background:var(--accent-wash)} -.slashlist{max-height:192px;overflow-y:auto;padding:4px 0} +/* ---- Status line: a chip, then machine text ---------------------------- + Variants in priority order (renderer composer()): approval gated · + provider waiting · busy · switch failed · Ready / Caution. */ +.statusstrip{display:flex;align-items:center;gap:8px;min-height:28px;min-width:0;padding:0 6px; + font-size:12.5px;line-height:1.3;color:var(--ink-2)} +.statusstrip > *{flex:none} +.statusstrip .tk-chip svg,.statusstrip .ann svg{width:12px;height:12px} +/* The kit's red chip text on --well is 4.1:1 in light at 11.5px. Mixing a + little --ink into the red darkens it in light and lightens it in dark, so + the word clears 4.5:1 in both without a new hue. */ +.statusstrip .ann.critical{background:color-mix(in srgb, var(--critical) 8%, transparent);color:color-mix(in srgb, var(--critical) 85%, var(--ink))} +.statusstrip .ss-grow{flex:1 1 auto;min-width:0} +.statusstrip .readout,.statusstrip .ss-time{flex:0 1 auto;min-width:0;font:400 11.5px/18px var(--font-mono); + color:var(--ink-2);font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.statusstrip .ss-text,.statusstrip .ss-why{flex:0 1 auto;min-width:0;white-space:nowrap;overflow:hidden; + text-overflow:ellipsis;color:var(--ink-2)} +.statusstrip .ss-why::before{content:"· "} +.statusstrip .btn svg{width:11px;height:11px} +.statusstrip .ss-jump{color:var(--blue)} +.statusstrip .ss-jump:hover{color:var(--blue-deep)} +.statusstrip .ss-jump svg{width:13px;height:13px} +.ss-phase{min-width:0;max-width:60%;flex:0 1 auto} +.ss-phase .ss-word{min-width:0;overflow:hidden;text-overflow:ellipsis} +.ss-dot{width:6px;height:6px;border-radius:50%;background:var(--amber);flex:none;animation:pulse 1.6s var(--ease) infinite} +.threedot{display:inline-flex;align-items:center;gap:3px;flex:none} +.threedot i{width:4px;height:4px;border-radius:50%;background:currentColor;animation:pulse 1s var(--ease) infinite} +.threedot i:nth-child(2){animation-delay:.2s} +.threedot i:nth-child(3){animation-delay:.4s} -@media (max-width:1120px){.cfoot .gauge,.cfoot .gaugelb{display:none}} +/* ---- Queue tray: outline pills "Queued · text · ×" --------------------- */ +.qtray{display:flex;flex-wrap:wrap;gap:6px;padding:0 4px;min-width:0} +.qchip{display:inline-flex;align-items:center;gap:6px;height:30px;max-width:300px;min-width:0; + padding:0 3px 0 11px;border-radius:var(--r-pill);box-shadow:inset 0 0 0 1.5px var(--bd-2); + font-size:12.5px;color:var(--ink-2)} +.qchip .qlb{flex:none;font-weight:600;color:var(--ink-2)} +.qchip .qtx{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ink)} +.qchip .qx{width:24px;height:24px} +.qchip .qx svg{width:12px;height:12px} -/* Lane B — context before the first message (item 3): a projection is visibly not a measurement */ -.ctxbtn.proj .gauge i { background: repeating-linear-gradient(90deg, var(--text-tertiary) 0 3px, transparent 3px 5px); } -.ctxbtn.proj:hover .gauge i { background: repeating-linear-gradient(90deg, var(--accent) 0 3px, transparent 3px 5px); } -.ctxbtn.proj .gaugelb { font-style: italic; } +/* ---- Voice strip ------------------------------------------------------- + Always in the DOM; `hidden` while idle, so the display rule below must + not resurrect it. */ +.voicestrip{display:grid;gap:4px;min-width:0;padding:9px 10px 8px 14px;border-radius:var(--r-card); + background:var(--lift);box-shadow:var(--sh-card)} +.voicestrip[hidden]{display:none} +.vsrow{display:flex;align-items:center;gap:10px;min-height:30px;min-width:0;font-size:14.5px;line-height:1.45;color:var(--ink)} +.vsdot{width:8px;height:8px;flex:none;border-radius:50%;background:var(--critical);opacity:.5} +.vsdot.live{opacity:1;animation:pulse 1.6s var(--ease) infinite} +/* Nine level bars. `--lv` (0…1) is the live level the audio tap reports; + each bar scales its own share of it and breathes on a stagger. Reduced + motion stops the breathing; the bars still follow the level. */ +.vslevel{--lv:0;display:flex;align-items:center;gap:2px;height:20px;flex:none} +.vslevel i{display:block;width:3px;border-radius:2px;background:var(--brand); + height:calc(16% + var(--lv) * var(--h, 60%));transform-origin:center; + transition:height .1s linear;animation:vswave 1.1s ease-in-out infinite} +.vslevel i:nth-child(1){--h:38%;animation-delay:0s} +.vslevel i:nth-child(2){--h:70%;animation-delay:-.13s} +.vslevel i:nth-child(3){--h:50%;animation-delay:-.26s} +.vslevel i:nth-child(4){--h:78%;animation-delay:-.39s} +.vslevel i:nth-child(5){--h:84%;animation-delay:-.52s} +.vslevel i:nth-child(6){--h:66%;animation-delay:-.65s} +.vslevel i:nth-child(7){--h:80%;animation-delay:-.78s} +.vslevel i:nth-child(8){--h:46%;animation-delay:-.91s} +.vslevel i:nth-child(9){--h:62%;animation-delay:-1.04s} +@keyframes vswave{0%,100%{transform:scaleY(.6)}50%{transform:scaleY(1)}} +.vsspin{width:14px;height:14px} +.vstext{flex:1;min-width:0;white-space:pre-wrap;overflow-wrap:anywhere;max-height:44px;overflow-y:auto} +.vspart{color:var(--ink-3)} +.vswait{color:var(--ink-2)} +.vslabel{font-size:13px;color:var(--ink-2)} +.vschip{display:inline-flex;align-items:center;gap:6px;height:30px;flex:none;padding:0 8px 0 10px; + border-radius:var(--r-pill);font:500 12.5px/1 var(--font-ui);color:var(--ink-2);white-space:nowrap; + transition:background var(--t-state) var(--ease),color var(--t-state) var(--ease)} +.vschip:hover{background:color-mix(in srgb, var(--ink) 7%, transparent);color:var(--ink)} +.vschip svg{width:12px;height:12px;opacity:.6} +.vschip.won{background:var(--success-tint);color:var(--success)} +.vschip.won:hover{background:color-mix(in srgb, var(--success) 18%, transparent);color:var(--success)} +.vsnote{font-size:11.5px;line-height:1.4;color:var(--ink-2)} +.vsico svg{width:13px;height:13px} +.vserr{flex:1;min-width:0;font-size:13.5px;font-weight:600;line-height:1.4;color:var(--critical)} +.voicestrip .vsopen{flex:none} +.voicestrip .vsx svg{width:13px;height:13px} -/* ---- Item 2: voice input ---- - The microphone sits inside `.field`, immediately left of send, and the - interim strip sits above the composer — never inside the textarea, whose - value is S.draft and only S.draft. */ -.micbtn{width:28px;height:28px;flex:none;border-radius:0;display:flex;align-items:center;justify-content:center; - background:transparent;color:var(--text-secondary)} -.micbtn:hover:not([disabled]){background:var(--bg-hover);color:var(--text-primary)} -.micbtn[disabled]{color:var(--text-disabled);cursor:default} -.micbtn.rec{color:var(--danger);background:var(--danger-wash);animation:micpulse 1.4s var(--ease) infinite} -@keyframes micpulse{0%,100%{opacity:1}50%{opacity:.55}} -.voicestrip{padding:0 0 var(--s3)} -.vsrow{display:flex;align-items:center;gap:var(--s3);min-height:28px;font-size:13px;color:var(--text-primary)} -.vsdot{width:8px;height:8px;flex:none;border-radius:999px;background:var(--text-tertiary)} -.vsdot.live{background:var(--danger);animation:micpulse 1.2s var(--ease) infinite} -.vslevel{width:36px;height:4px;flex:none;border-radius:0;background:var(--bg-sunken);overflow:hidden} -.vslevel i{display:block;height:100%;background:var(--danger);transition:width .1s linear} -.vstext{flex:1;min-width:0;white-space:pre-wrap;overflow-wrap:anywhere;max-height:46px;overflow-y:auto;line-height:23px} -.vspart{opacity:.65} -.vswait{color:var(--text-tertiary)} -.vserr{flex:1;color:var(--danger);font-size:13px} -.vschip{height:22px;flex:none;display:flex;align-items:center;gap:4px;padding:0 6px;border-radius:0; - font-size:11px;color:var(--text-secondary)} -.vschip:hover{background:var(--bg-hover);color:var(--text-primary)} -.vschip.won{color:var(--accent-text);background:var(--accent-wash-soft)} -.vsnote{font-size:11px;line-height:16px;color:var(--text-tertiary);padding-top:2px} -.vsmenu{margin-top:var(--s3);border:1px solid var(--line);border-radius:0;background:var(--bg-raised); - max-height:280px;overflow-y:auto;padding:var(--s2) 0} -.vsmhead{padding:var(--s2) var(--s4) 2px;font-size:11px;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:.04em} -.vsmrow{display:flex;align-items:center;gap:var(--s2);padding:0 var(--s2) 0 0} -.vsmrow.on{background:var(--accent-wash-soft)} -.vsmpick{flex:1;min-width:0;display:flex;align-items:center;gap:var(--s3);height:28px;padding:0 var(--s4); - font-size:13px;color:var(--text-primary);text-align:left} -.vsmrow:hover{background:var(--bg-hover)} -.vsmname{flex:none} -.vsmtag{font-size:11px} -.vsmnote{flex:1;min-width:0;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right} -.vsmbadge{flex:none;font-size:10px;padding:1px 5px;border-radius:0;background:var(--accent-wash);color:var(--accent-text)} -.vsmadd{width:22px;height:22px;flex:none;display:flex;align-items:center;justify-content:center;border-radius:0;color:var(--text-tertiary)} -.vsmadd.on{color:var(--accent-text)} -.vsmadd:hover{background:var(--bg-active);color:var(--text-primary)} -.vsmerr{padding:var(--s2) var(--s4);font-size:11px;color:var(--danger)} -.vsmfoot{padding:var(--s3) var(--s4) var(--s2);font-size:11px;line-height:16px;color:var(--text-tertiary)} -.vsmempty{padding:var(--s3) var(--s4);font-size:12px;color:var(--text-secondary)} -@media (prefers-reduced-motion:reduce){.micbtn.rec,.vsdot.live{animation:none}} +/* The dictation-language menu is the popovers slice's surface; these are + zero-specificity defaults so its own rules win wherever they exist. */ +:where(.vsmenu){margin-top:4px;padding:4px 0 2px;box-shadow:0 -1px 0 var(--divider);max-height:280px;overflow-y:auto} +:where(.vsmhead){padding:8px 10px 2px;font-size:12px;font-weight:600;color:var(--ink-2)} +:where(.vsmrow){display:flex;align-items:center;gap:6px;padding:0 4px 0 0;border-radius:12px} +:where(.vsmrow:hover){background:var(--hover)} +:where(.vsmrow.on){background:var(--brand-wash)} +:where(.vsmpick){flex:1;min-width:0;display:flex;align-items:center;gap:10px;min-height:32px;padding:0 10px; + font-size:13px;color:var(--ink);text-align:left} +:where(.vsmname){flex:none} +:where(.vsmtag){font-size:11px} +:where(.vsmnote){flex:1;min-width:0;font-size:11.5px;color:var(--ink-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right} +:where(.vsmbadge){flex:none;height:18px;display:inline-flex;align-items:center;padding:0 6px;border-radius:var(--r-pill); + font-size:11px;background:var(--brand-wash);color:var(--blue)} +:where(.vsmadd){width:26px;height:26px;flex:none;display:grid;place-items:center;border-radius:50%;color:var(--ink-2)} +:where(.vsmadd.on){color:var(--blue)} +:where(.vsmadd:hover){background:var(--press);color:var(--ink)} +:where(.vsmerr){padding:6px 10px;font-size:12px;color:var(--critical)} +:where(.vsmfoot){padding:8px 10px 4px;font-size:11.5px;line-height:1.45;color:var(--ink-2)} +:where(.vsmempty){padding:8px 10px;font-size:12.5px;color:var(--ink-2)} -/* ---- Item 2: voice input — review fixes ---- - The error strip's own dismiss. Escape deliberately no longer clears the - error state (it would outrank whatever modal the user is looking at), so - the strip carries the control that does. */ -.vsx{width:22px;height:22px;flex:none;display:flex;align-items:center;justify-content:center; - border-radius:0;color:var(--text-tertiary)} -.vsx:hover{background:var(--bg-hover);color:var(--text-primary)} +/* ---- The composer card -------------------------------------------------- + Translucent lift over a blur — the only glass in the app — with the + indigo-tinted composer shadow. Typing in it adds the brand ring. */ +.composer{position:relative;display:flex;flex-direction:column;gap:6px;min-width:0; + padding:10px 10px 8px 16px;border-radius:var(--r-comp);cursor:text; + background:color-mix(in srgb, var(--lift) 86%, transparent); + -webkit-backdrop-filter:blur(18px) saturate(1.3);backdrop-filter:blur(18px) saturate(1.3); + box-shadow:var(--sh-comp);transition:box-shadow var(--t-state) var(--ease)} +.composer:has(#entry:focus),.composer.focus{box-shadow:var(--sh-comp),0 0 0 2px color-mix(in srgb, var(--brand) 45%, transparent)} +/* Running: a soft brand glint travelling along the top edge, not a bar. */ +.composer.running::after{content:"";position:absolute;top:0;left:28px;right:28px;height:2px;border-radius:2px;pointer-events:none; + background:linear-gradient(90deg, transparent, color-mix(in srgb, var(--brand) 60%, transparent), transparent) no-repeat; + background-size:36% 100%;animation:cmp-glint 2.2s var(--ease) infinite} +@keyframes cmp-glint{from{background-position:-60% 0}to{background-position:160% 0}} -/* ---- r5 item 10: the switch lock on the send button ------------------------ - The user's words: "the send button is a bit locked and has a small loading - indicator on it." Muted like `.sendbtn.mute` — this is not an error state, - it is a wait — with one small ring spinning inside it. The button itself - carries `disabled`, so the pointer never turns into a hand. The markup only - appears after SWX_SPINNER_DELAY_MS, so a 2 ms coding-mode switch never - paints it at all. */ -.sendbtn.locked{background:var(--bg-active);color:var(--text-disabled);cursor:default} -.sspin{width:13px;height:13px;border-radius:999px;border:1.5px solid var(--text-disabled); - border-top-color:transparent;animation:sspin .7s linear infinite} -@keyframes sspin{to{transform:rotate(360deg)}} -@media (prefers-reduced-motion:reduce){.sspin{animation-duration:2.4s}} +.composer .field{display:flex;align-items:flex-end;gap:4px;min-width:0} +#entry{flex:1;min-width:0;height:36px;max-height:180px;padding:6px 8px 6px 0;border:0;outline:none; + background:transparent;resize:none;overflow-y:auto; + font:400 15px/24px var(--font-ui);letter-spacing:-.01em;color:var(--ink)} +#entry::placeholder{color:var(--ink-3)} -/* ============================================================ - Selector lane — the composer's model slot as a call to action - ============================================================ */ +/* Mic: a quiet round icon; recording turns it red and it breathes. */ +.micbtn{width:34px;height:34px;flex:none;display:grid;place-items:center;border-radius:50%; + background:transparent;color:var(--ink-2); + transition:background var(--t-state) var(--ease),color var(--t-state) var(--ease)} +.micbtn svg{width:16px;height:16px} +.micbtn:hover:not([disabled]){background:var(--hover);color:var(--ink)} +.micbtn[disabled]{color:var(--ink-3);opacity:.55;cursor:default} +.micbtn.rec,.micbtn.rec:hover:not([disabled]){background:var(--critical-wash);color:var(--critical);animation:cmp-rec 1.4s var(--ease) infinite} +@keyframes cmp-rec{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb, var(--critical) 22%, transparent)} + 50%{box-shadow:0 0 0 5px color-mix(in srgb, var(--critical) 0%, transparent)}} -/* composer-meta-controls.tsx draws `download model` in the rail's own warn, - bold, rather than the route's foreground: "this slot is the one thing on - the bar the operator has to act on, and in the route's own tone it reads as - just another label". `--warn` is the desktop's counterpart, and it is - declared in all three theme blocks. */ -.cchip.dlchip{color:var(--warn);font-weight:600} -.cchip.dlchip:hover{background:var(--warn-wash);color:var(--warn)} +/* Send: 36px circle. Empty = muted · draft = ink · busy + empty = stop · + busy + draft = brand (steer) · switch lock = muted with a small spinner. */ +.sendbtn{width:36px;height:36px;flex:none;display:grid;place-items:center;border-radius:50%; + background:var(--ink);color:var(--paper); + transition:background var(--t-state) var(--ease),color var(--t-state) var(--ease),transform var(--t-state) var(--ease)} +.sendbtn svg{width:16px;height:16px;stroke-width:2.2} +.sendbtn:hover{background:color-mix(in srgb, var(--ink) 84%, var(--paper))} +.sendbtn:active:not([disabled]):not(.mute){transform:scale(.94)} +.sendbtn.mute,.sendbtn.mute:hover{background:var(--panel-2);color:var(--ink-3);cursor:default} +.sendbtn.stop svg{width:12px;height:12px} +.sendbtn.steer{background:var(--brand);color:var(--on-strong)} +.sendbtn.steer:hover{background:var(--brand-press);color:var(--on-strong)} +.sendbtn.locked,.sendbtn.locked:hover{background:var(--panel-2);color:var(--ink-3);cursor:default} +.sspin{width:14px;height:14px;border-radius:50%;border:2px solid color-mix(in srgb, var(--ink-3) 35%, transparent); + border-top-color:var(--ink-2);animation:spin .9s linear infinite} -/* --- the microphone refusal offers the switch, not directions --- */ -.voicestrip .vsopen{height:24px;padding:0 10px;font-size:12px;margin-left:8px;white-space:nowrap} +/* ---- Control row: Backend · Provider · Model, spacer, Context · Mode ---- + Pill selects, transparent until hovered or open. Captions ("Backend", + "Provider") are drawn from `data-cap` with ::before, so the chip's + textContent stays exactly the id the drivers compare. */ +.cfoot{display:flex;align-items:center;gap:2px;min-width:0;margin-left:-6px} +.cfoot .cgrow{flex:1 1 auto;min-width:6px} +.cfoot .cchip{display:inline-flex;align-items:center;gap:6px;height:30px;min-width:0;flex:none; + padding:0 8px 0 10px;border-radius:var(--r-pill);background:transparent;color:var(--ink); + font:500 12.5px/1 var(--font-ui);white-space:nowrap; + transition:background var(--t-state) var(--ease),color var(--t-state) var(--ease)} +.cfoot .cchip:hover,.cfoot .cchip.is-open{background:color-mix(in srgb, var(--ink) 7%, transparent)} +.cfoot .cchip:active{transform:scale(.97)} +.cfoot .cchip > svg{width:14px;height:14px;color:var(--ink-2)} +.cfoot .cchip > svg.chev{width:12px;height:12px;opacity:.6} +.cfoot .cchip > .logo,.cfoot .cchip > .tk-ico{width:20px;height:20px;margin-left:-4px} +.cfoot .cchip > .tk-ico svg{width:12px;height:12px} +.cfoot .cval{min-width:0;overflow:hidden;text-overflow:ellipsis} +.cfoot .cval[data-cap]::before{content:attr(data-cap);margin-right:5px;font-size:11px;font-weight:500;color:var(--ink-2)} +/* When the row runs out of room only the model id gives way: a provider id + is short and is the thing that tells two routes apart. */ +.cfoot .modelchip{flex:0 1 auto} +.cfoot .providerchip .cval{max-width:22ch} +.cfoot .modelchip .cval{max-width:30ch;font:500 12px/1 var(--font-mono);letter-spacing:0} -/* A model control with nothing chosen is a call to action, not a label: - same warn treatment the `download model` CTA already uses, so the one - control the operator has to touch reads as one. */ -.cchip.modelchip.needsmodel{color:var(--warn);border-color:var(--warn-wash)} -.cchip.modelchip.needsmodel:hover{background:var(--warn-wash)} +/* The model slot as a call to action: `choose a model` / `download model`. */ +.cfoot .cchip.needsmodel,.cfoot .cchip.dlchip{background:var(--amber-wash);color:var(--amber-text)} +.cfoot .cchip.needsmodel:hover,.cfoot .cchip.dlchip:hover, +.cfoot .cchip.needsmodel.is-open{background:color-mix(in srgb, var(--amber) 28%, transparent)} +.cfoot .cchip.needsmodel > svg,.cfoot .cchip.dlchip > svg{color:var(--amber-text)} -/* The app's own report on itself (F10): an annunciator and a readout, on the - line above the composer — not rows in the conversation. */ -.statusstrip.appstatus{display:flex;align-items:center;gap:var(--s3);min-height:24px; - padding:0 var(--s2)} -.statusstrip.appstatus .readout{background:transparent;color:var(--ink-3);text-align:left; - overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:var(--fs-label)} +/* Context gauge: a 20px ring and used / window in mono. */ +.cfoot .ctxbtn{padding:0 10px;color:var(--ink-2)} +.ctxring{width:20px;height:20px;flex:none} +.ctxring circle{fill:none;stroke-width:3} +.ctxring .bg{stroke:color-mix(in srgb, var(--ink) 17%, transparent)} +/* Butt caps: a near-empty window reads as an empty track, never as a + spinner beside the send button's real one. */ +.ctxring .fg{stroke:var(--brand);stroke-linecap:butt} +.ctxring.warn .fg{stroke:var(--amber)} +.ctxring.crit .fg{stroke:var(--critical)} +.ctxbtn .gaugelb{font:400 11.5px/1 var(--font-mono);letter-spacing:0;font-variant-numeric:tabular-nums;color:var(--ink-2)} +.ctxbtn.warn .gaugelb{color:var(--amber-text)} +.ctxbtn.crit .gaugelb{color:var(--critical)} -/* B.7 — the composer's control row as a legend plate: the name above, the - value below, values in machine type. Five unlabelled dropdowns is not a - control row, it is a puzzle. */ -/* The row was a single 28px strip clipped to its own height; a named control - is two lines, so it sizes to its content. */ -.cfoot{display:flex;align-items:flex-end;gap:var(--s4);height:auto;overflow:visible; - padding:var(--s2) var(--s3) var(--s1) var(--s4);box-shadow:none;margin:0; - border-top:1px solid var(--line-hair)} -.cgrp{display:flex;flex-direction:column;gap:1px;min-width:0} -.clabel{font:500 var(--fs-label)/1.2 var(--font-ui);letter-spacing:var(--tr-label); - text-transform:uppercase;color:var(--ink-3);padding:0 var(--s2)} -.cfoot .cchip{height:22px;font-family:var(--font-mono);font-size:var(--fs-label); - color:var(--ink);border-radius:var(--r);max-width:26ch;overflow:hidden;text-overflow:ellipsis} -.cfoot .cchip:hover{background:var(--panel)} +/* Mode chip tones: default neutral · plan blue · auto amber · bypass red. */ +.cfoot .cmodechip.tone-plan{background:var(--brand-wash);color:var(--blue)} +.cfoot .cmodechip.tone-plan:hover,.cfoot .cmodechip.tone-plan.is-open{background:var(--brand-wash-2)} +.cfoot .cmodechip.tone-auto{background:var(--amber-wash);color:var(--amber-text)} +.cfoot .cmodechip.tone-auto:hover,.cfoot .cmodechip.tone-auto.is-open{background:color-mix(in srgb, var(--amber) 28%, transparent)} +.cfoot .cmodechip.tone-bypass{background:var(--critical-wash);color:var(--critical)} +.cfoot .cmodechip.tone-bypass:hover,.cfoot .cmodechip.tone-bypass.is-open{background:color-mix(in srgb, var(--critical) 18%, transparent)} +.cfoot .cmodechip.tone-plan > svg,.cfoot .cmodechip.tone-auto > svg,.cfoot .cmodechip.tone-bypass > svg{color:currentColor} +.cfoot .cmodechip.blank{color:var(--ink-3)} +.cfoot .cmodechip.blank > svg{color:var(--ink-3)} -/* F14 — the command list is anchored on the composer's WRAPPER, because the - composer itself clips (it has to: the running indicator and the field's own - corners depend on it). The wrapper is the same anchor visually — the - composer is its last child — and the popover keeps its own scroller so a - long list cannot run off the top of the window. */ -.composerwrap{position:relative} -.composerwrap > .slash{bottom:auto;top:auto;left:var(--s6);right:var(--s6);width:auto; - max-width:none;transform:translateY(-100%);margin-top:calc(var(--s2) * -1)} -.slash{background:var(--paper);border:1px solid var(--line);border-radius:var(--r); - box-shadow:none;max-height:min(48vh,360px);overflow-y:auto;z-index:40;scrollbar-gutter:stable} -.slashlist{display:flex;flex-direction:column} -.slashrow{height:32px;border:0;background:transparent;text-align:left;cursor:pointer; - border-left:var(--rule) solid transparent} -.slashrow:hover{background:var(--panel)} -.slashrow.on{background:var(--panel);border-left-color:var(--accent)} -.slashrow .cmd{color:var(--ink);font-weight:500} -.slashrow .ds{color:var(--ink-2)} -.slashrow .hint{color:var(--ink-3)} +/* ---- Slash commands ------------------------------------------------------ + F14: anchored on the WRAPPER, never inside #composer — it opens upward + above the whole dock and keeps its own scroller. */ +.composerwrap > .slash{position:absolute;left:28px;right:28px;bottom:calc(100% - 4px);z-index:40; + max-width:560px;display:flex;flex-direction:column;padding:6px;border-radius:var(--r-pop); + background:var(--lift);box-shadow:var(--sh-pop)} +.slashlist{display:flex;flex-direction:column;gap:1px;max-height:min(44vh, 340px);overflow-y:auto;min-height:0} +.slashrow{display:grid;grid-template-columns:104px minmax(0,1fr) auto;align-items:center;gap:12px; + height:34px;flex:none;width:100%;padding:0 10px;border-radius:12px;background:transparent;color:var(--ink); + text-align:left;cursor:pointer;transition:background var(--t-state) var(--ease)} +.slashrow:hover{background:var(--hover)} +.slashrow.on{background:var(--brand-wash)} +.slashrow .cmd{font:400 13px/1 var(--font-mono);letter-spacing:0;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.slashrow .cmd b{font-weight:500;color:var(--blue)} +.slashrow .ds{font-size:13px;color:var(--ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.slashrow .hint{font:400 11px/1 var(--font-mono);letter-spacing:0;color:var(--ink-3);white-space:nowrap} +.slashrow.nomatch{display:flex;cursor:default} +.slashrow.nomatch:hover{background:transparent} +.slashrow.nomatch .cmd{font:500 13px/1 var(--font-ui);color:var(--amber-text)} +.slashfoot{display:flex;align-items:center;gap:4px;margin-top:4px;padding:7px 10px 2px;box-shadow:0 -1px 0 var(--divider);font-size:12px;color:var(--ink-2)} +.slashfoot .grow{flex:1} -@media (max-width:820px){ - .composerwrap{padding:0 var(--s4) var(--s4)} - .cfoot .seg{display:none} -} +/* ---- Narrow windows ------------------------------------------------------ */ +@media (max-width:1120px){.cfoot .ctxbtn:has(.ctxring) .gaugelb{display:none}} +@media (max-width:820px){.composerwrap{padding:0 16px 12px}.composerwrap > .slash{left:16px;right:16px}} diff --git a/desktop/renderer/renderer.js b/desktop/renderer/renderer.js index 58bf93a9..a06f2522 100644 --- a/desktop/renderer/renderer.js +++ b/desktop/renderer/renderer.js @@ -1985,26 +1985,42 @@ function apprCard(m) { function composer() { const running = S.busy || !!S.pending; + /* Soft Tactile: every status line is a chip, then machine text. What the + drivers and the ticker read is kept — `.statusstrip` (+ gated / waiting / + appstatus), the waiting strip's `.ann` / `.readout` / `.ob-help`, the busy + strip's FIRST `.tnum` (the 100 ms ticker writes the elapsed time into it), + `data-act="stop"` and `data-act="jump:appr"`. */ const status = S.pending - ? '
' + ic('warn') + 'Waiting for your approval' - + '
' + ? '
' + + '' + ic('alert') + 'Waiting for your approval' + + '' + + '
' /* F3 — a parked turn says so, and says when it tries again. The brief's shape: WAITING · · ATTEMPT n · NEXT TRY 30s, with a Stop. Caution, not critical: nothing has failed yet. */ : WAIT ? '
' - + 'Waiting' + + 'Waiting' + '' + esc(waitReadout()) + '' - + (WAIT.reason ? '' + esc(humanWaitReason(WAIT.reason)) + '' : '') - + '
' + + (WAIT.reason ? '' + esc(humanWaitReason(WAIT.reason)) + '' : '') + + '' + + '' : S.busy - ? '
' + S.phase + '' - + '' + (S.elapsed / 10).toFixed(1) + 's' - + '
' + ? '
' + + '' + + '' + S.phase + '' + + '' + (S.elapsed / 10).toFixed(1) + 's' + + '' + + '
' // r5 item 10: where the lock was, the reason it ended. A toast fades; - // the operator needs this next to the button that was disabled. + // the operator needs this next to the button that was disabled. The + // 45 s watchdog's line is a wait, not a failure, so it keeps Caution. : SWX.err - ? '
' + ic('warn') + esc(SWX.err) + '
' + ? '
' + + (/has not finished/.test(SWX.err) + ? '' + ic('alert') + 'Caution' + : '' + ic('alert') + 'Switch failed') + + '' + esc(SWX.err) + '
' /* F10 — where the app reports on itself. Lowest priority: a running turn, a pending approval or a failed switch all matter more than the last thing that changed. */ @@ -2015,8 +2031,9 @@ function composer() { + '' + esc(APPSTATUS.text) + '' : ''; const q = S.queued.length ? '
' + S.queued.map((t, i) => - '
Queued' + esc(t) + '' - + '
').join('') + '
' : ''; + '
Queued' + esc(t) + '' + + '
').join('') + '' : ''; + const backend = selBackend(); return '
' + status + q // Item 2 (voice input): the strip is ALWAYS emitted, hidden and empty // when there is nothing to say, so refreshVoice() can repaint it by @@ -2042,36 +2059,45 @@ function composer() { : PLAN.on ? 'Type to change the plan — it stays in plan mode…' : 'Ask for an outcome, or / for a command') + '">' + micButton() + sendButton() + '
' - /* B.7 — the control row is a legend plate: each control is named above - it in the 11px label style, and its value is machine text. It used to - read as five unlabelled dropdowns, and on the cloud route three of - them are the route itself — backend, then provider, then model — with - no way to tell which was which without opening one. */ + /* B.7, Soft Tactile — Valerii's ruling: Backend · Provider · Model, a + spacer, then Context · Mode. The controls are still named, but the + name is a caption INSIDE the pill, drawn by CSS from `data-cap` + (::before), so each chip's textContent stays exactly the id the + drivers compare. Every chip is a direct child of `.cfoot`, so the + popovers can anchor to `#composer .cfoot [data-sel-open=…]`. */ + '
' - + 'Backend' - + '' + + '' // SELECTOR LANE: the visible control set follows composerSwitchKindsFor, // not a hard-coded `cloud` test — see selKinds(). Cloud and custom draw // the provider control; the managed-local route draws none, because on // that route the second control IS the model. + (selHasKind('provider') - ? 'Provider' - + '' + ? '' : '') // Lane B — backend switch: the TUI's ComposerMetaControls renders // no model control when there is no model (cloud provider without // a chatModel, or local before the snapshot lands); the pane stays // reachable through the provider chip and the backend rows. - + (modelChipHtml() - ? 'Model' + modelChipHtml() + '' : '') - + '' - + 'Context' + contextChip() + '' - + 'Mode' + codingModeChip() + '' + + modelChipHtml() + + '' + + contextChip() + + codingModeChip() + '
' + ''; } +/** ' is-open' while the popover a composer chip opens is up (presentation only). */ +function cchipOpen(kind) { + if (kind === 'backend' || kind === 'provider' || kind === 'model') { + return SEL.open && !OB.open && SEL.kind === kind ? ' is-open' : ''; + } + return S.overlay === kind ? ' is-open' : ''; +} + /** * r6 cloud item 4 — does a switch in flight have to hold the operator's * message? ONE answer, read by the send button and by `submit()`, so the @@ -2125,7 +2151,7 @@ function sendButton() { // Item 7C: this sends a steer into the running turn. It is parked as // the next turn only when the agent refuses it, which is a fact the // route answers and this button cannot know in advance. - if (S.draft.trim()) return ''; + if (S.draft.trim()) return ''; return ''; } return ''; @@ -2183,9 +2209,14 @@ function voiceStripHTML() { if (!rec && !VOICE.menu && VOICE.state !== 'error') return ''; let h = '
'; if (rec) { + /* Soft Tactile: nine level bars share one `--lv` (0…1) — refreshVoice + patches that single property in place on every audio chunk. While the + helper finalizes there is no audio, so a spinner stands in for them. */ h += '
' + '' - + '' + + (VOICE.state === 'finishing' + ? '' + : '' + ''.repeat(9) + '') + '' + voiceTextInner() + '' + voiceChipHTML() + '
'; @@ -2196,13 +2227,14 @@ function voiceStripHTML() { again, so a sentence naming the Settings pane leaves the operator to find it themselves. Give them the switch instead — the button opens Privacy & Security › Microphone directly. */ - h += '
' + esc(VOICE.err) + '' - + (VOICE.canOpenSettings ? '' : '') - + (VOICE.canResetPermission ? '' : '') + h += '
' + ic('micOff') + '' + + '' + esc(VOICE.err) + '' + + (VOICE.canOpenSettings ? '' : '') + + (VOICE.canResetPermission ? '' : '') + voiceChipHTML() - + '
'; + + '
'; } else { - h += '
Dictation language' + voiceChipHTML() + '
'; + h += '
Dictation language' + voiceChipHTML() + '
'; } // The sentence has to be true in the state it is painted in: while the // helper is finalizing, Enter no longer stops anything — submit() holds it @@ -2248,7 +2280,7 @@ function voiceChipHTML() { ? voiceLangName(VOICE.winner) + ' matched' : voiceLangName(primary) + (second ? ' + ' + voiceLangName(second) : ''); return ''; + + esc(label) + ic('chevD', 'chev') + ''; } function voiceMenuHTML() { @@ -2613,11 +2645,15 @@ function slashMatches() { function slashPopover() { const m = slashMatches(); const q = S.draft.replace(/^\//, ''); - if (!m.length) return '
no matching command
'; + // Soft Tactile: one amber row, no inline colour. + if (!m.length) return '
no matching command
'; + // Rows scroll inside .slashlist; the footer (count + keys) stays put. return '
' + m.map(([n, d, a], i) => '').join('') + '
'; + + '' + esc(a || '') + '').join('') + '
' + + '
' + m.length + (m.length === 1 ? ' command' : ' commands') + '' + + '' + keycaps('↑↓ tab esc') + '
'; } /* ---------------- overlays ---------------- */ @@ -4126,8 +4162,9 @@ function refreshVoice() { // this branch leaves the dot where it is. const t = strip.querySelector('.vstext'); if (t) t.innerHTML = voiceTextInner(); - const l = strip.querySelector('.vslevel i'); - if (l) l.style.width = voiceLevelPct() + '%'; + // The nine bars read one custom property, so the level is one write. + const l = strip.querySelector('.vslevel'); + if (l) l.style.setProperty('--lv', String(voiceLevelPct() / 100)); } else { VOICE_STRIP_KEY = key; if (strip) strip.outerHTML = voiceStripHTML(); @@ -10000,14 +10037,21 @@ function repaintContextChip() { function contextChip() { if (!CTX.tokens) return ''; const proj = CTX.source === 'projected'; + // Soft Tactile spaces the figure: `36.6k / 1.0M`, projected `~7.4k / 256k`. const label = (proj ? '~' : '') + (CTX.window - ? fmtTokens(CTX.tokens) + '/' + fmtTokens(CTX.window) + ? fmtTokens(CTX.tokens) + ' / ' + fmtTokens(CTX.window) : fmtTokens(CTX.tokens)); const pct = CTX.window ? Math.min(100, (CTX.tokens / CTX.window) * 100) : 0; - return ''; + + ring + '' + label + ''; } /* ============================================================ @@ -10026,23 +10070,24 @@ function codingModeChip() { // No route on this agent build: print the honest blank rather than a // mode. Painting 'default' would name a stance the agent does not have. if (MODE.supported === false) { - return ''; + return ''; } // The same blank, for the same reason, one state earlier: the route has not // answered yet (or its last answer was an error). Painting the seed would // name a stance the agent has not confirmed — at approvalLevel 5 it would // say a green `default` while the live stance is `bypass`. if (!MODE.known) { - return ''; + return ''; } const id = currentMode(); const look = CODING_MODES.find((m) => m.id === id) || CODING_MODES[0]; - const colour = look.tone === 'bad' ? 'var(--danger)' : look.tone === 'warn' ? 'var(--warn)' - : look.tone === 'accent' ? 'var(--accent-text)' : 'var(--success)'; + /* Soft Tactile tones as classes: default neutral, plan blue, auto amber, + bypass permissions red — each with its own icon. */ + const tone = look.tone === 'bad' ? ' tone-bypass' : look.tone === 'warn' ? ' tone-auto' + : look.tone === 'accent' ? ' tone-plan' : ''; + const icon = look.tone === 'bad' ? 'bolt' : look.tone === 'warn' ? 'edit' : look.tone === 'accent' ? 'list' : 'shield'; // At a configured level of 5 the agent seeds its stance by inference and // reports `bypass`, because default/auto/bypass all resolve to level 5 // with plan off — so the chip opens red until a mode is chosen. Say so @@ -10051,8 +10096,8 @@ function codingModeChip() { ? 'what the agent may do without asking — your configured approval level is ' + MAX_APPROVAL_LEVEL + ' of ' + MAX_APPROVAL_LEVEL + ', so default already approves everything' : 'what the agent may do without asking'; - return ''; + return ''; } function modesHTML() { @@ -11718,8 +11763,8 @@ function modelChipHtml() { route (a provider with a catalogue behind it) gets the call to action; everything else keeps the honest blank. */ if (!selHasKind('model') || selBackend() !== 'cloud') return ''; - return ''; + return ''; } /* SELECTOR LANE — the model slot's TWO components, as composer-meta-controls.tsx has them. A model label is a `Control`, and @@ -11734,8 +11779,11 @@ function modelChipHtml() { route's model control, for the composer strip and for anything reading it. `data-sel-dl` is the component split. */ const cta = label === DOWNLOAD_MODEL_LABEL; - return ''; + // Soft Tactile: the model family's real mark (CPU badge when there is none), + // the download icon on the call to action; the id itself in DM Mono. + return ''; } /** * What the two facts change on screen, repainted in place. These land @@ -11752,7 +11800,7 @@ function bswRepaint() { const html = modelChipHtml(); const el = foot.querySelector('.modelchip'); if (el) { if (!html) el.remove(); else if (el.outerHTML !== html) el.outerHTML = html; } - else if (html) { const spacer = foot.querySelector(':scope > span'); if (spacer) spacer.insertAdjacentHTML('beforebegin', html); } + else if (html) { const spacer = foot.querySelector(':scope > .cgrow'); if (spacer) spacer.insertAdjacentHTML('beforebegin', html); } } if (!SEL.open || OB.open) return; const f = document.getElementById('sel-filter');