Snapshot of the owner's UI passes 1-92 (wt-gui-look working tree, uncommitted, taken read-only; line endings normalised to LF; includes untracked liquid.css and scripts/browser.mjs)

This commit is contained in:
Niko1221 committed 2026-11-01 10:54:55 +01:00
1 parent 4cf0d8cd38
commit 682c21d641
34 files changed
+1447 -214

No files matched your search

+98 -37
View File
@@ -16,10 +16,15 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const APP = join(ROOT, 'src/styles/app.css');
const STYLE_DIR = join(ROOT, 'src/styles');
const KIT_SRC = join(ROOT, '..', 'ui');
const KIT_COPY = join(ROOT, 'src/styles/kit');
/** The app's own stylesheets, in cascade order: the last one wins. */
const APP_SHEETS = ['app.css', 'liquid.css']
.map((name) => ({ name, css: existsSync(join(STYLE_DIR, name)) ? readFileSync(join(STYLE_DIR, name), 'utf8') : null }))
.filter((sheet) => sheet.css !== null);
const KIT_SCALES = {
radius: new Set(['8px', '14px', '20px', '24px', '9999px', '999px', '50%', '6px', '10px', '12px', '15px', '16px', '2px', '1.5px', 'inherit', '0']),
fontSize: new Set(['11px', '12px', '13px', '14px', '15px', '16px', '17px', '18px', '19px', '22px', '24px', '30px', '32px', '40px', 'inherit']),
@@ -66,9 +71,12 @@ const BLUR_ALLOWED = new Set([
'.menu',
'.telepop',
'.scrim',
".scrim[data-open='true']", // the same scrim, once it is showing
'.dialog-scrim', // the sheet's scrim: it frosts the whole working screen
'.config-savebar', // floating commit controls
'.mobile-bar', // compact navigation controls
'.st-toast--toned',
'.nav', // the navigation layer over the ambient depth layer
]);
/**
@@ -83,6 +91,7 @@ const ELEVATION_ALLOWED = new Set([
'.app[data-nav-open=\'true\'] > .nav', // dismissible navigation sheet
'.st-tab[aria-checked=\'true\']', // selected segmented control
'.nav-new', // primary action affordance
'.nav-new.st-btn--primary', // the same affordance, with its accent recipe
'.st-drawer.sheet',
'.slashmenu',
'.select__pop',
@@ -98,6 +107,8 @@ const ELEVATION_ALLOWED = new Set([
'.st-btn--primary', // the accent fill's own lift
'.st-slider::-webkit-slider-thumb', // a control affordance: the thumb needs to read against its track
'.st-toggle::after', // same: the knob needs to read against its track
'.family-switch button[aria-selected="true"]', // a selected segmented control, like .st-tab
'.native-choice__col .native-resource-choice button[data-selected="true"]', // same
]);
/**
@@ -110,25 +121,61 @@ const CAPSULE_CONTROLS = ['.st-btn', '.st-input', '.st-tab', '.navitem', '.navse
/** Literal colours that are deliberate and documented. */
const DOCUMENTED_LITERALS = ['#05261b', '#12c68e', '#0a7a55'];
/**
* The only selector allowed to paint a gradient: the single ambient depth layer.
*
* The flat-surface rule exists so surfaces read as paper rather than decoration.
* Depth was the one thing it could not express, and the navigation material has
* nothing to sample over a flat fill. A fixed, two-field wash on the page — too
* quiet to read as a gradient at ~1.04:1 — is the smallest thing that makes the
* glass mean anything. It is body-only, it never moves, and both reduced-
* transparency gates remove it. See src/styles/liquid.css §1.
*/
const AMBIENT_SELECTORS = new Set(['body']);
const failures = [];
const notes = [];
const fail = (file, line, message) => failures.push(`${file}:${line} ${message}`);
/* ------------------------------------------------------------ app.css ---- */
/* ------------------------------------------------------------ app sheets -- */
const css = readFileSync(APP, 'utf8');
const lines = css.split('\n');
let selector = '(top level)';
let inKeyframes = false;
/**
* Scan one app stylesheet for the design-system, motion and material rules.
*
* Run over every sheet in the cascade, not only app.css: a rule appended in a
* later file ships just the same, and the "audit palette" block that broke AA on
* the primary button survived precisely because it had never been scanned.
*/
function scanStylesheet(file, css) {
const lines = css.split('\n');
let selector = '(top level)';
let inKeyframes = false;
lines.forEach((raw, i) => {
const line = raw.replace(/\/\*.*?\*\//g, '');
const n = i + 1;
lines.forEach((raw, i) => {
const line = raw.replace(/\/\*.*?\*\//g, '');
const n = i + 1;
const sel = line.match(/^([^@{}\s][^{}]*)\{$/);
// Selectors are usually indented inside a media query or a nested block, so
// the line is trimmed before it is recognised. Without this every indented
// rule was attributed to "(top level)", and the material and elevation
// allowlists — which match on the selector — rejected most of the file.
const sel = line.trim().match(/^([^@{}\s][^{}]*)\{$/);
if (sel) selector = sel[1].trim();
if (/@keyframes/.test(line)) inKeyframes = true;
if (inKeyframes && /^\}/.test(line)) inKeyframes = false;
// A whole rule on one line (`a, b { … }`) never ends with `{`, so it used to
// be attributed to whatever rule ran before it — which is why a shadow on a
// segmented control was reported against `.menu .response-stats`. The rule's
// own selector is used for its declarations.
let selectorForLine = selector;
if (!sel && !inKeyframes) {
const inline = line.trim().match(/^([^@{}][^{}]*?)\s*\{/);
if (inline && /\}/.test(line)) selectorForLine = inline[1].trim();
}
// A one-line `@keyframes name { … }` opens and closes on the same line, so it
// must not switch the tracker on: it never sees a closing brace at column 0,
// and every rule after it was then treated as part of an animation — which
// disabled the one-line selector attribution above.
if (/@keyframes/.test(line) && !/\}/.test(line)) inKeyframes = true;
if (inKeyframes && /^\}/.test(raw)) inKeyframes = false;
for (const [, prop, rawValue] of line.matchAll(/([a-z-]+)\s*:\s*([^;]+);/g)) {
const value = rawValue.trim();
@@ -137,24 +184,24 @@ lines.forEach((raw, i) => {
if (prop === 'border-radius') {
for (const part of value.split(/\s+/)) {
if (!KIT_SCALES.radius.has(part) && !part.startsWith('var(') && !part.includes('%')) {
fail('app.css', n, `off-scale radius "${part}" in ${selector}`);
fail(file, n, `off-scale radius "${part}" in ${selectorForLine}`);
}
}
}
if (prop === 'font-size' && !value.startsWith('var(') && !value.includes('clamp') && !KIT_SCALES.fontSize.has(value)) {
fail('app.css', n, `off-scale font-size "${value}" in ${selector}`);
fail(file, n, `off-scale font-size "${value}" in ${selectorForLine}`);
}
if (prop === 'box-shadow' && !KIT_SCALES.shadow.some((re) => re.test(value))) {
fail('app.css', n, `shadow is not a kit token: "${value}" in ${selector}`);
fail(file, n, `shadow is not a kit token: "${value}" in ${selectorForLine}`);
}
if (/gradient|radial|conic/.test(value) && prop.startsWith('background')) {
fail('app.css', n, `gradient in ${selector} — the system is flat`);
if (/gradient|radial|conic/.test(value) && prop.startsWith('background') && !AMBIENT_SELECTORS.has(selector)) {
fail(file, n, `gradient in ${selectorForLine} — the system is flat`);
}
if (/^(color|background|background-color|border-color)$/.test(prop)) {
const literals = value.match(/#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)/g);
if (literals && !value.includes('data:image') && !value.includes('currentColor')
&& !DOCUMENTED_LITERALS.some((d) => value.includes(d))) {
fail('app.css', n, `literal colour "${value}" in ${selector} — use a token`);
fail(file, n, `literal colour "${value}" in ${selectorForLine} — use a token`);
}
}
@@ -163,57 +210,70 @@ lines.forEach((raw, i) => {
// Values must be a --st-material-* token: that is what collapses the blur
// under reduced transparency, so gating cannot be forgotten.
if (prop === 'backdrop-filter' || prop === '-webkit-backdrop-filter') {
if (!/^var\(--st-material-(thin|regular|thick)\)$/.test(value) && !(selector === '.scrim' && value === 'none !important')) {
fail('app.css', n, `blur must use a --st-material-* token (it carries the gating): "${value}"`);
if (!/^var\(--st-material-(thin|regular|thick)\)( !important)?$/.test(value) && !(selector === '.scrim' && value === 'none !important')) {
fail(file, n, `blur must use a --st-material-* token (it carries the gating): "${value}"`);
}
if (!BLUR_ALLOWED.has(selector)) {
fail('app.css', n, `material on "${selector}", which is not an approved surface — it does not overlay moving content`);
if (!BLUR_ALLOWED.has(selectorForLine)) {
fail(file, n, `material on "${selectorForLine}", which is not an approved surface — it does not overlay moving content`);
}
}
if (prop === 'box-shadow' && /var\(--st-sh-(card|bar|pop)\)/.test(value) && !ELEVATION_ALLOWED.has(selector)) {
fail('app.css', n, `elevation shadow on "${selector}" — shadows are reserved for surfaces that overlap content`);
if (prop === 'box-shadow' && /var\(--st-sh-(card|bar|pop)\)/.test(value) && !ELEVATION_ALLOWED.has(selectorForLine)) {
fail(file, n, `elevation shadow on "${selectorForLine}" — shadows are reserved for surfaces that overlap content`);
}
if (prop === 'transition') {
if (/^\d/.test(value) || /(^|[\s,])\d+m?s\b/.test(value)) {
fail('app.css', n, `raw duration in transition — use a --st-t-* token: "${value}"`);
fail(file, n, `raw duration in transition — use a --st-t-* token: "${value}"`);
}
for (const part of value.split(',')) {
const prop0 = part.trim().split(/\s+/)[0];
if (LAYOUT_PROPS.includes(prop0)) {
fail('app.css', n, `transition on layout property "${prop0}" in ${selector}`);
fail(file, n, `transition on layout property "${prop0}" in ${selectorForLine}`);
}
}
}
if (prop === 'transition-property') {
for (const p of value.split(',').map((v) => v.trim())) {
if (LAYOUT_PROPS.includes(p)) fail('app.css', n, `transition on layout property "${p}" in ${selector}`);
if (LAYOUT_PROPS.includes(p)) fail(file, n, `transition on layout property "${p}" in ${selectorForLine}`);
}
}
if (prop === 'animation' || prop === 'animation-name') {
const name = value.match(/^(st-[a-z-]+)/)?.[1];
const infinite = /infinite/.test(value);
if (infinite && name && !INFINITE_OK.has(name)) {
fail('app.css', n, `looping animation "${name}" is not on the allowlist — nothing loops while idle`);
fail(file, n, `looping animation "${name}" is not on the allowlist — nothing loops while idle`);
}
const duration = value.match(/\b(\d+(?:\.\d+)?m?s)\b/)?.[1];
if (duration && !LOOP_DURATION_OK.has(duration)) {
fail('app.css', n, `raw animation duration "${duration}" in ${selector} — use a --st-t-* token`);
fail(file, n, `raw animation duration "${duration}" in ${selectorForLine} — use a --st-t-* token`);
}
}
if (prop === 'animation-duration' && /^\d/.test(value) && !LOOP_DURATION_OK.has(value)) {
fail('app.css', n, `raw animation-duration "${value}" in ${selector}`);
fail(file, n, `raw animation-duration "${value}" in ${selectorForLine}`);
}
}
});
}
});
}
for (const sheet of APP_SHEETS) scanStylesheet(sheet.name, sheet.css);
const css = APP_SHEETS.find((sheet) => sheet.name === 'app.css')?.css ?? '';
const allAppCss = APP_SHEETS.map((sheet) => sheet.css).join('\n');
/* ------------------------------------------------- kit sync + motion scale -- */
for (const file of ['tokens.css', 'components.css']) {
const a = join(KIT_SRC, file);
const b = join(KIT_COPY, file);
if (!existsSync(a) || !existsSync(b)) {
fail('kit', 0, `${file} missing on one side`);
// Some checkouts of this branch do not carry the design-kit source (apps/ui),
// only the synced copy. There is nothing to compare against then, so the check
// reports that instead of failing every run with a condition no one can fix.
if (!existsSync(a)) {
notes.push(`kit copy: ${file} has no source in this checkout — drift check skipped`);
continue;
}
if (!existsSync(b)) {
fail('kit', 0, `${file} missing from src/styles/kit — run npm run sync:kit`);
continue;
}
// The sync rewrites @font-face URLs to absolute; everything else must match.
@@ -223,10 +283,11 @@ for (const file of ['tokens.css', 'components.css']) {
}
}
// Which selectors have been given the capsule radius.
// Which selectors have been given the capsule radius. Checked across the whole
// cascade: a control is a capsule wherever the rule that says so lives.
const capsuled = new Set();
{
const rules = css.replace(/\/\*[\s\S]*?\*\//g, '');
const rules = allAppCss.replace(/\/\*[\s\S]*?\*\//g, '');
for (const [, sel, body] of rules.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
if (!/border-radius:\s*var\(--st-r-pill\)/.test(body)) continue;
for (const part of sel.split(',')) capsuled.add(part.trim());
@@ -263,7 +324,7 @@ const referenced = new Set();
for (const file of [...readdirSync(join(ROOT, 'src'), { recursive: true })].filter((f) => /\.tsx$/.test(String(f)))) {
const full = join(ROOT, 'src', String(file));
if (!statSync(full).isFile()) continue;
for (const m of readFileSync(full, 'utf8').matchAll(/name="([a-z-]+)"/g)) referenced.add(m[1]);
for (const m of readFileSync(full, 'utf8').matchAll(/<(?:Icon|IconBtn)\s[^>]*?\bname="([a-z-]+)"/g)) referenced.add(m[1]);
}
for (const name of referenced) {
if (!symbols.has(name)) fail('src', 0, `icon "${name}" is referenced but not in the sprite — run npm run sync:kit`);
+54
View File
@@ -0,0 +1,54 @@
/**
* Where the Chromium binary the probes drive actually lives.
*
* The probes launched a fixed macOS cache path, so on Windows and Linux they
* printed "No cached Chromium found" and every runtime gate (contrast, measure,
* luminance, motion) was skipped rather than run. Resolution is now a single
* place with the same order everywhere:
*
* 1. STRATA_CHROME — an explicit override, for a CI image or a second Chrome
* 2. the cached Playwright build (macOS, the original platforms)
* 3. an installed browser: Chrome, Edge, Chromium
*
* `requireChromium` exits with the harness message the scripts already used, so
* a machine with no browser at all still fails loudly instead of silently
* passing a gate.
*/
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
const PLAYWRIGHT_MAC = [
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
];
const INSTALLED = [
// Windows
'C:/Program Files/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
'C:/Program Files/Microsoft/Edge/Application/msedge.exe',
// macOS
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
// Linux
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
];
export function findChromium() {
const candidates = [process.env.STRATA_CHROME, ...PLAYWRIGHT_MAC, ...INSTALLED].filter(Boolean);
return candidates.find((path) => existsSync(path)) ?? null;
}
export function requireChromium() {
const path = findChromium();
if (!path) {
console.error('No Chromium found. Set STRATA_CHROME, or install Chrome, Edge or Chromium.');
process.exit(2);
}
return path;
}
+2 -4
View File
@@ -19,13 +19,11 @@ import { chromium } from 'playwright-core';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { requireChromium } from './browser.mjs';
const BASE = process.env.STRATA_URL ?? 'http://localhost:5199';
const executablePath = [
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
].find((p) => existsSync(p));
const executablePath = requireChromium();
if (!executablePath) {
console.error('No cached Chromium found. Run `npm run shot -- x.png <url>` once to confirm the harness.');
+2 -4
View File
@@ -7,11 +7,9 @@ import { chromium } from 'playwright-core';
import { existsSync, mkdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import { requireChromium } from './browser.mjs';
const executablePath = [
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
].find((p) => existsSync(p));
const executablePath = requireChromium();
const BASE = process.env.STRATA_URL ?? 'http://localhost:5199';
const flow = process.argv[2] ?? 'chat';
+2 -4
View File
@@ -25,6 +25,7 @@ import { chromium } from 'playwright-core';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { requireChromium } from './browser.mjs';
const BASE = process.env.STRATA_URL ?? 'http://localhost:5199';
/** Anything at or above this projected area is "large". Roughly a 64×64 block. */
@@ -43,10 +44,7 @@ const MIN_THICKNESS = 32;
/** Ceiling for the relative luminance of a large surface, in dark mode. */
const CEILING = 0.15;
const executablePath = [
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
].find((p) => existsSync(p));
const executablePath = requireChromium();
if (!executablePath) {
console.error('No cached Chromium found.');
+2 -4
View File
@@ -20,15 +20,13 @@ import { chromium } from 'playwright-core';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { requireChromium } from './browser.mjs';
const BASE = process.env.STRATA_URL ?? 'http://localhost:5199';
const MIN = 45;
const MAX = 75;
const executablePath = [
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
].find((p) => existsSync(p));
const executablePath = requireChromium();
if (!executablePath) {
console.error('No cached Chromium found.');
+2 -4
View File
@@ -21,6 +21,7 @@ import { promisify } from 'node:util';
import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import { requireChromium } from './browser.mjs';
const run = promisify(execFile);
const BASE = process.env.STRATA_URL ?? 'http://localhost:5199';
@@ -28,10 +29,7 @@ const ROOT = resolve(process.env.STRATA_SHOTS ?? '.shots');
const TMP = join(ROOT, 'motion-frames');
mkdirSync(TMP, { recursive: true });
const executablePath = [
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
].find((p) => existsSync(p));
const executablePath = requireChromium();
const ffmpeg = [
join(homedir(), 'Library/Caches/ms-playwright/ffmpeg-1011/ffmpeg-mac'),
+2 -4
View File
@@ -20,14 +20,12 @@ import { chromium } from 'playwright-core';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { requireChromium } from './browser.mjs';
const BASE = process.env.STRATA_URL ?? 'http://localhost:5199';
const BUDGET = 1.2;
const executablePath = [
join(homedir(), 'Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'),
join(homedir(), 'Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell'),
].find((p) => existsSync(p));
const executablePath = requireChromium();
if (!executablePath) {
console.error('No cached Chromium found. Run `npm run shot -- x.png <url>` once to confirm the harness.');
+7 -4
View File
@@ -9,7 +9,7 @@ import { Sidebar } from './Sidebar';
import { CommandPalette } from './CommandPalette';
import { ShortcutsDialog } from './Shortcuts';
import { FolderPathDialog } from '../components/FolderPathDialog';
import { handleAppShortcut, runCommand } from '../lib/commands';
import { MOD, handleAppShortcut, runCommand } from '../lib/commands';
import { useMedia } from '../lib/responsive';
import { Btn, Dialog, IconBtn, Toasts } from '../components/ui';
import { Brand } from '../components/Brand';
@@ -111,6 +111,9 @@ function ConnectionBar() {
* way out. Everything else on this screen is the conversation.
*/
function FocusBar() {
// The hints are chords, not decorations: on Windows the modifier is Ctrl and
// Alt is spelled out, because ⌘ and ⌥ are not on that keyboard.
const mac = MOD === '⌘';
const native = useNative(), nativeCurrent = native?.instances.find(i => i.id === native.defaultId);
const library = useStore((s) => s.library);
const monitor = useStore((s) => s.monitor);
@@ -139,11 +142,11 @@ function FocusBar() {
{/* A chord reads as one key: the two caps sit tight together, with
the space before the label doing the separating. */}
<span className="focusbar__chord">
<kbd>⌥↑</kbd>
<kbd>⌥↓</kbd>
<kbd>{mac ? '⌥↑' : 'Alt+↑'}</kbd>
<kbd>{mac ? '⌥↓' : 'Alt+↓'}</kbd>
</span>{tr("turns")}</span>
<span>
<kbd>⌘↓</kbd>{tr(" latest")}</span>
<kbd>{mac ? '⌘↓' : 'Ctrl+↓'}</kbd>{tr(" latest")}</span>
<span>
<kbd>{tr("Esc")}</kbd>{tr(" leave")}</span>
</span>
+6 -1
View File
@@ -136,7 +136,7 @@ export function CommandPalette({ open }: {
<input ref={inputRef} className="st-input st-input--bare" style={{ flex: 1 }} placeholder={tr("Search chats, screens or actions…")} aria-label={tr("Search commands")} aria-activedescendant={flat[cursor] ? `command-${flat[cursor].id}` : undefined} value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={onKeyDown}/>
<kbd className="kbd">{tr("esc")}</kbd>
</div>
<div style={{ overflowY: 'auto', padding: 8 }}>
<div className="palette__list">
{tr(flat.length === 0 ? (<div className="small muted" style={{ padding: 18 }}>{tr("Nothing matches “{0}”. Try > for commands, # for conversations, or @ for workspaces.", [query])}</div>) : (grouped.map(([group, items]) => (<div key={group} style={{ marginBottom: 6 }}>
<div className="navgroup__label" style={{ padding: '8px 12px 4px' }}>
{tr(group)}
@@ -167,6 +167,11 @@ export function CommandPalette({ open }: {
}))}
</div>))))}
</div>
<div className="palette__foot">
<span className="palette__hint"><kbd className="kbd">Enter</kbd>{tr("Open")}</span>
<span className="spacer"/>
<span className="palette__hint"><kbd className="kbd">esc</kbd>{tr("Close")}</span>
</div>
</div>
</>);
}
+3 -3
View File
@@ -10,7 +10,7 @@ import { FloatingMenu } from '../components/FloatingMenu';
import { QuitStrataDialog } from '../components/QuitStrata';
import { useAppMode } from '../lib/app-mode';
import { href, useLocation, type Route } from '../lib/router';
import { cx, relTime } from '../lib/format';
import { cx, relTime, relTimeShort } from '../lib/format';
import { useMedia } from '../lib/responsive';
import { recentChats, recentChatDescription } from '../lib/recent-chats';
import type { Session } from '../lib/types';
@@ -92,7 +92,7 @@ export function Sidebar({ containerRef }: {
<div className={cx('ws__row', activeWorkspace === w.id && 'is-active')}><button className="ws__name" aria-expanded={expanded} onClick={() => { setActiveWorkspace(w.id); setFolders(f => ({ ...f, [w.id]: !expanded })); }}><Icon name="chevron" size={14} className={cx('ws__chev', expanded && 'is-open')}/><Icon name="folder" size={17}/><span>{w.name}</span></button><IconBtn name="pencil" title={tr("Rename {0}", [w.name])} onClick={() => { setWsName(w.name); setNavOpen(false); setRenameWsId(w.id); }}/>{tr(w.id !== 'root' ? <IconBtn name="x" title={tr("Remove {0} from sidebar", [w.name])} onClick={() => { setNavOpen(false); setRemoveWorkspaceId(w.id); }}/> : null)}</div>
{tr(expanded ? <div className="navsessions">
{tr(shown.map(s => <div key={s.id}>
<div className="ws__session row"><a className="navsession" href={href('chat', s.id)} aria-current={activeSession === s.id && location.route === 'chat' ? 'page' : undefined} onClick={() => selectSession(s.id)} onContextMenu={e => { e.preventDefault(); openMenu(s.id, e.currentTarget); }} title={sessionTitle(s)}><span className="navsession__title">{sessionTitle(s)}</span>{tr(s.pinned ? <span aria-label={tr("Pinned")}><Icon name="pin" size={12}/></span> : <span className="navsession__time">{tr(relTime(s.updatedAt))}</span>)}{tr(isUnread(s, activeSession) ? <span className="navsession__unread" aria-label={tr("Unread")}/> : null)}{tr(s.archived ? <span className="xs muted">{tr("Archived")}</span> : null)}</a><button className="st-btn st-btn--icon session-overflow" aria-label={tr("Actions for {0}", [sessionTitle(s)])} onClick={e => openMenu(s.id, e.currentTarget)}><Icon name="ellipsis" size={16}/></button></div>
<div className="ws__session row"><a className="navsession" href={href('chat', s.id)} aria-current={activeSession === s.id && location.route === 'chat' ? 'page' : undefined} onClick={() => selectSession(s.id)} onContextMenu={e => { e.preventDefault(); openMenu(s.id, e.currentTarget); }} title={sessionTitle(s)}><span className="navsession__title">{sessionTitle(s)}</span>{tr(s.pinned ? <span aria-label={tr("Pinned")}><Icon name="pin" size={12}/></span> : <span className="navsession__time">{relTimeShort(s.updatedAt)}</span>)}{tr(isUnread(s, activeSession) ? <span className="navsession__unread" aria-label={tr("Unread")}/> : null)}{tr(s.archived ? <span className="xs muted">{tr("Archived")}</span> : null)}</a><button className="st-btn st-btn--icon session-overflow" aria-label={tr("Actions for {0}", [sessionTitle(s)])} onClick={e => openMenu(s.id, e.currentTarget)}><Icon name="ellipsis" size={16}/></button></div>
</div>))}
{tr(!shown.length ? <p className="small muted ws__empty">{tr(q ? 'No matching chats' : 'No chats yet')}</p> : null)}
{tr(!q && rows.length > shown.length ? <button className="ws-view small" onClick={() => { setNavOpen(false); setRecentOpen(true); }}>{tr("View all chats")}</button> : null)}
@@ -190,5 +190,5 @@ function MoreMenu({ current }: {
const [quitOpen, setQuitOpen] = useState(false);
const appMode = useAppMode();
const ref = useRef<HTMLButtonElement>(null);
return <><button ref={ref} className="navitem nav__more" aria-label={tr("More — Serving and Settings")} aria-haspopup="menu" aria-expanded={open} data-active={['serving', 'settings'].includes(current) || undefined} onClick={() => setOpen(v => !v)}><Icon name="ellipsis"/><span className="navitem__text">{tr("More")}</span></button>{tr(open ? <FloatingMenu anchor={ref.current} title={tr("More")} onClose={() => { setOpen(false); ref.current?.focus(); }}>{tr(([{ route: 'serving', label: 'Serving', icon: 'link' }, { route: 'settings', label: 'Settings', icon: 'settings' }] as const).map(d => <a key={d.route} role="menuitem" className="menu__item" href={href(d.route)} onClick={() => setOpen(false)}><Icon name={d.icon}/>{tr(d.label)}</a>))}{appMode.canQuit ? <button type="button" role="menuitem" className="menu__item" onClick={() => { setOpen(false); setNavOpen(false); setQuitOpen(true); }}><Icon name="x"/>{tr("Quit Strata")}</button> : null}</FloatingMenu> : null)}<QuitStrataDialog open={quitOpen} onClose={() => setQuitOpen(false)}/></>;
return <><button ref={ref} className="navitem nav__more" title={tr("More — Serving and Settings")} aria-label={tr("More — Serving and Settings")} aria-haspopup="menu" aria-expanded={open} data-active={['serving', 'settings'].includes(current) || undefined} aria-current={['serving', 'settings'].includes(current) ? 'page' : undefined} onClick={() => setOpen(v => !v)}><Icon name="ellipsis"/><span className="navitem__text">{tr("More")}</span></button>{tr(open ? <FloatingMenu anchor={ref.current} title={tr("More")} onClose={() => { setOpen(false); ref.current?.focus(); }}>{tr(([{ route: 'serving', label: 'Serving', icon: 'link' }, { route: 'settings', label: 'Settings', icon: 'settings' }] as const).map(d => <a key={d.route} role="menuitem" className="menu__item" href={href(d.route)} onClick={() => setOpen(false)}><Icon name={d.icon}/>{tr(d.label)}</a>))}{appMode.canQuit ? <button type="button" role="menuitem" className="menu__item" onClick={() => { setOpen(false); setNavOpen(false); setQuitOpen(true); }}><Icon name="x"/>{tr("Quit Strata")}</button> : null}</FloatingMenu> : null)}<QuitStrataDialog open={quitOpen} onClose={() => setQuitOpen(false)}/></>;
}
+1 -1
View File
@@ -20,7 +20,7 @@ function NativeChatModelPicker() {
const [open, setOpen] = useState(false), [error, setError] = useState('');
const ref = useRef<HTMLButtonElement>(null);
const current = native?.instances.find(i => i.id === native.defaultId);
return <><button className="chat-model-badge chat-model-picker" ref={ref} aria-label={tr("Choose model: {0}", [current?.label ?? 'Native model'])} aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(v => !v)}><Icon name="models" size={16}/><span>{tr(current?.label ?? 'Native model')}</span><Icon name="chevron" size={12}/></button>{tr(open ? <FloatingMenu anchor={ref.current} title={tr("Chat model")} width={340} onClose={() => setOpen(false)}>{tr(native?.instances.filter(i => i.status !== 'error').map(i => <button className="menu__item" role="menuitemradio" aria-checked={current?.id === i.id} disabled={streaming || i.status !== 'ready'} key={i.id} onClick={() => void nativeAction(`instances/${i.id}/default`).then(() => setOpen(false)).catch(e => setError(e.message))}><Icon name="models"/><span><strong>{tr(i.label)}</strong><small>{tr(i.gpus.map(g => `GPU ${g + 1}`).join(" + ") || "Automatic GPU")}</small></span></button>))}{tr(providers?.connections.flatMap(c => c.models.map(m => <button className="menu__item" role="menuitem" disabled={streaming} key={`${c.id}-${m.id}`} onClick={() => { selectRemoteModel({ connectionId: c.id, modelId: m.id }); setOpen(false); }}><Icon name="link"/>{m.name} · {c.name}</button>)))}<button className="menu__item" role="menuitem" onClick={() => { setOpen(false); go('models'); }}>{tr("Manage models")}</button>{tr(error ? <p className="small inline-error">{tr(error)}</p> : null)}</FloatingMenu> : null)}</>;
return <><button className="chat-model-badge chat-model-picker" ref={ref} aria-label={tr("Choose model: {0}", [current?.label ?? 'Native model'])} aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(v => !v)}><Icon name="models" size={16}/><span>{tr(current?.label ?? 'Native model')}</span><Icon name="chevron" size={12}/></button>{tr(open ? <FloatingMenu anchor={ref.current} title={tr("Chat model")} width={340} onClose={() => setOpen(false)}>{tr(native?.instances.filter(i => i.status !== 'error').map(i => <button className="menu__item" role="menuitemradio" aria-checked={current?.id === i.id} disabled={streaming || i.status !== 'ready'} key={i.id} onClick={() => void nativeAction(`instances/${i.id}/default`).then(() => setOpen(false)).catch(e => setError(e.message))}><Icon name="models"/><span><strong>{tr(i.label)}</strong><small>{tr(i.gpus.map(g => `GPU ${g + 1}`).join(" + ") || "Automatic GPU")}</small></span>{tr(current?.id === i.id ? <Icon name="check" size={16} className="menu__check"/> : null)}</button>))}{tr(providers?.connections.flatMap(c => c.models.map(m => <button className="menu__item" role="menuitem" disabled={streaming} key={`${c.id}-${m.id}`} onClick={() => { selectRemoteModel({ connectionId: c.id, modelId: m.id }); setOpen(false); }}><Icon name="link"/>{m.name} · {c.name}</button>)))}<button className="menu__item" role="menuitem" onClick={() => { setOpen(false); go('models'); }}>{tr("Manage models")}</button>{tr(error ? <p className="small inline-error">{tr(error)}</p> : null)}</FloatingMenu> : null)}</>;
}
function LegacyChatModelPicker() {
const native = useNative();
@@ -45,6 +45,25 @@ export function ComposerControls({ session, onPreview, onAttach, onError, onSlas
const remote = useStore(s => s.remoteModel), providers = useStore(s => s.providers);
const apiModel = providers?.connections.find(c => c.id === remote?.connectionId)?.models.find(m => m.id === remote?.modelId);
const [menu, setMenu] = useState(false), [preferences, setPreferences] = useState<'mode' | 'thinking' | null>(null), [reading, setReading] = useState(false);
// The mode menu prints 1–3 beside its rows. They were decoration: make those
// keys pick the mode while the menu is open, and leave modified keys to the app.
useEffect(() => {
if (preferences !== 'mode')
return;
const onKey = (event: KeyboardEvent) => {
if (event.metaKey || event.ctrlKey || event.altKey)
return;
const option = modes[Number(event.key) - 1];
if (!option)
return;
event.preventDefault();
setChatPreferences(session?.id, { mode: option.value });
setPreferences(null);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [preferences, session?.id]);
const [urlOpen, setUrlOpen] = useState(false), [url, setUrl] = useState(''), [error, setError] = useState('');
const files = useRef<HTMLInputElement>(null), folder = useRef<HTMLInputElement>(null), previewFile = useRef<HTMLInputElement>(null);
const trigger = useRef<HTMLButtonElement>(null), modeTrigger = useRef<HTMLButtonElement>(null), thinkingTrigger = useRef<HTMLButtonElement>(null);
+17 -5
View File
@@ -50,8 +50,20 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
const [deleting, setDeleting] = useState<string | null>(null);
const families = nativeCatalogModels(native?.catalog?.families ?? []);
const expertSection = useRef<HTMLElement>(null);
useEffect(() => { if (expert)
expertSection.current?.scrollIntoView({ block: 'start', behavior: 'smooth' }); }, [expert]);
// The card is inserted above the fold and the browser's scroll anchoring
// immediately compensates by scrolling down, which cancelled the smooth
// scroll started in the same frame: the page stopped 121px in and clipped
// the cards above. Scroll after the layout has settled, and never animate
// when motion is reduced.
useEffect(() => {
if (!expert)
return;
const frame = requestAnimationFrame(() => expertSection.current?.scrollIntoView({
block: 'start',
behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
}));
return () => cancelAnimationFrame(frame);
}, [expert]);
const selectedFamily = families.find(f => f.family === family || f.sizes.some(size => size.sourceFamily === family)) ?? families[0], selectedSize = selectedFamily?.sizes.find(s => s.id === size) ?? selectedFamily?.sizes[0];
const installed = native?.installed?.models.find(m => m.model === selectedSize?.id);
// The model that is running is shown as running, with Unload: not offered for loading again.
@@ -189,7 +201,7 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
<section className="st-card native-choice">{tr(selectedFamily && (!selectedSize || selectedSize.needsNewerEngine) ? <><h2 className="h3">{tr("This model needs a newer engine.")}</h2><p className="small muted">{tr("Update your engine or import a local GGUF for this model.")}</p><Btn onClick={() => { setSettingsSection('Engine'); go('settings'); }}>{tr("Update engine")}</Btn><Btn variant="ghost" onClick={onImport ?? onOtherModels}>{tr("Use a local GGUF\u2026")}</Btn></> : null)}{tr(!selectedFamily ? <><h2 className="h3">{tr("Set up Strata")}</h2><p className="small muted">{tr("Connect your local installation to choose an optimized model.")}</p><Btn variant="primary" onClick={onConnectEngine ?? (() => { setSettingsSection('Engine'); go('settings'); })}>{tr("Connect engine")}</Btn>{tr(native?.error ? <p className="small inline-error">{tr(native.error)}</p> : null)}<Btn variant="ghost" onClick={onOtherModels}>{tr("Other local models")}</Btn></> : null)}
{tr(selectedSize ? <>
<div className="native-choice__head"><span className="native-choice__label">{tr("Version")}</span><span className="native-choice__tools"><NativePresets values={values} onApply={v => { const f = families.find(f => f.family === v.family || f.sizes.some(s => s.sourceFamily === v.family)); if (!f)
return; presetPending.current = true; setFamily(f.family); setSize(f.sizes.find(s => s.model === v.model && (s.sourceFamily === v.family || f.family === v.family))?.id ?? ''); setContext(v.context ?? 32768); setVision(v.vision ?? 'no'); setKv(v.kv ?? 'int8'); setLowRam(v.low_ram ?? 'auto'); }}/><Btn icon="models" onClick={() => setManage(true)}>{tr("Manage models")}</Btn></span></div>
return; presetPending.current = true; setFamily(f.family); setSize(f.sizes.find(s => s.model === v.model && (s.sourceFamily === v.family || f.family === v.family))?.id ?? ''); setContext(v.context ?? 32768); setVision(v.vision ?? 'no'); setKv(v.kv ?? 'int8'); setLowRam(v.low_ram ?? 'auto'); }}/><Btn icon="models" size="sm" variant="ghost" onClick={() => setManage(true)}>{tr("Manage models")}</Btn></span></div>
<div className="native-version"><VersionPicker pick={native?.catalog?.recommendation} versions={allSizes} selectedId={selectedSize.id} hardware={fitHardware} downloaded={readyIds} onSelect={id => { const f = families.find(f => f.sizes.some(s => s.id === id)); if (f) { setFamily(f.family); setSize(id); setError(''); } }} onOwnGguf={() => (onImport ?? onOtherModels)()}/></div>
{tr(noFit ? <p className="small muted native-spill-note"><Icon name="disk" size={16}/>{tr("May run slower \u00B7 Model data can be read from SSD.")}</p> : null)}
{tr(selectedSize?.multi_file && deepseekContextNote(context) ? <p className="small muted native-spill-note"><Icon name="info" size={16}/>{tr(deepseekContextNote(context))}</p> : null)}
@@ -214,12 +226,12 @@ export function NativeModels({ onOtherModels, onReady, onImport, onConnectEngine
{tr(expert && IMAGE_GENERATION_VISIBLE ? <><details className="native-generation"><summary>{tr("Image generation ")}<span className="muted">{tr("Optional")}</span></summary><RunImageModel library={imageLibrary} disabled={pending || !!active} onChange={() => { }}/></details></> : null)}
{tr(expert ? <section id="native-expert-settings" ref={expertSection} className="st-card native-execution"><h2 className="h4">{tr("Expert settings")}</h2>{tr(tunedNow ? <p className="small muted native-tuned-note">{tr("Showing the values of your tuning '{0}'.", [tunedNow.name])}</p> : null)}<label className="native-toggle"><span><strong>{tr("Run multiple models")}</strong><small>{tr("Loading another model keeps the others loaded.")}</small></span><button className="st-toggle" role="switch" aria-label={tr("Run multiple models")} aria-checked={multiple} onClick={() => setMultiple(v => !v)}/></label>{tr(cardsGpus.length > 1 ? <label className="input-field"><span>{tr("Layer split")}</span><input className="st-input" value={split} placeholder={tr("auto or 16,32")} onChange={e => setSplit(e.target.value)}/></label> : null)}<div className="native-field-grid"><label className="input-field"><span>{tr("Context memory")}</span><Select ariaLabel={tr("Native KV format")} value={kv} options={[{ value: 'int8', label: 'INT8' }, { value: 'q4_0', label: 'Q4' }, { value: 'k8v4', label: 'K8 / V4' }]} onChange={setKv}/></label><label className="input-field"><span>{tr("Low-RAM mode")}</span><Select ariaLabel={tr("Native low RAM mode")} value={lowRam} options={['auto', 'resident', 'mmap'].map(v => ({ value: v, label: v === 'auto' ? 'Automatic' : v === 'resident' ? 'Keep experts in RAM' : 'Read experts from disk' }))} onChange={setLowRam}/></label></div>{tr(installed ? <><NativeSettingsFile model={installed.model}/><NativeSettings model={installed.model}/></> : <p className="small muted">{tr("More engine parameters become available after preparing this version.")}</p>)}</section> : null)}
</div>
<aside className="stack stack--lg"><PcFitCard gpuName={pcGpu?.name} ramGb={pcRam} vramGb={pcVram} diskGb={selectedSize?.multi_file ? selectedSize.disk_gb ?? selectedSize.download_gb : null} inUse={keptModels.length ? { ramGb: Math.round(keptRam), vramGb: Math.round(keptVramOn(chosenCards)) } : null} fit={selectedSize ? fitOn(selectedSize, fitHardware, true, { context, kv }) : null}/><section className="st-card"><h2 className="h4">{tr("Model files")}</h2><p className="small muted">{tr("Strata prepares all shards, the draft layer and shared files. Completed downloads are reused.")}</p><code className="native-path">{installed?.dataFolder ?? native?.downloadDirectory ?? native?.installed?.data_folder ?? 'Choose an installation'}</code><Btn size="sm" variant="ghost" onClick={() => { setSettingsSection('Storage'); go('settings'); }}>{tr("Storage settings")}</Btn></section><Btn size="sm" variant="ghost" onClick={onOtherModels}>{tr("Other local models\u2026")}</Btn></aside>
<aside className="stack stack--lg"><PcFitCard gpuName={pcGpu?.name} ramGb={pcRam} vramGb={pcVram} diskGb={selectedSize?.multi_file ? selectedSize.disk_gb ?? selectedSize.download_gb : null} inUse={keptModels.length ? { ramGb: Math.round(keptRam), vramGb: Math.round(keptVramOn(chosenCards)) } : null} fit={selectedSize ? fitOn(selectedSize, fitHardware, true, { context, kv }) : null}/><section className="st-card"><h2 className="h4">{tr("Model files")}</h2><p className="small muted">{tr("Strata prepares all shards, the draft layer and shared files. Completed downloads are reused.")}</p><code className="native-path">{installed?.dataFolder ?? native?.downloadDirectory ?? native?.installed?.data_folder ?? 'Choose an installation'}</code><Btn size="sm" variant="ghost" onClick={() => { setSettingsSection('Storage'); go('settings'); }}>{tr("Storage settings")}</Btn></section><Btn size="sm" variant="ghost" onClick={onOtherModels} className="other-models">{tr("Other local models\u2026")}</Btn></aside>
</div>
<Dialog open={!!plan} title={tr(installed?.ready ? "Apply settings and load" : "Download and prepare")} compact onClose={() => !pending && setPlan(null)} actions={<><Btn disabled={pending} onClick={() => setPlan(null)}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={pending || !!plan?.plan?.disk_short} onClick={() => void download()}>{tr(installed?.ready ? "Apply & load" : "Download & load")}</Btn></>}><p><strong>{tr(plan?.plan?.title ?? selectedFamily?.title)}</strong>{tr(installed?.ready ? null : <>{tr(" \u00B7 approximately ")}{tr(plan?.plan?.download_gb ?? selectedSize?.download_gb)}{tr(" GB")}</>)}</p>{tr(plan?.drafting?.needed ? <p className="small muted">{tr("Choosing DFlash2 also downloads its drafter: about {0} GB, once. If it does not fit in graphics memory, the model uses the MTP instead.", [plan.drafting.gb])}</p> : null)}{tr(installed?.ready ? <p className="small muted">{tr("The model files are already on this computer. Nothing will be downloaded; only the settings you chose are applied.")}</p> : null)}{tr(plan?.plan?.disk_short ? <ErrorNotice message={plan.plan.disk_short}/> : null)}{tr(installed?.ready ? <p className="small muted">{tr("Any currently loaded native models will be unloaded first.")}</p> : <><p className="small muted">{tr("Existing files are checked and reused. Any currently loaded native models will be unloaded before preparing files.")}</p><p className="small muted">{tr("Your computer may feel slower while weights load. You can stop the download and resume later.")}</p></>)}{tr(selectedSize?.images ? <p className="small muted">{tr("Vision encoder files are included. Picture reading loads only when enabled.")}</p> : null)}</Dialog>
<NativeDeleteDialog id={deleting} onClose={() => setDeleting(null)}/>
<Dialog open={manage} title={tr("Manage models")} onClose={() => setManage(false)} actions={<Btn onClick={() => setManage(false)}>{tr("Done")}</Btn>}><div className="native-manage">
<Dialog open={manage} compact title={tr("Manage models")} onClose={() => setManage(false)} actions={<Btn onClick={() => setManage(false)}>{tr("Done")}</Btn>}><div className="native-manage">
{tr(downloaded.some(m => m.ready || m.diskGb) ? <div className="native-manage-list" role="list">{tr(downloaded.filter(m => m.ready || m.diskGb).map(m => { const v = allSizes.find(x => x.id === m.model), loaded = native?.instances.some(i => i.model === m.model && i.status === 'ready'); return <div className="native-manage-row" role="listitem" key={m.model} data-selected={selectedSize?.id === m.model || undefined}>
<span className="native-manage-name"><strong>{tr(v ? `${catalogVersionLabel(v)}` : m.model_name ?? m.model)}</strong><small>{tr(v ? v.model : m.model)}{tr(m.diskGb ? ` · ${sizeGb(m.diskGb)}` : '')}</small></span>
<span className={loaded ? 'st-pill st-pill--accent' : 'st-pill'}>{tr(loaded ? 'Loaded' : 'Downloaded')}</span>
@@ -116,7 +116,7 @@ export function NativeOperations() {
return <div className="stack stack--lg native-engine-settings">
<section className="st-card"><div className="section-toolbar"><h2 className="h3">{tr("Update component")}</h2>{tr(update === 'available' ? <Btn size="sm" variant="primary" disabled={!!active || (native?.instances.length ?? 0) > 0} onClick={() => setConfirm(true)}>{tr("Update available")}</Btn> : null)}{tr(update !== 'available' ? <Btn size="sm" disabled={update === 'checking'} onClick={() => void checkForUpdates()}>{tr(update === 'checking' ? 'Checking…' : 'Check for updates')}</Btn> : null)}</div><p className="small muted">{tr("Update the engine for this computer. Your models and settings are kept.")}</p>{tr(update === 'current' ? <p className="small muted">{tr("Up to date")}</p> : null)}{tr(checkError ? <p className="small inline-error" role="alert">{tr(checkError)}</p> : null)}{tr(update === 'available' && (native?.instances.length ?? 0) > 0 ? <p className="small muted">{tr("Open Models and unload the running native models first.")}</p> : null)}</section>
<section className="st-card"><div className="section-toolbar"><h2 className="h3">{tr("Diagnostics")}</h2><Btn size="sm" onClick={() => setShowLogs(true)}>{tr("View logs")}</Btn></div><p className="small muted">{tr("Downloads, preparation and engine failures include their original messages here.")}</p>{tr(native?.jobs.filter(j => j.state === 'error').slice(-3).map(j => <ErrorNotice key={j.id} message={jobErrorText(j, native.jobs)} detail={j.details}/>))}</section>
<Dialog open={showLogs} wide title={tr("Engine logs")} onClose={() => setShowLogs(false)} actions={<><Btn icon="download" disabled={saving} onClick={() => void saveLogs()}>{tr(saving ? 'Working…' : 'Save logs')}</Btn><Btn variant="primary" onClick={() => setShowLogs(false)}>{tr("Close")}</Btn></>}>
<Dialog open={showLogs} wide compact title={tr("Engine logs")} onClose={() => setShowLogs(false)} actions={<><Btn icon="download" disabled={saving} onClick={() => void saveLogs()}>{tr(saving ? 'Working…' : 'Save logs')}</Btn><Btn variant="primary" onClick={() => setShowLogs(false)}>{tr("Close")}</Btn></>}>
<pre className="native-log" aria-label={tr("Engine log")}>{logs.length ? logs.map(l => `${l.at} · ${l.line}`).join(String.fromCharCode(10)) : tr('No engine messages yet.')}</pre>
{tr(saved ? <p className="small" role="status">{tr("Saved to {0}", [saved])}</p> : null)}
</Dialog>
+1 -1
View File
@@ -29,6 +29,6 @@ export function NativePresets({ values, onApply }: {
setError('Browser storage is full. Free space before saving settings.');
return false;
} }
return <><button className="st-btn st-btn--secondary" ref={trigger} aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(v => !v)}><Icon name="layers"/>{tr("Saved settings ")}<Icon name="chevron" size={12}/></button>{tr(open ? <FloatingMenu anchor={trigger.current} title={tr("Saved settings")} width={290} onClose={() => setOpen(false)}><p className="model-picker-heading">{tr("Saved settings")}</p>{tr(items.map(item => <button className="menu__item" role="menuitem" key={item.id} onClick={() => { onApply(item.values); setOpen(false); }}><Icon name="sliders"/><span>{item.name}<small>{tr(item.values.model)} · {tr((item.values.context ?? 32768) / 1024)}{tr("K")}</small></span></button>))}{tr(!items.length ? <p className="model-picker-empty">{tr("Save a setup to use it again.")}</p> : null)}<button className="menu__item" role="menuitem" onClick={() => { setOpen(false); setSave(true); setName(''); }}><Icon name="plus"/>{tr("Save current settings\u2026")}</button></FloatingMenu> : null)}<Dialog open={save} compact title={tr("Saved settings")} onClose={() => setSave(false)} actions={<><Btn onClick={() => setSave(false)}>{tr("Done")}</Btn><Btn variant="primary" disabled={!name.trim()} onClick={() => { if (persist([...items, { id: crypto.randomUUID(), name: name.trim(), values }]))
return <><button className="st-btn st-btn--ghost st-btn--sm" ref={trigger} aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(v => !v)}><Icon name="layers"/>{tr("Saved settings ")}<Icon name="chevron" size={12}/></button>{tr(open ? <FloatingMenu anchor={trigger.current} title={tr("Saved settings")} width={290} onClose={() => setOpen(false)}><p className="model-picker-heading">{tr("Saved settings")}</p>{tr(items.map(item => <button className="menu__item" role="menuitem" key={item.id} onClick={() => { onApply(item.values); setOpen(false); }}><Icon name="sliders"/><span>{item.name}<small>{tr(item.values.model)} · {tr((item.values.context ?? 32768) / 1024)}{tr("K")}</small></span></button>))}{tr(!items.length ? <p className="model-picker-empty">{tr("Save a setup to use it again.")}</p> : null)}<button className="menu__item" role="menuitem" onClick={() => { setOpen(false); setSave(true); setName(''); }}><Icon name="plus"/>{tr("Save current settings\u2026")}</button></FloatingMenu> : null)}<Dialog open={save} compact title={tr("Saved settings")} onClose={() => setSave(false)} actions={<><Btn onClick={() => setSave(false)}>{tr("Done")}</Btn><Btn variant="primary" disabled={!name.trim()} onClick={() => { if (persist([...items, { id: crypto.randomUUID(), name: name.trim(), values }]))
setSave(false); }}>{tr("Save")}</Btn></>}><label className="input-field"><span>{tr("Name")}</span><input className="st-input" value={name} maxLength={80} onChange={e => setName(e.target.value)} placeholder={tr("Everyday")}/></label>{tr(items.map(item => <div className="row row--between" key={item.id}><span>{item.name}</span><Btn size="sm" variant="ghost" onClick={() => persist(items.filter(i => i.id !== item.id))}>{tr("Remove")}</Btn></div>))}{tr(error ? <p role="alert" className="small inline-error">{tr(error)}</p> : null)}</Dialog></>;
}
+2 -2
View File
@@ -26,7 +26,7 @@ export function TimeSkipWorking({ run, sessionId }: { run: TimeSkipRun; sessionI
{fork ? <ForkProgress job={run.job!}/> : null}
<div className="tsw__bar">
<div className="tsw__step" role="status"><strong>{activityWords(run.job)}</strong><span className="small muted">{clock(now - run.startedAt)}</span>
{fork ? null : <div className="tsw__prog" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(fraction * 100)}><i style={{ width: `${Math.round(fraction * 100)}%` }}/></div>}</div>
{fork ? null : <div className="tsw__prog" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(fraction * 100)}><i style={{ transform: `scaleX(${Math.min(1, Math.max(0, fraction))})` }}/></div>}</div>
<Btn size="sm" icon="stop" disabled={stopping || !run.job?.id} onClick={() => { setStopping(true); void stopTimeSkip(sessionId, run.id).finally(() => setStopping(false)); }}>{tr("Stop")}</Btn>
</div>
</section>;
@@ -39,7 +39,7 @@ function Lane({ name, lane, waitingText }: { name: string; lane: ForkLane | null
const words = !lane ? waitingText : lane.waiting ? waitingText : lane.line || (lane.text ? lane.text : tr('Starting…'));
return <div className="tsf__lane" data-state={lane?.state ?? 'waiting'}>
<span className="tsf__name">{name}</span>
<div className="tsf__prog" role="progressbar" aria-label={tr('Progress of {0}', [name])} aria-valuemin={0} aria-valuemax={100} aria-valuenow={pct}><i style={{ width: `${pct}%` }}/></div>
<div className="tsf__prog" role="progressbar" aria-label={tr('Progress of {0}', [name])} aria-valuemin={0} aria-valuemax={100} aria-valuenow={pct}><i style={{ transform: `scaleX(${Math.min(1, Math.max(0, pct / 100))})` }}/></div>
<span className="tsf__words">{words}</span>
</div>;
}
+1 -1
View File
@@ -51,7 +51,7 @@ export function TuneRunView({ progress, ratio, saving }: { progress: TuneProgres
<span className="label">{tr("Speed now")}</span>
<div className="tune-live__big">{now != null ? fmt(now) : '—'}<small>{tr("tokens per second")}</small></div>
<Spark values={speeds.map(x => x.tok_s)}/>
<div className="tune-live__bar" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(ratio * 100)}><i style={{ width: `${Math.max(3, Math.round(ratio * 100))}%` }}/></div>
<div className="tune-live__bar" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(ratio * 100)}><i style={{ transform: `scaleX(${Math.min(1, Math.max(.03, ratio))})` }}/></div>
<p className="small muted">{left != null ? tr("about {0} min left", [left]) : tr("This takes about 20 minutes.")}</p>
<ul className="tune-live__list">{speeds.slice(-6).map((x, i) => <li key={`${x.label}-${i}`}><span>{x.label === 'base' ? tr("Your settings") : x.label}</span><b data-up={base != null && x.label !== 'base' && x.tok_s > base ? 'true' : undefined}>{fmt(x.tok_s)}</b></li>)}</ul>
</aside>
+1 -1
View File
@@ -75,6 +75,6 @@ export function UsageDashboard({ loaded }: {
</>)}
</div>
</div>
{tr(!canChat ? <Btn onClick={() => go('models')}>{tr("Choose a model")}</Btn> : null)}
{tr(!canChat ? <Btn variant="primary" onClick={() => go('models')}>{tr("Choose a model")}</Btn> : null)}
</section>;
}
+4 -1
View File
@@ -532,6 +532,9 @@ export function ErrorNotice({ message, next, action, onRetry, detail, secondary
const known = plain.kind !== 'plain';
const nextStep = known ? (plain.next ? tr(plain.next) : next) : next;
const raw = detail ?? (known ? plain.detail : undefined);
return <div className="error-notice" role="alert"><Icon name="error" size={18}/><div><strong>{tr(known ? plain.message : userError(message))}</strong>{tr(nextStep ? <p>{tr(nextStep)}</p> : null)}{tr(raw ? <details className="error-detail"><summary className="small">{tr("Details")}</summary><pre className="small muted">{raw}</pre></details> : null)}</div>{tr(onRetry ? (secondary ? <div className="row"><Btn size="sm" onClick={onRetry}>{tr(action ?? 'Try again')}</Btn><Btn size="sm" variant="ghost" onClick={secondary.onClick}>{tr(secondary.label)}</Btn></div> : <Btn size="sm" onClick={onRetry}>{tr(action ?? 'Try again')}</Btn>) : null)}</div>;
// An unrecognised failure still goes through plainFailure: it peels an
// `{"error":"…"}` body down to the sentence, where userError only capitalised
// the JSON and showed an object to the person.
return <div className="error-notice" role="alert"><Icon name="error" size={18}/><div><strong>{tr(plain.message)}</strong>{tr(nextStep ? <p>{tr(nextStep)}</p> : null)}{tr(raw ? <details className="error-detail"><summary className="small">{tr("Details")}</summary><pre className="small muted">{raw}</pre></details> : null)}</div>{tr(onRetry ? (secondary ? <div className="row"><Btn size="sm" onClick={onRetry}>{tr(action ?? 'Try again')}</Btn><Btn size="sm" variant="ghost" onClick={secondary.onClick}>{tr(secondary.label)}</Btn></div> : <Btn size="sm" onClick={onRetry}>{tr(action ?? 'Try again')}</Btn>) : null)}</div>;
}
export function PreviewBadge() { const demo = useStore(s => s.library?.mode === 'simulated'); return demo ? <span className="preview-badge">{tr("Preview build")}</span> : null; }
+23
View File
@@ -35,6 +35,29 @@ export function relTime(iso: string | number | Date): string {
return `${days}d ago`;
return new Date(iso).toLocaleDateString(localeTag(), { month: 'short', day: 'numeric' });
}
/**
* The same age without the words: "9m", "3h", "5d".
*
* A sidebar row has no width to spare, and "26m ago" cost a third of the space a
* chat title needed (the title was the thing being cut). The unit carries the
* meaning on its own; the sentence form stays everywhere there is room for it.
*/
export function relTimeShort(iso: string | number | Date): string {
const then = new Date(iso).getTime();
const diff = Date.now() - then;
if (diff < 45000)
return 'now';
const mins = Math.round(diff / 60000);
if (mins < 60)
return `${mins}m`;
const hours = Math.round(mins / 60);
if (hours < 24)
return `${hours}h`;
const days = Math.round(hours / 24);
if (days < 7)
return `${days}d`;
return new Date(iso).toLocaleDateString(localeTag(), { month: 'short', day: 'numeric' });
}
export function duration(ms: number): string {
if (ms < 1000)
return `${Math.round(ms)} ms`;
+28 -1
View File
@@ -49,7 +49,7 @@ const RULES: Rule[] = [
/** The message as a person should read it, its next step, and the original text for the Details disclosure. */
export function plainFailure(raw: string | null | undefined): PlainFailure {
const text = String(raw ?? '').replace(/^\s*Fixture:\s*/i, '').trim();
const text = unwrapErrorPayload(String(raw ?? '').replace(/^\s*Fixture:\s*/i, '').trim());
for (const rule of RULES) {
const m = text.match(rule.match);
if (!m) continue;
@@ -58,6 +58,33 @@ export function plainFailure(raw: string | null | undefined): PlainFailure {
return { kind: 'plain', message: text.replace(/^./, c => c.toUpperCase()) };
}
/**
* Reduce an error body to the sentence inside it.
*
* The host answers a refused request with `{"error":"…"}` and the fetch layer
* forwards `HTTP 400: {"error":"…"}`; both reached the screen verbatim, so a
* person was shown a JSON object where a sentence belonged. The wrapper is
* peeled here — before the rules run, so a wrapped message still matches its
* own rule — and anything that is not a recognisable error body is returned
* untouched.
*/
function unwrapErrorPayload(text: string): string {
// `HTTP 400: {…}` or a bare `{…}` body.
const body = text.replace(/^HTTP\s+\d{3}\s*:\s*/i, '').trim();
if (!body.startsWith('{')) return text;
try {
const parsed = JSON.parse(body) as Record<string, unknown>;
// FastAPI and the host both use `detail`/`error`/`message`, and `detail`
// is itself sometimes an object with a `message`.
const detail = parsed.detail;
const candidate = [parsed.error, parsed.message, typeof detail === 'string' ? detail : (detail as Record<string, unknown> | undefined)?.message]
.find((value): value is string => typeof value === 'string' && value.trim().length > 0);
return candidate ? candidate.trim() : text;
} catch {
return text;
}
}
/** Translated copy of a failure, ready to show. */
export function plainFailureText(raw: string | null | undefined) {
const f = plainFailure(raw);
+5 -1
View File
@@ -26,4 +26,8 @@ export function scopedMonitor(live:MonitorState|null,runtime:RuntimeModels|null,
totals:{...live.totals,tokensIn:count('tokensIn'),tokensOut:count('tokensOut'),requests:done.length,cachedTokens:0,uncachedTokens:count('tokensIn'),promptMs:undefined,decodeMs:undefined,avgTps:0}
};
}
export const instanceLabel=(i:RuntimeModel)=>`${i.family}${i.family.toLowerCase().endsWith(i.name.toLowerCase())?"":` · ${i.name}`}${i.memory.context>0?` · ${i.memory.context/1024}K`:""}`;
// A quantisation tag is written the way the rest of the app writes it: Q2_0, not q2_0.
// The catalogue stores the id in lower case, so labels read as a typo beside the
// Models screen without this.
const quantLabel=(name:string)=>/^q\d/i.test(name)?name.toUpperCase():name;
export const instanceLabel=(i:RuntimeModel)=>`${i.family}${i.family.toLowerCase().endsWith(i.name.toLowerCase())?"":` · ${quantLabel(i.name)}`}${i.memory.context>0?` · ${i.memory.context/1024}K`:""}`;
+4
View File
@@ -28,3 +28,7 @@ import './styles/onboarding.css';
import './styles/board.css';
import './styles/gpu-roles.css';
// Loaded last: the shared depth, material and interaction treatment. See the
// header of the file for the order of the layers and why it sits here.
import './styles/liquid.css';
+6 -3
View File
@@ -182,12 +182,12 @@ function LegacyMonitorScreen({ native }: {
{tr(multipleModels ? <section className="st-card"><h2 className="h4">{tr("Model performance")}</h2><div className="st-tablewrap"><table className="st-table native-performance-table"><thead><tr><th>{tr("Model / device")}</th><th>{tr("Generating tok/s")}</th><th>{tr("Reading tok/s")}</th><th>{tr("State")}</th><th>{tr("Active / queued")}</th></tr></thead><tbody>{tr(runtime?.instances.map(i => { const recent = (data.requests ?? []).find(r => r.instanceId === i.id); const writing = i.phase === 'generating' || i.phase === 'thinking', reading = i.phase === 'reading'; return <tr key={i.id}><td><button className="text-btn strong" onClick={() => chooseInstance(i.id)}>{tr(instanceLabel(i))}</button><small>{tr(deviceLabel(i.gpuIds, inventory))}</small></td><td>{tr(number(writing ? i.outputTps : recent?.tps, 1))}<small>{tr(writing ? 'Now' : recent ? 'Last request' : 'No request yet')}</small></td><td>{tr(number(reading ? i.promptTps : recent?.promptTps, 0))}<small>{tr(reading ? 'Now' : recent ? 'Last request' : 'No request yet')}</small></td><td>{tr(i.phase)}{tr(runtime.mode === 'simulated' ? <small>{tr("Illustrative")}</small> : null)}</td><td>{tr(number(i.active))} / {tr(number(i.queued))}</td></tr>; }))}</tbody></table></div></section> : null)}
{tr(multipleModels ? <p className="xs muted monitor-scope-note">{tr(selectedInstance ? selectedInstance.gpuIds.length ? 'Requests, context and speed are scoped to this model. GPU readings include all work on the selected devices.' : 'CPU-only model. Requests and context are scoped to this model; system metrics are shared host measurements.' : 'Select a model for its context and speed. GPU readings and system RAM are shared host measurements.')}</p> : null)}
<section className="st-card monitor-state" aria-label={tr("Model state")} data-snapshot-skip>
<div className="monitor-state__head"><h2 className="h4">{tr(multipleScope ? 'Model activity' : 'Model state')}</h2><div className="monitor-state__badges">{tr([{ id: 'idle', label: 'Idle' }, { id: 'reading', label: 'Reading' }, { id: 'generating', label: 'Generating' }, { id: 'queued', label: 'Queued' }, { id: 'error', label: 'Error' }].map(s => <span key={s.id} className="st-badge" data-phase={s.id} data-active={phase === s.id || s.id === 'queued' && data.queue.waiting > 0}>{tr(s.label)}{tr(s.id === 'queued' && data.queue.waiting > 0 ? ` (${data.queue.waiting})` : '')}</span>))}</div><div className="monitor-device-selectors">{tr(devices.length > 1 ? <Select ariaLabel={tr("Monitor GPU")} value={gpuChoice} options={[...(selectedInstance ? [{ value: 'assigned', label: 'Assigned GPUs' }] : []), { value: 'all', label: `All GPUs (${defaultDevices.length})` }, ...devices.map((gpu, i) => ({ value: gpuId(gpu), label: `GPU ${i + 1} · ${gpu.name}` }))]} menuMinWidth={340} onChange={setGpuSelection}/> : null)}{tr(processors.length > 1 ? <Select ariaLabel={tr("Monitor processor")} value={cpuChoice} options={[{ value: 'all', label: `All processors (${processors.length})` }, ...processors.map((p, i) => ({ value: p.id, label: `CPU ${i + 1} · ${p.model}` }))]} onChange={setCpuSelection}/> : null)}</div></div>
<div className="monitor-state__head"><h2 className="h4">{tr(multipleScope ? 'Model activity' : 'Model state')}</h2><div className="monitor-state__badges">{tr(<span className="st-badge" data-phase={phase} data-active="true">{tr(phase === 'reading' ? 'Reading' : writing ? 'Generating' : phase === 'queued' ? 'Queued' : phase === 'error' ? 'Error' : phase === 'loading' ? 'Loading' : 'Idle')}{tr(phase === 'queued' && data.queue.waiting > 0 ? ` (${data.queue.waiting})` : '')}</span>)}</div><div className="monitor-device-selectors">{tr(devices.length > 1 ? <Select ariaLabel={tr("Monitor GPU")} value={gpuChoice} options={[...(selectedInstance ? [{ value: 'assigned', label: 'Assigned GPUs' }] : []), { value: 'all', label: `All GPUs (${defaultDevices.length})` }, ...devices.map((gpu, i) => ({ value: gpuId(gpu), label: `GPU ${i + 1} · ${gpu.name}` }))]} menuMinWidth={340} onChange={setGpuSelection}/> : null)}{tr(processors.length > 1 ? <Select ariaLabel={tr("Monitor processor")} value={cpuChoice} options={[{ value: 'all', label: `All processors (${processors.length})` }, ...processors.map((p, i) => ({ value: p.id, label: `CPU ${i + 1} · ${p.model}` }))]} onChange={setCpuSelection}/> : null)}</div></div>
<div className="monitor-state__reading small"><span>{tr(multipleScope ? `${number(data.queue.running)} active · ${number(data.queue.waiting)} queued` : data.phase === 'idle' ? 'Waiting for a request' : data.phaseLabel)}</span><span className="muted num">{tr(statusDetail)}</span></div>
{tr(!multipleScope ? <Bar label={tr("Model progress")} value={progress ?? 0} indeterminate={writing && !hasReading(progress)} tone={data.phase === 'reading' ? 'info' : 'accent'}/> : null)}
</section>
<div className="monitor-metrics">
{tr(!multipleScope ? <MonitorMetric label={tr("Speed")} icon="gauge" value={number(decode, 1)} unit={hasReading(decode) ? 't/s' : ''} source={throughput} sub={writing ? 'Decode now' : last ? 'Decode last request' : 'Decode'} secondary={<div><div className="st-metric__value">{tr(number(prefill))}{tr(hasReading(prefill) ? <span className="st-metric__unit">{tr("t/s")}</span> : null)}</div><div className="monitor-metric__sub xs muted">{tr(data.phase === 'reading' ? 'Prefill now' : writing ? 'Prefill this request' : last ? 'Prefill last request' : 'Prefill')}</div></div>} chart={<ReferenceSpark label={tr("Speed")} data={history.map(s => throughputKnown ? s.outputTps : null)} secondary={{ data: history.map(s => throughputKnown ? s.promptTps : null), label: 'Prefill', tone: 'var(--st-chart-prefill)' }} tone="var(--st-chart-decode)" unit="t/s" timestamps={timestamps}/>}/> : null)}
{tr(!multipleScope ? <MonitorMetric label={tr("Speed")} icon="gauge" value={number(decode, 1)} unit={hasReading(decode) ? 'tok/s' : ''} source={throughput} sub={writing ? 'Decode now' : last ? 'Decode last request' : 'Decode'} secondary={<div><div className="st-metric__value">{tr(number(prefill))}{tr(hasReading(prefill) ? <span className="st-metric__unit">{tr("tok/s")}</span> : null)}</div><div className="monitor-metric__sub xs muted">{tr(data.phase === 'reading' ? 'Prefill now' : writing ? 'Prefill this request' : last ? 'Prefill last request' : 'Prefill')}</div></div>} chart={<ReferenceSpark label={tr("Speed")} data={history.map(s => throughputKnown ? s.outputTps : null)} secondary={{ data: history.map(s => throughputKnown ? s.promptTps : null), label: 'Prefill', tone: 'var(--st-chart-prefill)' }} tone="var(--st-chart-decode)" unit="tok/s" timestamps={timestamps}/>}/> : null)}
<MonitorMetric label={tr("GPU load")} icon="gpu" value={number(gpuLoad)} unit={hasReading(gpuLoad) ? '%' : ''} source={gpuSource('utilizationPct', 'mean')} sub={whose('utilizationPct', 'mean') ?? gpuName} chart={chart('GPU load', historyGpu('utilizationPct', 'gpuLoad', 'mean'), '%', 100)}/>
<MonitorMetric label={tr(shared ? 'Shared GPU memory' : 'VRAM')} icon="layers" value={number(vram, 1)} unit={hasReading(vram) ? vramTotal ? `/ ${number(vramTotal, 0)} GB` : 'GB' : ''} source={gpuSource('memoryUsedGb')} sub={whose('memoryUsedGb') ?? (shared ? 'Same memory pool as system RAM' : multipleGpus ? 'Total across selected GPUs' : cacheKnown && devices.length === 1 ? `${number(data.expertCache.used)} experts cached` : 'GPU memory')} chart={chart(shared ? 'Shared GPU memory' : 'VRAM', historyGpu('memoryUsedGb', 'vramUsedGb'), 'GB', vramTotal)}/>
<MonitorMetric label={tr("GPU temp")} icon="thermometer" value={number(temperature)} unit={hasReading(temperature) ? '°C' : ''} source={gpuSource('temperatureC', 'max')} sub={whose('temperatureC', 'max') ?? (multipleGpus ? 'Hottest selected GPU' : selectedGpus[0]?.name ?? (selectedInstance && !selectedInstance.gpuIds.length ? native ? 'Automatic GPU placement' : 'CPU only · no GPU assigned' : 'No GPU detected'))} chart={chart('GPU temperature', historyGpu('temperatureC', 'gpuTempC', 'max'), '°C', 90, 'var(--st-warn)')}/>
@@ -205,7 +205,10 @@ function LegacyMonitorScreen({ native }: {
</section>
<section className="st-card monitor-requests"><div className="section-toolbar"><h2 className="h4">{tr("Recent requests")}</h2>{tr(requests.length > 12 ? <Btn size="sm" onClick={() => setShowAll(!showAll)}>{tr(showAll ? 'Show fewer' : `Show all (${requests.length})`)}</Btn> : null)}</div>
<div className="st-tablewrap monitor-request-table" data-expanded={showAll}><table className="st-table"><thead><tr><th>{tr("Time")}</th><th>{tr("Status")}</th>{tr(multipleModels ? <th>{tr("Model")}</th> : null)}<th className="num">{tr("Prompt")}</th><th className="num">{tr("Reused")}</th><th className="num">{tr("Output")}</th><th className="num">{tr("Tok/s")}</th><th className="num" title={tr("Experts already in GPU memory; PCIe share is shown separately when reported.")}>{tr("VRAM hit rate")}</th><th className="num">{tr("Duration")}</th></tr></thead><tbody>{tr(visibleRequests.length ? visibleRequests.map(r => <tr key={r.id}><td>{tr(new Date(r.at).toLocaleTimeString(localeTag(), { hour: '2-digit', minute: '2-digit', second: '2-digit' }))}</td><td><span className="request-status" data-state={r.state}>{tr(requestStatus(r))}</span>{tr(r.usageSource === 'demo' ? <span className="monitor-request-source xs muted">{tr("Illustrative")}</span> : null)}</td>{tr(multipleModels ? <td><span className="xs">{tr(r.model)}</span></td> : null)}<td className="num">{tr(number(r.tokensIn))}</td><td className="num">{tr(number(r.cachedTokens))}</td><td className="num">{tr(number(r.tokensOut))}</td><td className="num">{tr(number(r.tps, 1))}</td><td className="num">{tr(hasReading(r.hitRate) ? `${number(r.hitRate * 100, 1)}%` : '—')}{tr(hasReading(r.pcieShare) && r.pcieShare > 0 ? <span className="xs muted"> +{tr(number(r.pcieShare * 100, 1))}{tr("% PCIe")}</span> : null)}</td><td className="num">{tr(r.durationMs ? duration(r.durationMs) : '—')}</td></tr>) : <tr><td colSpan={multipleModels ? 9 : 8} className="muted">{tr("No requests yet")}</td></tr>)}</tbody></table></div>
{tr(data.totals.requests > 0 ? <p className="small muted monitor-request-totals">{requestSummary({ requests: data.totals.requests, output: outputSpeed, reading: promptSpeed, reused: data.totals.cachedTokens }, { number, tokens })}</p> : null)}
{/* One request is already the row above: the aggregate sentence would only
restate it, and in English it also read “In 1 requests”. Show it from two
requests on, where it carries information the table cannot. */}
{tr(data.totals.requests > 1 ? <p className="small muted monitor-request-totals">{requestSummary({ requests: data.totals.requests, output: outputSpeed, reading: promptSpeed, reused: data.totals.cachedTokens }, { number, tokens })}</p> : null)}
</section>
</div>
<section className="st-card monitor-cache" data-snapshot-skip><div className="section-toolbar"><h2 className="h4">{tr("Conversation cache")}</h2>{tr(lastReused ? <span className="small muted">{tr("{0} of {1} requests reused prompt tokens", [number(cache?.requestsReused ?? reused.filter(r => (r.cachedTokens ?? 0) > 0).length),number(cache?.requests ?? requests.length)])}</span> : null)}</div>
+1 -1
View File
@@ -87,7 +87,7 @@ export function ServingScreen() {
<p className="xs muted">{tr("Set ")}<code>model</code>{tr(" in each request. Each model has its own queue.")}{tr(runtime?.mode === 'simulated' ? ' Design preview · responses are illustrative.' : '')}</p>
</section> : null)}
<section className="request-section"><SectionHead title={tr("Requests")}/><p className="small muted">{tr(scopeStats.running)}{tr(" active \u00B7 ")}{tr(scopeStats.waiting)}{tr(" queued \u00B7 ")}{tr(scopeStats.completed)}{tr(" completed")}</p><div className="section-toolbar"><Seg value={filter} ariaLabel={tr("Request status filter")} options={[{ value: 'all', label: 'All' }, { value: 'active', label: 'Active' }, { value: 'done', label: 'Done' }, { value: 'error', label: 'Errors' }]} onChange={setFilter}/>{tr(multipleModels ? <Select ariaLabel={tr("Serving model filter")} value={filterId} options={[{ value: 'all', label: 'All models' }, ...(runtime?.instances ?? []).map(i => ({ value: i.id, label: instanceLabel(i) }))]} onChange={setModelFilter}/> : null)}<input className="st-input" aria-label={tr("Search requests")} placeholder={tr("Client, model or request ID…")} value={query} onChange={e => setQuery(e.target.value)}/></div>
{tr(requests.length ? <div className="st-tablewrap"><table className="st-table"><thead><tr><th aria-sort={sort === 'at' ? 'descending' : 'none'}><button className="st-btn st-btn--ghost st-btn--sm" onClick={() => setSort('at')}>{tr("Time")}</button></th><th>{tr("Client / endpoint")}</th>{tr(multipleModels ? <th>{tr("Model")}</th> : null)}<th>{tr("Status")}</th><th>{tr("Tokens in / out")}</th><th aria-sort={sort === 'durationMs' ? 'descending' : 'none'}><button className="st-btn st-btn--ghost st-btn--sm" onClick={() => setSort('durationMs')}>{tr("Duration")}</button></th></tr></thead><tbody>{tr(requests.map(r => <tr key={r.id}><td><button className="st-btn st-btn--ghost st-btn--sm" aria-label={tr("View request {0}", [r.id])} onClick={() => setSelectedId(r.id)}>{tr(clockTime(r.at))}</button></td><td>{tr(r.client)}<div className="xs muted">{tr(r.endpoint)}</div></td>{tr(multipleModels ? <td><code className="xs">{r.model}</code></td> : null)}<td>{tr(<span className="request-status" data-state={r.state}>{tr(r.cancelRequested ? r.cancellable ? 'Cancelling…' : 'Cancelled' : r.state.charAt(0).toUpperCase() + r.state.slice(1))}</span>)}</td><td className="num">{tr(tokens(r.tokensIn))} / {tr(tokens(r.tokensOut))}<div className="xs muted">{tr(r.usageSource === 'demo' ? 'Simulated' : r.usageSource === 'reported' ? 'Reported by model' : 'Usage unavailable')}</div></td><td className="num">{tr(r.durationMs ? duration(r.durationMs) : '—')}</td></tr>))}</tbody></table></div> : <Empty icon="link" title={tr(serving.requests.length ? 'No matching requests' : 'No requests yet')} body={serving.requests.length ? 'Try another filter or client name.' : 'Requests appear here when a client or Strata chat uses the API.'} action={tr(serving.requests.length ? <Btn onClick={() => { setQuery(''); setFilter('all'); setModelFilter('all'); }}>{tr("Clear filters")}</Btn> : <Btn onClick={() => setGuide(true)}>{tr("Connect an app")}</Btn>)}/>)}</section>
{tr(requests.length ? <div className="st-tablewrap"><table className="st-table"><thead><tr><th aria-sort={sort === 'at' ? 'descending' : 'none'}><button className="st-btn st-btn--ghost st-btn--sm" onClick={() => setSort('at')}>{tr("Time")}</button></th><th>{tr("Client / endpoint")}</th>{tr(multipleModels ? <th>{tr("Model")}</th> : null)}<th>{tr("Status")}</th><th>{tr("Tokens in / out")}</th><th aria-sort={sort === 'durationMs' ? 'descending' : 'none'}><button className="st-btn st-btn--ghost st-btn--sm" onClick={() => setSort('durationMs')}>{tr("Duration")}</button></th></tr></thead><tbody>{tr(requests.map(r => <tr key={r.id}><td><button className="st-btn st-btn--ghost st-btn--sm" aria-label={tr("View request {0}", [r.id])} onClick={() => setSelectedId(r.id)}>{tr(clockTime(r.at))}</button></td><td>{tr(r.client)}<div className="xs muted">{tr(r.endpoint)}</div></td>{tr(multipleModels ? <td><code className="xs">{r.model}</code></td> : null)}<td>{tr(<span className="request-status" data-state={r.state}>{tr(r.cancelRequested ? r.cancellable ? 'Cancelling…' : 'Cancelled' : r.state.charAt(0).toUpperCase() + r.state.slice(1))}</span>)}</td><td className="num">{tr(tokens(r.tokensIn))} / {tr(tokens(r.tokensOut))}<div className="xs muted">{tr(r.usageSource === 'demo' ? 'Simulated' : r.usageSource === 'reported' ? 'Reported by model' : 'Usage unavailable')}</div></td><td className="num">{tr(r.durationMs ? duration(r.durationMs) : '—')}</td></tr>))}</tbody></table></div> : <Empty icon="link" title={tr(serving.requests.length ? 'No matching requests' : 'No requests yet')} body={serving.requests.length ? 'Try another filter or client name.' : 'Requests appear here when a client or Strata chat uses the API.'} action={tr(serving.requests.length ? <Btn onClick={() => { setQuery(''); setFilter('all'); setModelFilter('all'); }}>{tr("Clear filters")}</Btn> : <Btn variant="primary" onClick={() => setGuide(true)}>{tr("Connect an app")}</Btn>)}/>)}</section>
{tr(error ? <ErrorNotice message={error}/> : null)}
</div><Dialog open={guide} title={tr("Connect Strata to an app")} onClose={() => setGuide(false)} headerActions={<div className="protocol-switch"><Seg panelId="api-protocol-panel" value={protocol} ariaLabel={tr("API protocol")} options={[{ value: 'openai', label: 'Chat Completions' }, { value: 'responses', label: 'Responses' }, { value: 'anthropic', label: 'Anthropic' }]} onChange={setProtocol}/></div>} sheet><div className="stack">{tr(multipleModels ? <label className="input-field"><span>{tr("Model for this app")}</span><Select ariaLabel={tr("Connection guide model")} value={loaded?.apiModelId ?? ''} options={modelOptions} disabled={!modelOptions.length} onChange={setGuideModel}/></label> : null)}<ol><li>{tr("In your client, choose a compatible provider.")}</li><li>{tr("Set its base URL to ")}<code>{appBaseUrl(serving.baseUrl, protocol)}</code>.</li><li>{tr("Use model ID ")}<code>{loaded?.apiModelId ?? 'from the Models screen after loading'}</code>.</li><li>{tr(serving.access.apiKeySet ? 'Enter your existing API key in the client.' : 'This local API has no key configured.')}</li></ol>{tr(protocol === 'anthropic' ? <p className="small muted">{tr("Anthropic apps add /v1/messages themselves, so this address has no /v1 at the end. In Claude Code, set ANTHROPIC_BASE_URL to it.")}</p> : null)}{tr(protocol === 'responses' && (!native?.instances.some(i => i.status === 'ready') || native.capabilities.responses === false) ? <p className="small muted">{tr("Responses requires a running native model and a Strata version that supports this API. Update the native installation in Settings \u2192 About if needed.")}</p> : null)}<div id="api-protocol-panel" role="tabpanel" aria-labelledby={`api-protocol-panel-tab-${protocol}`} tabIndex={0}><div className="st-code"><div className="st-code__head"><span>{tr("curl")}</span><IconBtn name="copy" title={tr("Copy request template")} onClick={() => void navigator.clipboard.writeText(code)}/></div><pre>{code}</pre></div><p className="xs muted">{tr("Model list: ")}{tr(serving.baseUrl)}{tr(serving.endpoints.models)}{tr(". Network access depends on the host\u2019s listen address and your firewall.")}</p></div></div></Dialog>
+1 -1
View File
@@ -62,7 +62,7 @@ export function SettingsScreen() {
</nav>
<div className="settings-pane" ref={scrollRef}>
{tr(PANELS.map(panel => <div key={panel.name} role="tabpanel" tabIndex={0} id={`settings-panel-${panel.name}`} aria-labelledby={`settings-tab-${panel.name}`} hidden={active !== panel.name}>
{tr(panel.name === 'Models' ? <SettingsModels /> : panel.name === 'Tools' ? (active === panel.name ? <SettingsTools /> : null) : panel.name === 'Engine' && active === panel.name ? <NativeOperations /> : active === panel.name ? <div className="stack stack--lg settings-body">
{tr(panel.name === 'Models' ? <SettingsModels /> : panel.name === 'Tools' ? (active === panel.name ? <SettingsTools /> : null) : panel.name === 'Engine' && active === panel.name ? <div className="stack stack--lg settings-body"><section className="settings-section"><h2 className="h3" style={{ marginBottom: 14 }}>{tr("Engine")}</h2><NativeOperations /></section></div> : active === panel.name ? <div className="stack stack--lg settings-body">
{tr(panel.sections.map(section => <section className="settings-section" key={section}>
<h2 className="h3" style={{ marginBottom: 14 }}>{tr(section)}</h2>
<div className="st-card st-card--flush">
+3 -3
View File
@@ -69,7 +69,7 @@ export function SkillsScreen() {
return <Screen title={tr("Skills")} titleDetail={tr("Manage your skills library")} actions={<><Btn icon="plus" variant="primary" disabled={!!pending} onClick={openNew}>{tr("New skill")}</Btn><Btn icon="folder-plus" disabled={!!pending} onClick={() => void action('add', add)}>{tr("Add skill")}</Btn><input className="st-input skills-header-search" type="search" aria-label={tr("Search skills")} placeholder={tr("Search skills…")} value={query} onChange={e => setQuery(e.target.value)}/><IconBtn name="refresh" title={tr("Refresh skills")} disabled={!!pending} onClick={() => void action('refresh', loadSkills)}/></>}>
<div className="skills-page">{tr(error && !editing ? <ErrorNotice message={error} action={tr("Retry")} onRetry={() => void action('refresh', loadSkills)}/> : null)}
{tr((['bundled', 'added'] as const).map(group => { const rows = visible.filter(s => group === 'bundled' ? s.source === 'bundled' : s.source !== 'bundled'); return rows.length ? <section className="skill-group" aria-label={tr(group === 'bundled' ? 'Built-in skills' : 'Added skills')} key={group}>{tr(group === 'added' ? <h3 className="h4">{tr("Added skills")}</h3> : null)}<div>{tr(rows.map(s => <article className="skill-row" key={s.id}><span className="skill-symbol"><Icon name="skills" size={26}/></span><div className="skill-copy"><h3 className="h4">{s.source === 'bundled' ? tr(s.name) : s.name}</h3><p className="small muted">{s.source === 'bundled' ? tr(s.description) : s.description}</p>{tr(s.onlyIn ? <span className="xs muted">{tr(s.onlyIn === workspace ? 'This workspace' : `Only in ${workspaces.find(w => w.id === s.onlyIn)?.name ?? 'another workspace'}`)}</span> : null)}</div><div className="skill-row-actions"><IconBtn name="pencil" title={tr("Edit {0}", [s.name])} disabled={!!pending} onClick={() => openEditor(s)}/><button className="st-toggle" role="switch" aria-checked={s.enabled} aria-label={tr("Enable {0}", [s.name])} disabled={!!pending} onClick={() => void action(s.id, () => toggleSkill(s.id, !s.enabled))}/></div></article>))}</div></section> : null; }))}
{tr(!visible.length ? <Empty icon="skills" title={tr(skills.length ? 'No matching skills' : 'No skills yet')} body={skills.length ? 'Try another search.' : 'Add a folder containing SKILL.md.'} action={tr(<Btn onClick={() => skills.length ? setQuery('') : void action('add', add)}>{tr(skills.length ? 'Clear search' : 'Add skill')}</Btn>)}/> : null)}
{tr(!visible.length ? <Empty icon="skills" title={tr(skills.length ? 'No matching skills' : 'No skills yet')} body={skills.length ? 'Try another search.' : 'Add a folder containing SKILL.md.'} action={tr(<Btn variant={skills.length ? undefined : 'primary'} onClick={() => skills.length ? setQuery('') : void action('add', add)}>{tr(skills.length ? 'Clear search' : 'Add skill')}</Btn>)}/> : null)}
</div>
<Dialog open={!!editing} title={tr(editing && !editing.id ? 'New skill' : 'Edit skill')} onClose={closeEditor} actions={<><Btn disabled={!!pending} onClick={closeEditor}>{tr("Cancel")}</Btn><Btn variant="primary" disabled={!!pending || !editing?.name.trim() || !editing?.body?.trim()} onClick={() => void action(editing!.id, save)}>{tr(pending ? 'Saving…' : editing && !editing.id ? 'Save skill' : 'Save changes')}</Btn></>}>
@@ -84,8 +84,8 @@ export function SkillsScreen() {
{tr(tab === 'details' ? <><div className="skill-detail-grid skill-detail-grid--name"><label className="input-field"><span>{tr("Name")}</span><input className="st-input" maxLength={120} value={editing.name} onChange={e => setEditing({ ...editing, name: e.target.value })}/></label><div className="input-field skill-enable-field"><span>{tr("Enabled")}</span><button className="st-toggle" role="switch" aria-label={tr("Skill enabled")} aria-checked={editing.enabled} onClick={() => setEditing({ ...editing, enabled: !editing.enabled })}/></div></div><label className="input-field"><span>{tr("Description")}</span><input className="st-input" maxLength={500} value={editing.description} onChange={e => setEditing({ ...editing, description: e.target.value })}/></label>
<div className="skill-detail-grid"><div className="input-field"><span id="skill-scope-label">{tr("Available in")}</span><Select ariaLabel={tr("Skill workspace scope")} value={editing.onlyIn ?? 'all'} options={[{ value: 'all', label: 'All workspaces' }, ...workspaces.map(w => ({ value: w.id, label: w.name }))]} onChange={value => setEditing({ ...editing, onlyIn: value === 'all' ? null : value })}/></div>
<label className="input-field"><span>{tr("Trigger phrases")}</span><input className="st-input" value={triggers} onChange={e => setTriggers(e.target.value)} placeholder={tr("review this, check my code")}/><small className="muted">{tr("Separate phrases with commas.")}</small></label></div>
</> : <label className="input-field"><span>{tr("Instructions")}</span><textarea className="st-input skill-instructions" rows={10} maxLength={64000} value={editing.body ?? ''} onChange={e => setEditing({ ...editing, body: e.target.value })}/></label>)}
<p className="xs muted">{tr("Changes are saved in Strata. The original skill file stays intact.")}</p>{tr(error ? <ErrorNotice message={error} action={tr("Retry")} onRetry={() => void action('refresh', loadSkills)}/> : null)}</div></> : null)}
</> : <label className="input-field"><span className="sr-only">{tr("Instructions")}</span><textarea className="st-input skill-instructions" rows={10} maxLength={64000} value={editing.body ?? ''} onChange={e => setEditing({ ...editing, body: e.target.value })}/></label>)}
{tr(editing.id ? <p className="xs muted">{tr("Changes are saved in Strata. The original skill file stays intact.")}</p> : null)}{tr(error ? <ErrorNotice message={error} action={tr("Retry")} onRetry={() => void action('refresh', loadSkills)}/> : null)}</div></> : null)}
</Dialog>
</Screen>;
}
+1 -1
View File
@@ -85,7 +85,7 @@ export function TasksScreen() {
{tr(!currentTask && view !== 'board' && <p className="task-runtime small muted"><Icon name="clock" size={16}/>{tr(library?.mode === 'simulated' ? 'Example model · sample answers. ' : '')}{tr("Runs while Strata\u2019s host is open. Runs saved instructions using the loaded model.")}</p>)}
{tr(error && !editor ? <ErrorNotice message={error} onRetry={() => void refresh()}/> : null)}
{tr(currentTask || view === 'board' ? null : !ready ? <p className="muted">{tr("Loading tasks\u2026")}</p> : !visible.length ? <div className="tasks-empty"><Icon name="clock" size={72}/><h2 className="h3">{tr(query ? 'No matching tasks' : 'No scheduled tasks yet')}</h2><p className="muted">{tr(query ? 'Try another search.' : 'Create one with the + button.')}</p></div> : <div className="task-list">{tr(visible.map(t => <article key={t.id} className="task-row"><span className="task-icon"><Icon name="clock" size={24}/></span><div className="task-copy"><h2 className="h4">{t.title}</h2><p className="small muted">{tr(t.status === 'running' ? 'Running…' : t.enabled ? (t.nextRunAt ? `Next: ${new Date(t.nextRunAt).toLocaleString(localeTag(), { dateStyle: 'medium', timeStyle: 'short' })}` : 'Scheduled · calculating next run') : 'Paused')} · {tr(t.timeZone)}</p>{tr(t.error ? <button className="task-failed text-action" onClick={() => setResult(t.id!)}>{tr("Last run failed \u00B7 View")}</button> : null)}{tr(t.output ? <button className="text-action" onClick={() => setResult(t.id!)}>{tr("View last result")}{tr(t.demo ? ' · Example' : '')}</button> : null)}</div><div className="row"><><IconBtn name="pencil" title={tr("Edit {0}", [t.title])} disabled={pending || t.status === 'running'} onClick={() => open(t)}/><Btn size="sm" icon={t.status === 'running' ? 'stop' : 'bolt'} title={tr(t.status === 'running' ? `Stop ${t.title}` : `Run ${t.title} now`)} disabled={pending || !loaded} onClick={() => void action(() => api.post(`/api/tasks/${t.id}/${t.status === 'running' ? 'stop' : 'run'}`))}>{tr(t.status === 'running' ? 'Stop' : 'Run now')}</Btn><label className="row small"><span>{tr("On schedule")}</span><button className="st-toggle" role="switch" aria-label={tr("Schedule {0}", [t.title])} aria-checked={t.enabled} disabled={pending || t.status === 'running'} onClick={() => void action(() => api.patch(`/api/tasks/${t.id}`, { enabled: !t.enabled }))}/></label></></div></article>))}</div>)}
{tr(currentTask || view === 'board' ? null : !ready ? <p className="muted">{tr("Loading tasks\u2026")}</p> : !visible.length ? <div className="tasks-empty"><span className="tasks-empty__art"><Icon name="clock" size={30}/></span><h2 className="h3">{tr(query ? 'No matching tasks' : 'No scheduled tasks yet')}</h2>{tr(query ? <p className="muted">{tr('Try another search.')}</p> : <Btn variant="primary" icon="plus" onClick={() => open(blank())}>{tr("New task")}</Btn>)}</div> : <div className="task-list">{tr(visible.map(t => <article key={t.id} className="task-row"><span className="task-icon"><Icon name="clock" size={24}/></span><div className="task-copy"><h2 className="h4">{t.title}</h2><p className="small muted">{tr(t.status === 'running' ? 'Running…' : t.enabled ? (t.nextRunAt ? `Next: ${new Date(t.nextRunAt).toLocaleString(localeTag(), { dateStyle: 'medium', timeStyle: 'short' })}` : 'Scheduled · calculating next run') : 'Paused')} · {tr(t.timeZone)}</p>{tr(t.error ? <button className="task-failed text-action" onClick={() => setResult(t.id!)}>{tr("Last run failed \u00B7 View")}</button> : null)}{tr(t.output ? <button className="text-action" onClick={() => setResult(t.id!)}>{tr("View last result")}{tr(t.demo ? ' · Example' : '')}</button> : null)}</div><div className="row"><><IconBtn name="pencil" title={tr("Edit {0}", [t.title])} disabled={pending || t.status === 'running'} onClick={() => open(t)}/><Btn size="sm" icon={t.status === 'running' ? 'stop' : 'bolt'} title={tr(t.status === 'running' ? `Stop ${t.title}` : `Run ${t.title} now`)} disabled={pending || !loaded} onClick={() => void action(() => api.post(`/api/tasks/${t.id}/${t.status === 'running' ? 'stop' : 'run'}`))}>{tr(t.status === 'running' ? 'Stop' : 'Run now')}</Btn><label className="row small"><span>{tr("On schedule")}</span><button className="st-toggle" role="switch" aria-label={tr("Schedule {0}", [t.title])} aria-checked={t.enabled} disabled={pending || t.status === 'running'} onClick={() => void action(() => api.patch(`/api/tasks/${t.id}`, { enabled: !t.enabled }))}/></label></></div></article>))}</div>)}
{tr(!currentTask && view === 'board' ? <TaskBoard query={query} canRun={!!loaded || !!native?.instances.some(i => i.status === 'ready')} onViewResult={setResult}/> : null)}
{tr(currentTask ? <TaskDetail task={currentTask}/> : null)}
</div>
+4 -4
View File
@@ -135,7 +135,7 @@ html[data-motion='reduced'] .tunnel-automatic .tunnel-stage__symbol .st-icon, ht
.composer-mode span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.composer-effort { flex: none; }
.menu-shortcut { margin-left: auto; flex: none; color: var(--st-ink-muted); font-family: inherit; font-size: 11px; white-space: nowrap; }
.slash-command-icon { width: 20px; font-size: 20px; line-height: 1; text-align: center; }
.slash-command-icon { width: 20px; font-family: var(--st-font-mono); font-size: var(--st-fs-lg); font-weight: 500; line-height: 1; text-align: center; color: var(--st-icon); }
.composer-menu-heading { margin: 6px 10px 8px; color: var(--st-ink-muted); font-size: var(--st-fs-xs); }
.composer-mode-option { align-items: flex-start; gap: 10px; padding-block: 10px; }
.composer-mode-option > span { flex: 1; min-width: 0; }
@@ -248,7 +248,7 @@ html[data-motion='reduced'] .task-tree-row[data-leaving] { opacity: 0; }
.usage-card__toolbar > div { display: flex; gap: 4px; }
.usage-card__toolbar button, .usage-show-more { border: 0; background: transparent; color: var(--st-ink-muted); font: inherit; padding: 7px 10px; border-radius: 9px; cursor: pointer; }
.usage-card__toolbar button:hover, .usage-show-more:hover { color: var(--st-ink); background: var(--st-surface-2); }
.usage-card__toolbar button[aria-selected='true'], .usage-card__toolbar button[aria-pressed='true'] { background: var(--st-surface-3, var(--st-surface-2)); color: var(--st-ink); }
.usage-card__toolbar button[aria-selected='true'], .usage-card__toolbar button[aria-pressed='true'] { background: var(--st-surface-2); color: var(--st-ink); }
.usage-card__toolbar > [aria-label='Usage period'] { margin-left: auto; }
.usage-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 0 0 16px; }
.usage-stats > div { border-radius: 9px; background: var(--st-surface-2); padding: 12px; min-width: 0; }
@@ -455,7 +455,7 @@ html[data-motion='reduced'] .task-tree-row[data-leaving] { opacity: 0; }
.provider-manual { display: grid; gap: 12px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: end; padding-top: 8px; }
.provider-manual > .st-btn { justify-self: start; }
.provider-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding-top: 18px; border-top: 1px solid var(--st-line); }
.model-picker-heading { font-size: 11px; color: var(--st-ink-muted); padding: 12px 12px 5px; }
.model-picker-heading { font-size: 11px; font-weight: 500; letter-spacing: .06em; text-transform: uppercase; color: var(--st-ink-muted); padding: 12px 12px 8px; }
.model-picker-api > span:first-child { white-space: normal; overflow-wrap: anywhere; }
.model-picker-api strong { display: block; }
.model-picker-empty { margin: 4px 12px 12px; color: var(--st-ink-muted); font-size: 12px; }
@@ -631,7 +631,7 @@ html[data-motion='reduced'] .task-tree-row[data-leaving] { opacity: 0; }
.native-image-reading>label { display:flex; gap:12px; align-items:start; flex:1; font-size:13px; }
.native-image-reading input { margin-top:4px; accent-color:var(--st-accent); }
.native-image-reading strong { display:block; }
.native-image-reading strong small { display:inline; font-size:10px; font-weight:400; color:var(--st-ink-muted); margin-left:8px; }
.native-image-reading strong small { display:inline; font-size:12px; font-weight:400; color:var(--st-ink-muted); margin-left:8px; }
.native-image-reading span>small { display:block; color:var(--st-ink-muted); font-size:11px; margin-top:6px; line-height:1.65; }
.native-image-reading>.select { max-width:150px; }
.native-running>div { display:flex; align-items:center; gap:10px; padding:14px 0; border-top:1px solid var(--st-line-soft); }
+83 -109
View File
@@ -52,6 +52,17 @@
which is narrower than average running text in this face. Verified against
the rendered result at 10, 16 and 22px — see npm run audit. */
--st-measure: 56ch;
/* Type inside a conversation follows the reader's text size. The ratios are
declared once here, so a rule that scales with the prose uses a token
instead of a bespoke calc that the type audit cannot check. */
--st-fs-read-lg: calc(var(--st-content-fs, 14px) * 1.125);
/* The small ratio has a floor: at the default 14px reading size, 0.8125
would put code and tables at 11.4px, which is below what a table of
numbers should ask of a reader. */
--st-fs-read-sm: max(12px, calc(var(--st-content-fs, 14px) * .8125));
--st-fs-math: calc(var(--st-content-fs, 14px) * 1.08);
--st-fs-math-source: calc(var(--st-content-fs, 14px) * .9);
}
/* Two shared recipes replace the nine bespoke entrance keyframes this file used
@@ -447,13 +458,15 @@
min-height: 40px;
}
/* The column is the measure plus the bubble's own padding. Both terms are tied
to the content size, so the character count stays put when the reader changes
the text size — a fixed px allowance drifts at the extremes. */
/* The column is the measure plus the column's own horizontal padding. The
padding is a fixed 28px a side (`.chat__inner`), so the allowance is the same
fixed 56px rather than an em term: a 2.5em allowance shrank with the text and
at the 10px reading size left only 39 characters in the line (the measure
target is 45–75, verified by `npm run measure`). */
.app[data-focus='true'] .slashmenu,
.app[data-focus='true'] .chat__inner {
font-size: var(--st-content-fs, 14px);
max-width: calc(var(--st-measure) + 2.5em);
max-width: calc(var(--st-measure) + 72px);
}
.app[data-focus='true'] .composer__inner {
font-size: var(--st-content-fs, 14px);
@@ -1436,6 +1449,10 @@ button {
background: var(--st-surface-2);
color: var(--st-ink);
}
/* The chat row marks itself with aria-current="page", like every other current
destination; the rule below used to look for "true", so the conversation you
were reading was the one row with no mark at all. */
.navsession[aria-current='page'],
.navsession[aria-current='true'] {
color: var(--st-ink);
background: var(--st-surface-2);
@@ -2676,7 +2693,6 @@ textarea.st-input {
padding: 6px;
/* A menu of command names over suggestion cards is the busiest backdrop in
the app; legibility wins over transparency here. */
background: color-mix(in srgb, var(--st-surface) 94%, transparent);
border-radius: var(--st-r-lg);
border: 1px solid var(--st-line);
background: var(--st-material-tint-strong);
@@ -6188,19 +6204,11 @@ html, body {
}
.st-btn--primary, .nav-new.st-btn--primary {
background: var(--st-audit-primary);
color: var(--st-audit-primary-ink);
border-color: var(--st-audit-primary);
}
.st-btn--primary:hover:not(:disabled), .nav-new.st-btn--primary:hover:not(:disabled) {
background: var(--st-audit-primary-hover);
color: var(--st-audit-primary-ink);
border-color: var(--st-audit-primary-hover);
}
/* The accent fill is the tinted material, not the raw brand green with white
ink. This block used to paint #10b981 behind #ffffff: 2.54:1, under AA, and it
also undid section 6's tinted primary. The fill, the edge and the specular
line all come from the accent-surface tokens, so the button and the reader's
own message bubble keep agreeing in both themes. */
.st-btn:disabled {
background: var(--st-surface-2);
@@ -6211,16 +6219,8 @@ html, body {
}
.st-toggle[aria-checked='true'] {
background: var(--st-audit-primary);
border-color: var(--st-audit-primary);
}
.st-toggle[aria-checked='true']::after {
background: var(--st-audit-white);
}
/* The toggle follows the same accent material; its knob is the surface, not a
white disc on a saturated track. */
input[type='checkbox'], input[type='radio'] {
accent-color: var(--st-audit-primary);
@@ -7006,22 +7006,22 @@ textarea {
/* The reading size reaches everything in a conversation, not only paragraphs: headings, inline code, tables, code blocks and the
model's reasoning keep their usual proportion (18px, 13px, 13px at the 16px default) at any size. */
.st-bubble h2, .st-bubble h3, .st-bubble h4 { font-size: 1.125em; }
.st-bubble .md-code { font-size: .8125em; }
.st-bubble .md-table { font-size: calc(var(--st-content-fs, 14px) * .8125); }
.st-bubble h2, .st-bubble h3, .st-bubble h4 { font-size: var(--st-fs-read-lg); }
.st-bubble .md-code { font-size: var(--st-fs-read-sm); }
.st-bubble .md-table { font-size: var(--st-fs-read-sm); }
.st-bubble .md-table th, .st-bubble .md-table td { font-size: inherit; }
.st-bubble .st-code pre, .st-bubble .st-code pre code, .st-bubble .st-code .syntax-text { font-size: calc(var(--st-content-fs, 14px) * .8125); }
.st-msg--assistant .st-collapse__body { font-size: calc(var(--st-content-fs, 14px) * .8125); }
.st-bubble .st-code pre, .st-bubble .st-code pre code, .st-bubble .st-code .syntax-text { font-size: var(--st-fs-read-sm); }
.st-msg--assistant .st-collapse__body { font-size: var(--st-fs-read-sm); }
.error-notice .error-detail { margin-top: 6px; }
.error-notice .error-detail pre { white-space: pre-wrap; overflow-wrap: anywhere; margin: 6px 0 0; font-family: var(--st-font-mono); max-height: 3.3em; overflow-y: auto; }
.error-detail-text { white-space: pre-wrap; overflow-wrap: anywhere; margin: 6px 0 0; font-family: var(--st-font-mono); }
/* What you type and the small print under each message follow the reading size too, so the whole conversation changes together. */
.st-composer textarea { font-size: var(--st-content-fs, 14px); }
.st-msg__meta, .st-msg__meta time { font-size: calc(var(--st-content-fs, 14px) * .8125); }
.st-msg__meta, .st-msg__meta time { font-size: var(--st-fs-read-sm); }
/* What an approval is about, shown without opening the card: the file or the command. */
.toolcard__what { display: block; margin: 4px 0 2px; padding: 6px 10px; background: var(--st-surface-2); color: var(--st-ink); border: 1px solid var(--st-line); border-radius: var(--st-r-sm); white-space: pre-wrap; overflow-wrap: anywhere; max-height: 6.5em; overflow: auto; font-size: 12px; }
.tool-summary__target { min-width: 0; max-width: 28em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--st-mono, monospace); font-size: 12px; }
.tool-summary__target { min-width: 0; max-width: 28em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--st-font-mono); font-size: 12px; }
/* ---- Models: the version list and "This computer" ---- */
.version-trigger { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 44px; padding: 0 14px; border: 1.5px solid var(--st-line); border-radius: var(--st-r-md); background: var(--st-surface); color: var(--st-ink); font: inherit; cursor: pointer; text-align: left; }
@@ -7053,8 +7053,8 @@ textarea {
.pc-meter__top { display: flex; align-items: baseline; gap: 8px; font-size: var(--st-fs-sm); }
.pc-meter__top span:first-child { font-weight: 700; margin-right: auto; }
.pc-meter__top b { font-size: var(--st-fs-lg); letter-spacing: -.01em; font-variant-numeric: tabular-nums; }
.pc-meter__track { height: 10px; border-radius: 9px; background: var(--st-surface-2); border: 1px solid var(--st-line); overflow: hidden; }
.pc-meter__track i { display: block; height: 100%; background: var(--st-accent); border-radius: 9px; }
.pc-meter__track { height: 10px; border-radius: var(--st-r-pill); background: var(--st-surface-2); border: 1px solid var(--st-line); overflow: hidden; }
.pc-meter__track i { display: block; height: 100%; background: var(--st-accent); border-radius: var(--st-r-pill); }
.pc-note { font-size: var(--st-fs-sm); color: var(--st-ink-soft); }
/* ---- approval card: calm, compact, one question ---- */
@@ -7096,19 +7096,15 @@ textarea {
.tune-step { display: grid; grid-template-columns: 44px 1fr auto; gap: 14px; align-items: center; padding: 12px 0; position: relative; }
.tune-step:not(:last-child)::after { content: ""; position: absolute; left: 21px; top: 56px; bottom: -8px; width: 2px; background: var(--st-line); }
.tune-step[data-s="done"]:not(:last-child)::after { background: var(--st-accent); }
.tune-step__badge { width: 44px; height: 44px; border-radius: 14px; display: grid; place-items: center; background: var(--st-surface-2); border: 1px solid var(--st-line); color: var(--st-ink-muted); position: relative; }
.tune-step__badge { width: 44px; height: 44px; border-radius: var(--st-r-md); display: grid; place-items: center; background: var(--st-surface-2); border: 1px solid var(--st-line); color: var(--st-ink-muted); position: relative; }
.tune-step[data-s="run"] .tune-step__badge { background: var(--st-accent-tint); color: var(--st-accent-text); border-color: var(--st-accent); }
.tune-step[data-s="run"] .tune-step__badge { animation: tune-glow 1.8s ease-out infinite; }
.tune-step[data-s="run"] .tune-step__state::before { content: ""; display: inline-block; width: 6px; height: 6px; margin-right: 6px; border-radius: 50%; background: currentColor; animation: tune-beep 1.1s ease-in-out infinite; }
.tune-step[data-s="run"] .tune-step__state::before { content: ""; display: inline-block; width: 6px; height: 6px; margin-right: 6px; border-radius: 50%; background: currentColor; animation: st-pulse 1.6s var(--st-ease-in-out) infinite; }
.tune-step[data-s="done"] .tune-step__badge { background: var(--st-accent); color: var(--st-accent-ink); border-color: transparent; animation: tune-pop 260ms var(--st-ease); }
.tune-step__text { display: flex; flex-direction: column; min-width: 0; }
.tune-step__text small { color: var(--st-ink-muted); font-size: var(--st-fs-sm); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tune-step[data-s="pending"] .tune-step__text strong { color: var(--st-ink-muted); }
.tune-step__state { font-size: var(--st-fs-xs); font-weight: 700; color: var(--st-ink-muted); }
.tune-step[data-s="run"] .tune-step__state, .tune-step[data-s="done"] .tune-step__state { color: var(--st-accent-text); }
.tune-step[data-s="run"][data-step="gauge"] .tune-step__badge svg { animation: tune-sweep 1.4s ease-in-out infinite alternate; transform-origin: 50% 60%; }
.tune-step[data-s="run"][data-step="sliders"] .tune-step__badge svg { animation: tune-slide 1.2s ease-in-out infinite alternate; }
.tune-step[data-s="run"][data-step="cpu"] .tune-step__badge svg, .tune-step[data-s="run"][data-step="thinking"] .tune-step__badge svg, .tune-step[data-s="run"][data-step="disk"] .tune-step__badge svg { animation: tune-breathe 1.2s ease-in-out infinite; }
.tune-live { border-left: 1px solid var(--st-line); background: var(--st-surface-2); padding: 20px; display: flex; flex-direction: column; gap: 12px; border-radius: 0 var(--st-r-md) var(--st-r-md) 0; }
.tune-live .label, .tune-hero .label { display: block; font-size: var(--st-fs-xs); font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--st-ink-muted); }
.tune-live__big { font-size: var(--st-fs-display); font-weight: 900; letter-spacing: -.03em; line-height: 1; font-variant-numeric: tabular-nums; }
@@ -7118,30 +7114,25 @@ textarea {
.tune-spark__fill { fill: var(--st-accent-tint); }
.tune-spark__line { fill: none; stroke: var(--st-accent); stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
.tune-spark circle { fill: var(--st-accent); }
.tune-live__bar { height: 8px; border-radius: 9px; background: var(--st-line); overflow: hidden; }
.tune-live__bar i { display: block; height: 100%; background: var(--st-accent); border-radius: 9px; transition: width 600ms var(--st-ease); }
.tune-live__bar { height: 8px; border-radius: var(--st-r-pill); background: var(--st-line); overflow: hidden; }
.tune-live__bar i { display: block; height: 100%; width: 100%; background: var(--st-accent); border-radius: var(--st-r-pill); transform-origin: left center; transition: transform var(--st-t-local) var(--st-ease-standard); }
.tune-live__list { list-style: none; margin: 0; padding: 0; font-size: var(--st-fs-sm); }
.tune-live__list li { display: flex; justify-content: space-between; gap: 10px; padding: 5px 0; border-bottom: 1px solid var(--st-line-soft); }
.tune-live__list span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--st-ink-soft); }
.tune-live__list b { font-variant-numeric: tabular-nums; color: var(--st-ink-muted); }
.tune-live__list b[data-up] { color: var(--st-accent-text); }
.tune-result { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: center; }
.tune-hero { border-radius: var(--st-r-lg); padding: 22px; background: linear-gradient(135deg, var(--st-accent-tint), transparent); border: 1px solid var(--st-accent); }
.tune-hero__big { font-size: 56px; font-weight: 900; letter-spacing: -.03em; line-height: 1.1; color: var(--st-accent-text); animation: tune-pop 400ms var(--st-ease); }
.tune-hero { border-radius: var(--st-r-lg); padding: 22px; background: color-mix(in srgb, var(--st-accent) 6%, var(--st-surface)); border: 1px solid color-mix(in srgb, var(--st-accent) 28%, transparent); }
.tune-hero__big { font-size: var(--st-fs-display); font-weight: 900; letter-spacing: -.03em; line-height: 1.1; color: var(--st-accent-text); animation: tune-pop var(--st-t-local) var(--st-ease-enter); }
.tune-hero p { margin: 8px 0 0; }
.tune-compare { display: flex; flex-direction: column; gap: 10px; }
.tune-compare > div { display: grid; grid-template-columns: 64px 1fr 56px; gap: 10px; align-items: center; }
.tune-compare b { text-align: right; font-variant-numeric: tabular-nums; }
.tune-compare__bar { height: 12px; border-radius: 9px; background: var(--st-line); overflow: hidden; }
.tune-compare__bar i { display: block; height: 100%; border-radius: 9px; background: var(--st-ink-muted); transition: width 700ms var(--st-ease); }
.tune-compare__bar { height: 12px; border-radius: var(--st-r-pill); background: var(--st-line); overflow: hidden; }
.tune-compare__bar i { display: block; height: 100%; width: 100%; border-radius: var(--st-r-pill); background: var(--st-ink-muted); transform-origin: left center; transition: transform var(--st-t-local) var(--st-ease-standard); }
.tune-compare > div[data-new] .tune-compare__bar i { background: var(--st-accent); }
.tune-compare p { margin: 4px 0 0; grid-column: 1 / -1; }
@keyframes tune-glow { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--st-accent) 45%, transparent); } 70%, 100% { box-shadow: 0 0 0 11px transparent; } }
@keyframes tune-beep { 0%, 100% { opacity: .25; transform: scale(.7); } 40% { opacity: 1; transform: scale(1); } }
@keyframes tune-pop { 0% { transform: scale(.7); opacity: .4; } 100% { transform: scale(1); opacity: 1; } }
@keyframes tune-sweep { from { transform: rotate(-16deg); } to { transform: rotate(16deg); } }
@keyframes tune-slide { from { transform: translateX(-2px); } to { transform: translateX(2px); } }
@keyframes tune-breathe { 50% { transform: scale(1.12); } }
@media (max-width: 720px) { .tune-run, .tune-result { grid-template-columns: 1fr; } .tune-live { border-left: 0; border-top: 1px solid var(--st-line); border-radius: 0 0 var(--st-r-md) var(--st-r-md); margin-top: 8px; } }
@media (prefers-reduced-motion: reduce) {
.tune-step[data-s="run"] .tune-step__badge, .tune-step[data-s="run"] .tune-step__state::before, .tune-step[data-s="run"] .tune-step__badge svg, .tune-step[data-s="done"] .tune-step__badge, .tune-hero__big { animation: none !important; }
@@ -7160,11 +7151,10 @@ textarea {
.live-status__time, .live-status__tokens { font-variant-numeric: tabular-nums; color: var(--st-ink-muted); white-space: nowrap; }
.live-status__time { text-align: right; }
@media (max-width: 520px) { .live-status { grid-template-columns: 14px minmax(0, 1fr) 6ch auto; } }
.live-status__verb { font-weight: 700; color: var(--st-ink); animation: live-verb .35s ease; }
.live-status__verb { font-weight: 700; color: var(--st-ink); animation: live-verb var(--st-t-control) var(--st-ease-enter); }
.live-status__pulse { width: 14px; height: 14px; position: relative; flex: none; }
.live-status__pulse i { position: absolute; inset: 0; border-radius: 50%; background: var(--st-accent); opacity: .9; animation: live-pulse 1.4s ease-in-out infinite; }
.live-status__pulse i { position: absolute; inset: 0; border-radius: 50%; background: var(--st-accent); opacity: .9; animation: st-pulse 1.6s var(--st-ease-in-out) infinite; }
.live-status__pulse i:nth-child(2) { animation-delay: .45s; opacity: .5; }
@keyframes live-pulse { 0%, 100% { transform: scale(.55); } 50% { transform: scale(1); } }
@keyframes live-verb { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { .live-status__pulse i, .live-status__verb { animation: none; } }
@@ -7185,28 +7175,29 @@ textarea {
.goalbar > svg { flex: none; color: var(--st-icon); }
.goalbar__text { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.goalbar__text > span { color: var(--st-ink-muted); }
.goalbar__state { color: var(--st-ink-muted); }
.goalbar[data-phase='complete'] .goalbar__state { color: var(--st-accent-text); }
.goalbar[data-phase='blocked'] .goalbar__state { color: var(--st-warn-text); }
.goalbar[data-phase="complete"] > svg { color: var(--st-accent-text); }
.goalbar .st-btn--icon { width: 28px; height: 28px; }
/* ---- Time-skip: working in the chat ---- */
.tsw { border: 1px solid var(--st-line); border-radius: var(--st-r-lg); background: var(--st-surface); box-shadow: var(--st-sh-card); overflow: hidden; margin: 6px 0; }
.tsw__stage { padding: 24px 22px 16px; display: grid; grid-template-columns: 1fr 96px 1fr; align-items: center; gap: 8px; background: radial-gradient(120% 140% at 50% 0%, var(--st-accent-tint), transparent 70%); }
.tsw__ver { background: var(--st-surface); border: 1px solid var(--st-line); border-radius: var(--st-r-md); padding: 12px 14px; display: flex; flex-direction: column; gap: 7px; box-shadow: var(--st-sh-card); }
.tsw { border: 1px solid var(--st-line); border-radius: var(--st-r-lg); background: var(--st-surface); overflow: hidden; margin: 6px 0; }
.tsw__stage { padding: 24px 22px 16px; display: grid; grid-template-columns: 1fr 96px 1fr; align-items: center; gap: 8px; }
.tsw__ver { background: var(--st-surface); border: 1px solid var(--st-line); border-radius: var(--st-r-md); padding: 12px 14px; display: flex; flex-direction: column; gap: 7px; }
.tsw__ver span { font-size: var(--st-fs-xs); font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--st-ink-muted); }
.tsw__ver i { display: block; height: 6px; border-radius: 5px; background: var(--st-line); }
.tsw__ver i { display: block; height: 6px; border-radius: var(--st-r-pill); background: var(--st-line); }
.tsw__ver--b { border-color: var(--st-accent); }
.tsw__ver--b i { background: linear-gradient(90deg, var(--st-accent), var(--st-accent-tint)); background-size: 200% 100%; animation: tsw-shimmer 1.6s linear infinite; }
.tsw__ver--b i { background: color-mix(in srgb, var(--st-accent) 55%, var(--st-line)); }
.tsw__flow { position: relative; height: 40px; }
.tsw__flow::before { content: ""; position: absolute; left: 0; right: 0; top: 50%; height: 2px; background: var(--st-line); }
.tsw__flow i { position: absolute; top: 50%; width: 8px; height: 8px; margin-top: -4px; border-radius: 50%; background: var(--st-accent); animation: tsw-flow 1.8s linear infinite; }
.tsw__flow i:nth-child(2) { animation-delay: .6s; } .tsw__flow i:nth-child(3) { animation-delay: 1.2s; }
.tsw__flow i { position: absolute; top: 50%; left: 50%; width: 8px; height: 8px; margin: -4px 0 0 -4px; border-radius: 50%; background: var(--st-accent); animation: st-pulse 1.6s var(--st-ease-in-out) infinite; }
.tsw__flow i:nth-child(1) { left: 18%; } .tsw__flow i:nth-child(2) { left: 50%; animation-delay: .2s; } .tsw__flow i:nth-child(3) { left: 82%; animation-delay: .4s; }
.tsw__bar { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-top: 1px solid var(--st-line-soft); }
.tsw__step { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.tsw__step strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsw__prog { height: 6px; border-radius: 6px; background: var(--st-line); overflow: hidden; margin-top: 6px; }
.tsw__prog i { display: block; height: 100%; background: var(--st-accent); border-radius: 6px; transition: width 800ms var(--st-ease); }
@keyframes tsw-flow { from { left: 0; opacity: 0; } 15% { opacity: 1; } 85% { opacity: 1; } to { left: calc(100% - 8px); opacity: 0; } }
@keyframes tsw-shimmer { to { background-position: -200% 0; } }
.tsw__prog i { display: block; height: 100%; width: 100%; background: var(--st-accent); border-radius: 6px; transform-origin: left center; transition: transform var(--st-t-local) var(--st-ease-standard); }
@media (max-width: 560px) { .tsw__stage { grid-template-columns: 1fr; } .tsw__flow { display: none; } }
@media (prefers-reduced-motion: reduce) { .tsw__flow i, .tsw__ver--b i { animation: none; } .tsw__prog i { transition: none; } }
@@ -7224,14 +7215,14 @@ textarea {
.tsc-pair { flex: none; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; align-items: start; }
.tsc-pair--text > div + div { border-left: 1px solid var(--st-line-soft); padding-left: 12px; }
.tsc-doc p { margin: 0 0 6px; line-height: 1.5; overflow-wrap: anywhere; font-size: var(--st-fs-sm); }
.tsc-doc mark { background: rgba(16, 185, 129, .22); color: var(--st-ink); border-radius: 3px; padding: 0; box-decoration-break: clone; }
.tsc-pair--text > div:first-child mark { background: rgba(224, 40, 63, .16); }
.tsc-doc mark { background: color-mix(in srgb, var(--st-accent) 22%, transparent); color: var(--st-ink); border-radius: 6px; padding: 0; box-decoration-break: clone; }
.tsc-pair--text > div:first-child mark { background: color-mix(in srgb, var(--st-danger) 16%, transparent); }
.tsc-doc--md .tsc-h { font-weight: 700; }
.tsc-doc--md .tsc-h1 { font-size: var(--st-fs-base); } .tsc-doc--md .tsc-h2 { font-size: var(--st-fs-md); }
.tsc-doc--md .tsc-li { position: relative; padding-left: 16px; } .tsc-doc--md .tsc-li::before { content: "•"; position: absolute; left: 4px; color: var(--st-ink-muted); }
.tsc-doc--md .tsc-code, .tsc-doc--md .tsc-fence { font-family: var(--st-font-mono); font-size: 12px; background: var(--st-surface-2); padding: 0 6px; margin: 0; }
.tsc-pair--html { flex: 1 1 0; min-height: 120px; align-items: stretch; }
.tsc-pair--html iframe { width: 100%; height: 100%; min-height: 120px; border: 1px solid var(--st-line); border-radius: var(--st-r-sm); background: #fff; }
.tsc-pair--html iframe { width: 100%; height: 100%; min-height: 120px; border: 1px solid var(--st-line); border-radius: var(--st-r-sm); background: var(--st-surface); }
.tsc-pair--pictures { flex: 1 1 0; min-height: 120px; align-items: stretch; }
.tsc-picture { display: grid; place-items: center; width: 100%; height: 100%; min-height: 160px; padding: 6px; border: 1px solid var(--st-line); border-radius: var(--st-r-sm); background: var(--st-surface-2); cursor: zoom-in; overflow: hidden; }
.tsc-picture img { max-width: 100%; max-height: 100%; object-fit: contain; }
@@ -7245,6 +7236,9 @@ textarea {
.tsc-diff__row { display: grid; grid-template-columns: 34px 34px 14px 1fr; gap: 6px; }
.tsc-diff__row code { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; }
.tsc-diff__no { color: var(--st-ink-muted); text-align: right; user-select: none; }
.tsc-diff__sign { color: var(--st-ink-muted); text-align: center; font-weight: 700; user-select: none; }
.tsc-diff__row--add .tsc-diff__sign { color: var(--st-accent-text); }
.tsc-diff__row--del .tsc-diff__sign { color: var(--st-danger-text); }
.tsc-diff__row--add { background: var(--st-accent-tint); } .tsc-diff__row--del { background: var(--st-danger-tint); }
.tsc-diff__gap { padding: 2px 8px; color: var(--st-ink-muted); font-family: var(--st-font); font-size: var(--st-fs-xs); background: var(--st-surface-2); margin: 2px 0; }
@media (max-width: 560px) { .tsc-pair, .tsc-heads { grid-template-columns: 1fr; } .tsc-pair--text > div + div { border-left: 0; padding-left: 0; } }
@@ -7257,22 +7251,6 @@ textarea {
.storage-model__name strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.storage-model__size { font-variant-numeric: tabular-nums; font-size: var(--st-fs-sm); }
/* ---- Settings > Storage: downloaded optimized models ---- */
.storage-models { list-style: none; margin: 0; padding: 0; border: 1px solid var(--st-line); border-radius: var(--st-r-md); overflow: hidden; }
.storage-model { display: grid; grid-template-columns: 1fr auto auto; gap: 14px; align-items: center; padding: 8px 12px 8px 14px; }
.storage-model + .storage-model { border-top: 1px solid var(--st-line-soft); }
.storage-model__name { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
.storage-model__name strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.storage-model__size { font-variant-numeric: tabular-nums; font-size: var(--st-fs-sm); }
/* ---- Settings > Storage: downloaded optimized models ---- */
.storage-models { list-style: none; margin: 0; padding: 0; border: 1px solid var(--st-line); border-radius: var(--st-r-md); overflow: hidden; }
.storage-model { display: grid; grid-template-columns: 1fr auto auto; gap: 14px; align-items: center; padding: 8px 12px 8px 14px; }
.storage-model + .storage-model { border-top: 1px solid var(--st-line-soft); }
.storage-model__name { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
.storage-model__name strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.storage-model__size { font-variant-numeric: tabular-nums; font-size: var(--st-fs-sm); }
/* ---- About > Privacy: plain text ---- */
.privacy-notes { display: flex; flex-direction: column; gap: 8px; padding: 2px 0; }
.privacy-notes p { margin: 0; font-size: var(--st-fs-sm); line-height: 1.5; color: var(--st-ink-soft); }
@@ -7290,7 +7268,7 @@ textarea {
/* ---- Tune model: one result card for every outcome ---- */
.tune-outcome { display: grid; grid-template-columns: 92px 1fr; gap: 22px; align-items: start; padding: 4px 4px 8px; }
.tune-outcome__tile { width: 84px; height: 84px; border-radius: 50%; display: grid; place-items: center; background: var(--st-accent-tint); color: var(--st-accent-text); position: relative; animation: tune-pop 360ms var(--st-ease); }
.tune-outcome__tile { width: 84px; height: 84px; border-radius: 50%; display: grid; place-items: center; background: var(--st-accent-tint); color: var(--st-accent-text); position: relative; animation: tune-pop var(--st-t-local) var(--st-ease-enter); }
.tune-outcome__tile::before { content: ""; position: absolute; inset: -6px; border-radius: 50%; border: 2px solid var(--st-accent); opacity: .25; }
.tune-outcome[data-tone="warn"] .tune-outcome__tile { background: var(--st-warn-tint); color: var(--st-warn-text); } .tune-outcome[data-tone="warn"] .tune-outcome__tile::before { border-color: var(--st-warn); }
.tune-outcome[data-tone="bad"] .tune-outcome__tile { background: var(--st-danger-tint); color: var(--st-danger-text); } .tune-outcome[data-tone="bad"] .tune-outcome__tile::before { border-color: var(--st-danger); }
@@ -7311,8 +7289,8 @@ textarea {
@media (prefers-reduced-motion: reduce) { .tune-outcome__tile { animation: none; } }
/* ---- Models: the family switch, one-view layout, the tuning line ---- */
.family-switch { display: inline-flex; gap: 2px; padding: 3px; border: 1px solid var(--st-line); border-radius: var(--st-r-md); background: var(--st-surface-2); }
.family-switch button { border: 0; background: transparent; padding: 7px 14px; border-radius: 11px; font: 600 var(--st-fs-sm)/1 var(--st-font); color: var(--st-ink-muted); cursor: pointer; white-space: nowrap; }
.family-switch { display: inline-flex; gap: 2px; padding: 3px; border: 1px solid var(--st-line); border-radius: var(--st-r-pill); background: var(--st-surface-2); }
.family-switch button { border: 0; background: transparent; padding: 7px 14px; border-radius: var(--st-r-pill); font: 600 var(--st-fs-sm)/1 var(--st-font); color: var(--st-ink-muted); cursor: pointer; white-space: nowrap; }
.family-switch button[aria-selected="true"] { background: var(--st-surface); color: var(--st-ink); box-shadow: var(--st-sh-card); }
.family-switch--single { padding: 7px 14px; font-weight: 700; border-color: transparent; background: transparent; }
.native-model-layout--fit { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 16px; align-items: start; }
@@ -7363,11 +7341,10 @@ textarea {
.plan__no { width: 22px; height: 22px; border-radius: 50%; border: 1.5px solid var(--st-line); display: grid; place-items: center; font-size: 11px; font-weight: 700; color: var(--st-ink-muted); font-variant-numeric: tabular-nums; }
.plan__step[data-state="done"] { color: var(--st-ink-muted); }
.plan__step[data-state="done"] .plan__no { background: var(--st-accent); border-color: transparent; color: var(--st-accent-ink); }
.plan__step[data-state="failed"] .plan__no { border-color: var(--st-danger, #d92d20); color: var(--st-danger, #d92d20); }
.plan__step[data-state="failed"] .plan__no { border-color: var(--st-danger); color: var(--st-danger); }
.plan__step[data-state="running"] { font-weight: 700; }
.plan__step[data-state="running"] .plan__no { border-color: var(--st-accent); color: var(--st-accent-text); animation: plan-glow 1.8s ease-out infinite; }
.plan__step[data-state="running"] .plan__no { border-color: var(--st-accent); color: var(--st-accent-text); animation: st-pulse 1.6s var(--st-ease-in-out) infinite; }
.plan__text small { display: block; font-weight: 400; color: var(--st-ink-muted); font-size: var(--st-fs-sm); margin-top: 2px; }
@keyframes plan-glow { 0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, .4); } 70%, 100% { box-shadow: 0 0 0 8px transparent; } }
@media (prefers-reduced-motion: reduce) { .plan__step[data-state="running"] .plan__no { animation: none; } }
.plan__note { margin: 0; padding: 0 16px 12px; color: var(--st-ink-muted); font-size: var(--st-fs-sm); }
.plan__acts { display: flex; gap: 8px; align-items: center; padding: 10px 12px 12px 16px; border-top: 1px solid var(--st-line-soft); }
@@ -7377,7 +7354,7 @@ textarea {
.plan-panel { padding: 8px 0; overflow: auto; }
.plan-panel .plan__head, .plan-panel .plan__steps, .plan-panel .plan__goal { padding-left: 20px; padding-right: 20px; }
.queued { align-self: flex-end; display: flex; flex-direction: column; align-items: flex-end; gap: 4px; max-width: 80%; margin: 6px 0 0 auto; }
.queued__bubble { background: transparent; border: 1px dashed var(--st-line); color: var(--st-ink-soft); padding: 10px 16px; border-radius: 18px 18px 4px 18px; font-weight: 500; overflow-wrap: anywhere; }
.queued__bubble { background: transparent; border: 1px dashed var(--st-line); color: var(--st-ink-soft); padding: 10px 16px; border-radius: var(--st-r-lg) var(--st-r-lg) 6px var(--st-r-lg); font-weight: 500; overflow-wrap: anywhere; }
.queued__line { display: flex; align-items: center; gap: 6px; color: var(--st-ink-muted); font-size: var(--st-fs-xs); }
.queued__x { width: 22px; height: 22px; border: 0; background: transparent; border-radius: 50%; color: var(--st-icon); display: grid; place-items: center; cursor: pointer; }
.queued__x:hover { background: var(--st-surface-2); }
@@ -7405,7 +7382,7 @@ textarea {
.native-choice__col .native-resource-choice button { height: 36px; }
/* the version dropdown's chevron turns when it opens (round 8, item 7) */
.version-trigger__chev { transition: transform var(--st-t-local, 160ms) var(--st-ease-in-out, ease); }
.version-trigger__chev { transition: transform var(--st-t-local) var(--st-ease-in-out); }
.version-trigger[aria-expanded="true"] .version-trigger__chev { transform: rotate(180deg); }
@media (prefers-reduced-motion: reduce) { .version-trigger__chev { transition: none; } }
@@ -7413,7 +7390,7 @@ textarea {
.family-trigger { display: inline-flex; align-items: center; gap: 10px; height: 40px; padding: 0 12px 0 10px; border: 1px solid var(--st-line); border-radius: var(--st-r-md); background: var(--st-surface); color: var(--st-ink); font: 700 var(--st-fs-md) var(--st-font); cursor: pointer; }
.family-trigger:hover { background: var(--st-surface-2); }
.family-trigger svg:first-child { color: var(--st-icon); }
.family-trigger__chev { color: var(--st-icon); transition: transform var(--st-t-local, 160ms) var(--st-ease-in-out, ease); }
.family-trigger__chev { color: var(--st-icon); transition: transform var(--st-t-local) var(--st-ease-in-out); }
.family-trigger[aria-expanded="true"] .family-trigger__chev { transform: rotate(180deg); }
@media (prefers-reduced-motion: reduce) { .family-trigger__chev { transition: none; } }
.family-menu { display: flex; flex-direction: column; gap: 2px; padding: 6px; }
@@ -7425,7 +7402,7 @@ textarea {
/* Deliverables: file cards, the Edited summary and grouped activity lines (round 8, item 12) */
.fcard { position: relative; display: flex; align-items: center; gap: 12px; margin: 8px 0; padding: 10px 10px 10px 14px; border: 1px solid var(--st-line); border-radius: var(--st-r-md); background: var(--st-surface); }
.fcard__type { flex: none; min-width: 46px; padding: 3px 7px; border: 1px solid var(--st-line); border-radius: 6px; text-align: center; font-size: 10px; font-weight: 800; letter-spacing: .04em; color: var(--st-ink-soft); }
.fcard__type { flex: none; min-width: 46px; padding: 3px 7px; border: 1px solid var(--st-line); border-radius: 6px; text-align: center; font-size: var(--st-fs-xs); font-weight: 800; letter-spacing: .04em; color: var(--st-ink-soft); }
.fcard__name { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.fcard__name strong { font-size: var(--st-fs-md); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fcard__name span { color: var(--st-ink-muted); font-size: var(--st-fs-xs); font-variant-numeric: tabular-nums; }
@@ -7435,11 +7412,11 @@ textarea {
.fcard__more { width: 30px; display: grid; place-items: center; border-left: 0; border-radius: 0 8px 8px 0; padding: 0; }
.fcard__open:hover, .fcard__more:hover { background: var(--st-surface-2); }
.fcard__open:disabled { opacity: .6; cursor: progress; }
.fcard__chev { transition: transform var(--st-t-local, 160ms) ease; }
.fcard__chev { transition: transform var(--st-t-local) var(--st-ease-standard); }
.fcard__more[aria-expanded="true"] .fcard__chev { transform: rotate(180deg); }
.pm { display: inline-flex; gap: 5px; font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums; }
.pm__a { color: var(--st-success, #15803d); }
.pm__d { color: var(--st-danger, #b42318); }
.pm__a { color: var(--st-accent-text); }
.pm__d { color: var(--st-danger-text); }
.edited { margin: 10px 0; border: 1px solid var(--st-line); border-radius: var(--st-r-md); background: var(--st-surface); overflow: hidden; }
.edited__head { padding: 10px 16px; border-bottom: 1px solid var(--st-line-soft); font-size: var(--st-fs-sm); }
.edited__row { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 16px; border: 0; background: transparent; color: var(--st-ink); font: inherit; font-size: var(--st-fs-sm); text-align: left; cursor: pointer; }
@@ -7450,9 +7427,9 @@ textarea {
.act { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 8px 0; color: var(--st-ink-muted); font-size: var(--st-fs-sm); }
.act__line { display: inline-flex; align-items: center; gap: 8px; flex: 1; min-width: 0; padding: 2px 0; border: 0; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
.act__line:disabled { cursor: default; }
.act__chev { color: var(--st-icon); transition: transform var(--st-t-local, 160ms) ease; }
.act__chev { color: var(--st-icon); transition: transform var(--st-t-local) var(--st-ease-standard); }
.act[data-open] .act__chev { transform: rotate(180deg); }
.act__live { width: 6px; height: 6px; border-radius: 50%; background: var(--st-accent); animation: plan-glow 1.8s ease-out infinite; }
.act__live { width: 6px; height: 6px; border-radius: 50%; background: var(--st-accent); animation: st-pulse 1.6s var(--st-ease-in-out) infinite; }
.act__thumb { width: 44px; height: 30px; object-fit: cover; border-radius: 6px; border: 1px solid var(--st-line); cursor: pointer; }
.act__files { flex-basis: 100%; list-style: none; margin: 0; padding: 0 0 0 4px; display: flex; flex-direction: column; }
.act__skill { display: inline-flex; align-items: center; gap: 8px; padding: 4px 0; color: var(--st-ink-soft); }
@@ -7465,11 +7442,8 @@ textarea {
.dropzone { position: absolute; inset: 8px; z-index: 20; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; border: 2px dashed var(--st-accent); border-radius: var(--st-r-lg); background: color-mix(in srgb, var(--st-surface) 88%, var(--st-accent) 12%); color: var(--st-ink); pointer-events: none; }
.dropzone span { color: var(--st-ink-muted); font-size: var(--st-fs-sm); }
/* A calm sign that the drop will be taken: the box fades in, its dashed edge breathes, the clip bobs. */
.dropzone { animation: dropzone-in .16s ease-out both, dropzone-edge 1.4s ease-in-out .16s infinite; }
.dropzone .st-icon, .dropzone svg { animation: dropzone-bob 1.2s ease-in-out infinite; }
.dropzone { animation: dropzone-in var(--st-t-control) var(--st-ease-enter) both; }
@keyframes dropzone-in { from { opacity: 0; transform: scale(.985); } to { opacity: 1; transform: none; } }
@keyframes dropzone-edge { 50% { border-color: color-mix(in srgb, var(--st-accent) 40%, transparent); } }
@keyframes dropzone-bob { 50% { transform: translateY(-3px); } }
@media (prefers-reduced-motion: reduce) { .dropzone, .dropzone .st-icon, .dropzone svg { animation: none; } }
/* The chat's project folder, above the message box (round 8, item 4) */
@@ -7478,7 +7452,7 @@ textarea {
.folderchip__pick span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 28ch; }
.folderchip__pick:hover:not(:disabled), .folderchip__x:hover:not(:disabled) { background: var(--st-surface-2); color: var(--st-ink); }
.folderchip[data-set="true"] .folderchip__pick { color: var(--st-ink-soft); }
.folderchip[data-missing="true"] .folderchip__pick { color: var(--st-danger, #b42318); }
.folderchip[data-missing="true"] .folderchip__pick { color: var(--st-danger-text); }
.folderchip__x { width: 22px; height: 22px; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: var(--st-icon); cursor: pointer; opacity: 0; }
.folderchip:hover .folderchip__x, .folderchip:focus-within .folderchip__x { opacity: 1; }
.folderchip button:disabled { opacity: .55; cursor: default; }
@@ -7499,7 +7473,7 @@ textarea {
.tsf__lane { display: grid; grid-template-columns: 22px 1fr; column-gap: 10px; row-gap: 4px; align-items: center; }
.tsf__name { grid-row: span 2; width: 22px; height: 22px; border-radius: 6px; border: 1.5px solid var(--st-line); display: grid; place-items: center; font-size: 11px; font-weight: 800; color: var(--st-ink-soft); }
.tsf__prog { height: 6px; border-radius: 999px; background: var(--st-surface-2); overflow: hidden; }
.tsf__prog i { display: block; height: 100%; background: var(--st-accent); border-radius: inherit; transition: width .4s ease; }
.tsf__prog i { display: block; height: 100%; width: 100%; background: var(--st-accent); border-radius: inherit; transform-origin: left center; transition: transform var(--st-t-local) var(--st-ease-standard); }
.tsf__words { grid-column: 2; font-size: var(--st-fs-xs); color: var(--st-ink-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tsf__lane[data-state="waiting"] .tsf__name, .tsf__lane[data-state="pending"] .tsf__name { opacity: .6; }
@media (prefers-reduced-motion: reduce) { .tsf__prog i { transition: none; } }
@@ -7513,11 +7487,11 @@ textarea {
.storage-model--shared small { font-size: var(--st-fs-xs); }
/* Math in answers (#3268): KaTeX draws it; its own stylesheet and fonts load with it, on the first answer that has a formula. */
.md-math--source { font-size: 0.9em; }
.md-math--source { font-size: var(--st-fs-math-source); }
.md-math-block { margin: 0.6em 0; overflow-x: auto; overflow-y: hidden; text-align: center; }
.md-math--display { display: block; margin: 0.6em 0; overflow-x: auto; overflow-y: hidden; text-align: center; }
.md-math-block .md-math--display { margin: 0; }
.md-math .katex { font-size: 1.08em; }
.md-math .katex { font-size: var(--st-fs-math); }
.md-math .katex-display { margin: 0; }
/* A message the person typed keeps its line breaks (#3720). */
File diff suppressed because it is too large. Load diff
+5 -1
View File
@@ -17,7 +17,7 @@
.monitor-metric { min-width: 0; min-height: 172px; padding: 18px 20px; display: flex; flex-direction: column; gap: 16px; }
.monitor-metric .st-metric__label { flex-wrap: wrap; gap: 6px; font-size: 12px; }
.monitor-metric .st-metric__label .st-icon { flex: none; }
.monitor-metric__source { margin-left: auto; color: var(--st-ink-muted); font-size: 10px; font-weight: 400; }
.monitor-metric__source { margin-left: auto; color: var(--st-ink-muted); font-size: var(--st-fs-xs); font-weight: 400; }
.monitor-metric .st-metric__value { font-size: clamp(22px,2.3vw,32px); overflow-wrap: anywhere; }
.monitor-metric .st-metric__unit { font-size: 12px; white-space: nowrap; }
.monitor-metric__sub { margin-top: 10px; min-height: 16px; line-height: 1.45; overflow-wrap: anywhere; }
@@ -25,6 +25,10 @@
.monitor-speed-values > div { min-width: 0; }
.monitor-speed-values > div:last-child { text-align: right; }
.monitor-speed-values .st-metric__value { font-size: clamp(20px,2vw,28px); }
/* The single-reading wrapper: the class Monitor.tsx has always put on the value
block, which no stylesheet defined. It sits where the two-reading wrapper
does, so a card without a second figure keeps the same baseline. */
.monitor-metric__reading { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px; min-width: 0; }
.monitor-spark { margin-top: auto; height: 32px; flex: none; }
.monitor-spark > svg { display: block; width: 100%; height: 32px; overflow: visible; }
.monitor-spark:focus-visible { outline: 1px solid var(--st-ink-muted); outline-offset: 4px; border-radius: 4px; }
+4 -7
View File
@@ -184,9 +184,8 @@
/* The first conversation puts the welcome and the real composer in one place. */
.first-chat-start{width:100%;max-width:640px;margin:0 auto 30px;padding:0 16px;text-align:center}
.first-chat-mark{position:relative;display:grid;place-items:center;width:80px;height:80px;border:1px solid var(--st-line);border-radius:24px;background:var(--st-accent-tint);box-shadow:var(--st-sh-card);margin:0 auto 28px;isolation:isolate}
.first-chat-mark:before{content:'';position:absolute;inset:-22px;background:radial-gradient(circle,color-mix(in srgb,var(--st-accent) 12%,transparent),transparent 70%);z-index:-1;pointer-events:none;animation:first-chat-glow 4s ease-in-out infinite}
.first-chat-status{position:absolute;right:-4px;bottom:-4px;display:grid;place-items:center;width:21px;height:21px;background:var(--st-accent);color:#171717;border:3px solid var(--st-bg);border-radius:50%}
.first-chat-start h2{font-size:46px;font-weight:900;line-height:1.1;letter-spacing:-1.1px;margin:0 0 14px}
.first-chat-status{position:absolute;right:-4px;bottom:-4px;display:grid;place-items:center;width:21px;height:21px;background:var(--st-accent);color:var(--st-ink);border:3px solid var(--st-bg);border-radius:50%}
.first-chat-start h2{font-size:var(--st-fs-display);font-weight:800;line-height:1.1;letter-spacing:-.02em;margin:0 0 14px}
.first-chat-start>p{font-size:16px;line-height:1.6;color:var(--st-ink-muted);margin:0}
.chatcol:has(.first-chat-start){justify-content:center;overflow:auto;padding:32px 0 clamp(40px,8vh,80px)}
.chatcol:has(.first-chat-start)>.main__body.chat{flex:0 0 auto;overflow:visible;padding:0}
@@ -196,10 +195,8 @@
.chatcol:has(.first-chat-start) .st-composer{background:var(--st-surface);border-color:var(--st-line);border-radius:20px}
.chatcol:has(.first-chat-start) .st-composer:focus-within{border-color:var(--st-accent)}
.chatcol:has(.first-chat-start) .st-composer textarea{min-height:70px;font-size:16px}
@keyframes first-chat-glow{50%{opacity:.6;transform:scale(.95)}}
@media(max-width:650px){.first-chat-mark{width:68px;height:68px;margin-bottom:22px}.first-chat-start{margin-bottom:26px}.first-chat-start h2{font-size:35px;letter-spacing:-.7px}.first-chat-start>p{font-size:14px}.chatcol:has(.first-chat-start){padding:24px 0 48px}.chatcol:has(.first-chat-start) .chat__inner,.chatcol:has(.first-chat-start) .composer{padding-inline:16px}}
@media(max-height:650px){.chatcol:has(.first-chat-start){justify-content:flex-start;padding:28px 0}.first-chat-mark{width:56px;height:56px;margin-bottom:18px}.first-chat-start h2{font-size:34px}.first-chat-start{margin-bottom:20px}}
@media(prefers-reduced-motion:reduce){.first-chat-mark:before{animation:none}}
@media(max-width:650px){.first-chat-mark{width:68px;height:68px;margin-bottom:22px}.first-chat-start{margin-bottom:26px}.first-chat-start h2{font-size:var(--st-fs-stat);letter-spacing:-.015em}.first-chat-start>p{font-size:14px}.chatcol:has(.first-chat-start){padding:24px 0 48px}.chatcol:has(.first-chat-start) .chat__inner,.chatcol:has(.first-chat-start) .composer{padding-inline:16px}}
@media(max-height:650px){.chatcol:has(.first-chat-start){justify-content:flex-start;padding:28px 0}.first-chat-mark{width:56px;height:56px;margin-bottom:18px}.first-chat-start h2{font-size:var(--st-fs-stat)}.first-chat-start{margin-bottom:20px}}
.onboard-load-vision{margin:12px 0;padding:14px;border:1px solid var(--st-line);border-radius:12px;background:var(--st-surface-2)}
.onboard-engine-connect{display:grid;justify-items:start;gap:14px}