From 60b721fefc726b29f364f7796f09364e732e17fa Mon Sep 17 00:00:00 2001 From: Valera Brizhatiuk <19537764+plombeer31@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:24:28 +0300 Subject: [PATCH] =?UTF-8?q?desktop:=20composer=20popovers=20in=20Soft=20Ta?= =?UTF-8?q?ctile=20=E2=80=94=20per-chip=20anchoring,=20brand-mark=20rows,?= =?UTF-8?q?=20query=20highlight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Where it runs, Provider, Model, the add-provider wizard inside the popover, context breakdown, modes and the dictation language menu, restyled as floating lift cards (radius 18, pop shadow) with the app's logic and copy unchanged. - anchorStyle takes a selector or an element, opens 8px above the composer card of the chip that opened the popover, aligns with that chip (right edge for chips on the right half), clamps inside the content column and caps the height to what the column leaves. Widths per pane: backend 440, provider 420, model 520, wizard 460, presets 440, context 380, modes 360. - Route rows: real provider/model marks (server/CPU badge otherwise), route icons on backend rows with a radio, ids in DM Mono, Unverified amber chip, blue download chip, a divider before the trailing action row, the typed query marked inside model ids without changing .nm text. - Wizard: provider mark in the header, key/URL fields with a key icon that take the error (red) or could-not-check (amber) tone, Tactile footer buttons. - Context: breakdown bar (amber above 70%, red above 85%, striped when projected) with the reply reserve, amber trimming line, icon steppers. - Modes: title, rows whose word takes the mode colour, level-5 note in amber, Update agent in the footer when the agent lacks the route. - Voice menu floats above the strip with radio, badge, progress bar and a short-window height budget. Hooks kept: .popover, .selpop, .selhead text and busy captions, .nm, .modelrow, first .cap as row detail, the inline danger colour on switch errors, .ob-err, #wiz-key, #sel-filter, #sel-key, [data-wizmodel], .popfoot [data-act=close], .ctxdial/.ctxbound/.ctxbasis/.hd, .poprow(.on/.dim), .vsmrow/.vsmfoot, every data-act and data-* attribute. --- desktop/renderer/css/popovers.css | 233 +++++++++++++++++++----- desktop/renderer/renderer.js | 285 +++++++++++++++++++++--------- 2 files changed, 392 insertions(+), 126 deletions(-) diff --git a/desktop/renderer/css/popovers.css b/desktop/renderer/css/popovers.css index 218b7f1e..85bd4036 100644 --- a/desktop/renderer/css/popovers.css +++ b/desktop/renderer/css/popovers.css @@ -3,63 +3,206 @@ Loaded after styles.css (tokens + kit). Colours come from tokens only. ============================================================ */ -.popover{position:absolute;background:var(--bg-overlay);border-radius:0;box-shadow:var(--shadow-overlay);width:340px;z-index:25;overflow:hidden} -.poprow{display:flex;align-items:flex-start;gap:var(--s3);padding:8px var(--s4);width:100%} -.poprow:hover{background:var(--bg-hover)} -.poprow.on{background:var(--accent-wash)} +/* ---- The popover shell ---- + Every composer popover is a floating --lift card: radius 18, the pop shadow, + 6px inside (the kit's .tk-pop rides on the same element). anchorStyle() pins + it above the composer card of the chip that opened it, gives it that chip's + width and caps its height to what the content column leaves; the numbers + here are only the fallback for a popover with no chip on screen. */ +.popover{position:absolute;z-index:25;width:340px;max-width:calc(100vw - 32px);overflow:hidden; + background:var(--lift);border-radius:var(--r-pop);box-shadow:var(--sh-pop);padding:6px; + display:flex;flex-direction:column;gap:1px;color:var(--ink)} +.popfoot{flex:none;display:flex;align-items:center;justify-content:flex-end;gap:6px; + margin-top:4px;padding:6px 0 0 2px;box-shadow:inset 0 1px 0 var(--divider)} +.popfoot .grow{flex:1;min-width:0} +/* Footer verbs are the small pill size everywhere, including the ones another + function hands the shell (the model step's Back · Use default · Use this model). */ +.popover .popfoot .btn:not(.icon){height:26px;padding:0 10px;font-size:12px} +.popover .btn svg{width:14px;height:14px} -.ctxbar{display:flex;height:6px;border-radius:0;overflow:hidden;margin-top:10px;gap:1px} -.ctxbar i{display:block} -.swatch{width:8px;height:8px;border-radius:0;flex:none} -.popfoot{display:flex;align-items:center;justify-content:flex-end;gap:var(--s3);padding:var(--s4);box-shadow:inset 0 1px 0 var(--line-soft)} +/* ---------------- model selector popup ---------------- */ +.selpop{width:460px;max-height:60vh} +.selhead{flex:none;display:flex;align-items:center;gap:8px;min-height:32px;padding:4px 8px; + font:600 13px/1.3 var(--font-ui);letter-spacing:-.003em;color:var(--ink)} +.selhead .selttl{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.selhead .logo,.selhead .tk-ico{flex:none} +.selhead .selbusy{margin-left:auto;flex:none;display:inline-flex;align-items:center;gap:6px;font-weight:500;color:var(--ink-2)} +.selhead .selbusy .tk-spin{width:11px;height:11px} -.modellist{display:flex;flex-direction:column} -.modelrow{display:flex;align-items:center;gap:var(--s4);padding:8px var(--s4);width:100%;border-left:2px solid transparent;cursor:pointer} -.modelrow + .modelrow{box-shadow:inset 0 1px 0 var(--line-soft)} -.modelrow:hover{background:var(--bg-hover)} -.modelrow.on{background:var(--accent-wash);border-left-color:var(--accent)} -.modelrow .col{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px} -.modelrow .nm{color:var(--text-primary);font-size:13px} +.selsearch{flex:none;padding:0 0 6px} +.selpop .tk-inpwrap{height:36px;padding:0 12px} +.selpop .tk-inpwrap > svg{width:15px;height:15px;flex:none} +.selpop .tk-inpwrap input{font-size:13.5px} +.selpop .tk-inpwrap.is-error{background:var(--lift);border-color:var(--critical);box-shadow:0 0 0 4px var(--critical-wash)} +.selpop .tk-inpwrap.is-warn{border-color:var(--amber)} +.selpop .tk-inpwrap .tk-spin{flex:none;margin-right:4px} -/* ---------------- model selector popup ---------------- */ -.selpop { width: 460px; max-width: calc(100vw - 32px); display: flex; flex-direction: column; max-height: 60vh; } -.selhead { - flex: none; height: 36px; display: flex; align-items: center; gap: var(--s3); - padding: 0 var(--s5); font-size: 13px; font-weight: 500; - box-shadow: inset 0 -1px 0 var(--line-soft); -} -.selsearch { flex: none; padding: var(--s3) var(--s5); } -.selbody { flex: 1; min-height: 0; overflow-y: auto; } +.selbody{flex:1 1 auto;min-height:0;overflow-y:auto;display:flex;flex-direction:column} /* The list scrolls inside the popup — it must never grow past the window. */ -.sellist { flex: 1; min-height: 0; max-height: 320px; overflow-y: auto; display: flex; flex-direction: column; } -.selpop .popfoot { flex: none; } +.sellist{flex:1 1 auto;min-height:0;max-height:340px;overflow-y:auto;display:flex;flex-direction:column;gap:1px} +.selbody > .sellist{flex:none} +.selpop .popfoot{flex:none} + +/* One row: badge · id over its one-line facts · right slot (radio, Unverified, download). */ +.selpop .modelrow{display:flex;align-items:center;gap:10px;width:100%;min-height:42px;padding:6px 10px;flex:none; + border:0;border-radius:var(--r-well);background:transparent;color:var(--ink);text-align:left;cursor:pointer; + transition:background var(--t-state) var(--ease)} +.selpop .modelrow:hover{background:var(--hover)} +.selpop .modelrow.on{background:var(--brand-wash)} +.selpop .modelrow:focus-visible{outline:2px solid var(--brand);outline-offset:-2px} +.selpop .modelrow .col{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px} +.selpop .modelrow .nm{font:600 13.5px/1.3 var(--font-ui);letter-spacing:-.003em;color:var(--ink); + overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.selpop .modelrow .nm.mono{font:500 13px/18px var(--font-mono);letter-spacing:0} +.selpop .modelrow .col > .cap{font-size:12px;line-height:1.4;color:var(--ink-2);overflow:hidden; + display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2} +.selpop .modelrow .cap.mono,.selpop .modelrow .cap .mono{font-family:var(--font-mono);font-size:11.5px;letter-spacing:0} +.selpop .modelrow.dim .nm{color:var(--ink-3)} +.selpop .modelrow.dim .logo,.selpop .modelrow.dim .tk-ico{opacity:.55} +.selpop .selr{margin-left:auto;flex:none;display:flex;align-items:center;gap:6px;padding-left:4px} +.selpop .seldl svg{width:13px;height:13px} +.selpop .selsep{margin:4px 8px} +.selspin{width:22px;height:22px;flex:none;display:grid;place-items:center} +/* MP-11: the typed query inside a result. A wrapper around the matched letters + only — the row's .nm keeps exactly the text it had. */ +.selhit{background:var(--brand-wash);color:var(--blue);border-radius:3px} + +.selnote{flex:none;display:flex;align-items:center;gap:8px;margin:0;padding:12px 10px;font-size:12.5px;line-height:1.45;color:var(--ink-2)} +/* A switch error. The inline colour on it is what drive-selector reads — keep it. */ +.selerr{flex:none;display:flex;align-items:flex-start;gap:8px;margin:6px 0 0;padding:8px 10px;border-radius:var(--r-well); + background:var(--critical-wash);font-size:12.5px;line-height:1.45;overflow-wrap:anywhere;max-height:140px;overflow-y:auto} +.selerr > svg{flex:none;width:14px;height:14px;margin-top:2px} + +/* MP-16: the popover's own download — the raw CLI line in a mono well. */ +.selpull{gap:8px;padding:2px 2px 6px} +.selpull .selpullline{margin:0;padding:10px 12px;border-radius:var(--r-well);background:var(--well);color:var(--ink); + font:400 12px/1.5 var(--font-mono);white-space:pre-wrap;overflow-wrap:anywhere} +.selpull > .cap:not(.selpullline){margin:0 4px} + +/* MP-04: the preset list's key field. */ +.selkey{flex:none;padding:8px 0 2px} + +/* ---- The add-provider wizard inside the popover (MP-06…10) ---- */ +.selwiz{gap:6px;padding:0 2px 6px} +.selwiz .tk-lbl{padding:4px 4px 0} +.selwiz #wiz-key,.selwiz #wiz-url{font-family:var(--font-mono);font-size:12.5px} +.selpop .ob-help{margin:0;padding:0 4px;max-width:none;font-size:12.5px;line-height:1.45;color:var(--ink-2)} +.selpop .ob-err{margin:0;padding:0 4px;max-width:none;font-size:12.5px;line-height:1.45;white-space:pre-wrap;color:var(--critical)} +.selpop .ob-err.tk-help--warn{color:var(--amber-text)} + +/* Inside the popover the shell's own body is the scroller, so the list must + not add a second one — two nested scrollers is how the footer ends up + somewhere nobody can reach. */ +.selpop .ob-wizlist{max-height:none;overflow:visible} +.selpop .ob-wiz{display:flex;flex-direction:column;gap:6px;width:100%;padding:0 0 4px} +/* The popover's header already names the provider; the step's own kicker and heading would say it twice. */ +.selpop .ob-wiz > .ob-kicker,.selpop .ob-wiz > .ob-h{display:none} +.selpop #wiz-model-q{width:100%;height:34px;flex:none;padding:0 14px;border:1px solid transparent;border-radius:var(--r-pill); + background:var(--well);color:var(--ink);font:400 13.5px/1 var(--font-ui);outline:none} +.selpop #wiz-model-q::placeholder{color:var(--ink-3)} +.selpop #wiz-model-q:focus{background:var(--lift);border-color:var(--brand);box-shadow:var(--focus-ring)} +.selpop .prows{display:flex;flex-direction:column;gap:1px;border:0;box-shadow:none;background:transparent} +.selpop .prows [data-wizmodel]{display:flex;align-items:center;gap:10px;width:100%;min-height:42px;padding:6px 10px; + border:0;border-radius:var(--r-well);background:transparent;box-shadow:none;color:var(--ink);text-align:left;cursor:pointer} +.selpop .prows [data-wizmodel]:hover{background:var(--hover)} +.selpop .prows [data-wizmodel].on{background:var(--brand-wash)} +.selpop .prows [data-wizmodel] .col{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px} +.selpop .prows [data-wizmodel] .nm{font:500 13.5px/1.3 var(--font-ui);color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.selpop .prows [data-wizmodel] .ep{font:400 11.5px/1.4 var(--font-mono);color:var(--ink-2)} +.selpop .prows [data-wizmodel] .ann{margin-left:auto;flex:none;background:var(--brand-wash);color:var(--blue)} +/* ---- Context breakdown (CM-15…17) ---- */ +.ctxpop{width:380px} +.ctxin{flex:none;display:flex;flex-direction:column;gap:10px;padding:8px 8px 10px} +.ctxpop .ctxttl{margin:0;font:500 12.5px/1.4 var(--font-mono);letter-spacing:0;color:var(--ink)} +.ctxmeter{display:flex;flex:none;height:8px} +.ctxmeter > i,.ctxmeter > b{display:block;height:100%;flex:none;border-radius:0} +.ctxmeter > b{background:var(--bd-2)} +.ctxmeter.proj > i{background:repeating-linear-gradient(90deg,var(--brand) 0 3px,transparent 3px 5px)} +.ctxpop .kvgrid{grid-template-columns:minmax(0,1fr) max-content;gap:5px 12px;font-size:13px} +.ctxpop .kvgrid dt{color:var(--ink)} +.ctxpop .kvgrid dd{text-align:right;font-size:12px;line-height:18px;color:var(--ink)} +.ctxpop .kvgrid .ter{color:var(--ink-2)} +.ctxpop .ctxin > .cap{margin:0;line-height:1.5} +/* ---- Item 7C: the context panel's binding line ---- */ +.ctxbound{display:flex;align-items:flex-start;gap:6px;color:var(--amber-text)} +.ctxbound > svg{flex:none;width:13px;height:13px;margin-top:2px} /* context panel dials */ -.ctxdials { box-shadow: inset 0 1px 0 var(--line-soft); padding: var(--s3) 0; } -.ctxdial { display: flex; align-items: center; gap: var(--s4); padding: var(--s2) var(--s5); } -.ctxdial .col { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; font-size: 13px; } -.ctxdial .btn { min-width: 28px; justify-content: center; } +.ctxdials{flex:none;padding:0 8px 2px} +.ctxdial{display:flex;align-items:center;gap:6px;padding:10px 0 4px;box-shadow:inset 0 1px 0 var(--divider)} +.ctxdial .col{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px} +.ctxdial .ctxdt{font:600 13px/1.3 var(--font-ui);color:var(--ink)} +.ctxdial .ctxstep{display:flex;align-items:center;gap:4px} +.ctxdial .ctxval{min-width:32px;text-align:center;font-size:13px} -.modelrow.dim { opacity: .5; } +/* ---- Modes (CM-12…14) ---- */ +.modepop{width:360px} +.modehead{flex:none;display:flex;align-items:baseline;gap:8px;min-height:30px;padding:6px 8px 4px; + font:600 13px/1.3 var(--font-ui);color:var(--ink)} +.modehead .cap{margin-left:auto;min-width:0;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.poprow{display:flex;align-items:center;gap:10px;width:100%;min-height:44px;padding:6px 10px;flex:none; + border:0;border-radius:var(--r-well);background:transparent;color:var(--ink);text-align:left;cursor:pointer; + transition:background var(--t-state) var(--ease)} +.poprow:hover{background:var(--hover)} +.poprow:focus-visible{outline:2px solid var(--brand);outline-offset:-2px} +.poprow .col{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px} +.poprow .ml{font:600 13.5px/1.3 var(--font-ui);color:var(--ink)} +.poprow .col > .cap{font-size:12px;line-height:1.4} +.poprow .modecur{margin-left:auto;flex:none} +/* The row's word takes the chip's colour: default ink, plan blue, auto amber, bypass red. */ +.poprow.modetone-plan .ml{color:var(--blue)} +.poprow.modetone-auto .ml{color:var(--amber-text)} +.poprow.modetone-bypass .ml{color:var(--critical)} +.modenote{flex:none;display:flex;flex-direction:column;gap:4px;padding:6px 10px 2px} +.modenote p{margin:0;line-height:1.5} +.modenote .modewarn{color:var(--amber-text)} +.modenote .ob-help{max-width:none;font-size:12px;color:var(--ink-2)} /* ============================================================ Coding-mode popover — the unavailable state (item 6) ============================================================ `.dim` is applied to the mode rows when the agent has no - /api/coding-mode route, but the only `.dim` rules that existed were - `.palrow`, `.modelrow` and `.tuirow`, so the unavailable state was - invisible. A disabled row also has to stop looking clickable. */ -.poprow.dim{opacity:.5} + /api/coding-mode route. A disabled row has to look unavailable + and stop looking clickable. (The smoke looks for this rule.) */ +.poprow.dim{cursor:default} +.poprow.dim .ml,.poprow.dim .col > .cap{color:var(--ink-3)} .poprow[disabled]{cursor:default} .poprow[disabled]:hover{background:transparent} -/* ---- Item 7C: the context panel's binding line ---- */ -.ctxbound{color:var(--warn)} - -.modelrow .ann{margin-left:auto} - -/* Inside the popover the shell's own body is the scroller, so the list must - not add a second one — two nested scrollers is how the footer ends up - somewhere nobody can reach. */ -.selpop .ob-wizlist{max-height:none;overflow:visible} -.selpop .ob-wiz{padding:var(--s4) var(--s5)} +/* ---- Voice language menu (CM-08) ---- + voiceMenuHTML draws it inside the voice strip; it floats above the strip as a + popover, off .composerwrap (the strip's positioned ancestor). The .tk-vmenu + compound keeps these declarations ahead of the strip's own .vsm* rules. */ +.voicestrip .vsmenu.tk-vmenu{position:absolute;z-index:16;right:var(--s6);bottom:calc(100% + 6px); + width:420px;max-width:calc(100% - 16px);max-height:none;margin:0;padding:6px;border:0;border-radius:var(--r-pop); + background:var(--lift);box-shadow:var(--sh-pop);display:flex;flex-direction:column;gap:1px;overflow:hidden} +.voicestrip .tk-vmenu .vsmtitle{flex:none;padding:6px 8px 2px;font:600 13px/1.3 var(--font-ui);color:var(--ink)} +/* The list gives up height first on a short window, so the menu never climbs under the toolbar (940×620). */ +.voicestrip .tk-vmenu .vsmscroll{min-height:0;max-height:min(300px,max(160px,calc(100vh - 460px)));overflow-y:auto;display:flex;flex-direction:column;gap:1px} +.voicestrip .tk-vmenu .vsmhead{padding:8px 8px 3px;font:600 12px/1.3 var(--font-ui);letter-spacing:0;text-transform:none;color:var(--ink-2)} +.voicestrip .tk-vmenu .vsmrow{display:flex;align-items:center;gap:6px;padding:0 8px 0 0;border-radius:var(--r-well);background:transparent} +.voicestrip .tk-vmenu .vsmrow:hover{background:var(--hover)} +.voicestrip .tk-vmenu .vsmpick{flex:1;min-width:0;display:flex;align-items:center;gap:10px;height:auto;min-height:42px;padding:5px 10px; + border:0;border-radius:var(--r-well);background:transparent;color:var(--ink);text-align:left} +.voicestrip .tk-vmenu .vsmbody{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px} +.voicestrip .tk-vmenu .vsmline{display:flex;align-items:center;gap:6px;min-width:0} +.voicestrip .tk-vmenu .vsmname{flex:none;font:500 13.5px/1.3 var(--font-ui);color:var(--ink)} +.voicestrip .tk-vmenu .vsmtag{flex:none;font:400 11px/1 var(--font-mono);color:var(--ink-2)} +.voicestrip .tk-vmenu .vsmnote{flex:none;text-align:left;font-size:12px;line-height:1.35;color:var(--ink-2); + white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.voicestrip .tk-vmenu .vsmbadge{flex:none;height:18px;display:inline-flex;align-items:center;padding:0 7px;border-radius:var(--r-pill); + background:var(--brand-wash);color:var(--blue);font:600 11px/1 var(--font-ui)} +.voicestrip .tk-vmenu .vsmadd{width:26px;height:26px;flex:none;display:grid;place-items:center;border:0;border-radius:50%; + background:transparent;color:var(--ink-2)} +.voicestrip .tk-vmenu .vsmadd:hover{background:var(--press);color:var(--ink)} +.voicestrip .tk-vmenu .vsmadd.on{background:var(--brand-wash);color:var(--blue)} +.voicestrip .tk-vmenu .vsmadd svg{width:14px;height:14px} +.voicestrip .tk-vmenu .vsmprog{flex:none;width:60px;min-width:0} +.voicestrip .tk-vmenu .vsmerr{padding:6px 10px;font-size:12.5px;line-height:1.45;color:var(--critical)} +.voicestrip .tk-vmenu .vsmfoot{flex:none;margin-top:4px;padding:8px 10px 4px;box-shadow:inset 0 1px 0 var(--divider); + font-size:12px;line-height:1.5;color:var(--ink-2)} +.voicestrip .tk-vmenu .vsmempty{padding:10px;font-size:12.5px;color:var(--ink-2)} +/* On a short window the long note scrolls in its own few lines instead of pushing the languages out. */ +@media (max-height:700px){ + .voicestrip .tk-vmenu .vsmfoot{max-height:78px;overflow-y:auto} +} diff --git a/desktop/renderer/renderer.js b/desktop/renderer/renderer.js index 58bf93a9..dd493279 100644 --- a/desktop/renderer/renderer.js +++ b/desktop/renderer/renderer.js @@ -2253,15 +2253,19 @@ function voiceChipHTML() { function voiceMenuHTML() { if (!VOICE.supported.length) { - return '
'; + return ''; } const installed = VOICE.supported.filter((id) => VOICE.installed.indexOf(id) >= 0); const rest = VOICE.supported.filter((id) => VOICE.installed.indexOf(id) < 0); + /* Soft Tactile (CM-08): a radio for the first language, the name with its + mono tag and `first` badge over the note, a bar while a model downloads, + and the + / ✓ that adds a second language on the right. */ const row = (id) => { const on = VOICE.locales.indexOf(id) >= 0; const primary = VOICE.locales[0] === id; const here = VOICE.installed.indexOf(id) >= 0; const eng = voiceEngineOf(id); + const pct = VOICE.installing === id ? Math.round(VOICE.installFraction * 100) : -1; const note = here ? (eng === 'dictation' ? 'on this Mac · plain text, no punctuation' : 'on this Mac') : (VOICE.installing === id @@ -2269,20 +2273,26 @@ function voiceMenuHTML() { : (eng === 'dictation' ? 'downloads a model · plain text, no punctuation' : 'downloads a model')); return '