mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 14:21:06 -10:00
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:
1 parent
4cf0d8cd38
commit
682c21d641
34 files changed
+1447
-214
No files matched your search
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.');
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>);
|
||||
}
|
||||
@@ -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)}/></>;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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></>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`:""}`;
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user