From 009ff8875f14601cbd18b1bd11f6e40ad76a089b Mon Sep 17 00:00:00 2001 From: Valera Brizhatiuk <19537764+plombeer31@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:36:39 +0300 Subject: [PATCH 1/2] =?UTF-8?q?desktop:=20onboarding=20in=20Soft=20Tactile?= =?UTF-8?q?=20=E2=80=94=20indigo=20rail,=20route=20cards,=20logo=20lists,?= =?UTF-8?q?=20pill=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Title card: full indigo with a brand glow, 96px mark, 64px wordmark, build line. Every later step: an indigo side rail (lockup, phase statement, 01 Setup / 02 Data steps, build) beside the step column (34px title, lede, body, action bar, keycap hint strip). Route choice as three lifted cards with icons and 1-3 keycaps, plus Continue. Model, provider, file and import lists as lifted cards of 46px rows with real brand logos (models, providers, Hugging Face, import sources, the app mark for the terminal agent). Best fit / Recommended / New / Use at your own risk chips; fit lines green, amber text, or muted; out-of-reach models in a quiet well. Fields are 52px pills with icons; errors are notices under the field; the key screen names the provider with its logo; could-not-reach reads amber and a rejection red. The download shows real progress bars in a card and the two offers as cards with their keycap. Rows no longer shrink inside scrolling lists. The rail narrows below 1240px and 1060px and drops its statement on windows shorter than 720px. Logic untouched: same reducer, key router, act names, data attributes, ids and compared text. The shared wizard parts the composer popover also draws keep an unscoped base style. --- desktop/renderer/css/onboarding.css | 854 +++++++++++++--------------- desktop/renderer/renderer.js | 518 ++++++++++------- 2 files changed, 696 insertions(+), 676 deletions(-) diff --git a/desktop/renderer/css/onboarding.css b/desktop/renderer/css/onboarding.css index f6d3a77a..50ccf4a0 100644 --- a/desktop/renderer/css/onboarding.css +++ b/desktop/renderer/css/onboarding.css @@ -1,484 +1,394 @@ /* ============================================================ - First-run wizard — title card, route choice, local picker, Hugging Face, download, cloud wizard, custom endpoint, second-backend offer, import. Screens ON-01…23. + First-run wizard in Soft Tactile — screens ON-01…23. Loaded after styles.css (tokens + kit). Colours come from tokens only. - ============================================================ */ - -/* ---------------- setup wizard ---------------- */ -#onboarding { - position: absolute; inset: 0; z-index: 40; - background: var(--bg-content); - display: flex; align-items: center; justify-content: center; - padding: var(--s8); - -webkit-app-region: drag; -} -#onboarding > * { -webkit-app-region: no-drag; } -.ob { width: min(560px, 100%); display: flex; flex-direction: column; gap: var(--s6); } -.ob-head { display: flex; flex-direction: column; gap: var(--s3); align-items: center; text-align: center; } -.ob-mark { opacity: .9; } -.ob-step { - font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; - color: var(--text-tertiary); -} -.ob-title { font-size: 22px; line-height: 28px; font-weight: 600; letter-spacing: -.02em; } -.ob-sub { font-size: 13px; color: var(--text-secondary); max-width: 44ch; } -.ob-list { display: flex; flex-direction: column; gap: var(--s3); } -.ob-opt { - display: grid; grid-template-columns: 20px 1fr auto; gap: var(--s4); align-items: center; - padding: var(--s4) var(--s5); width: 100%; text-align: left; - border: 1px solid var(--line); border-radius:0; background: var(--bg-raised); -} -.ob-opt:hover { border-color: var(--line-strong); } -.ob-opt.on { border-color: var(--accent); background: var(--accent-wash); } -.ob-opt .t { font-size: 15px; font-weight: 500; } -.ob-opt .d { font-size: 13px; color: var(--text-secondary); } -.ob-opt .tag { - font-size: 11px; font-weight: 500; color: var(--accent-text); - border: 1px solid var(--accent-line); border-radius:0; padding: 1px 6px; -} -.ob-opt[disabled] { opacity: .5; cursor: default; } -.ob-models { max-height: 46vh; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; } -.ob-foot { display: flex; align-items: center; gap: var(--s4); } -.ob-foot .grow { flex: 1; } -.ob-note { font-size: 12px; color: var(--text-tertiary); } -.ob-prog { - font-family: var(--font-mono); font-size: 11px; color: var(--text-secondary); - background: var(--bg-sunken); border-radius:0; padding: var(--s3); - max-height: 120px; overflow-y: auto; white-space: pre-wrap; -} - -/* The wizard shell. The intro draws no header and fills the layer edge to - edge (the sky is the screen); every other step keeps the centred column. */ -#onboarding.ob-intro-layer{padding:0;background:var(--bg-sunken)} -.ob-hints{ - position:absolute;left:0;right:0;bottom:0;height:34px;display:flex;align-items:center; - justify-content:center;gap:6px;font-size:11px;color:var(--text-tertiary); - border-top:1px solid var(--line-soft);background:var(--bg-content); - -webkit-app-region:no-drag; -} -#onboarding.ob-intro-layer .ob-hints{background:transparent;border-top-color:transparent} -.ob-hints .kc{margin-right:2px} -.ob-hints .hint{display:inline-flex;align-items:center;gap:4px;margin:0 6px} -.ob{max-height:calc(100% - 44px);overflow-y:auto;padding-bottom:var(--s4)} - -/* Header lockup: the mark, `atomic`, and where in the flow we are — the - TUI's onboarding-header.tsx, laid out as a row rather than a column. */ -.ob-lock{display:flex;flex-direction:row;align-items:center;gap:10px;justify-content:center} -.ob-lock .ob-wm{font-size:15px;font-weight:600;color:var(--accent-text);line-height:1.1} -.ob-lock .ob-sub2{font-size:12px;color:var(--text-tertiary);line-height:1.2} - -/* Intro: the canvas sky, the mark hanging in it, the wordmark and tagline. */ -/* The title card. Centred on the window, sized to it — the block used to be - a fixed 320px column, which at 1470x923 was the 2% of the screen the tester - could not read. Nothing is animated and nothing is decorative: the mark, the - name, one rule, the build, and the line that tells you how to leave. */ -#ob-intro{position:absolute;inset:0;display:flex;align-items:center;justify-content:center; - background:var(--paper);padding:var(--s8)} -.ob-introc{position:relative;display:flex;flex-direction:column;align-items:center; - gap:var(--s4);width:min(720px,100%);text-align:center} -.ob-introc .ob-markbig{width:64px;height:64px;display:block} -.ob-introc .ob-markbig svg{width:64px;height:64px;display:block} -.ob-introc .ob-word{margin:0;font-size:var(--fs-display);font-weight:600; - letter-spacing:var(--tr-display);line-height:1.15;color:var(--ink)} -.ob-introc .ob-rule{width:100%;max-width:420px;border:0;border-top:var(--rule) solid var(--accent);margin:0} -.ob-introc .ob-build{font-family:var(--font-mono);font-size:var(--fs-cap);color:var(--ink-2)} -.ob-introc .ob-any{font-size:var(--fs-label);letter-spacing:var(--tr-label); - text-transform:uppercase;color:var(--ink-3);margin-top:var(--s5)} - -/* Rows. The TUI marks the selected row with `› `; here the marker is a real - gutter so mouse and keyboard land on the same box. */ -.ob-row{display:grid;grid-template-columns:18px 1fr;gap:var(--s3);align-items:start; - width:100%;text-align:left;padding:var(--s3) var(--s4);border:1px solid transparent; - border-radius:0;background:transparent;color:var(--text-primary)} -.ob-row:hover{background:var(--bg-hover)} -.ob-row.on{border-color:var(--accent-line);background:var(--accent-wash)} -.ob-row .mk{color:var(--accent-text);font-family:var(--font-mono)} -.ob-row .t{font-size:14px;font-weight:500} -.ob-row.on .t{color:var(--accent-text)} -.ob-row .d{font-size:12px;color:var(--text-secondary);display:block;line-height:1.5} -.ob-row .box{font-family:var(--font-mono);color:var(--text-secondary);margin-right:6px} -.ob-explain{font-size:13px;color:var(--text-secondary);line-height:1.6;white-space:pre-line} -.ob-h{font-size:14px;font-weight:600} -.ob-ok{color:var(--ok)} -.ob-warn{color:var(--warn)} -.ob-err{color:var(--danger);white-space:pre-wrap;max-width:72ch;font-size:12px} -.ob-mono{font-family:var(--font-mono);font-size:12px;white-space:pre} -.ob-inp{width:100%;height:30px;padding:0 8px;border:1px solid var(--line);border-radius:0; - background:var(--bg-sunken);color:var(--text-primary);font-family:var(--font-mono);font-size:12px} -.ob-inp:disabled{color:var(--text-tertiary)} - -/* Download screen: the two phase rows, and the two offer blocks the TUI - draws with a `┃ ` terminal rule — a real left border here. */ -.ob-phase{display:flex;align-items:center;gap:10px;font-family:var(--font-mono);font-size:11px} -.ob-phase .pl{color:var(--text-secondary);min-width:19ch} -.ob-phase .pb b{color:var(--accent);font-weight:400} -.ob-phase .pb i{color:var(--line-strong);font-style:normal} -/* The phase states dlResetPhases/dlOnPull actually emit are waiting, - active and done — `pending` was never written, so the waiting phase's - empty bar drew at full weight instead of the TUI's dimmed border - (onboarding-download-progress.tsx PhaseLine). */ -.ob-phase.waiting .pb i{color:var(--line)} -.ob-phase .pt{color:var(--text-secondary);font-variant-numeric:tabular-nums} -.ob-rate{font-size:12px;color:var(--text-tertiary);margin-top:var(--s3)} -/* r8: the two phase rows and the rate line are wrapped in one container so - a progress sample can repaint THEM and nothing else (refreshDlProgress) — - rebuilding the whole layer four times a second was destroying the row or - the offer card under the pointer, which is what "lighting up and down" - was. `display:contents` means the wrapper draws no box at all: the rows - stay direct flex items of `.ob`'s column and keep its gap, so the screen - is pixel-for-pixel what it was before the container existed. */ -.ob-dlprog{display:contents} -.ob-offer{border-left:2px solid var(--line);padding:2px 0 2px 12px;margin-top:var(--s5); - font-size:13px;color:var(--text-secondary);line-height:1.6;text-align:left;width:100%;background:none} -.ob-offer.cloud{border-left-color:var(--accent-line)} -.ob-offer b{display:block;font-weight:600;color:var(--text-primary);margin-top:2px} -.ob-offer:hover{background:var(--bg-hover)} - -/* The cloud step hosts the add-provider wizard inline: same rows, same - verification, but on the wizard layer rather than in the anchored popover - the model chip opens (there is no chip on screen during first run). */ -.ob-wiz{display:flex;flex-direction:column;gap:var(--s3);width:100%} -.ob-wiz .modelrow{width:100%;text-align:left;border:1px solid var(--line);border-radius:0; - background:var(--bg-raised);padding:var(--s3) var(--s4);display:flex} -.ob-wiz .modelrow:hover{border-color:var(--line-strong)} -.ob-wizlist{max-height:38vh;overflow-y:auto;display:flex;flex-direction:column;gap:6px} - -/* -------------------------------------------------------------------------- - r5 item 7 review fix — the cloud step's list cursor and its `/` search box. - The step's footer is the TUI's `↑/↓ move / search enter select esc - back`; the keys behind those hints are now real, so the row under the - cursor has to be visible without a mouse. - -------------------------------------------------------------------------- */ -.ob-wiz .modelrow.on{border-color:var(--accent);background:var(--accent-wash)} -.ob-wiz .modelrow.on .nm{color:var(--accent-text)} - -/* ========================================================================== - r6 UX — the setup wizard as a desktop surface. - - The style is the one the flow already had; what changes is the - interaction. A control now looks like a control and behaves like one: - a target big enough to hit, a hover that answers the pointer, a press - state, a focus ring for the keyboard, a disabled state that looks - disabled, and the primary action always in the same place — bottom - right, with the way back bottom left. - ========================================================================== */ - -/* The action bar every step now shares. Secondary left, primary right, - pinned under the body with a rule so the eye finds it in one place on - every screen instead of hunting for the key hint that meant it. */ -.ob-foot.ob-actions{ - padding-top:var(--s4); - border-top:1px solid var(--line-soft); - display:flex;align-items:center;gap:var(--s3); -} -.ob-foot.ob-actions .grow{flex:1} -.ob-foot .btn{height:32px;padding:0 14px;border-radius:0} -.ob-foot .btn-p{min-width:112px;justify-content:center} -.ob-foot .btn-g{border:1px solid transparent} -.ob-foot .btn-g:hover{background:var(--bg-hover);border-color:var(--line)} -.ob-foot .btn-g:active,.ob-foot .btn-p:active,.ob-wiz .ob-foot .btn:active{transform:translateY(1px)} -/* A disabled control must read as unavailable before it is clicked, not - after nothing happens. `.btn[disabled]` already greys the fill; the - pointer has to say so too. */ -.ob-foot .btn[disabled]{opacity:.55;pointer-events:none;border-color:transparent} - -/* Rows are the primary choice on six of the screens, so they get the - press state and the ring a button gets. One click activates them — - the pointer says so. */ -.ob-row{cursor:pointer;transition:background .1s ease,border-color .1s ease} -.ob-row:active{background:var(--accent-wash-soft,var(--bg-active))} -.ob-row:focus-visible,.ob-wiz .modelrow:focus-visible,.ob-offer:focus-visible{ - outline:2px solid var(--accent);outline-offset:2px; -} -.ob-wiz .modelrow{cursor:pointer;transition:background .1s ease,border-color .1s ease} -.ob-wiz .modelrow:active{background:var(--bg-active)} -/* An already-configured provider is dimmed; dimmed must not read as - dead, because the row still works — it re-opens the same key screen. */ -.ob-wiz .modelrow.dim:hover{opacity:.8} - -/* The two download offers are this screen's only controls. They were - drawn as the terminal's `┃ ` rule around a paragraph, which is a label, - not a button. Same copy, same chord, now a card. */ -.ob-offer{ - border:1px solid var(--line);border-left-width:2px;border-radius:0; - padding:var(--s3) var(--s4);cursor:pointer; - transition:background .1s ease,border-color .1s ease; -} -.ob-offer:hover{background:var(--bg-hover);border-color:var(--line-strong)} -.ob-offer:active{background:var(--bg-active)} -.ob-offer.cloud{border-left-color:var(--accent)} -.ob-offer.cloud:hover{background:var(--accent-wash)} -/* `press c` is the chord, so it is drawn as a key, not as bold prose. */ -.ob-offer b{ - display:inline-flex;align-items:center;height:20px;padding:0 7px;margin-top:6px; - border:1px solid var(--line);border-radius:0;background:var(--bg-sunken); - font-family:var(--font-mono);font-size:11px;font-weight:400;color:var(--text-secondary); -} -.ob-offer.cloud b{border-color:var(--accent-line);color:var(--accent-text)} -/* The Hugging Face `[ clear ]` sits beside a field, not on the download - screen, so it keeps its quiet inline shape rather than the card. */ -.ob-offer.ob-offer-inline{ - border:0;border-radius:0;padding:2px 8px;margin-top:var(--s2); - width:auto;display:inline-flex;font-size:12px; -} -.ob-offer.ob-offer-inline:hover{background:var(--bg-hover)} - -/* Fields: a 30px box is a terminal's idea of a text input. 34 is a - comfortable target, and the focus border says which one is live. */ -.ob-inp{height:34px;transition:border-color .1s ease} -.ob-inp:hover{border-color:var(--line-strong)} -.ob-inp:focus{border-color:var(--accent);outline:none} -.ob-inp:disabled{background:var(--bg-active);cursor:not-allowed} - -/* An error belongs under the control that caused it, at the size of a - hint rather than a headline. */ -.ob-err-field{margin-top:6px;display:flex;gap:6px;align-items:flex-start} -.ob-err-field::before{content:"✗";color:var(--danger)} - -/* `.ob` is already a flex column with a gap, so the offers' own - `margin-top` was doubling the space above them and pushing the second - card off a short window. The gap owns the rhythm; the cards sit in it. */ -#onboarding .ob-offer{margin-top:0} -/* A list that is longer than its box scrolls, the way a desktop list - does. The TUI painted six rows and a `↓ 7 more` line because a - terminal cannot scroll a region — which left the seventh model - unreachable by any mouse gesture at all. Same height, same rhythm; - the rows below it are now simply there. */ -#onboarding .ob-scroll{max-height:46vh;overflow-y:auto;overscroll-behavior:contain} + The title card is one indigo block. Every later step is two columns: + an indigo rail (lockup, the phase statement, the two phase steps, the + build) and the step itself on the window ground — title, lede, body, + the action bar and the keycap hint strip. + ============================================================ */ -/* The import step's tick boxes. `[x]` in a monospace span is a terminal - drawing a checkbox with the characters it has; this window has real - ones. Same two states, same row, same accent. */ -#onboarding .ob-row .box{ - display:inline-flex;align-items:center;justify-content:center; - width:15px;height:15px;margin-right:8px;vertical-align:-2px; - border:1px solid var(--line-strong);border-radius:0; - background:var(--bg-sunken);font-family:var(--font-ui);font-size:10px;line-height:1; - color:transparent;transition:background .1s ease,border-color .1s ease; +/* ---------------- shared wizard parts ---------------- + These are drawn OUTSIDE the first-run layer too: the composer's route + popover renders wizModelStepHTML(false) and its own key screen with + `.ob-help` / `.ob-err`, and `.ob-help` carries the composer's wait reason + and the modes popover's version note. Their base look stays unscoped; + the first-run layout below is scoped to #onboarding. */ +.ob-wiz{display:flex;flex-direction:column;gap:10px;width:100%;min-width:0} +.ob-kicker{font:600 12.5px/1.4 var(--font-ui);color:var(--ink-2);letter-spacing:0;text-transform:none} +.ob-wiz .ob-h{display:flex;align-items:center;gap:10px;min-width:0;font-size:15px;font-weight:600;letter-spacing:-.015em;color:var(--ink)} +.ob-help{font-size:12.5px;line-height:1.45;color:var(--ink-2);max-width:62ch} +.ob-err{color:var(--critical);white-space:pre-wrap;max-width:62ch;font-size:12.5px;line-height:1.45} +.ob-inp{width:100%;min-width:0;height:38px;padding:0 15px;border-radius:var(--r-pill);border:1px solid transparent; + background:var(--well);color:var(--ink);font:400 14px/1 var(--font-ui);outline:none; + transition:background var(--t-state) var(--ease),border-color var(--t-state) var(--ease),box-shadow var(--t-state) var(--ease)} +.ob-inp::placeholder{color:var(--ink-3)} +.ob-inp:hover:not(:disabled){border-color:var(--bd)} +.ob-inp:focus{background:var(--lift);border-color:var(--brand);box-shadow:var(--focus-ring)} +.ob-inp:disabled{color:var(--ink-3);cursor:not-allowed} +.ob-wizlist{display:flex;flex-direction:column;min-height:0;overflow-y:auto;overscroll-behavior:contain} +.prows{display:flex;flex-direction:column;min-width:0} +.prow{display:flex;align-items:center;gap:12px;width:100%;min-width:0;min-height:46px;padding:7px 12px;border:0; + border-radius:var(--r-row);background:transparent;color:var(--ink);text-align:left;cursor:pointer; + transition:background var(--t-state) var(--ease)} +.prow + .prow{box-shadow:0 -1px 0 var(--divider)} +.prow:hover{background:var(--hover)} +.prow:active{background:var(--press)} +.prow.on{background:var(--brand-wash);box-shadow:none} +.prow.on + .prow{box-shadow:none} +.prow:focus-visible{outline:2px solid var(--brand);outline-offset:-2px} +.prow .col{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1} +.prow .nm{font-size:14px;font-weight:600;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.prow .ep{font:400 12px/1.4 var(--font-mono);color:var(--ink-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.prow .ann{flex:none} +.prow-more{font:600 12.5px/1.4 var(--font-ui);color:var(--ink-2);letter-spacing:0;text-transform:none;margin:16px 0 8px 10px} + +/* ---------------- the layer ---------------- */ +#onboarding{ + position:absolute;inset:0;z-index:40; + display:grid;grid-template-columns:400px minmax(0,1fr); + background:var(--well);color:var(--ink); + -webkit-app-region:drag; } -#onboarding .ob-row .box.on{background:var(--accent);border-color:var(--accent);color:var(--on-fill)} -#onboarding .ob-row.ob-check:hover .box{border-color:var(--accent-line)} - -/* The offer cards read as one sentence with a keycap wedged into the - middle of it. A card is a control: the sentence on the left, the chord - that fires it on the right, both on one baseline. */ -#onboarding .ob-offer:not(.ob-offer-inline){ - display:flex;align-items:center;justify-content:space-between;gap:var(--s5); - width:100%;text-align:left; +#onboarding > *{-webkit-app-region:no-drag} + +/* ---------------- the title card (ON-01) ---------------- + `.ob-introc` keeps exactly its five children (mark, name, rule, build, + dismiss line) — the smoke counts them — so the glow is a sibling and the + dismiss line is placed against the card, not the column. The rule is + kept as a 3px transparent spacer: Soft Tactile draws no 3px rules, and + the smoke still reads its border width. Nothing here animates. */ +#onboarding.ob-intro-layer{display:block;background:var(--indigo)} +#ob-intro{position:absolute;inset:0;display:grid;place-items:center;overflow:hidden; + padding:var(--s8);background:var(--indigo);color:var(--on-strong)} +#ob-intro .ob-glow{position:absolute;left:50%;top:50%;width:760px;height:760px;margin:-380px 0 0 -380px; + border-radius:50%;pointer-events:none; + background:radial-gradient(circle,color-mix(in srgb,var(--brand) 90%,transparent) 0%,transparent 62%)} +.ob-introc{position:static;display:flex;flex-direction:column;align-items:center;gap:0;width:min(720px,100%);text-align:center} +.ob-introc .ob-markbig{position:relative;display:block;width:96px;height:96px;border-radius:22px; + box-shadow:0 30px 60px -20px var(--shade-50)} +.ob-introc .ob-markbig svg{display:block;width:96px;height:96px} +.ob-introc .ob-word{position:relative;margin:24px 0 0;font:700 64px/1.05 var(--font-ui);letter-spacing:-.055em;color:var(--on-strong)} +.ob-introc .ob-rule{position:relative;width:0;height:0;margin:0;border:0;border-top:3px solid transparent} +.ob-introc .ob-build{position:relative;margin-top:17px;font:400 12.5px/1.4 var(--font-mono);letter-spacing:0;color:var(--on-indigo)} +.ob-introc .ob-any{position:absolute;left:0;right:0;bottom:34px;font:400 13px/1.4 var(--font-ui); + letter-spacing:0;text-transform:none;color:var(--indigo-muted)} + +/* ---------------- the rail ---------------- + Room at the top for the real macOS traffic lights (hiddenInset at 20,20); + the rail carries no controls, so it is also the window's drag handle. */ +#onboarding .ob-rail{ + position:relative;overflow:hidden;min-height:0; + margin:10px 0 10px 10px;padding:60px 28px 26px;border-radius:var(--r-pop); + display:flex;flex-direction:column;gap:22px; + background:var(--indigo);color:var(--on-indigo); + -webkit-app-region:drag;user-select:none; } -#onboarding .ob-offer:not(.ob-offer-inline) b{margin-top:0;flex:0 0 auto} - -/* A list that scrolls must say so. macOS hides its overlay scrollbar - until something moves, so the wizard's long lists — 18 providers, a - whole model catalogue — ended in a row sliced in half with no hint - that there was more under it. A thin permanent bar, in the app's own - line colour. */ -/* Deliberately the -webkit- pseudo-elements and NOT `scrollbar-width` / - `scrollbar-color`: setting either of the standard properties makes - Chromium ignore these rules and fall back to the platform's OVERLAY - scrollbar, which on macOS is invisible until something moves — the - very thing this is here to fix. */ +#onboarding .ob-rail > *{position:relative} +#onboarding .ob-rail > .ob-orb{position:absolute;border-radius:50%;pointer-events:none;filter:blur(2px)} +#onboarding .ob-orb-a{width:440px;height:440px;right:-180px;bottom:-160px; + background:radial-gradient(circle,color-mix(in srgb,var(--brand) 75%,transparent) 0%,transparent 68%)} +#onboarding .ob-orb-b{width:240px;height:240px;left:-90px;top:230px; + background:radial-gradient(circle,color-mix(in srgb,var(--indigo-muted) 25%,transparent) 0%,transparent 70%)} +#onboarding .ob-lock{display:flex;align-items:center;gap:12px;min-width:0} +#onboarding .ob-lock .ob-mark{display:flex;flex:none} +#onboarding .ob-lock .ob-mark svg{width:30px;height:30px} +#onboarding .ob-lock .ob-wm{font:600 17px/1.2 var(--font-ui);letter-spacing:-.02em;color:var(--on-strong);white-space:nowrap} +#onboarding .ob-statement{margin:auto 0 0;max-width:13ch;font:600 30px/1.13 var(--font-ui);letter-spacing:-.04em;color:var(--on-strong)} +#onboarding .ob-stepmarks{display:grid;gap:4px} +#onboarding .ob-stepmark{display:flex;align-items:center;gap:12px;height:44px;padding:0 12px;border-radius:var(--r-pill); + font:500 14px/1 var(--font-ui);letter-spacing:0;text-transform:none;color:var(--indigo-muted)} +#onboarding .ob-stepmark .n{width:26px;height:26px;border-radius:50%;display:grid;place-items:center;flex:none; + font:500 11.5px/1 var(--font-mono);box-shadow:inset 0 0 0 1.5px var(--glass)} +#onboarding .ob-stepmark .n svg{width:13px;height:13px;stroke-width:2.2} +#onboarding .ob-stepmark.on{background:var(--glass-12);color:var(--on-strong)} +#onboarding .ob-stepmark.on .n{background:var(--on-strong);color:var(--indigo);box-shadow:none} +#onboarding .ob-stepmark.done{color:var(--on-strong)} +#onboarding .ob-stepmark.done .n{background:var(--glass-20);box-shadow:none} +#onboarding .ob-railbuild{font:400 11px/1.4 var(--font-mono);letter-spacing:0;color:var(--indigo-muted)} + +/* ---------------- the step column ---------------- + Title, then a body that owns the flexible height (its lists scroll + inside it), then the action bar and the hint strip, always in the same + place. The body's side padding is room for card shadows and the 2px + selection ring, which a scrolling box would otherwise clip. */ +#onboarding .ob{position:relative;display:flex;flex-direction:column;gap:0;width:auto;max-height:none; + min-width:0;min-height:0;overflow:hidden;padding:48px 64px 12px} +#onboarding .ob-head{flex:none;display:block} +#onboarding .ob-title{margin:0;max-width:760px;font:600 34px/1.15 var(--font-ui);letter-spacing:-.04em;color:var(--ink)} +#onboarding .ob-body{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:14px; + margin:10px -16px 0;padding:4px 16px 14px;overflow-y:auto;overscroll-behavior:contain} +#onboarding .ob-body > *{max-width:720px;flex-shrink:0} +#onboarding .ob-body > .ob-scroll,#onboarding .ob-body > .ob-wiz{flex-shrink:1} +/* The wizard carries its own action bar, which spans the column like every + other step's; its content is held to 720px below. */ +#onboarding .ob-body > .ob-wiz{max-width:none} +#onboarding .ob-explain{font-size:15.5px;line-height:1.5;color:var(--ink-2);max-width:60ch;white-space:pre-line} +#onboarding .ob-body > .ob-explain:first-child{margin:-4px 0 8px} +#onboarding .ob-h{font:600 15px/1.35 var(--font-ui);letter-spacing:-.01em;color:var(--ink)} +#onboarding .ob-h.ob-sec{font-size:12.5px;letter-spacing:0;color:var(--ink-2);margin:2px 0 -6px 2px} +#onboarding .ob-h.ob-ready{display:flex;align-items:center;gap:10px;font-size:17px;letter-spacing:-.015em} +#onboarding .ob-ok{color:var(--success)} +#onboarding .ob-mono{font-family:var(--font-mono);letter-spacing:0} + +/* ---------------- rows ---------------- + A list is a lifted card of ≥46px rows with inset dividers and a brand + tint for the row under the cursor. The TUI's `›` marker stays in the + markup and is not drawn — the tint is the cursor. */ +#onboarding .ob-list{display:flex;flex-direction:column;min-width:0} +/* Rows in a scrolling column must not shrink: an explicit min-height + replaces `min-height:auto`, and the rows then squeezed to 46px and drew + over each other instead of scrolling. */ +#onboarding .ob-list > *,#onboarding .ob-wizlist > *,#onboarding .prows > *{flex-shrink:0} +#onboarding .ob-list:not(.ob-routes){padding:6px;border-radius:var(--r-card);background:var(--lift);box-shadow:var(--sh-card)} +#onboarding .ob-row{position:relative;display:flex;align-items:center;gap:12px;width:100%;min-width:0;min-height:46px; + padding:8px 12px;border:0;border-radius:var(--r-row);background:transparent;color:var(--ink);text-align:left;cursor:pointer; + transition:background var(--t-state) var(--ease),box-shadow var(--t-state) var(--ease)} +#onboarding .ob-row .mk{display:none} +#onboarding .ob-list:not(.ob-routes) .ob-row + .ob-row{box-shadow:0 -1px 0 var(--divider)} +#onboarding .ob-row:hover{background:var(--hover)} +#onboarding .ob-row:active{background:var(--press)} +#onboarding .ob-row.on,#onboarding .ob-list:not(.ob-routes) .ob-row.on{background:var(--brand-wash);box-shadow:none} +#onboarding .ob-list:not(.ob-routes) .ob-row.on + .ob-row{box-shadow:none} +#onboarding .ob-row:focus-visible{outline:2px solid var(--brand);outline-offset:-2px} +#onboarding .ob-rbody{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} +#onboarding .ob-row .t{display:block;font-size:14px;font-weight:600;line-height:1.35;color:var(--ink)} +#onboarding .ob-row .d{display:block;font-size:12.5px;line-height:1.45;color:var(--ink-2)} +#onboarding .ob-row > .kc{margin-left:auto} +#onboarding .ob-row > .tk-ico,#onboarding .ob-row > .logo{flex:none} +/* A model with no brand mark wears the CPU badge at the logo's size, so + every row's text starts on the same line. */ +#onboarding .ob-row > .tk-ico--sm{width:30px;height:30px} + +/* Route cards (ON-02): three lifted cards, the one under the cursor ringed. */ +#onboarding .ob-routes{gap:12px;max-width:660px} +#onboarding .ob-routes .ob-row{gap:16px;padding:14px 18px;border-radius:var(--r-block);background:var(--lift);box-shadow:var(--sh-card)} +#onboarding .ob-routes .ob-row:hover{background:color-mix(in srgb,var(--ink) 3%,var(--lift))} +#onboarding .ob-routes .ob-row:active{background:color-mix(in srgb,var(--ink) 6%,var(--lift))} +#onboarding .ob-routes .ob-row.on{background:var(--lift);box-shadow:0 0 0 2px var(--brand),var(--sh-card)} +#onboarding .ob-routes .ob-row:focus-visible{outline-offset:3px} +#onboarding .ob-routes .ob-row .t{font-size:16px;letter-spacing:-.02em} +#onboarding .ob-routes .ob-row .d{font-size:13px} + +/* ---------------- the local model list (ON-03, ON-04) ---------------- */ +#onboarding .ob-models{flex:0 1 auto;min-height:140px;max-height:none;overflow-y:auto;overscroll-behavior:contain} +#onboarding .ob-scroll{min-height:120px;overflow-y:auto;overscroll-behavior:contain} +#onboarding .ob-models .ob-row{align-items:flex-start} +#onboarding .ob-models .ob-row > .logo,#onboarding .ob-models .ob-row > .tk-ico{margin-top:2px} +#onboarding .ob-row .t .ob-mono{font-size:13.5px;font-weight:500} +#onboarding .ob-badge{display:inline-flex;align-items:center;height:22px;padding:0 8px;margin-left:8px;vertical-align:1px; + border-radius:var(--r-pill);font:500 11.5px/1 var(--font-ui);letter-spacing:0;white-space:nowrap; + background:var(--brand-wash);color:var(--blue)} +#onboarding .ob-badge-best{background:var(--indigo);color:var(--on-indigo)} +#onboarding .ob-badge-warn{background:var(--amber-wash);color:var(--amber-text)} +#onboarding .ob-row .d > span{display:block} +#onboarding .ob-row .d > .ob-desc{display:inline} +#onboarding .ob-row .d > .ob-desc + span{display:inline} +#onboarding .ob-row .d > .ob-desc::after{content:" · "} +#onboarding .ob-row .d .ob-fit{margin-top:1px} +#onboarding .ob-fit-comfortable{color:var(--success)} +#onboarding .ob-fit-tight{color:var(--amber-text)} +#onboarding .ob-fit-over{color:var(--ink-2)} +#onboarding .ob-row .d .ob-caution{margin-top:2px;color:var(--ink-2)} +#onboarding .ob-hfrow{min-height:40px;padding:6px 12px} +#onboarding .ob-hfrow .ob-rbody{flex-direction:row;align-items:baseline;flex-wrap:wrap;gap:2px 8px} +#onboarding .ob-hfrow .t{font-size:13.5px} +/* Out of reach: shown, explained, not a control — a quiet well inside the + list card, after the rows it could not be one of. */ +#onboarding .ob-out-h{margin:12px 8px 6px;font-size:13.5px;font-weight:600;color:var(--ink)} +#onboarding .ob-out-list{display:flex;flex-direction:column;gap:2px;padding:6px;border-radius:var(--r-row);background:var(--well)} +#onboarding .ob-out{display:grid;grid-template-columns:18px minmax(0,1fr);column-gap:10px;align-items:center; + padding:6px 8px;border:0;color:var(--ink-2);cursor:default} +#onboarding .ob-out > .logo,#onboarding .ob-out > .tk-ico{grid-row:1 / span 3;align-self:start;margin-top:1px} +#onboarding .ob-out .t{font:500 13px/1.35 var(--font-mono);color:var(--ink-2)} +#onboarding .ob-out .ob-desc,#onboarding .ob-out .ob-fit{display:block;font-size:12.5px;line-height:1.4;color:var(--ink-2)} + +/* ---------------- fields ---------------- */ +#onboarding .ob-lbl{display:block;margin:4px 0 -6px 2px;font:600 13px/1.4 var(--font-ui);color:var(--ink)} +#onboarding .ob-lbl .ob-explain{display:inline;font-size:inherit;line-height:inherit;font-weight:500;color:var(--ink-2);white-space:normal} +#onboarding .ob-inp{height:52px;max-width:640px;padding:0 18px;border:0;background:var(--lift); + box-shadow:inset 0 0 0 1px var(--bd-2);font-size:14.5px} +#onboarding .ob-inp:hover:not(:disabled){box-shadow:inset 0 0 0 1px var(--ink-3)} +#onboarding .ob-inp:focus{background:var(--lift);box-shadow:inset 0 0 0 1.5px var(--brand),var(--focus-ring)} +#onboarding .ob-field{display:flex;align-items:center;gap:10px;width:100%;max-width:640px;height:52px;padding:0 8px 0 18px; + border-radius:var(--r-pill);background:var(--lift);box-shadow:inset 0 0 0 1px var(--bd-2);color:var(--ink-3); + transition:box-shadow var(--t-state) var(--ease)} +#onboarding .ob-field:hover{box-shadow:inset 0 0 0 1px var(--ink-3)} +#onboarding .ob-field:focus-within{box-shadow:inset 0 0 0 1.5px var(--brand),var(--focus-ring)} +#onboarding .ob-field.is-error{box-shadow:inset 0 0 0 1.5px var(--critical),0 0 0 4px var(--critical-wash)} +#onboarding .ob-field.is-warn{box-shadow:inset 0 0 0 1.5px var(--amber)} +#onboarding .ob-field > svg{width:16px;height:16px;flex:none} +#onboarding .ob-field > .logo{margin-left:-6px} +#onboarding .ob-field > .tk-spin{margin-right:10px} +#onboarding .ob-field > .tk-ico{margin-right:4px} +#onboarding .ob-field > .kc{margin-right:10px} +#onboarding .ob-field .ob-inp,#onboarding .ob-field .ob-inp:hover:not(:disabled),#onboarding .ob-field .ob-inp:focus{ + flex:1;height:100%;max-width:none;padding:0;border:0;border-radius:0;background:transparent;box-shadow:none} +#onboarding .ob-field .ob-inp{font:400 14.5px/1 var(--font-mono);color:var(--ink)} +#onboarding .ob-field.ob-search .ob-inp{font-family:var(--font-ui)} +#onboarding .ob-field .ob-inp:focus-visible{outline:none} +#onboarding .ob-offer.ob-offer-inline{display:inline-flex;align-items:center;flex:none;width:auto;height:32px;margin:0;padding:0 14px; + border:0;border-radius:var(--r-pill);background:var(--hover);color:var(--ink-2);font:600 12.5px/1 var(--font-ui);cursor:pointer; + transition:background var(--t-state) var(--ease),color var(--t-state) var(--ease)} +#onboarding .ob-offer.ob-offer-inline:hover{background:var(--press);color:var(--ink)} +#onboarding .ob-help{padding-left:4px} +#onboarding .ob-explain.ob-examples{font-size:13px;line-height:1.5} + +/* Errors: a red notice under the control that caused it; a surface error + sits above the action bar in the same shape. */ +#onboarding .ob-err{display:flex;align-items:flex-start;gap:10px;max-width:640px;padding:10px 14px;border-radius:18px; + background:color-mix(in srgb,var(--critical) 8%,var(--lift));color:var(--ink);font-size:13px;line-height:1.5;white-space:pre-wrap} +#onboarding .ob-err > svg{width:16px;height:16px;flex:none;margin-top:2px;color:var(--critical)} +#onboarding .ob-err > span{min-width:0} +#onboarding .ob-note{display:flex;align-items:center;gap:10px;max-width:720px;padding:10px 14px;border-radius:18px; + font-size:13px;line-height:1.45;color:var(--ink);white-space:normal} +#onboarding .ob-note > svg{width:16px;height:16px;flex:none} +#onboarding .ob-note.is-blue{background:color-mix(in srgb,var(--brand) 9%,var(--lift))} +#onboarding .ob-note.is-blue > svg{color:var(--blue)} +#onboarding .ob-note.is-amber{background:color-mix(in srgb,var(--amber) 13%,var(--lift))} +#onboarding .ob-note.is-amber > svg{color:var(--amber-text)} + +/* ---------------- the cloud wizard (ON-09…15) ---------------- */ +#onboarding .ob-wiz{flex:1 1 auto;min-height:0;gap:12px} +#onboarding .ob-wiz > *{max-width:720px;flex-shrink:0} +#onboarding .ob-wiz > .ob-wizlist{flex:0 1 auto;min-height:120px;margin:0 -16px;padding:4px 16px 14px;max-width:752px} +#onboarding .ob-wiz > .ob-foot{max-width:none;margin-top:auto} +#onboarding .ob-wizlist .prows{padding:6px;border-radius:var(--r-card);background:var(--lift);box-shadow:var(--sh-card)} +#onboarding .prow .nm{font-size:14.5px} +#onboarding .prow .ann.lit{height:22px} +#onboarding [data-wizmodel] .ann.lit{background:var(--brand-wash);color:var(--blue)} +#onboarding .prow-more{margin:14px 0 8px 10px} +#onboarding .ob-wiz .ob-kicker{margin-bottom:-10px} +#onboarding .ob-wiz .ob-h{gap:14px;font:600 34px/1.15 var(--font-ui);letter-spacing:-.04em;color:var(--ink)} +#onboarding .ob-wiz .ob-h .logo,#onboarding .ob-wiz .ob-h .tk-ico{width:44px;height:44px} +#onboarding .ob-wiz .ob-h .tk-ico svg{width:20px;height:20px} +#onboarding .ob-wiz .ob-flabel{margin:6px 0 -4px 2px;font:600 13px/1.4 var(--font-ui);color:var(--ink);letter-spacing:0;text-transform:none} +#onboarding .ob-wiz .ob-field{margin-top:10px} +#onboarding .ob-wiz .ob-err{padding:0 0 0 4px;background:transparent;color:var(--critical);font-size:12.5px} +#onboarding .ob-wiz .ob-err > svg{width:14px;height:14px;margin-top:2px} +#onboarding .ob-wiz .ob-err.is-warn,#onboarding .ob-wiz .ob-err.is-warn > svg{color:var(--amber-text)} + +/* ---------------- the action bar and the hint strip ---------------- */ +#onboarding .ob-foot{flex:none;display:flex;align-items:center;gap:10px;padding-top:16px;border:0} +#onboarding .ob-foot .grow{flex:1} +#onboarding .ob-foot .btn{height:44px;padding:0 22px;font-size:14.5px} +#onboarding .ob-foot .btn svg{width:16px;height:16px} +#onboarding .ob-foot .btn-p{min-width:112px} +#onboarding .ob-foot .btn .tk-spin{width:14px;height:14px} +/* The quiet action keeps a hairline edge at rest: a Back that reads as a + label until the pointer is on it was a tester finding, and + onboarding-mouse.mjs still measures it. */ +#onboarding .ob-foot .btn-g{padding:0 18px 0 14px;border:1px solid var(--bd-2)} +#onboarding .ob-foot .btn-g:hover{background:var(--hover);color:var(--ink)} +#onboarding .ob-hints{position:static;flex:none;display:flex;align-items:center;justify-content:center;flex-wrap:wrap; + gap:2px 12px;min-height:40px;height:auto;padding:10px 0 0;border:0;background:transparent;font-size:12px;color:var(--ink-2)} +#onboarding .ob-hints .hint{display:inline-flex;align-items:center;gap:6px;height:24px;margin:0;padding:0 6px;border-radius:8px; + font-size:12px;letter-spacing:0} +#onboarding .ob-hints .hint-live{font:inherit;color:inherit;background:transparent;border:0;cursor:pointer; + transition:background var(--t-state) var(--ease),color var(--t-state) var(--ease)} +#onboarding .ob-hints .hint-live:hover{background:var(--hover);color:var(--ink)} +#onboarding .ob-hints .kc{margin:0} +#onboarding .ob-hints .kc + .kc{margin-left:-2px} + +/* ---------------- the download (ON-07, ON-08, ON-18) ---------------- + `.ob-dlprog` is the one box a progress sample repaints (refreshDlProgress); + its rows are fixed-width and never wrap, so a sample cannot move the + offer card a pointer rests on. */ +#onboarding .ob-dlprog{display:grid;gap:14px;max-width:640px;padding:18px 20px 16px;border-radius:var(--r-card); + background:var(--lift);box-shadow:var(--sh-card)} +#onboarding .ob-phase{display:grid;grid-template-columns:24px max-content minmax(48px,1fr) 23ch;align-items:center;gap:12px;white-space:nowrap} +#onboarding .ob-phase .tk-ico{width:24px;height:24px} +#onboarding .ob-phase .tk-ico svg{width:13px;height:13px} +#onboarding .ob-phase .pl{min-width:0;overflow:hidden;text-overflow:ellipsis;font-size:14px;font-weight:600;color:var(--ink)} +#onboarding .ob-phase .pb{height:8px;min-width:0} +#onboarding .ob-phase .pb i{font-style:normal;color:var(--brand);background:currentColor} +#onboarding .ob-phase.waiting .pb i{color:var(--ink-3)} +#onboarding .ob-phase.done .pb{visibility:hidden} +#onboarding .ob-phase .pt{min-width:0;text-align:right;white-space:pre;font:400 12px/1 var(--font-mono); + font-variant-numeric:tabular-nums;color:var(--ink-2)} +#onboarding .ob-rate{margin:0;font:400 12px/1.4 var(--font-mono);color:var(--ink-2)} +#onboarding .ob-dlprog > .ob-err{padding:0;background:transparent;color:var(--critical)} +#onboarding .ob-offer:not(.ob-offer-inline){display:flex;align-items:center;gap:14px;width:100%;max-width:640px;min-height:64px;margin:0; + padding:12px 16px;border:0;border-radius:var(--r-block);background:var(--lift);box-shadow:var(--sh-card); + color:var(--ink);text-align:left;cursor:pointer; + transition:background var(--t-state) var(--ease),box-shadow var(--t-state) var(--ease)} +#onboarding .ob-offer:not(.ob-offer-inline):hover{background:color-mix(in srgb,var(--ink) 3%,var(--lift))} +#onboarding .ob-offer:not(.ob-offer-inline):active{background:color-mix(in srgb,var(--ink) 6%,var(--lift))} +#onboarding .ob-offer:focus-visible{outline:2px solid var(--brand);outline-offset:2px} +#onboarding .ob-offer .ob-rbody{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} +#onboarding .ob-offer .t{font-size:14.5px;font-weight:600;color:var(--ink)} +#onboarding .ob-offer .d{font-size:13px;line-height:1.45;color:var(--ink-2)} +#onboarding .ob-offer > .kc{margin-left:auto} + +/* ---------------- the import (ON-20…23) ---------------- */ +#onboarding .ob-row .box{width:18px;height:18px;margin:0;border:0;border-radius:6px;display:inline-grid;place-items:center;flex:none; + background:transparent;box-shadow:inset 0 0 0 1.5px var(--ink-3);color:var(--on-strong); + transition:background var(--t-state) var(--ease),box-shadow var(--t-state) var(--ease)} +#onboarding .ob-row .box svg{width:12px;height:12px;stroke-width:2.6} +#onboarding .ob-row .box.on{background:var(--brand);box-shadow:none} +#onboarding .ob-row.ob-check:hover .box:not(.on){box-shadow:inset 0 0 0 1.5px var(--brand)} +#onboarding .ob-check .d{font:400 12px/1.45 var(--font-mono)} +#onboarding .ob-models > .ob-explain{padding:10px 12px;font-size:13.5px} +#onboarding .ob-explain.ob-help{font-size:12.5px;line-height:1.45;max-width:62ch;padding-left:4px} +#onboarding .prow > .tk-ico{width:30px;height:30px} +#onboarding .ob-h.ob-lede{font:400 15.5px/1.5 var(--font-ui);letter-spacing:0;color:var(--ink-2)} +#onboarding .ob-out > :not(.logo):not(.tk-ico){grid-column:2} +#onboarding .ob-applogo svg{width:100%;height:100%} +#onboarding .ob-list.is-busy{opacity:.7} +#onboarding .ob-scanstrip{display:flex;align-items:center;gap:10px;max-width:680px;padding:10px 14px;border-radius:18px; + background:color-mix(in srgb,var(--amber) 13%,var(--lift));font-size:13px;color:var(--ink)} +#onboarding .ob-scanstrip .tk-spin{border-color:var(--amber-wash);border-top-color:var(--amber)} +#onboarding .ob-scanstrip .ob-scan-k{font-weight:700} +#onboarding .ob-scanstrip .readout{font:400 12px/1 var(--font-mono);color:var(--ink);min-width:0} +#onboarding .ob-scanstrip .ob-help{padding:0;font-size:13px;color:var(--ink-2)} +#onboarding .ob-summary{display:grid;gap:8px;max-width:700px;padding:16px 18px;border-radius:var(--r-card); + background:var(--lift);box-shadow:var(--sh-card);white-space:normal;font-size:14px;color:var(--ink-2)} +#onboarding .ob-sumrow{display:grid;grid-template-columns:130px minmax(0,1fr);gap:12px} +#onboarding .ob-sumrow .k{font-weight:600;color:var(--ink)} +#onboarding .ob-sumrow .v.is-warn{color:var(--amber-text)} +#onboarding .ob-failures{display:flex;flex-wrap:wrap;gap:8px} +#onboarding .ob-failtbl{max-width:700px;padding:4px 6px 8px;border-radius:var(--r-card);background:var(--lift);box-shadow:var(--sh-card)} +#onboarding .ob-failtbl td:first-child{font:500 12px/1 var(--font-mono)} + +/* Hugging Face file pick (ON-06). */ +#onboarding .ob-repo{display:flex;align-items:center;gap:10px;font:500 14px/1.3 var(--font-mono);letter-spacing:0;color:var(--ink)} +#onboarding .ob-file .t{font:500 13px/1.35 var(--font-mono)} +#onboarding .ob-file .ob-size{margin-left:auto;font:400 12px/1 var(--font-mono);color:var(--ink-2);white-space:nowrap} + +/* A list that scrolls must say so. macOS hides its overlay scrollbar until + something moves, so the long lists ended in a row sliced in half with no + hint of more. Deliberately the -webkit- pseudo-elements and NOT + `scrollbar-width` / `scrollbar-color`: either standard property makes + Chromium fall back to the invisible overlay bar. */ #onboarding .ob-scroll::-webkit-scrollbar,#onboarding .ob-wizlist::-webkit-scrollbar{width:10px} #onboarding .ob-scroll::-webkit-scrollbar-thumb,#onboarding .ob-wizlist::-webkit-scrollbar-thumb{ - background:var(--line-strong);border:3px solid transparent;background-clip:content-box;border-radius:0; -} + background:var(--bd-2);border:3px solid transparent;background-clip:content-box;border-radius:var(--r-pill)} #onboarding .ob-scroll::-webkit-scrollbar-thumb:hover,#onboarding .ob-wizlist::-webkit-scrollbar-thumb:hover{ - background:var(--text-disabled);background-clip:content-box; + background:var(--ink-3);background-clip:content-box} + +/* ---------------- smaller windows ---------------- + The rail narrows, then gives up its statement on a short window; every + control stays on screen down to the 940×620 minimum. */ +@media (max-width:1240px){ + #onboarding{grid-template-columns:340px minmax(0,1fr)} + #onboarding .ob{padding:44px 44px 12px} + #onboarding .ob-statement{font-size:27px} } - -/* ========================================================================== - r6 UX — the secondary control has to look like a control - ========================================================================== - - Driving the wizard screen by screen turned up the brief's own rule - ("no control that looks like a label") broken in the two places a - pointer needs most. Both drew their border as `transparent` at rest - and revealed it only on hover — which is a discoverability trick that - only works for a pointer that is ALREADY on the control. Until then - `Back`, `Skip setup for now` and `Clear` are grey text on the surface, - sitting next to real prose, with nothing to say they can be pressed. - - The app already owns the answer: `.btn-s` — its standard secondary - button — rests at `1px solid var(--line)` and strengthens on hover. - Using that here is the app's existing idiom, not a new look: the same - token, the same weight, the same hover. Nothing else moves. */ -.ob-foot .btn-g{border-color:var(--line)} -.ob-foot .btn-g:hover{border-color:var(--line-strong)} - -/* `Clear` was 46x23px. The wizard's own floor is 32px for a row and 34 - for a field, and 23px is under every desktop platform's minimum for a - pointer target — on the one screen where a mouse user has to undo a - typo. It keeps its quiet inline shape and its 12px type; it just - becomes big enough to hit and outlined enough to find. */ -.ob-offer.ob-offer-inline{ - border:1px solid var(--line);min-height:26px;padding:3px 10px;align-items:center; +@media (max-width:1060px){ + #onboarding{grid-template-columns:272px minmax(0,1fr)} + #onboarding .ob-rail{padding:56px 20px 20px;gap:18px} + #onboarding .ob-statement{font-size:24px} + #onboarding .ob{padding:36px 32px 10px} + #onboarding .ob-title,#onboarding .ob-wiz .ob-h{font-size:28px} + #onboarding .ob-phase{gap:10px} } -.ob-offer.ob-offer-inline:hover{border-color:var(--line-strong)} - -/* ============================================================ - r6 cloud item 1 — the onboarding hint strip's `esc` chord is a real - control now, not decoration. It must keep the strip's exact metrics - (a '; + + (lead || '') + + '' + label + '' + + (detail ? '' + detail + '' : '') + '' + + (trail || '') + ''; } /** describeDownloadingModel (local-model-picks.ts:139-145). */ @@ -6914,10 +6951,18 @@ function obOutOfReach() { } function obChooseHTML() { - const rows = OB_CHOICES.map((choice, i) => - obRow(i, OB.cursor === i, esc(choice.label), esc(choice.detail[0]) + '
' + esc(choice.detail[1]))).join(''); - return '
' + esc(OB_COPY.chooseExplainer.join('\n')) + '
' - + '
' + rows + '
'; + /* Route cards: where it runs, the route, one line, and the digit that + jumps to it. */ + const look = {local:['laptop', ' tk-ico--indigo'], cloud:['cloud', ' tk-ico--blue'], custom:['server', '']}; + const rows = OB_CHOICES.map((choice, i) => { + const l = look[choice.id] || ['server', '']; + return obRow(i, OB.cursor === i, esc(choice.label), + esc(choice.detail[0]) + (choice.detail[1] ? '
' + esc(choice.detail[1]) : ''), '', '', + '' + ic(l[0]) + '', + '' + (i + 1) + ''); + }).join(''); + return '
' + esc(OB_COPY.chooseExplainer.join(' ')) + '
' + + '
' + rows + '
'; } function obLocalPickHTML() { @@ -6925,35 +6970,29 @@ function obLocalPickHTML() { const models = rows.filter((r) => r.kind === 'model'); const onHf = OB.cursor >= models.length; /* r6 UX: windowLocalPicks paints six rows and says `↓ 7 more`, because - a terminal cannot scroll a region. This one can — and while the - window was a mouse DEAD END (there is no gesture that reaches the - seventh model; only an arrow key moves the window), a scroller is - the desktop's own list. Every row is drawn, the box scrolls, and the - cursor is kept in view for the keyboard — which behaves exactly as - it did, six rows at a time or not. */ + a terminal cannot scroll a region. This one can: every row is drawn, + the box scrolls, and the cursor is kept in view for the keyboard. */ const best = bestModelFor(OB.models, OB.ram); const body = models.length ? models.map((row, at) => { const model = row.model; return obRow(at, !onHf && at === OB.cursor, obModelRowLabel(model, best && model.id === best.id), - obModelRowDetail(model)); + obModelRowDetail(model), '', '', modelMark(model.id)); }).join('') : '
' + (OB.busy ? 'reading the catalogue…' : obNothingFitsLine()) + '
'; const hf = obRow(models.length, onHf, esc(HF_ROW_LABEL), - esc('paste an owner/repo id or a huggingface.co URL')); + esc('paste an owner/repo id or a huggingface.co URL'), 'ob-hfrow', '', logoHTML('huggingface', 'sm')); return '
' + esc('One download, then it runs offline. This machine reports ' + OB.ram + ' GB of RAM, ' + 'and the list below is ordered for it — the best fit first.') + '
' - + '
' + esc(OB_COPY.localHeading) + '
' + + '
' + esc(OB_COPY.localHeading) + '
' /* The out-of-reach block lives INSIDE the scroller, not beside it. - Beside it, it collapsed the list to nothing: `.ob-models` carries - `overflow-y:auto`, which sets its `min-height` to 0, so as a flex - item next to a sibling that cannot shrink it absorbed every pixel of - shrinkage and rendered 0px tall — three model rows present in the - DOM, readable by script, and INVISIBLE on screen. Caught in a - screenshot of the 8 GB pass; the 68 GB pass has no such sibling and - looked perfect. `min-height` below is the second belt. */ + Beside it, it collapsed the list to nothing: an `overflow-y:auto` flex + item next to a sibling that cannot shrink absorbs every pixel of + shrinkage and renders 0px tall — rows present in the DOM, readable by + script, and invisible on screen. `min-height` on `.ob-models` is the + second belt. */ + '
' + body + obOutOfReachHTML() + '
' + hf; } @@ -7011,7 +7050,7 @@ function obOutOfReachHTML() { if (!out.length) return ''; return '
' + esc('Needs a bigger machine than this one') + '
' + '
' + out.map((model) => - '
' + '
' + modelMark(model.id, 'xs') + '' + esc(model.id) + '' + (model.description ? '' + esc(model.description) + '' : '') + '' + esc(fitFor(model, OB.ram).label) + '
').join('') @@ -7024,24 +7063,26 @@ function obErrorIsInline() { } /** The step error, rendered where the step wants it. */ function obInlineErrHTML() { - return OB.error ? '
' + esc(OB.error) + '
' : ''; + return OB.error ? '
' + ic('alert') + '' + esc(OB.error) + '
' : ''; } /** hf-reference-editor.tsx, as its own step rather than a jump into Settings. */ function obHfRefHTML() { /* r6 UX: a `
' + obInlineErrHTML() - + (OB.busy ? '
' + esc(OB_COPY.urlProbing) + '
' : ''); + + (chat ? '' : '
' + esc(OB_COPY.urlEmbeddingNote) + '
') + + (OB.busy ? '
' + esc(OB_COPY.urlProbing) + '
' : ''); } /** onboarding-download-step.tsx:18-38 / :87-124. */ @@ -7089,24 +7136,45 @@ function obDownloadHTML() { // offerCloudMeanwhile: "hidden once a cloud provider is configured — // nothing left to offer" (:110-111). const offerCloud = !OB.cloudReady; + /* r6 UX: these two ARE this screen's buttons — the only way off it short + of waiting — so they are cards a mouse can see, not the terminal's `┃` + rule around a paragraph. The copy and the `c` / `s` chords are the + TUI's, unchanged. */ const cloud = offerCloud - ? '' + ? obOfferHTML(' cloud', 'key:c', '' + ic('cloud') + '', + failed ? [OB_COPY.cloudOfferFailed] : OB_COPY.cloudOffer, OB_COPY.cloudOfferKey) : ''; - /* r6 UX: these two ARE this screen's buttons — the only way off it - short of waiting — so they are drawn as cards a mouse can see, not as - the terminal's `┃` rule around a paragraph. The copy and the `press - c` / `press s` chords are the TUI's, unchanged. */ - const skip = ''; + const skip = obOfferHTML('', 'key:s', '' + ic('arrowR') + '', + failed ? [OB_COPY.skipOfferFailed] : OB_COPY.skipOffer, OB_COPY.skipOfferKey); return '
' + esc(failed ? 'The ' + label + ' download failed.' : 'Downloading ' + label + '. You can leave this running.') + '
' + obProgressBlockHTML() + cloud + skip; } +/** + * One download offer as a card: an icon, the offer as a title and a detail + * line, and the chord's keycap. The copy is the TUI's two wrapped lines, + * rejoined and split where it reads: a first line ending in a dash is the + * title, otherwise the first sentence is. + */ +function obOfferHTML(cls, spec, icon, lines, chord) { + const text = lines.join(' '); + let title = text; + let detail = ''; + if (lines.length > 1 && /—\s*$/.test(lines[0])) { + title = lines[0].replace(/\s*—\s*$/, ''); + detail = lines.slice(1).join(' '); + } else if (text.indexOf('. ') > 0) { + title = text.slice(0, text.indexOf('. ')); + detail = text.slice(text.indexOf('. ') + 2); + } + return ''; +} + /** onboarding-wait-or-jump-step.tsx:38-55, :139-150. */ function obWaitOrJumpHTML() { const status = dlStatus(); @@ -7118,28 +7186,45 @@ function obWaitOrJumpHTML() { {label:'Add another cloud provider', detail:'one more key or endpoint, then straight back to this screen'}, ]; if (status === 'failed') rows.push({label:'Retry the download', detail:'starts the same download again'}); + const icons = [ + '' + ic('arrowR') + '', + '' + ic('plus') + '', + '' + ic('retry') + '', + ]; const cursor = OB.cursor % rows.length; - return '
' + esc(OB_COPY.cloudReadyLabel) + '
' + return obReadyHTML(OB_COPY.cloudReadyLabel) + (status === 'running' ? '
' + esc('Still downloading ' + label + ' — it keeps running whichever row you pick.') + '
' : '') - + (status === 'ready' - ? '
' + esc(label + ' downloaded — the local model is ready too') + '
' : '') + + (status === 'ready' ? obReadyHTML(label + ' downloaded — the local model is ready too') : '') + (status === 'failed' ? '
' + esc('The ' + label + ' download failed — the cloud model still works.') + '
' : '') + (status === 'ready' ? '' : obProgressBlockHTML()) - + '
' + rows.map((row, i) => obRow(i, cursor === i, esc(row.label), esc(row.detail))).join('') + '
'; + + '
' + rows.map((row, i) => + obRow(i, cursor === i, esc(row.label), esc(row.detail), '', '', icons[i])).join('') + '
'; +} + +/** Something that is done, said once: a green tick and the sentence. */ +function obReadyHTML(text) { + return '
' + ic('check') + '' + + esc(text) + '
'; } /** onboarding-propose-step.tsx:11-33. */ function obProposeHTML() { - const accept = OB.offer === 'local' + const local = OB.offer === 'local'; + const accept = local ? {label:'Set up local models too', detail:'one download, then it runs offline and costs nothing per token'} : {label:'Set up a cloud model too', detail:'an API key and a model — about a minute, for the heavy turns'}; const rows = [accept, OB_COPY.proposeSkip]; - return '
' + esc(obConfiguredLabel()) + '
' - + '
' + esc(OB_COPY.proposeExplainer.join('\n')) + '
' + const icons = [ + local ? '' + ic('laptop') + '' + : '' + ic('cloud') + '', + '' + ic('arrowR') + '', + ]; + return obReadyHTML(obConfiguredLabel()) + + '
' + esc(OB_COPY.proposeExplainer.join(' ')) + '
' + '
' - + rows.map((row, i) => obRow(i, (OB.cursor % 2) === i, esc(row.label), esc(row.detail))).join('') + '
'; + + rows.map((row, i) => obRow(i, (OB.cursor % 2) === i, esc(row.label), esc(row.detail), '', '', icons[i])).join('') + '
'; } /** buildImportPickRows (import-step.ts:101-113): agents, then the import @@ -7170,8 +7255,9 @@ function obScanStripHTML() { const all = OB.importAgents || []; const done = OB.scanDone || 0; const at = OB.scanAt || ''; - return '
' - + 'Scanning' + return '
' + + '' + + 'Scanning' + '' + esc(all.length ? (Math.min(done + 1, all.length) + ' of ' + all.length) : '…') + '' + (at ? '' + esc(at) + '' : '') + '
'; @@ -7180,26 +7266,27 @@ function obScanStripHTML() { function obImportPickHTML() { const rows = obImportRows(); const cursor = OB.cursor % rows.length; - return '
' + esc(OB_COPY.importExplainer.join('\n')) + '
' - + '
' + rows.filter((r) => !r.offList).map((row, i) => { + /* B.5 — the scan is a readout (which source, how far through), so a scan + can be told from a hang. It sits above the list it is reading, and the + list dims while it runs. */ + return '
' + esc(OB_COPY.importExplainer.join(' ')) + '
' + + (OB.busy ? obScanStripHTML() : '') + + '
' + rows.filter((r) => !r.offList).map((row, i) => { if (row.kind === 'agent') { - /* r6 UX: `[x]` and `[ ]` are a terminal's checkbox. This row IS - a checkbox — it toggles, it does not navigate — so it is - drawn as one and announced as one. The tick is the same - state, in the shape a desktop reader already knows. */ - return obRow(i, cursor === i, - '' + esc(row.agent.label), - esc(row.agent.dir), 'ob-check', - ' role="checkbox" aria-checked="' + (row.agent.enabled ? 'true' : 'false') + '"'); + /* r6 UX: this row IS a checkbox — it toggles, it does not + navigate — so it is drawn as one and announced as one. The + source wears its own mark; the terminal app wears ours. */ + const agent = row.agent; + const mark = agent.id === OB_TUI_AGENT_ID + ? '' + : providerMark(providerLogoKey(agent.id) ? agent.id : agent.label); + return obRow(i, cursor === i, esc(agent.label), esc(agent.dir), 'ob-check', + ' role="checkbox" aria-checked="' + (agent.enabled ? 'true' : 'false') + '"', + '' + mark); } return ''; - }).join('') + '
' - /* B.5 — "scanning the sources…" was a grey sentence that said nothing - about progress, so there was no way to tell a scan from a hang ("не было - полосы импорта. Я не понимаю, работает или нет"). It is a readout now: - which source, and how far through. */ - + (OB.busy ? obScanStripHTML() : ''); + }).join('') + '
'; } /** summarizeImportReport (import-step.ts:167-189) + reportHeadline (:200-212). */ @@ -7247,16 +7334,33 @@ function obImportReportHTML(executed) { const report = OB.importReport; const bad = obImportFailures(); const open = OB.importFailuresOpen; - return '
' + esc(obImportHeadline(report, executed)) + '
' - + (report ? '
' + esc(obImportSummary(report).join('\n')) + '
' : '') + /* The headline keeps its words; a leading ✓ or ⚠ becomes the matching + icon. The per-kind summary is a two-column card: kind, then counts. */ + const headline = obImportHeadline(report, executed); + const glyph = /^([✓⚠])\s+/.exec(headline); + const icon = !glyph ? '' + : glyph[1] === '✓' ? '' + ic('check') + '' + : '' + ic('alert') + ''; + const lines = report ? obImportSummary(report) : []; + return '
' + icon + + esc(glyph ? headline.slice(glyph[0].length) : headline) + '
' + + (lines.length + ? '
' + lines.map((line) => { + const at = line.indexOf(': '); + const kind = at > 0 ? line.slice(0, at) : ''; + const counts = at > 0 ? line.slice(at + 2) : line; + return '
' + esc(kind) + '' + + '' + esc(counts) + '
'; + }).join('') + '
' + : '') + (bad.length && !OB.busy ? '
' + '' - + (executed ? '' : '') + + esc((open ? 'Hide' : 'What failed') + ' (' + bad.length + ')') + ic(open ? 'chevU' : 'chevD') + '' + + (executed ? '' : '') + '
' + (open - ? '
' + ? '
' + '' + bad.slice(0, 200).map((i) => '' @@ -7291,21 +7395,28 @@ function wizModelStepHTML(withFoot) { const all = WIZ.models || []; const rows = q ? all.filter((m) => (m.id + ' ' + (m.name || '')).toLowerCase().includes(q)) : all; const shown = rows.slice(0, 200); + /* Soft Tactile: every row wears its model's mark. On the first-run layer + (withFoot) the provider's mark titles the step and search is a pill + with its magnifier; the popover's copy of this step keeps its plain + field and title. */ + const search = ''; + const providerId = WIZ.savedId || (WIZ.row && WIZ.row.id) || ''; return '
' + '
Model
' - + '
' + esc(WIZ.savedLabel || 'Choose a model') + '
' + + '
' + (withFoot ? providerMark(providerId) : '') + esc(WIZ.savedLabel || 'Choose a model') + '
' + (WIZ.unverifiedNote - ? '
' + esc(WIZ.unverifiedNote) + '
' + ? '
' + esc(WIZ.unverifiedNote) + '
' : '') + '
' + esc(WIZ.defaultModel ? 'Our default for this provider is ' + WIZ.defaultModel + '.' : 'Pick the model this provider should answer with.') + '
' + (all.length > 8 - ? '' + ? (withFoot ? '' : search) : '') + '
' + shown.map((m) => '
' + + '
' + '
'; } const k = WIZ.row; const verifying = WIZ.phase === 'verifying'; /* COPY, disclosed rather than silently dropped: providers-wizard.tsx also has an `Embedding backend` (:445) and a `Chat model id` (:450) - screen. The desktop's add-provider wizard has neither phase — it - takes the kind's default chat model and never offers a cloud - embedding backend — so those two titles have nowhere honest to go - and are NOT rendered. `LLM provider — add provider`, `API key — - ${service}` and the .env sentence are all present, verbatim. */ - /* r6 UX: the key box had no label at all — a bare dark rectangle under - a sentence about .env, which is a footnote and not a name. It is - labelled, it says what goes in it, and the .env sentence has moved - BELOW the field, where help text belongs. The sentence itself is the - copy contract's, verbatim. */ + screen. The desktop's add-provider wizard has neither phase, so those + two titles have nowhere honest to go and are NOT rendered. */ if (WIZ.phase === 'pick_model') return wizModelStepHTML(true); - /* B.4 — one label, not three. This screen used to say "API key — AI/ML API" - as a heading, "API key" again as the field's label, and then put the .env - sentence between them; the tester read it as the same words twice and she - was right. The screen is titled in the 11px label style, the provider is - the subhead, and naming the field is the placeholder's job. + /* B.4 — one label, not three: "API key" once as the kicker, the provider + (with its mark) as the title, and naming the field is the placeholder's + job. The .env sentence is the copy contract's, verbatim, under the field. F1 — when the key could not be CHECKED (as opposed to rejected), the two buttons below are the whole decision: try again, or save it knowing it is - unchecked. There is no path from here to a completion screen. */ + unchecked. Unreachable reads amber, rejected reads red. */ const service = k.label.split(' (')[0]; const unchecked = WIZ.uncheckedFor; + const tone = WIZ.error ? (unchecked ? ' is-warn' : ' is-error') : ''; return '
' + '
API key
' - + '
' + esc(service) + '
' + + '
' + providerMark(k.custom ? '' : k.id) + esc(service) + '
' + (k.custom ? '' + '' : '') - + '' - + (WIZ.error ? '
' + esc(WIZ.error) + '
' : '') + + '
' + ic('key') + + '' + + (verifying ? '' : '') + + '
' + + (WIZ.error + ? '
' + ic(unchecked ? 'info' : 'alert') + + '' + esc(WIZ.error) + '
' + : '') + (k.env ? '
' + esc('Saved to .env as ' + k.env + ' (mode 0600).') + '
' : '') + (verifying ? '
Asking ' + esc(service) + ' to answer once with this key…
' : '') + (unchecked ? '
' - + '' + + '' + '' + '' - + '' + + '' + '
' - : '
' + : '
' + '' + '
') + + (verifying ? 'Verifying…' : 'Next' + ic('arrowR')) + '
') + '
'; } @@ -7449,9 +7553,9 @@ function obWizardHTML() { — the same router the keyboard uses. A control cannot drift from a chord because there is nothing for it to drift to. -------------------------------------------------------------------------- */ -function obBtn(spec, label, cls, disabled) { +function obBtn(spec, label, cls, disabled, icon, trail) { return ''; + + (disabled ? ' disabled' : '') + '>' + (icon ? ic(icon) : '') + esc(label) + (trail ? ic(trail) : '') + ''; } /** The verbs of one step, or '' for a step whose rows already carry them. */ @@ -7462,27 +7566,30 @@ function obFootHTML() { switch (OB.step) { case 'choose': left = obBtn('nav:back', 'Skip setup for now', 'btn-g'); + /* Soft Tactile: Enter's verb as a button too. It presses Enter through + obPress, so it opens the route under the cursor, exactly as the key. */ + right = obBtn('nav:go', 'Continue', 'btn-p', false, '', 'arrowR'); break; case 'local_pick': - left = obBtn('nav:back', 'Back', 'btn-g'); + left = obBtn('nav:back', 'Back', 'btn-g', false, 'chevL'); break; case 'local_hf_ref': - left = obBtn('nav:back', busy ? 'Cancel' : 'Back', 'btn-g'); + left = obBtn('nav:back', busy ? 'Cancel' : 'Back', 'btn-g', false, busy ? '' : 'chevL'); right = obBtn('nav:go', busy ? 'Looking it up…' : 'Look it up', 'btn-p', busy || OB.hfReference.length === 0); break; case 'local_hf_pick': - left = obBtn('nav:back', 'Back', 'btn-g'); - right = obBtn('nav:go', 'Download this file', 'btn-p', false); + left = obBtn('nav:back', 'Back', 'btn-g', false, 'chevL'); + right = obBtn('nav:go', 'Download this file', 'btn-p', false, 'download'); break; case 'custom_chat_url': - left = obBtn('nav:back', 'Back', 'btn-g', busy); + left = obBtn('nav:back', 'Back', 'btn-g', busy, 'chevL'); right = obBtn('nav:go', busy ? 'Testing…' : 'Test and continue', 'btn-p', busy); break; case 'custom_embedding_url': - left = obBtn('nav:back', 'Back', 'btn-g', busy) - + obBtn('url:skip', 'Continue without embeddings', 'btn-g', busy); - right = obBtn('nav:go', busy ? 'Testing…' : 'Test and save', 'btn-p', busy); + left = obBtn('nav:back', 'Back', 'btn-g', busy, 'chevL'); + right = obBtn('url:skip', 'Continue without embeddings', 'btn-s', busy) + + obBtn('nav:go', busy ? 'Testing…' : 'Test and save', 'btn-p', busy); break; /* B.5 — the import step's two verbs, on the action bar with every other step's verbs rather than as underlined rows inside the list. Import is @@ -7491,10 +7598,9 @@ function obFootHTML() { case 'import_pick': { const picked = (OB.importAgents || []).filter((a) => a.enabled).length; left = obBtn('import:skip', OB_COPY.importSkipLabel, 'btn-g', busy); - /* Nothing ticked, no import verb. A greyed "Import from 0 agents" is a - control that exists only to be unusable — the row it replaced was - simply absent until something was ticked, and that was right. */ - right = picked === 0 ? '' : obBtn('import:go', busy ? 'Scanning…' : obImportActionLabel(picked), 'btn-p', busy); + /* Nothing ticked, no import verb: a greyed "Import from 0 agents" is a + control that exists only to be unusable. */ + right = picked === 0 ? '' : obBtn('import:go', busy ? 'Scanning…' : obImportActionLabel(picked), 'btn-p', busy, busy ? '' : 'import'); break; } case 'import_preview': { @@ -7505,7 +7611,8 @@ function obFootHTML() { break; } case 'import_done': - right = obBtn('nav:go', 'Start using the agent', 'btn-p', false); + // The go moment of the whole flow: brand blue, not ink. + right = obBtn('nav:go', 'Start using the agent', 'btn-p btn-blue', false, '', 'arrowR'); break; default: return ''; @@ -7537,14 +7644,17 @@ function obHTML() { /* r6 UX: an error belongs beside the control that produced it. The two URL steps and the Hugging Face reference draw their own, directly under the field that was rejected; for every other step the surface - error is the only place it can go, and it sits above the action bar - rather than under it. */ - const err = OB.error && !obErrorIsInline() ? '
' + esc(OB.error) + '
' : ''; + error sits at the end of the body, above the action bar. */ + const err = OB.error && !obErrorIsInline() + ? '
' + ic('alert') + '' + esc(OB.error) + '
' : ''; /* r6 UX: it IS a modal — the app's chrome is behind it and cannot be - operated — so it says so, and Tab is trapped inside it to match. */ + operated — so it says so, and Tab is trapped inside it to match. + Soft Tactile: the indigo rail on the left; on the right the title, a + body that owns the flexible height, the action bar and the hint strip. */ return ''; + + obRailHTML() + + '
' + obHeadHTML() + '
' + body + err + '
' + + obFootHTML() + obHintsHTML() + '
'; } /* ============================================================ From 4420eeb804f88df64899eab53f4d60a1061a0c17 Mon Sep 17 00:00:00 2001 From: Valera Brizhatiuk <19537764+plombeer31@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:40:54 +0300 Subject: [PATCH 2/2] desktop: the onboarding rail's orbs are soft by their gradient, not a blur filter The two orbs already fade to transparent inside their radial gradients, so filter:blur(2px) drew nothing visible and only added a paint surface on a 440px layer inside the rail. Removed; the rail looks the same. --- desktop/renderer/css/onboarding.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/desktop/renderer/css/onboarding.css b/desktop/renderer/css/onboarding.css index 50ccf4a0..0cecd3ae 100644 --- a/desktop/renderer/css/onboarding.css +++ b/desktop/renderer/css/onboarding.css @@ -85,7 +85,10 @@ -webkit-app-region:drag;user-select:none; } #onboarding .ob-rail > *{position:relative} -#onboarding .ob-rail > .ob-orb{position:absolute;border-radius:50%;pointer-events:none;filter:blur(2px)} +/* The orbs are radial gradients that already fade to nothing, so they carry + no blur filter: it drew nothing visible and only added a paint surface on + a 440px layer inside the rail. Drawn soft by the gradient alone. */ +#onboarding .ob-rail > .ob-orb{position:absolute;border-radius:50%;pointer-events:none} #onboarding .ob-orb-a{width:440px;height:440px;right:-180px;bottom:-160px; background:radial-gradient(circle,color-mix(in srgb,var(--brand) 75%,transparent) 0%,transparent 68%)} #onboarding .ob-orb-b{width:240px;height:240px;left:-90px;top:230px;
ItemSourceWhy
' + esc(i.name || i.id || i.kind || 'item') + '