From e330edef14baf9a247a80e1e1d3eadd4e9b73177 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 12 Jun 2026 21:14:16 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E9=87=8D=E6=9E=84=E6=A0=B7=E5=BC=8F?= =?UTF-8?q?=E4=B8=8E=E7=BB=84=E4=BB=B6=E4=BB=A5=E6=8F=90=E9=AB=98=E4=B8=80?= =?UTF-8?q?=E8=87=B4=E6=80=A7=E5=B9=B6=E4=BC=98=E5=8C=96=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=20=E2=80=A2=20=E6=9B=B4=E6=96=B0=E4=BA=86=20Card=20=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E6=A0=B7=E5=BC=8F=EF=BC=8C=E9=87=87=E7=94=A8=E6=96=B0?= =?UTF-8?q?=E7=9A=84=E5=8F=98=E9=87=8F=E6=9D=A5=E5=AE=9E=E7=8E=B0=E7=8E=BB?= =?UTF-8?q?=E7=92=83=E6=8B=9F=E6=80=81=E6=95=88=E6=9E=9C=E5=92=8C=E9=98=B4?= =?UTF-8?q?=E5=BD=B1=E3=80=82=20=E2=80=A2=20=E4=BF=AE=E6=94=B9=E4=BA=86=20?= =?UTF-8?q?Btn=20=E7=BB=84=E4=BB=B6=E4=B8=AD=E7=9A=84=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F=EF=BC=8C=E4=BB=A5=E7=AC=A6=E5=90=88=E6=96=B0?= =?UTF-8?q?=E7=9A=84=E9=A2=9C=E8=89=B2=E4=BB=A4=E7=89=8C=EF=BC=88tokens?= =?UTF-8?q?=EF=BC=89=E3=80=82=20=E2=80=A2=20=E8=B0=83=E6=95=B4=E4=BA=86=20?= =?UTF-8?q?Collapsible=20=E7=BB=84=E4=BB=B6=E6=A0=B7=E5=BC=8F=EF=BC=8C?= =?UTF-8?q?=E4=BB=A5=E8=8E=B7=E5=BE=97=E6=9B=B4=E5=A5=BD=E7=9A=84=E8=BE=B9?= =?UTF-8?q?=E6=A1=86=E5=92=8C=E8=83=8C=E6=99=AF=E4=B8=80=E8=87=B4=E6=80=A7?= =?UTF-8?q?=E3=80=82=20=E2=80=A2=20=E7=BB=9F=E4=B8=80=E4=BA=86=E5=90=84?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E7=BB=84=E4=BB=B6=E7=9A=84=E8=BE=B9=E6=A1=86?= =?UTF-8?q?=E5=9C=86=E8=A7=92=E5=92=8C=E8=83=8C=E6=99=AF=E9=A2=9C=E8=89=B2?= =?UTF-8?q?=E3=80=82=20=E2=80=A2=20=E5=A2=9E=E5=BC=BA=E4=BA=86=20AboutSect?= =?UTF-8?q?ion=E3=80=81CheckUpdateButton=20=E5=8F=8A=E5=85=B6=E4=BB=96?= =?UTF-8?q?=E6=9D=BF=E5=9D=97=EF=BC=8C=E4=BB=A5=E5=88=A9=E7=94=A8=E6=96=B0?= =?UTF-8?q?=E7=9A=84=E8=AE=BE=E8=AE=A1=E4=BB=A4=E7=89=8C=E3=80=82=20?= =?UTF-8?q?=E2=80=A2=20=E4=BC=98=E5=8C=96=E4=BA=86=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E5=A4=96=E5=A3=B3=EF=BC=88app=20shell=EF=BC=89=E5=92=8C=20aura?= =?UTF-8?q?=20=E8=A1=A8=E9=9D=A2=E7=9A=84=E5=85=A8=E5=B1=80=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F=EF=BC=8C=E7=A1=AE=E4=BF=9D=E8=A7=86=E8=A7=89=E6=95=88?= =?UTF-8?q?=E6=9E=9C=E7=9A=84=E4=B8=80=E8=87=B4=E6=80=A7=E3=80=82=20?= =?UTF-8?q?=E2=80=A2=20=E5=BC=95=E5=85=A5=E4=BA=86=E6=96=B0=E7=9A=84?= =?UTF-8?q?=E9=A2=9C=E8=89=B2=E4=BB=A4=E7=89=8C=EF=BC=8C=E4=BB=A5=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E6=9B=B4=E5=A5=BD=E7=9A=84=E4=B8=BB=E9=A2=98=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=92=8C=E6=97=A0=E9=9A=9C=E7=A2=8D=E6=80=A7=E3=80=82?= =?UTF-8?q?=20=E2=80=A2=20=E6=9B=B4=E6=96=B0=E4=BA=86=E5=A4=9A=E4=B8=AA?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E7=9A=84=20CSS=EF=BC=8C=E4=BB=A5=E5=8F=8D?= =?UTF-8?q?=E6=98=A0=E8=AE=BE=E8=AE=A1=E7=B3=BB=E7=BB=9F=E7=9A=84=E5=8F=98?= =?UTF-8?q?=E6=9B=B4=EF=BC=8C=E5=8C=85=E6=8B=AC=E6=8C=89=E9=92=AE=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=92=8C=E8=83=8C=E6=99=AF=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/scripts/aura-skin-contract.test.mjs | 348 ++++++++++++++- .../src/remote_server/assets/style.css | 45 +- .../app/src/components/AutoUpdate.tsx | 4 +- openless-all/app/src/components/Capsule.tsx | 22 +- .../app/src/components/FloatingShell.tsx | 276 ++---------- .../app/src/components/GithubLoginModal.tsx | 8 +- .../app/src/components/MarketplaceModal.tsx | 12 +- .../app/src/components/Onboarding.tsx | 34 +- .../app/src/components/SettingsModal.tsx | 70 +-- .../app/src/components/ShortcutRecorder.tsx | 12 +- openless-all/app/src/components/ui/Modal.tsx | 10 +- .../app/src/components/ui/SegSimple.tsx | 8 +- .../app/src/components/ui/SelectLite.tsx | 18 +- .../app/src/components/ui/SwitchLite.tsx | 6 +- openless-all/app/src/pages/History.tsx | 12 +- .../app/src/pages/LessComputerPanel.tsx | 43 +- openless-all/app/src/pages/LocalAsr.tsx | 22 +- openless-all/app/src/pages/Marketplace.tsx | 34 +- openless-all/app/src/pages/Overview.tsx | 12 +- openless-all/app/src/pages/QaPanel.tsx | 45 +- openless-all/app/src/pages/SelectionAsk.tsx | 8 +- openless-all/app/src/pages/Style.tsx | 42 +- openless-all/app/src/pages/Translation.tsx | 20 +- openless-all/app/src/pages/Vocab.tsx | 24 +- openless-all/app/src/pages/_atoms.tsx | 23 +- .../app/src/pages/settings/AboutSection.tsx | 6 +- .../src/pages/settings/CheckUpdateButton.tsx | 4 +- .../pages/settings/ClaudeConsoleSection.tsx | 2 +- .../src/pages/settings/MarketplaceSection.tsx | 6 +- .../src/pages/settings/ProvidersSection.tsx | 4 +- .../pages/settings/RecordingInputSection.tsx | 10 +- .../src/pages/settings/RemoteInputSection.tsx | 8 +- .../src/pages/settings/ShortcutsSection.tsx | 10 +- .../app/src/pages/settings/shared.tsx | 10 +- openless-all/app/src/pages/settings/tabs.tsx | 4 +- openless-all/app/src/styles/global.css | 399 ++++++++++++++++++ openless-all/app/src/styles/tokens.css | 45 +- 37 files changed, 1075 insertions(+), 591 deletions(-) diff --git a/openless-all/app/scripts/aura-skin-contract.test.mjs b/openless-all/app/scripts/aura-skin-contract.test.mjs index 015d9c3e..22b5baab 100644 --- a/openless-all/app/scripts/aura-skin-contract.test.mjs +++ b/openless-all/app/scripts/aura-skin-contract.test.mjs @@ -1,4 +1,6 @@ -import { readFile } from 'node:fs/promises'; +import { readFile, readdir } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; import assert from 'node:assert/strict'; const root = new URL('../', import.meta.url); @@ -11,6 +13,132 @@ function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +function extractCssBlock(css, selector) { + const escapedSelector = escapeRegExp(selector); + const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([\\s\\S]*?)\\n\\}`)); + assert.ok(match, `tokens.css must contain ${selector} block`); + return match[1]; +} + +function parseCustomProperties(block) { + const tokens = new Map(); + const re = /^\s*(--[\w-]+)\s*:\s*([^;]+);/gm; + let match; + while ((match = re.exec(block)) !== null) { + tokens.set(match[1], match[2].trim()); + } + return tokens; +} + +function resolveTokenValue(name, tokens, stack = new Set()) { + const raw = tokens.get(name); + assert.ok(raw, `missing token ${name}`); + if (!raw.startsWith('var(')) { + return raw; + } + const inner = raw.slice(4, -1).trim(); + const refName = inner.split(',')[0].trim(); + assert.ok(refName.startsWith('--'), `unsupported var() reference in ${name}: ${raw}`); + if (stack.has(refName)) { + throw new Error(`circular var() reference: ${[...stack, refName].join(' -> ')}`); + } + stack.add(refName); + return resolveTokenValue(refName, tokens, stack); +} + +function parseCssColor(value) { + const hexMatch = value.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (hexMatch) { + let hex = hexMatch[1]; + if (hex.length === 3) { + hex = hex + .split('') + .map((ch) => ch + ch) + .join(''); + } + return { + r: Number.parseInt(hex.slice(0, 2), 16), + g: Number.parseInt(hex.slice(2, 4), 16), + b: Number.parseInt(hex.slice(4, 6), 16), + a: 1, + }; + } + + const rgbaMatch = value.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i); + if (rgbaMatch) { + return { + r: Number(rgbaMatch[1]), + g: Number(rgbaMatch[2]), + b: Number(rgbaMatch[3]), + a: rgbaMatch[4] === undefined ? 1 : Number(rgbaMatch[4]), + }; + } + + throw new Error(`unsupported color format: ${value}`); +} + +function srgbChannel(value) { + const normalized = value / 255; + return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; +} + +function relativeLuminance({ r, g, b }) { + const R = srgbChannel(r); + const G = srgbChannel(g); + const B = srgbChannel(b); + return 0.2126 * R + 0.7152 * G + 0.0722 * B; +} + +function contrastRatio(foreground, background) { + const fg = parseCssColor(foreground); + const bg = parseCssColor(background); + assert.equal(fg.a, 1, `foreground must be opaque for contrast checks: ${foreground}`); + assert.equal(bg.a, 1, `background must be opaque for contrast checks: ${background}`); + const lighter = Math.max(relativeLuminance(fg), relativeLuminance(bg)); + const darker = Math.min(relativeLuminance(fg), relativeLuminance(bg)); + return (lighter + 0.05) / (darker + 0.05); +} + +function compositeOverBackground(fgValue, bgValue) { + const fg = parseCssColor(fgValue); + const bg = parseCssColor(bgValue); + if (fg.a === 1) { + return fgValue; + } + const alpha = fg.a; + const r = Math.round(fg.r * alpha + bg.r * (1 - alpha)); + const g = Math.round(fg.g * alpha + bg.g * (1 - alpha)); + const b = Math.round(fg.b * alpha + bg.b * (1 - alpha)); + return `rgb(${r}, ${g}, ${b})`; +} + +function contrastRatioOverBackground(foreground, background) { + const effectiveFg = compositeOverBackground(foreground, background); + return contrastRatio(effectiveFg, background); +} + +function assertSolidContrast(tokens, label, bgToken, inkToken, minRatio = 4.5) { + const bg = resolveTokenValue(bgToken, tokens); + const ink = resolveTokenValue(inkToken, tokens); + const ratio = contrastRatio(ink, bg); + assert.ok( + ratio >= minRatio, + `${label}: ${inkToken} on ${bgToken} must meet WCAG AA (${ratio.toFixed(2)}:1 < ${minRatio}:1)`, + ); + return ratio; +} + +function assertMutedContrast(tokens, label, bgToken, inkToken, minRatio = 4.5) { + const bg = resolveTokenValue(bgToken, tokens); + const ink = resolveTokenValue(inkToken, tokens); + const ratio = contrastRatioOverBackground(ink, bg); + assert.ok( + ratio >= minRatio, + `${label}: ${inkToken} on ${bgToken} must meet WCAG AA (${ratio.toFixed(2)}:1 < ${minRatio}:1)`, + ); + return ratio; +} + function assertUsesClassName(source, className, message) { const escapedClassName = escapeRegExp(className); const patterns = [ @@ -22,6 +150,23 @@ function assertUsesClassName(source, className, message) { assert.ok(patterns.some((pattern) => pattern.test(source)), message); } +const srcRoot = fileURLToPath(new URL('src/', root)); + +async function walkSourceFiles(dirPath, files = []) { + const entries = await readdir(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + await walkSourceFiles(entryPath, files); + continue; + } + if (/\.(tsx?|css)$/.test(entry.name)) { + files.push(path.relative(srcRoot, entryPath).replace(/\\/g, '/')); + } + } + return files; +} + assert.throws( () => assertUsesClassName('
ol-app-shell-bg
', 'ol-app-shell-bg', 'sample must require className usage'), /sample must require className usage/, @@ -41,22 +186,165 @@ assertUsesClassName( 'sample should accept className usage', ); -const [tokens, globalCss, shell, settingsModal, overview] = await Promise.all([ +const [tokens, globalCss, shell, settingsModal, overview, sourceFiles, remoteStyle] = await Promise.all([ read('src/styles/tokens.css'), read('src/styles/global.css'), read('src/components/FloatingShell.tsx'), read('src/components/SettingsModal.tsx'), read('src/pages/Overview.tsx'), + walkSourceFiles(srcRoot), + read('src-tauri/src/remote_server/assets/style.css'), ]); assert.match(tokens, /--ol-shell-radius:/, 'tokens.css must define --ol-shell-radius'); assert.match(tokens, /--ol-panel-radius:/, 'tokens.css must define --ol-panel-radius'); assert.match(tokens, /--ol-aura-shadow:/, 'tokens.css must define --ol-aura-shadow'); assert.match(tokens, /--ol-font-display:/, 'tokens.css must define --ol-font-display'); +assert.match(tokens, /--ol-on-accent:/, 'tokens.css must define --ol-on-accent'); +assert.match(tokens, /--ol-primary-solid-bg:/, 'tokens.css must define --ol-primary-solid-bg'); +assert.match(tokens, /--ol-primary-solid-ink:/, 'tokens.css must define --ol-primary-solid-ink'); +assert.match(tokens, /--ol-control-radius:/, 'tokens.css must define --ol-control-radius'); +assert.match(tokens, /--ol-accent-solid-bg:/, 'tokens.css must define --ol-accent-solid-bg'); +assert.match(tokens, /--ol-accent-solid-bg-hover:/, 'tokens.css must define --ol-accent-solid-bg-hover'); +assert.match(tokens, /--ol-accent-solid-ink:/, 'tokens.css must define --ol-accent-solid-ink'); +assert.match(tokens, /--ol-danger-solid-bg:/, 'tokens.css must define --ol-danger-solid-bg'); +assert.match(tokens, /--ol-danger-solid-ink:/, 'tokens.css must define --ol-danger-solid-ink'); + +assert.match( + tokens, + /\[data-ol-theme='dark'\][\s\S]*--ol-accent-solid-bg:/, + 'tokens.css must define --ol-accent-solid-bg in dark theme', +); +assert.match( + tokens, + /\[data-ol-theme='dark'\][\s\S]*--ol-accent-solid-bg-hover:/, + 'tokens.css must define --ol-accent-solid-bg-hover in dark theme', +); +assert.match( + tokens, + /\[data-ol-theme='dark'\][\s\S]*--ol-accent-solid-ink:/, + 'tokens.css must define --ol-accent-solid-ink in dark theme', +); +assert.match( + tokens, + /\[data-ol-theme='dark'\][\s\S]*--ol-danger-solid-bg:/, + 'tokens.css must define --ol-danger-solid-bg in dark theme', +); +assert.match( + tokens, + /\[data-ol-theme='dark'\][\s\S]*--ol-danger-solid-ink:/, + 'tokens.css must define --ol-danger-solid-ink in dark theme', +); + +assert.match( + tokens, + /\[data-ol-theme='dark'\][\s\S]*--ol-primary-solid-bg:/, + 'tokens.css must define --ol-primary-solid-bg in dark theme', +); +assert.match( + tokens, + /\[data-ol-theme='dark'\][\s\S]*--ol-primary-solid-ink:/, + 'tokens.css must define --ol-primary-solid-ink in dark theme', +); + +const lightTokens = parseCustomProperties(extractCssBlock(tokens, ':root')); +const darkTokens = parseCustomProperties(extractCssBlock(tokens, "[data-ol-theme='dark']")); + +const contrastPairs = [ + { + label: 'light accent-solid', + tokens: lightTokens, + bg: '--ol-accent-solid-bg', + ink: '--ol-accent-solid-ink', + }, + { + label: 'light primary-solid', + tokens: lightTokens, + bg: '--ol-primary-solid-bg', + ink: '--ol-primary-solid-ink', + }, + { + label: 'dark accent-solid', + tokens: darkTokens, + bg: '--ol-accent-solid-bg', + ink: '--ol-accent-solid-ink', + }, + { + label: 'dark primary-solid', + tokens: darkTokens, + bg: '--ol-primary-solid-bg', + ink: '--ol-primary-solid-ink', + }, + { + label: 'light danger-solid', + tokens: lightTokens, + bg: '--ol-danger-solid-bg', + ink: '--ol-danger-solid-ink', + }, + { + label: 'dark danger-solid', + tokens: darkTokens, + bg: '--ol-danger-solid-bg', + ink: '--ol-danger-solid-ink', + }, +]; + +const contrastRatios = {}; +for (const pair of contrastPairs) { + contrastRatios[pair.label] = assertSolidContrast(pair.tokens, pair.label, pair.bg, pair.ink); +} + +const mutedContrastPairs = [ + { + label: 'light ink-4 on surface', + tokens: lightTokens, + bg: '--ol-surface', + ink: '--ol-ink-4', + }, + { + label: 'light ink-4 on surface-2', + tokens: lightTokens, + bg: '--ol-surface-2', + ink: '--ol-ink-4', + }, +]; + +const mutedContrastRatios = {}; +for (const pair of mutedContrastPairs) { + mutedContrastRatios[pair.label] = assertMutedContrast(pair.tokens, pair.label, pair.bg, pair.ink); +} + +const remoteTokens = parseCustomProperties(extractCssBlock(remoteStyle, ':root')); + +const remoteMutedContrastPairs = [ + { + label: 'remote ink-4 on surface', + tokens: remoteTokens, + bg: '--surface', + ink: '--ink-4', + }, + { + label: 'remote ink-4 on surface-2', + tokens: remoteTokens, + bg: '--surface-2', + ink: '--ink-4', + }, +]; + +const remoteMutedContrastRatios = {}; +for (const pair of remoteMutedContrastPairs) { + remoteMutedContrastRatios[pair.label] = assertMutedContrast(pair.tokens, pair.label, pair.bg, pair.ink); +} assert.match(globalCss, /\.ol-app-shell-bg\b/, 'global.css must expose .ol-app-shell-bg'); assert.match(globalCss, /\.ol-aura-panel\b/, 'global.css must expose .ol-aura-panel'); assert.doesNotMatch(globalCss, /@keyframes ol-aura-halo/, 'global.css must not add an animated halo'); +assert.match(globalCss, /\.ol-aura-card\b/, 'global.css must expose .ol-aura-card'); +assert.match( + globalCss, + /\.ol-aura-settings\[data-ol-mobile="true"\]/, + 'global.css must scope mobile settings radius to the settings element itself', +); assertUsesClassName(shell, 'ol-app-shell-bg', 'FloatingShell must use the app shell background class'); assertUsesClassName(shell, 'ol-aura-sidebar', 'FloatingShell must expose an Aura sidebar hook'); @@ -65,4 +353,60 @@ assertUsesClassName(shell, 'ol-aura-panel', 'FloatingShell must expose an Aura p assertUsesClassName(settingsModal, 'ol-aura-settings', 'SettingsModal must expose an Aura settings wrapper'); assertUsesClassName(overview, 'ol-overview-hero', 'Overview must expose a high-visibility overview surface hook'); +const illegalCssStringPatterns = [ + /color:\s*'var\([^)]+\)';/, + /background:\s*'var\([^)]+\)';/, +]; + +const forbiddenInlineInkBackground = /background:\s*'var\(--ol-ink\)'/; + +const forbiddenBlueOnAccentCombo = + /background:[\s\S]{0,200}var\(--ol-blue\)[\s\S]{0,500}?color:[\s\S]{0,200}var\(--ol-on-accent\)|color:[\s\S]{0,200}var\(--ol-on-accent\)[\s\S]{0,500}?background:[\s\S]{0,200}var\(--ol-blue\)/; + +const forbiddenErrOnAccentCombo = + /background:[\s\S]{0,200}var\(--ol-err[^)]*\)[\s\S]{0,500}?color:[\s\S]{0,200}var\(--ol-on-accent\)|background:[\s\S]{0,200}var\(--ol-err[^)]*\)[\s\S]{0,500}?color:[\s\S]{0,200}var\(--ol-accent-solid-ink\)|color:[\s\S]{0,200}var\(--ol-on-accent\)[\s\S]{0,500}?background:[\s\S]{0,200}var\(--ol-err[^)]*\)|color:[\s\S]{0,200}var\(--ol-accent-solid-ink\)[\s\S]{0,500}?background:[\s\S]{0,200}var\(--ol-err[^)]*\)/; + +for (const relPath of sourceFiles) { + const source = await read(`src/${relPath}`); + for (const pattern of illegalCssStringPatterns) { + assert.doesNotMatch( + source, + pattern, + `src/${relPath} must not use quoted CSS custom properties inside injected CSS strings`, + ); + } + if (relPath.endsWith('.tsx')) { + assert.doesNotMatch( + source, + forbiddenInlineInkBackground, + `src/${relPath} must not use --ol-ink as a button/solid background`, + ); + assert.doesNotMatch( + source, + forbiddenBlueOnAccentCombo, + `src/${relPath} must not pair background var(--ol-blue) with color var(--ol-on-accent) (use --ol-accent-solid-* tokens)`, + ); + assert.doesNotMatch( + source, + forbiddenErrOnAccentCombo, + `src/${relPath} must not pair background var(--ol-err) with color var(--ol-on-accent) or var(--ol-accent-solid-ink) (use --ol-danger-solid-* tokens)`, + ); + } +} + console.log('Aura skin contract OK'); +console.log( + 'Solid contrast ratios:', + Object.fromEntries( + Object.entries(contrastRatios).map(([label, ratio]) => [label, `${ratio.toFixed(2)}:1`]), + ), +); +console.log( + 'Muted contrast ratios:', + Object.fromEntries( + Object.entries({ ...mutedContrastRatios, ...remoteMutedContrastRatios }).map(([label, ratio]) => [ + label, + `${ratio.toFixed(2)}:1`, + ]), + ), +); diff --git a/openless-all/app/src-tauri/src/remote_server/assets/style.css b/openless-all/app/src-tauri/src/remote_server/assets/style.css index 026c3625..3c11db75 100644 --- a/openless-all/app/src-tauri/src/remote_server/assets/style.css +++ b/openless-all/app/src-tauri/src/remote_server/assets/style.css @@ -15,13 +15,17 @@ --ink: #0a0a0b; --ink-2: #2a2a2d; --ink-3: rgba(10, 10, 11, 0.62); - --ink-4: rgba(10, 10, 11, 0.42); + --ink-4: rgba(10, 10, 11, 0.58); /* 蓝色强调 */ --blue: #2563eb; --blue-hover: #1d4ed8; --blue-soft: #eff4ff; --blue-ring: rgba(37, 99, 235, 0.22); + --on-accent: #ffffff; + --accent-solid-bg: var(--blue); + --accent-solid-bg-hover: var(--blue-hover); + --accent-solid-ink: var(--on-accent); /* 状态色 */ --ok: #16a34a; @@ -35,8 +39,13 @@ --shadow-lg: 0 20px 60px -20px rgba(15, 17, 22, 0.18), 0 8px 32px -16px rgba(15, 17, 22, 0.10), 0 0 0 0.5px rgba(0, 0, 0, 0.06); /* 圆角 */ + --control-radius: 8px; + --r-sm: 6px; + --r-md: 10px; --r-lg: 14px; --r-xl: 18px; + --bubble-radius: var(--r-lg); + --modal-radius: var(--r-xl); --r-2xl: 22px; --r-pill: 999px; @@ -179,7 +188,7 @@ body { padding: 15px 18px; font-size: 17px; font-weight: 600; - color: #fff; + color: var(--on-accent); border: none; border-radius: var(--r-lg); cursor: pointer; @@ -189,10 +198,10 @@ body { .btn:disabled { opacity: .5; cursor: default; } .btn-primary { - background: var(--blue); + background: var(--accent-solid-bg); box-shadow: 0 6px 18px -6px var(--blue-ring); } -.btn-primary:active { background: var(--blue-hover); } +.btn-primary:active { background: var(--accent-solid-bg-hover); } .hint-error { color: var(--danger); @@ -230,10 +239,10 @@ body { .help-link { display: inline-block; padding: 9px 16px; - border-radius: var(--r-md); + border-radius: var(--control-radius); border: none; - background: var(--blue); - color: #fff; + background: var(--accent-solid-bg); + color: var(--accent-solid-ink); font-size: 13px; font-weight: 600; font-family: inherit; @@ -242,7 +251,7 @@ body { -webkit-appearance: none; appearance: none; } -.help-link:active { background: var(--blue-hover); } +.help-link:active { background: var(--accent-solid-bg-hover); } .help-link-ghost { background: var(--surface); color: var(--blue); @@ -260,7 +269,7 @@ body { .app-icon { width: 26px; height: 26px; - border-radius: 7px; + border-radius: var(--control-radius); flex: none; box-shadow: var(--shadow-sm); } @@ -275,7 +284,7 @@ body { display: inline-flex; background: var(--surface-2); border: 0.5px solid var(--line); - border-radius: 12px; + border-radius: var(--r-lg); padding: 3px; gap: 2px; } @@ -288,13 +297,13 @@ body { font-size: 13px; font-weight: 600; padding: 7px 14px; - border-radius: 9px; + border-radius: var(--control-radius); cursor: pointer; transition: background .15s ease, color .15s ease; } .mode-btn.active { - background: var(--blue); - color: #fff; + background: var(--accent-solid-bg); + color: var(--accent-solid-ink); } /* ===== 录音主区 ===== */ @@ -315,8 +324,8 @@ body { border-radius: 50%; border: none; cursor: pointer; - color: #fff; - background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%); + color: var(--accent-solid-ink); + background: linear-gradient(180deg, var(--accent-solid-bg-hover) 0%, var(--accent-solid-bg) 100%); box-shadow: 0 16px 36px -10px rgba(37, 99, 235, .5), inset 0 1px 0 rgba(255, 255, 255, .25); @@ -359,7 +368,7 @@ body { box-shadow: 0 16px 36px -10px rgba(220, 38, 38, .5); animation: breathe 1.6s ease-in-out infinite; } -.record-btn.recording .record-btn-label { color: #fff; } +.record-btn.recording .record-btn-label { color: var(--on-accent); } .record-btn.recording .record-btn-ring { opacity: 1; border-color: rgba(220, 38, 38, .4); @@ -498,7 +507,7 @@ body { cursor: pointer; transition: background .15s ease, color .15s ease; } -.result-copy:active { background: var(--blue); color: #fff; } +.result-copy:active { background: var(--accent-solid-bg); color: var(--accent-solid-ink); } .result-copy.copied { background: var(--ok-soft); border-color: var(--ok); color: var(--ok); } /* ===== 提示文字 ===== */ @@ -570,7 +579,7 @@ body { font-size: 12px; line-height: 1.5; color: var(--ink-4); - background: rgba(255, 255, 255, .85); + background: rgba(255, 255, 255, .92); backdrop-filter: blur(12px) saturate(160%); -webkit-backdrop-filter: blur(12px) saturate(160%); border-top: 0.5px solid var(--line); diff --git a/openless-all/app/src/components/AutoUpdate.tsx b/openless-all/app/src/components/AutoUpdate.tsx index 0d0cfa0e..d59b9332 100644 --- a/openless-all/app/src/components/AutoUpdate.tsx +++ b/openless-all/app/src/components/AutoUpdate.tsx @@ -200,14 +200,14 @@ export function UpdateDialog({ const installing = status === 'installing'; return (
-
+
{t(`settings.about.updateDialog.${status}.title`)}
{t(`settings.about.updateDialog.${status}.desc`, { version })}
{(downloading || installing || status === 'downloaded') && (
-
+
diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index bb0bd822..f2b7323a 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -41,7 +41,7 @@ function AudioBars({ level }: AudioBarsProps) { display: 'inline-block', width: 3, height: base + (max - base) * visualVoice * env, - borderRadius: 999, + borderRadius: 'var(--ol-pill-radius)', background: 'var(--ol-blue)', opacity: 0.82, transformOrigin: 'center', @@ -113,12 +113,12 @@ function CircleButton({ variant, enabled, onClick }: CircleButtonProps) { style={{ width: 28, height: 28, - borderRadius: 999, - background: isCancel ? 'rgba(255, 255, 255, 0.55)' : 'rgba(255, 255, 255, 0.92)', + borderRadius: 'var(--ol-pill-radius)', + background: isCancel ? 'var(--ol-control-muted-strong)' : 'var(--ol-control-solid)', backdropFilter: useBackdrop ? 'blur(12px) saturate(160%)' : 'none', WebkitBackdropFilter: useBackdrop ? 'blur(12px) saturate(160%)' : 'none', color: 'var(--ol-ink)', - border: '0.8px solid rgba(0, 0, 0, 0.08)', + border: '0.8px solid var(--ol-glass-border)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', @@ -265,11 +265,11 @@ function Pill({ os, state, level, insertedChars, message, operating, onCancel, o width: metrics.width, height: metrics.height, boxSizing: metrics.boxSizing, - borderRadius: 999, - border: '1px solid rgba(255, 255, 255, 0.55)', + borderRadius: 'var(--ol-pill-radius)', + border: '1px solid var(--ol-glass-border)', boxShadow: os === 'win' - ? `0 10px 24px -14px rgba(0, 0, 0, ${(0.24 + ambient * 0.06).toFixed(3)}), 0 0 0 0.5px rgba(0, 0, 0, 0.08), inset 0 1px 0 0 rgba(255, 255, 255, 0.8)` - : `0 18px 50px -10px rgba(0, 0, 0, ${shadowAlpha.toFixed(3)}), 0 0 0 0.5px rgba(0, 0, 0, 0.08), inset 0 1px 0 0 rgba(255, 255, 255, 0.8)`, + ? `0 10px 24px -14px rgba(0, 0, 0, ${(0.24 + ambient * 0.06).toFixed(3)}), var(--ol-pill-shadow)` + : `0 18px 50px -10px rgba(0, 0, 0, ${shadowAlpha.toFixed(3)}), var(--ol-pill-shadow)`, color: 'var(--ol-ink)', fontFamily: 'var(--ol-font-sans)', transform: `scale(${scale.toFixed(4)})`, @@ -446,11 +446,11 @@ export function Capsule() { alignItems: 'center', gap: 5, padding: '3px 10px', - borderRadius: 999, + borderRadius: 'var(--ol-pill-radius)', fontSize: 10.5, fontWeight: 600, color: 'var(--ol-blue)', - background: 'rgba(255, 255, 255, 0.78)', + background: 'var(--ol-glass-bg-strong)', backdropFilter: 'blur(20px) saturate(180%)', WebkitBackdropFilter: 'blur(20px) saturate(180%)', border: '0.5px solid rgba(37, 99, 235, 0.25)', @@ -465,7 +465,7 @@ export function Capsule() { willChange: 'opacity, transform', }} > - + {t('capsule.translating')}
diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index ba22c926..266cd3c5 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -4,7 +4,7 @@ // // Ported verbatim from design_handoff_openless/variants.jsx::FloatingShell. -import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentType } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from './Icon'; import { WindowChrome, detectOS, type OS } from './WindowChrome'; @@ -199,6 +199,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia return (
@@ -392,7 +393,11 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
{mobileLayout && ( -
); } @@ -689,10 +463,10 @@ function ProviderSetupPrompt({ onLater, onOpenSettings }: { onLater: () => void;
void; style={{ width: 34, height: 34, - borderRadius: 8, - background: 'rgba(37,99,235,0.10)', + borderRadius: 'var(--ol-control-radius)', + background: 'var(--ol-blue-soft)', color: 'var(--ol-blue)', display: 'inline-flex', alignItems: 'center', @@ -724,7 +498,7 @@ function ProviderSetupPrompt({ onLater, onOpenSettings }: { onLater: () => void; style={{ height: 32, padding: '0 13px', - borderRadius: 8, + borderRadius: 'var(--ol-control-radius)', border: '0.5px solid var(--ol-line-strong)', background: 'var(--ol-surface)', color: 'var(--ol-ink-3)', @@ -742,10 +516,10 @@ function ProviderSetupPrompt({ onLater, onOpenSettings }: { onLater: () => void; style={{ height: 32, padding: '0 14px', - borderRadius: 8, + borderRadius: 'var(--ol-control-radius)', border: 0, - background: 'var(--ol-ink)', - color: 'var(--ol-on-accent)', + background: 'var(--ol-primary-solid-bg)', + color: 'var(--ol-primary-solid-ink)', fontFamily: 'inherit', fontSize: 12.5, fontWeight: 500, @@ -782,10 +556,10 @@ function HotkeyModeMigrationPrompt({ onLater, onOpenSettings }: { onLater: () =>
style={{ width: 34, height: 34, - borderRadius: 8, - background: 'rgba(37,99,235,0.10)', + borderRadius: 'var(--ol-control-radius)', + background: 'var(--ol-blue-soft)', color: 'var(--ol-blue)', display: 'inline-flex', alignItems: 'center', @@ -817,7 +591,7 @@ function HotkeyModeMigrationPrompt({ onLater, onOpenSettings }: { onLater: () => style={{ height: 32, padding: '0 13px', - borderRadius: 8, + borderRadius: 'var(--ol-control-radius)', border: '0.5px solid var(--ol-line-strong)', background: 'var(--ol-surface)', color: 'var(--ol-ink-3)', @@ -835,10 +609,10 @@ function HotkeyModeMigrationPrompt({ onLater, onOpenSettings }: { onLater: () => style={{ height: 32, padding: '0 14px', - borderRadius: 8, + borderRadius: 'var(--ol-control-radius)', border: 0, - background: 'var(--ol-ink)', - color: 'var(--ol-on-accent)', + background: 'var(--ol-primary-solid-bg)', + color: 'var(--ol-primary-solid-ink)', fontFamily: 'inherit', fontSize: 12.5, fontWeight: 500, diff --git a/openless-all/app/src/components/GithubLoginModal.tsx b/openless-all/app/src/components/GithubLoginModal.tsx index 8dc4c5c3..e2850c49 100644 --- a/openless-all/app/src/components/GithubLoginModal.tsx +++ b/openless-all/app/src/components/GithubLoginModal.tsx @@ -124,7 +124,7 @@ export function GithubLoginModal({ onClose, onSuccess }: GithubLoginModalProps) title={t('common.close')} onClick={close} style={{ - width: 28, height: 28, borderRadius: 8, + width: 28, height: 28, borderRadius: 'var(--ol-control-radius)', display: 'inline-grid', placeItems: 'center', border: '0.5px solid var(--ol-line-strong)', background: 'var(--ol-surface)', @@ -150,7 +150,7 @@ export function GithubLoginModal({ onClose, onSuccess }: GithubLoginModalProps)
@@ -174,7 +174,7 @@ export function GithubLoginModal({ onClose, onSuccess }: GithubLoginModalProps)
{t('marketplace.oauth.waiting')} @@ -197,7 +197,7 @@ export function GithubLoginModal({ onClose, onSuccess }: GithubLoginModalProps) {phase.kind === 'error' && (
{ - (e.currentTarget as HTMLButtonElement).style.background = 'rgba(255,255,255,0.85)'; + (e.currentTarget as HTMLButtonElement).style.background = 'var(--ol-glass-bg-strong)'; (e.currentTarget as HTMLButtonElement).style.color = 'var(--ol-ink-2)'; }} aria-label={t('common.close')} diff --git a/openless-all/app/src/components/Onboarding.tsx b/openless-all/app/src/components/Onboarding.tsx index bb518005..8fff0b04 100644 --- a/openless-all/app/src/components/Onboarding.tsx +++ b/openless-all/app/src/components/Onboarding.tsx @@ -132,7 +132,7 @@ function AndroidOnboarding({ onComplete }: OnboardingProps) { key={step.id} style={{ height: 4, - borderRadius: 999, + borderRadius: 'var(--ol-pill-radius)', background: index <= stepIndex ? 'var(--ol-blue)' : 'var(--ol-line-soft)', }} /> @@ -143,7 +143,7 @@ function AndroidOnboarding({ onComplete }: OnboardingProps) { style={{ background: 'var(--ol-surface)', border: '0.5px solid var(--ol-line)', - borderRadius: 14, + borderRadius: 'var(--ol-bubble-radius)', boxShadow: 'var(--ol-shadow-lg)', padding: 18, display: 'flex', @@ -347,7 +347,7 @@ function DesktopOnboarding({ padding: 32, boxSizing: 'border-box', background: 'var(--ol-surface)', - borderRadius: 14, + borderRadius: 'var(--ol-bubble-radius)', border: '0.5px solid var(--ol-line)', boxShadow: 'var(--ol-shadow-lg)', }} @@ -470,7 +470,7 @@ function AndroidStepCard({ children }: { children: ReactNode }) { flexDirection: 'column', gap: 12, padding: 14, - borderRadius: 10, + borderRadius: 'var(--ol-r-md)', background: 'var(--ol-surface-2)', border: '0.5px solid var(--ol-line-soft)', minWidth: 0, @@ -488,7 +488,7 @@ function StatusBadge({ granted, label }: { granted: boolean; label: string }) { flexShrink: 0, fontSize: 11, fontWeight: 600, - borderRadius: 999, + borderRadius: 'var(--ol-pill-radius)', padding: '4px 8px', color: granted ? 'var(--ol-ok)' : 'var(--ol-ink-4)', background: granted ? 'rgba(40, 160, 90, 0.12)' : 'rgba(0,0,0,0.06)', @@ -526,9 +526,9 @@ function PermissionStep({ index, title, desc, status, actionLabel, onAction, dis style={{ width: 22, height: 22, - borderRadius: 999, - background: granted ? 'var(--ol-blue)' : 'rgba(0,0,0,0.06)', - color: granted ? '#fff' : 'var(--ol-ink-3)', + borderRadius: 'var(--ol-pill-radius)', + background: granted ? 'var(--ol-accent-solid-bg)' : 'rgba(0,0,0,0.06)', + color: granted ? 'var(--ol-accent-solid-ink)' : 'var(--ol-ink-3)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', @@ -558,9 +558,9 @@ function PermissionStep({ index, title, desc, status, actionLabel, onAction, dis fontWeight: 500, fontFamily: 'inherit', border: 0, - borderRadius: 8, - background: granted ? 'var(--ol-surface-2)' : 'var(--ol-ink)', - color: granted ? 'var(--ol-ink-3)' : '#fff', + borderRadius: 'var(--ol-control-radius)', + background: granted ? 'var(--ol-surface-2)' : 'var(--ol-primary-solid-bg)', + color: granted ? 'var(--ol-ink-3)' : 'var(--ol-primary-solid-ink)', cursor: disabled ? 'not-allowed' : 'default', opacity: disabled && !granted ? 0.6 : 1, transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), opacity 0.18s var(--ol-motion-soft), transform 0.12s var(--ol-motion-quick)', @@ -580,9 +580,9 @@ const primaryButtonStyle = { fontWeight: 600, fontFamily: 'inherit', border: 0, - borderRadius: 10, - background: 'var(--ol-ink)', - color: '#fff', + borderRadius: 'var(--ol-r-md)', + background: 'var(--ol-primary-solid-bg)', + color: 'var(--ol-primary-solid-ink)', cursor: 'default', } as const; @@ -594,7 +594,7 @@ const secondaryButtonStyle = { fontWeight: 600, fontFamily: 'inherit', border: '0.5px solid var(--ol-line-strong)', - borderRadius: 10, + borderRadius: 'var(--ol-r-md)', background: 'var(--ol-surface)', color: 'var(--ol-ink-2)', cursor: 'default', @@ -607,7 +607,7 @@ const plainButtonStyle = { fontWeight: 500, fontFamily: 'inherit', border: 0, - borderRadius: 8, + borderRadius: 'var(--ol-control-radius)', background: 'transparent', color: 'var(--ol-ink-4)', cursor: 'default', @@ -616,7 +616,7 @@ const plainButtonStyle = { const footerHintStyle = { marginTop: 18, padding: '12px 14px', - borderRadius: 8, + borderRadius: 'var(--ol-control-radius)', background: 'var(--ol-surface-2)', fontSize: 11.5, color: 'var(--ol-ink-3)', diff --git a/openless-all/app/src/components/SettingsModal.tsx b/openless-all/app/src/components/SettingsModal.tsx index f55a682f..8a8f902e 100644 --- a/openless-all/app/src/components/SettingsModal.tsx +++ b/openless-all/app/src/components/SettingsModal.tsx @@ -80,6 +80,7 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
e.stopPropagation()} style={{ width: '100%', @@ -175,7 +176,7 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett onClick={onClose} style={{ position: 'absolute', top: 14, right: 14, zIndex: 2, - width: 28, height: 28, border: 0, borderRadius: 999, + width: 28, height: 28, border: 0, borderRadius: 'var(--ol-pill-radius)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'default', }} @@ -210,73 +211,6 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
-
); } diff --git a/openless-all/app/src/components/ShortcutRecorder.tsx b/openless-all/app/src/components/ShortcutRecorder.tsx index fad6e257..5106222d 100644 --- a/openless-all/app/src/components/ShortcutRecorder.tsx +++ b/openless-all/app/src/components/ShortcutRecorder.tsx @@ -128,10 +128,10 @@ export function ShortcutRecorder({ const recordButtonStyle: CSSProperties = { fontSize: 12, padding: '5px 14px', - background: recording ? 'rgba(37,99,235,0.12)' : 'var(--ol-blue)', - color: recording ? 'var(--ol-blue)' : '#fff', + background: recording ? 'rgba(37,99,235,0.12)' : 'var(--ol-accent-solid-bg)', + color: recording ? 'var(--ol-blue)' : 'var(--ol-accent-solid-ink)', border: 0, - borderRadius: 6, + borderRadius: 'var(--ol-r-sm)', fontFamily: 'inherit', fontWeight: 500, cursor: recording || disabled ? 'default' : 'pointer', @@ -144,7 +144,7 @@ export function ShortcutRecorder({ background: 'transparent', color: 'var(--ol-ink-4)', border: '0.5px solid var(--ol-line-strong)', - borderRadius: 6, + borderRadius: 'var(--ol-r-sm)', fontFamily: 'inherit', fontWeight: 500, cursor: recording ? 'default' : 'pointer', @@ -160,7 +160,7 @@ export function ShortcutRecorder({ return (
- + {formatComboLabel(value)}
@@ -195,7 +195,7 @@ export function ShortcutRecorder({ tabIndex={-1} onKeyDown={onKeyDown} onKeyUp={onKeyUp} - style={{ padding: '8px 12px', borderRadius: 8, background: 'rgba(37,99,235,0.06)', border: '1px solid rgba(37,99,235,0.2)', fontSize: 12, color: 'var(--ol-blue)', outline: 'none' }} + style={{ padding: '8px 12px', borderRadius: 'var(--ol-control-radius)', background: 'rgba(37,99,235,0.06)', border: '1px solid rgba(37,99,235,0.2)', fontSize: 12, color: 'var(--ol-blue)', outline: 'none' }} ref={el => el?.focus()} > {t('settings.recording.comboRecordHint')} diff --git a/openless-all/app/src/components/ui/Modal.tsx b/openless-all/app/src/components/ui/Modal.tsx index e84d376a..91594cab 100644 --- a/openless-all/app/src/components/ui/Modal.tsx +++ b/openless-all/app/src/components/ui/Modal.tsx @@ -22,7 +22,7 @@ export function Modal({ children, onClose, zIndex = 50, width = 'min(560px, 100% style={{ position: 'fixed', inset: 0, - background: 'rgba(0,0,0,0.22)', + background: 'var(--ol-overlay-bg)', display: 'grid', placeItems: 'center', zIndex, @@ -36,10 +36,10 @@ export function Modal({ children, onClose, zIndex = 50, width = 'min(560px, 100% width, maxHeight: '85vh', overflow: 'auto', - borderRadius: 16, - background: 'var(--ol-surface)', - border: '0.5px solid var(--ol-line-strong)', - boxShadow: '0 18px 42px rgba(0,0,0,0.18)', + borderRadius: 'var(--ol-modal-radius)', + background: 'var(--ol-card-bg)', + border: '0.5px solid var(--ol-card-border)', + boxShadow: 'var(--ol-shadow-lg)', padding: 22, animation: 'ol-modal-card-in 0.24s var(--ol-motion-spring)', }} diff --git a/openless-all/app/src/components/ui/SegSimple.tsx b/openless-all/app/src/components/ui/SegSimple.tsx index 802df27a..20a9e0ec 100644 --- a/openless-all/app/src/components/ui/SegSimple.tsx +++ b/openless-all/app/src/components/ui/SegSimple.tsx @@ -10,17 +10,17 @@ interface SegSimpleProps { export function SegSimple({ options, active }: SegSimpleProps) { const [v, setV] = useState(active); return ( -
+
{options.map((o) => ( @@ -451,12 +451,12 @@ function VocabChip({ entry, onRemove, onToggle }: VocabChipProps) { 0 ? 'var(--ol-blue-soft)' : 'var(--ol-surface)') : 'var(--ol-surface-2)', opacity: enabled ? 1 : 0.55, @@ -476,10 +476,10 @@ function VocabChip({ entry, onRemove, onToggle }: VocabChipProps) { 0 && enabled ? 'var(--ol-blue)' : 'rgba(0,0,0,0.06)', - color: entry.hits > 0 && enabled ? '#fff' : 'var(--ol-ink-4)', + background: entry.hits > 0 && enabled ? 'var(--ol-accent-solid-bg)' : 'rgba(0,0,0,0.06)', + color: entry.hits > 0 && enabled ? 'var(--ol-accent-solid-ink)' : 'var(--ol-ink-4)', fontFamily: 'var(--ol-font-sans)', }} >{entry.hits} @@ -487,7 +487,7 @@ function VocabChip({ entry, onRemove, onToggle }: VocabChipProps) { onClick={onRemove} aria-label={t('vocab.removeAria')} style={{ - width: 14, height: 14, padding: 0, border: 0, borderRadius: 999, + width: 14, height: 14, padding: 0, border: 0, borderRadius: 'var(--ol-pill-radius)', background: 'transparent', color: 'var(--ol-ink-4)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'default', }} diff --git a/openless-all/app/src/pages/_atoms.tsx b/openless-all/app/src/pages/_atoms.tsx index 1017f648..695d2103 100644 --- a/openless-all/app/src/pages/_atoms.tsx +++ b/openless-all/app/src/pages/_atoms.tsx @@ -83,13 +83,14 @@ export function Card({ children, style, padding = 18, glassy = false, className
@@ -161,7 +162,7 @@ interface BtnProps { export function Btn({ children, variant = 'ghost', size = 'md', icon, style, onClick, disabled = false }: BtnProps) { const variants: Record = { primary: { bg: 'linear-gradient(180deg, #141821, #0d1016)', color: 'var(--ol-on-accent)', bd: 'transparent', sh: '0 14px 32px -22px rgba(15,23,42,0.55)' }, - blue: { bg: 'linear-gradient(180deg, var(--ol-blue), var(--ol-blue-hover))', color: 'var(--ol-on-accent)', bd: 'transparent', sh: '0 14px 32px -22px rgba(37,99,235,.45)' }, + blue: { bg: 'linear-gradient(180deg, var(--ol-accent-solid-bg), var(--ol-accent-solid-bg-hover))', color: 'var(--ol-accent-solid-ink)', bd: 'transparent', sh: '0 14px 32px -22px rgba(37,99,235,.45)' }, ghost: { bg: 'var(--ol-control-muted)', color: 'var(--ol-ink-2)', bd: 'var(--ol-control-border)', sh: 'var(--ol-control-shadow)' }, soft: { bg: 'var(--ol-control-muted-strong)', color: 'var(--ol-ink-2)', bd: 'transparent', sh: 'var(--ol-control-shadow)' }, }; @@ -221,10 +222,10 @@ export function Collapsible({ title, desc, defaultOpen = false, embedded = false
-
+
{choices.map(([v, l]) => ( @@ -201,7 +201,7 @@ export function ShortcutsSection() { {t('modal.about.localFirst')} diff --git a/openless-all/app/src/styles/global.css b/openless-all/app/src/styles/global.css index 30fdfd61..3d975d52 100644 --- a/openless-all/app/src/styles/global.css +++ b/openless-all/app/src/styles/global.css @@ -177,3 +177,402 @@ html[data-ol-platform="linux"] .ol-frost { html[data-ol-platform="linux"] .ol-frost::before { content: none; } + +/* ── App shell + Aura surfaces ─────────────────────────────────────────── */ + +.ol-app-shell-bg { + background: var(--ol-app-shell-bg); +} + +.ol-aura-panel { + background: var(--ol-panel-bg); + border: 1px solid var(--ol-panel-border); + box-shadow: var(--ol-panel-shadow); + backdrop-filter: blur(var(--ol-aura-glass-blur)) saturate(150%); + -webkit-backdrop-filter: blur(var(--ol-aura-glass-blur)) saturate(150%); +} + +.ol-aura-card { + background: var(--ol-card-bg); + border: 1px solid var(--ol-card-border); + border-radius: var(--ol-card-radius); + box-shadow: var(--ol-card-shadow); +} + +html[data-ol-platform="linux"] .ol-aura-panel, +html[data-ol-platform="linux"] .ol-aura-card { + backdrop-filter: none; + -webkit-backdrop-filter: none; + background: var(--ol-surface); +} + +/* ── FloatingShell nav + sidebar ───────────────────────────────────────── */ + +.ol-nav-btn { + color: var(--ol-ink-3); + font-weight: 500; +} + +.ol-nav-btn.ol-nav-btn-active { + color: var(--ol-ink); + font-weight: 600; +} + +.ol-nav-btn:not(.ol-nav-btn-active):hover { + background: var(--ol-nav-hover-bg); + color: var(--ol-ink); +} + +.ol-aura-sidebar { + padding: 14px 12px 14px; + background: var(--ol-sidebar-bg); + border-right: 1px solid var(--ol-sidebar-border); +} + +.ol-aura-sidebar-brand { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 10px 16px; + margin-bottom: 6px; + border-radius: var(--ol-r-lg); + background: var(--ol-sidebar-brand-bg); + border: 1px solid var(--ol-sidebar-brand-border); + box-shadow: none; +} + +.ol-aura-sidebar-brand-mark { + width: 26px; + height: 26px; + border-radius: var(--ol-control-radius); + box-shadow: none; + box-sizing: border-box; + padding: 3px; + object-fit: contain; +} + +.ol-aura-sidebar-brand-title { + font-size: 14px; + font-weight: 600; + font-family: var(--ol-font-display); + color: var(--ol-ink); +} + +.ol-aura-sidebar-brand-kicker { + font-size: 10.5px; + color: var(--ol-ink-4); + font-family: var(--ol-font-mono); + letter-spacing: .08em; +} + +.ol-aura-sidebar-pill { + background: var(--ol-sidebar-pill-bg); + border-radius: var(--ol-r-lg); + border: 1px solid var(--ol-sidebar-pill-border); + box-shadow: none; +} + +.ol-aura-sidebar-nav-btn { + padding: 8px 12px; + border-radius: var(--ol-r-lg); + border: 0; + background: transparent; + font-family: inherit; + font-size: 13px; + cursor: default; + transition: color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick); + text-align: left; + position: relative; + z-index: 1; +} + +.ol-aura-sidebar-footer { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 10px 0; + margin-top: 10px; + border-top: 1px solid var(--ol-sidebar-footer-border); +} + +.ol-aura-sidebar-version { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 10px 12px; + font-family: var(--ol-font-sans); + font-size: 11px; + color: var(--ol-ink-4); + background: var(--ol-sidebar-version-bg); + border: 1px solid var(--ol-sidebar-version-border); + border-radius: var(--ol-pill-radius); + box-shadow: none; +} + +.ol-aura-beta-tag { + display: inline-block; + padding: 2px 8px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--ol-blue); + background: rgba(37,99,235,0.10); + border-radius: var(--ol-pill-radius); +} + +.ol-aura-sidebar-settings { + padding: 10px 12px; + border-radius: var(--ol-r-lg); + border: 1px solid var(--ol-sidebar-settings-border); + background: var(--ol-sidebar-settings-bg); + box-shadow: none; +} + +.ol-aura-sidebar-settings.ol-nav-btn-active { + background: var(--ol-sidebar-settings-active-bg); + box-shadow: none; +} + +.ol-aura-console-main { + border-radius: var(--ol-panel-radius); +} + +[data-ol-mobile="true"] .ol-aura-console-main { + border-radius: 0; +} + +.ol-aura-mobile-topbar { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: calc(10px + env(safe-area-inset-top, 0px)) 14px 10px; + border-bottom: 1px solid var(--ol-sidebar-border); + background: var(--ol-sidebar-bg); +} + +.ol-aura-mobile-brand { + min-width: 0; + display: flex; + align-items: center; + gap: 10px; +} + +.ol-aura-mobile-brand-mark { + width: 30px; + height: 30px; + border-radius: var(--ol-control-radius); + flex-shrink: 0; + box-sizing: border-box; + padding: 3px; + object-fit: contain; +} + +.ol-aura-mobile-brand-title { + font-size: 14px; + font-weight: 700; + color: var(--ol-ink); + line-height: 1.15; +} + +.ol-aura-mobile-brand-section { + margin-top: 2px; + font-size: 11px; + color: var(--ol-ink-4); + font-family: var(--ol-font-mono); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ol-aura-mobile-settings { + width: 36px; + height: 36px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--ol-r-lg); + color: var(--ol-ink-3); + background: var(--ol-sidebar-settings-bg); + border: 1px solid var(--ol-sidebar-settings-border); +} + +.ol-aura-mobile-settings-active { + color: var(--ol-ink); + background: var(--ol-sidebar-settings-active-bg); +} + +.ol-aura-mobile-nav { + flex-shrink: 0; + display: grid; + grid-template-columns: repeat(var(--ol-nav-count, 6), minmax(0, 1fr)); + gap: 2px; + padding: 7px 8px calc(7px + env(safe-area-inset-bottom, 0px)); + border-top: 1px solid var(--ol-sidebar-border); + background: var(--ol-sidebar-bg); +} + +.ol-aura-mobile-nav-btn { + min-width: 0; + height: 50px; + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + border-radius: var(--ol-r-lg); + color: var(--ol-ink-4); + font-size: 10px; + font-weight: 600; + line-height: 1.1; +} + +.ol-aura-mobile-nav-btn span { + max-width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.ol-aura-mobile-nav-btn-active { + color: var(--ol-ink); + background: var(--ol-sidebar-pill-bg); + border: 1px solid var(--ol-sidebar-pill-border); +} + +/* ── Settings modal ────────────────────────────────────────────────────── */ + +.ol-aura-settings { + background: var(--ol-panel-bg); + border-radius: var(--ol-shell-radius); + border: 1px solid var(--ol-panel-border); + box-shadow: var(--ol-panel-shadow); +} + +.ol-aura-settings[data-ol-mobile="true"] { + border-radius: 0; +} + +.ol-aura-settings-rail { + padding: 20px 14px; + gap: 16px; + background: var(--ol-settings-rail-bg); + border-right: 1px solid var(--ol-settings-rail-border); + border-bottom: 0; +} + +.ol-aura-settings[data-ol-mobile="true"] .ol-aura-settings-rail { + padding: calc(10px + env(safe-area-inset-top, 0px)) 10px 8px; + gap: 8px; + border-right: 0; + border-bottom: 1px solid var(--ol-settings-rail-border); +} + +.ol-aura-settings-pill { + background: var(--ol-sidebar-pill-bg); + border-radius: var(--ol-r-lg); + border: 1px solid var(--ol-sidebar-pill-border); + box-shadow: none; +} + +.ol-aura-settings-nav-btn { + padding: 7px 10px; + border-radius: var(--ol-r-lg); + border: 0; + background: transparent; + font-family: inherit; + font-size: 13px; + cursor: default; + text-align: left; + position: relative; + z-index: 1; + transition: color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick); +} + +.ol-aura-settings[data-ol-mobile="true"] .ol-aura-settings-nav-btn { + padding: 8px 11px; +} + +.ol-aura-settings-nav-btn.ol-nav-btn-active { + background: transparent; + border: 0; +} + +.ol-aura-settings[data-ol-mobile="true"] .ol-aura-settings-nav-btn.ol-nav-btn-active { + background: var(--ol-sidebar-pill-bg); + border: 1px solid var(--ol-sidebar-pill-border); +} + +.ol-aura-settings-links { + display: flex; + flex-direction: column; + gap: 1px; + padding-top: 10px; + padding-left: 0; + border-top: 1px solid var(--ol-settings-links-border); + border-left: 0; + overflow-x: visible; +} + +.ol-aura-settings[data-ol-mobile="true"] .ol-aura-settings-links { + flex-direction: row; + padding-top: 0; + padding-left: 8px; + border-top: 0; + border-left: 1px solid var(--ol-settings-links-border); + overflow-x: auto; +} + +.ol-aura-settings-content { + background: var(--ol-settings-content-bg); +} + +.ol-aura-settings-close { + background: var(--ol-settings-close-bg); + color: var(--ol-ink-3); + transition: background 0.16s var(--ol-motion-quick); +} + +.ol-aura-settings-close:hover { + background: var(--ol-settings-close-hover-bg); +} + +.ol-aura-settings-title { + padding: 24px 28px 10px; + font-size: 22px; + font-weight: 600; + letter-spacing: -0.02em; + font-family: var(--ol-font-display); + color: var(--ol-ink); +} + +.ol-aura-settings[data-ol-mobile="true"] .ol-aura-settings-title { + padding: 16px 48px 8px 16px; + font-size: 20px; +} + +/* ── Page switch + prompt keyframes ──────────────────────────────────── */ + +@keyframes ol-page-slide { + from { opacity: 0; transform: translate3d(10px, 0, 0) scale(.996); filter: blur(6px); } + to { opacity: 1; transform: translate3d(0, 0, 0) scale(1); filter: blur(0); } +} + +@keyframes ol-page-fadeout { + from { opacity: 1; filter: blur(0); } + to { opacity: 0; filter: blur(8px); } +} + +@keyframes ol-prompt-fade { + from { opacity: 0; backdrop-filter: blur(0); -webkit-backdrop-filter: blur(0); } + to { opacity: 1; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); } +} + +@keyframes ol-prompt-pop { + from { opacity: 0; transform: translateY(6px) scale(.97); filter: blur(6px); } + to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); } +} diff --git a/openless-all/app/src/styles/tokens.css b/openless-all/app/src/styles/tokens.css index a905b340..a19679e6 100755 --- a/openless-all/app/src/styles/tokens.css +++ b/openless-all/app/src/styles/tokens.css @@ -25,8 +25,11 @@ --ol-ink: #0a0a0b; --ol-ink-2: #2a2a2d; --ol-ink-3: rgba(10, 10, 11, 0.62); - --ol-ink-4: rgba(10, 10, 11, 0.42); + --ol-ink-4: rgba(10, 10, 11, 0.58); --ol-ink-5: rgba(10, 10, 11, 0.24); + --ol-ink-muted: var(--ol-ink-3); + --ol-ink-subtle: var(--ol-ink-4); + --ol-placeholder: var(--ol-ink-4); /* Blue accent */ --ol-blue: #2563eb; @@ -34,6 +37,11 @@ --ol-blue-soft: #eff4ff; --ol-blue-ring: rgba(37, 99, 235, 0.22); --ol-on-accent: #ffffff; + --ol-accent-solid-bg: var(--ol-blue); + --ol-accent-solid-bg-hover: var(--ol-blue-hover); + --ol-accent-solid-ink: var(--ol-on-accent); + --ol-primary-solid-bg: #111827; + --ol-primary-solid-ink: var(--ol-on-accent); /* Shared glass */ --ol-glass-bg: rgba(255, 255, 255, 0.62); @@ -73,6 +81,9 @@ --ol-r-xl: 18px; --ol-r-2xl: var(--ol-card-radius); --ol-r-pill: var(--ol-pill-radius); + --ol-control-radius: 8px; + --ol-bubble-radius: var(--ol-r-lg); + --ol-modal-radius: var(--ol-r-xl); /* Typography */ --ol-font-display: "Aptos", "Segoe UI Variable Display", "PingFang SC", "Microsoft YaHei", sans-serif; @@ -85,6 +96,8 @@ --ol-warn: #d97706; --ol-warn-soft: #fff7ed; --ol-err: #dc2626; + --ol-danger-solid-bg: var(--ol-err); + --ol-danger-solid-ink: #ffffff; /* Theme-aware shell and control tokens */ --ol-app-shell-bg: @@ -102,8 +115,8 @@ --ol-panel-bg: var(--ol-aura-surface); --ol-panel-border: var(--ol-aura-glass-border); --ol-panel-shadow: var(--ol-aura-shadow); - --ol-card-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(252, 252, 253, 0.82)); - --ol-card-border: rgba(255, 255, 255, 0.74); + --ol-card-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(250, 250, 251, 0.86)); + --ol-card-border: rgba(255, 255, 255, 0.78); --ol-card-shadow: var(--ol-aura-shadow-soft); --ol-sidebar-bg: linear-gradient(180deg, rgba(255,255,255,0.34), rgba(255,255,255,0.12)); @@ -147,8 +160,8 @@ --ol-pill-selected-ink: #ffffff; --ol-pill-selected-border: #111827; --ol-segmented-bg: rgba(0,0,0,0.04); - --ol-segmented-active-bg: #ffffff; - --ol-segmented-active-shadow: 0 1px 2px rgba(0,0,0,.06), 0 0 0 0.5px rgba(0,0,0,.06); + --ol-segmented-active-bg: var(--ol-control-elevated); + --ol-segmented-active-shadow: 0 1px 3px rgba(0,0,0,.08), 0 0 0 0.5px rgba(0,0,0,.08); --ol-toggle-off-bg: rgba(0,0,0,0.15); --ol-toggle-knob: #ffffff; --ol-overlay-bg: rgba(15,17,22,0.32); @@ -205,14 +218,22 @@ --ol-ink: #f4f7fb; --ol-ink-2: #d8dfeb; --ol-ink-3: rgba(244, 247, 251, 0.74); - --ol-ink-4: rgba(244, 247, 251, 0.50); + --ol-ink-4: rgba(244, 247, 251, 0.54); --ol-ink-5: rgba(244, 247, 251, 0.26); + --ol-ink-muted: var(--ol-ink-3); + --ol-ink-subtle: var(--ol-ink-4); + --ol-placeholder: var(--ol-ink-4); --ol-blue: #60a5fa; --ol-blue-hover: #3b82f6; --ol-blue-soft: rgba(96, 165, 250, 0.14); --ol-blue-ring: rgba(96, 165, 250, 0.30); --ol-on-accent: #f8fbff; + --ol-accent-solid-bg: #2563eb; + --ol-accent-solid-bg-hover: #1d4ed8; + --ol-accent-solid-ink: #ffffff; + --ol-primary-solid-bg: var(--ol-accent-solid-bg); + --ol-primary-solid-ink: var(--ol-accent-solid-ink); --ol-glass-bg: rgba(15, 19, 27, 0.68); --ol-glass-bg-strong: rgba(17, 22, 30, 0.84); @@ -231,6 +252,8 @@ --ol-warn: #f59e0b; --ol-warn-soft: rgba(245, 158, 11, 0.14); --ol-err: #f87171; + --ol-danger-solid-bg: #b91c1c; + --ol-danger-solid-ink: #ffffff; --ol-app-shell-bg: linear-gradient(180deg, #050607 0%, #090a0c 100%); --ol-window-bg: @@ -243,9 +266,9 @@ --ol-panel-bg: linear-gradient(180deg, rgba(33,33,36,0.96), rgba(28,28,31,0.94)); --ol-panel-border: rgba(255,255,255,0.07); --ol-panel-shadow: 0 18px 44px -34px rgba(0,0,0,0.62); - --ol-card-bg: linear-gradient(180deg, rgba(34,34,37,0.96), rgba(29,29,32,0.94)); - --ol-card-border: rgba(255,255,255,0.06); - --ol-card-shadow: none; + --ol-card-bg: linear-gradient(180deg, rgba(36,36,40,0.98), rgba(30,30,34,0.96)); + --ol-card-border: rgba(255,255,255,0.08); + --ol-card-shadow: 0 12px 32px -24px rgba(0,0,0,0.55); --ol-sidebar-bg: transparent; --ol-sidebar-border: rgba(255,255,255,0.06); @@ -288,8 +311,8 @@ --ol-pill-selected-ink: #f4f7fb; --ol-pill-selected-border: rgba(96,165,250,0.40); --ol-segmented-bg: rgba(255,255,255,0.06); - --ol-segmented-active-bg: rgba(255,255,255,0.12); - --ol-segmented-active-shadow: none; + --ol-segmented-active-bg: var(--ol-control-elevated); + --ol-segmented-active-shadow: 0 0 0 0.5px rgba(255,255,255,0.10); --ol-toggle-off-bg: rgba(255,255,255,0.16); --ol-toggle-knob: #f8fbff; --ol-overlay-bg: rgba(4, 8, 14, 0.58); From 00fd21d78ebe58d1dd8b54bca11e3b7b5658b343 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 12 Jun 2026 22:10:53 +0800 Subject: [PATCH 2/4] Add system/light/dark theme switching in settings. Apply theme before first paint via main.tsx, persist preference locally, and expose a segmented control in General settings with aura-skin contract coverage. Co-authored-by: Cursor --- .../app/scripts/aura-skin-contract.test.mjs | 22 +++++++++- .../app/src/components/SettingsModal.tsx | 3 +- .../app/src/components/ui/SegSimple.tsx | 37 ++++++++++------- openless-all/app/src/i18n/en.ts | 8 ++++ openless-all/app/src/i18n/ja.ts | 8 ++++ openless-all/app/src/i18n/ko.ts | 8 ++++ openless-all/app/src/i18n/zh-CN.ts | 8 ++++ openless-all/app/src/i18n/zh-TW.ts | 8 ++++ openless-all/app/src/lib/themeMode.ts | 41 +++++++++++++++++++ openless-all/app/src/main.tsx | 5 +++ .../app/src/pages/settings/ThemeSection.tsx | 36 ++++++++++++++++ openless-all/app/src/pages/settings/tabs.tsx | 2 + 12 files changed, 169 insertions(+), 17 deletions(-) create mode 100644 openless-all/app/src/lib/themeMode.ts create mode 100644 openless-all/app/src/pages/settings/ThemeSection.tsx diff --git a/openless-all/app/scripts/aura-skin-contract.test.mjs b/openless-all/app/scripts/aura-skin-contract.test.mjs index 22b5baab..57962599 100644 --- a/openless-all/app/scripts/aura-skin-contract.test.mjs +++ b/openless-all/app/scripts/aura-skin-contract.test.mjs @@ -186,12 +186,14 @@ assertUsesClassName( 'sample should accept className usage', ); -const [tokens, globalCss, shell, settingsModal, overview, sourceFiles, remoteStyle] = await Promise.all([ +const [tokens, globalCss, shell, settingsModal, overview, settingsTabs, themeMode, sourceFiles, remoteStyle] = await Promise.all([ read('src/styles/tokens.css'), read('src/styles/global.css'), read('src/components/FloatingShell.tsx'), read('src/components/SettingsModal.tsx'), read('src/pages/Overview.tsx'), + read('src/pages/settings/tabs.tsx'), + read('src/lib/themeMode.ts'), walkSourceFiles(srcRoot), read('src-tauri/src/remote_server/assets/style.css'), ]); @@ -353,6 +355,24 @@ assertUsesClassName(shell, 'ol-aura-panel', 'FloatingShell must expose an Aura p assertUsesClassName(settingsModal, 'ol-aura-settings', 'SettingsModal must expose an Aura settings wrapper'); assertUsesClassName(overview, 'ol-overview-hero', 'Overview must expose a high-visibility overview surface hook'); +assert.match( + settingsTabs, + /import\s+\{[^}]*ThemeSection[^}]*\}\s+from\s+['"]\.\/ThemeSection['"]/, + 'tabs.tsx GeneralTab must import ThemeSection', +); +assert.match(settingsTabs, //, 'tabs.tsx GeneralTab must render ThemeSection'); + +assert.match( + themeMode, + /prefers-color-scheme:\s*dark/, + 'themeMode.ts must listen for prefers-color-scheme: dark', +); +assert.match( + themeMode, + /data-ol-theme|olTheme|dataset\.olTheme/, + 'themeMode.ts must apply theme via data-ol-theme / dataset.olTheme', +); + const illegalCssStringPatterns = [ /color:\s*'var\([^)]+\)';/, /background:\s*'var\([^)]+\)';/, diff --git a/openless-all/app/src/components/SettingsModal.tsx b/openless-all/app/src/components/SettingsModal.tsx index 8a8f902e..9b202a93 100644 --- a/openless-all/app/src/components/SettingsModal.tsx +++ b/openless-all/app/src/components/SettingsModal.tsx @@ -4,8 +4,7 @@ // 「设置」还要再面对第二个侧栏。现在拍平成单层 —— 通用 / 服务 / 隐私 / 高级 / // 个性化 / 关于 六个 tab。每个 tab 的内容见 pages/settings/。 // -// 设计原则:每个可见控件都必须可用。没有后端支撑的占位(账号 / 主题切换 等) -// 不在此弹窗出现。 +// 设计原则:每个可见控件都必须可用。没有后端支撑的占位(如账号)不在此弹窗出现。 import { useLayoutEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/openless-all/app/src/components/ui/SegSimple.tsx b/openless-all/app/src/components/ui/SegSimple.tsx index 20a9e0ec..bb97df07 100644 --- a/openless-all/app/src/components/ui/SegSimple.tsx +++ b/openless-all/app/src/components/ui/SegSimple.tsx @@ -1,32 +1,41 @@ // SegSimple — segmented control used in the Settings modal sub-sections. -import { useState } from 'react'; +export type SegOption = { value: string; label: string }; interface SegSimpleProps { - options: string[]; - active: string; + options: SegOption[]; + value: string; + onChange?: (value: string) => void; } -export function SegSimple({ options, active }: SegSimpleProps) { - const [v, setV] = useState(active); +export function SegSimple({ options, value, onChange }: SegSimpleProps) { return ( -
- {options.map((o) => ( +
+ {options.map((o) => { + const selected = value === o.value; + return ( - ))} + ); + })}
); } diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index fbbc2fc5..4b98efed 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -958,6 +958,14 @@ export const en: typeof zhCN = { ko: '한국어 (Beta)', restartHint: 'Some native menus (system tray, etc.) may require an app restart to fully switch.', }, + appearance: { + title: 'Appearance', + desc: 'Choose light or dark mode, or follow your system setting.', + label: 'Theme', + followSystem: 'Follow system', + light: 'Light', + dark: 'Dark', + }, remoteInput: { title: 'Remote Input', enableLabel: 'Enable remote input', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 5942e5a6..445a9068 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -926,6 +926,14 @@ export const ja: typeof zhCN = { ko: '한국어 (Beta)', restartHint: '一部のネイティブメニュー(トレイ等)は再起動後に反映されます。', }, + appearance: { + title: '外観', + desc: 'ライト/ダークモードを選ぶか、システム設定に従います。', + label: 'テーマ', + followSystem: 'システムに従う', + light: 'ライト', + dark: 'ダーク', + }, remoteInput: { title: 'リモート入力', enableLabel: 'リモート入力を有効化', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 07030b0c..d80bd63f 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -926,6 +926,14 @@ export const ko: typeof zhCN = { ko: '한국어 (Beta)', restartHint: '일부 네이티브 메뉴(트레이 등)는 앱 재시작 후 반영될 수 있습니다.', }, + appearance: { + title: '외관', + desc: '라이트/다크 모드를 선택하거나 시스템 설정을 따릅니다.', + label: '테마', + followSystem: '시스템 따라가기', + light: '라이트', + dark: '다크', + }, remoteInput: { title: '원격 입력', enableLabel: '원격 입력 활성화', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index db8f3c3a..e26457ab 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -956,6 +956,14 @@ export const zhCN = { ko: '한국어 (Beta)', restartHint: '部分原生菜单(系统托盘等)可能需要重启 App 才会切换。', }, + appearance: { + title: '外观', + desc: '选择浅色或深色模式,或跟随系统设置。', + label: '主题', + followSystem: '跟随系统', + light: '浅色', + dark: '深色', + }, remoteInput: { title: '远程输入', enableLabel: '启用远程输入', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 523716fd..55aac730 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -924,6 +924,14 @@ export const zhTW: typeof zhCN = { ko: '한국어 (Beta)', restartHint: '部分原生菜單(系統托盤等)可能需要重啓 App 纔會切換。', }, + appearance: { + title: '外觀', + desc: '選擇淺色或深色模式,或跟隨系統設置。', + label: '主題', + followSystem: '跟隨系統', + light: '淺色', + dark: '深色', + }, remoteInput: { title: '遠端輸入', enableLabel: '啟用遠端輸入', diff --git a/openless-all/app/src/lib/themeMode.ts b/openless-all/app/src/lib/themeMode.ts new file mode 100644 index 00000000..14f2ba65 --- /dev/null +++ b/openless-all/app/src/lib/themeMode.ts @@ -0,0 +1,41 @@ +// 主题模式 — localStorage 持久化,通过 html[data-ol-theme] 切换暗色 token 集。 + +export type ThemeMode = 'system' | 'light' | 'dark'; + +const THEME_MODE_KEY = 'ol-theme-mode'; + +function systemPrefersDark(): boolean { + return window.matchMedia('(prefers-color-scheme: dark)').matches; +} + +export function readThemeMode(): ThemeMode { + try { + const v = window.localStorage.getItem(THEME_MODE_KEY); + if (v === 'system' || v === 'light' || v === 'dark') return v; + } catch { /* localStorage 不可用:忽略,落回默认 */ } + return 'system'; +} + +export function applyThemeMode(mode: ThemeMode): void { + const root = document.documentElement; + if (mode === 'dark' || (mode === 'system' && systemPrefersDark())) { + root.dataset.olTheme = 'dark'; + } else { + delete root.dataset.olTheme; + } +} + +export function setThemeMode(mode: ThemeMode): ThemeMode { + try { window.localStorage.setItem(THEME_MODE_KEY, mode); } catch { /* 忽略 */ } + applyThemeMode(mode); + return mode; +} + +export function subscribeThemeMode(): () => void { + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + const onChange = () => { + if (readThemeMode() === 'system') applyThemeMode('system'); + }; + mq.addEventListener('change', onChange); + return () => mq.removeEventListener('change', onChange); +} diff --git a/openless-all/app/src/main.tsx b/openless-all/app/src/main.tsx index 73111798..3fff5d83 100644 --- a/openless-all/app/src/main.tsx +++ b/openless-all/app/src/main.tsx @@ -3,9 +3,14 @@ import ReactDOM from "react-dom/client"; import { App } from "./App"; import { detectOS } from "./components/WindowChrome"; import i18n from "./i18n"; // 副作用:触发 i18next init +import { applyThemeMode, readThemeMode, subscribeThemeMode } from "./lib/themeMode"; import "./styles/tokens.css"; import "./styles/global.css"; +// 首帧前应用主题,避免暗色偏好下先闪一帧亮色。 +applyThemeMode(readThemeMode()); +subscribeThemeMode(); + import type { OS } from "./components/WindowChrome"; const params = new URLSearchParams(window.location.search); diff --git a/openless-all/app/src/pages/settings/ThemeSection.tsx b/openless-all/app/src/pages/settings/ThemeSection.tsx new file mode 100644 index 00000000..d57c4786 --- /dev/null +++ b/openless-all/app/src/pages/settings/ThemeSection.tsx @@ -0,0 +1,36 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { SegSimple } from '../../components/ui/SegSimple'; +import { readThemeMode, setThemeMode, type ThemeMode } from '../../lib/themeMode'; +import { Card } from '../_atoms'; +import { SettingRow } from './shared'; + +const MODES: ThemeMode[] = ['system', 'light', 'dark']; + +export function ThemeSection() { + const { t } = useTranslation(); + const [mode, setMode] = useState(readThemeMode()); + + const options = useMemo(() => MODES.map((value) => ({ + value, + label: t(`settings.appearance.${value === 'system' ? 'followSystem' : value}`), + })), [t]); + + const apply = (next: ThemeMode) => { + setMode(next); + setThemeMode(next); + }; + + return ( + +
{t('settings.appearance.title')}
+ + apply(next as ThemeMode)} + /> + +
+ ); +} diff --git a/openless-all/app/src/pages/settings/tabs.tsx b/openless-all/app/src/pages/settings/tabs.tsx index 4dd1bae9..84378ae0 100644 --- a/openless-all/app/src/pages/settings/tabs.tsx +++ b/openless-all/app/src/pages/settings/tabs.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from 'react'; import { RecordingInputSection } from './RecordingInputSection'; import { ShortcutsSection } from './ShortcutsSection'; import { LanguageSection } from './LanguageSection'; +import { ThemeSection } from './ThemeSection'; import { ProvidersSection } from './ProvidersSection'; import { MarketplaceSection } from './MarketplaceSection'; import { PermissionsSection } from './PermissionsSection'; @@ -33,6 +34,7 @@ export function GeneralTab() { <> {showDesktopShortcuts && } + ); From 012cb77cea1f8111b9b78c4810d160e263f0d88a Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 12 Jun 2026 23:15:12 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=8D=A1=E7=89=87=20UI?= =?UTF-8?q?=20=E8=AE=BE=E8=AE=A1=EF=BC=8C=E4=BC=98=E5=8C=96=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E5=88=87=E6=8D=A2=E4=B8=8E=E8=83=8C=E6=99=AF=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/scripts/aura-skin-contract.test.mjs | 24 ++++++ .../app/scripts/windows-ui-config.test.mjs | 55 ++++++++++--- openless-all/app/src-tauri/src/lib.rs | 82 ++++++++++++++----- openless-all/app/src/components/Capsule.tsx | 4 +- openless-all/app/src/lib/platform.ts | 18 ++++ openless-all/app/src/lib/themeMode.ts | 13 ++- .../app/src/pages/settings/shared.tsx | 4 +- openless-all/app/src/styles/global.css | 22 ++--- openless-all/app/src/styles/tokens.css | 37 +++++++++ 9 files changed, 212 insertions(+), 47 deletions(-) diff --git a/openless-all/app/scripts/aura-skin-contract.test.mjs b/openless-all/app/scripts/aura-skin-contract.test.mjs index 57962599..4e6b1c99 100644 --- a/openless-all/app/scripts/aura-skin-contract.test.mjs +++ b/openless-all/app/scripts/aura-skin-contract.test.mjs @@ -338,6 +338,30 @@ for (const pair of remoteMutedContrastPairs) { remoteMutedContrastRatios[pair.label] = assertMutedContrast(pair.tokens, pair.label, pair.bg, pair.ink); } +const olFrostBlock = extractCssBlock(globalCss, '.ol-frost'); +assert.doesNotMatch( + olFrostBlock, + /rgba\(\s*255\s*,\s*255\s*,\s*255/i, + '.ol-frost in global.css must not hardcode white rgba background gradients', +); +assert.match(globalCss, /background:\s*var\(--ol-frost-bg\)/, '.ol-frost must use --ol-frost-bg token'); + +assert.match( + tokens, + /\[data-ol-theme='dark'\][\s\S]*--ol-input-ink:/, + 'tokens.css must define --ol-input-ink in dark theme', +); +assert.match( + tokens, + /\[data-ol-theme='dark'\][\s\S]*--ol-capsule-confirm-bg:/, + 'tokens.css must define --ol-capsule-confirm-bg in dark theme', +); +assert.match( + tokens, + /\[data-ol-theme='dark'\][\s\S]*--ol-capsule-confirm-ink:/, + 'tokens.css must define --ol-capsule-confirm-ink in dark theme', +); + assert.match(globalCss, /\.ol-app-shell-bg\b/, 'global.css must expose .ol-app-shell-bg'); assert.match(globalCss, /\.ol-aura-panel\b/, 'global.css must expose .ol-aura-panel'); assert.doesNotMatch(globalCss, /@keyframes ol-aura-halo/, 'global.css must not add an animated halo'); diff --git a/openless-all/app/scripts/windows-ui-config.test.mjs b/openless-all/app/scripts/windows-ui-config.test.mjs index 4d9ddb61..c83f64ad 100644 --- a/openless-all/app/scripts/windows-ui-config.test.mjs +++ b/openless-all/app/scripts/windows-ui-config.test.mjs @@ -22,7 +22,8 @@ const capsuleTsx = await readFile(new URL('../src/components/Capsule.tsx', impor const capsuleLayoutTs = await readFile(new URL('../src/lib/capsuleLayout.ts', import.meta.url), 'utf-8'); const windowChromeTsx = await readFile(new URL('../src/components/WindowChrome.tsx', import.meta.url), 'utf-8'); const floatingShellTsx = await readFile(new URL('../src/components/FloatingShell.tsx', import.meta.url), 'utf-8'); -const tokensCss = await readFile(new URL('../src/styles/tokens.css', import.meta.url), 'utf-8'); +const themeModeTs = await readFile(new URL('../src/lib/themeMode.ts', import.meta.url), 'utf-8'); +const platformTs = await readFile(new URL('../src/lib/platform.ts', import.meta.url), 'utf-8'); if (!capsuleWindow) { throw new Error('capsule window config missing'); @@ -34,13 +35,31 @@ assertEqual(capsuleWindow.width, 220, 'windows capsule config keeps translation- assertEqual(capsuleWindow.height, 110, 'windows capsule config keeps translation-capable height baseline'); assertEqual(capsuleWindow.transparent, true, 'capsule window should keep transparent visuals'); assertEqual(capsuleWindow.alwaysOnTop, true, 'capsule window should stay above the focused app while recording'); -assertEqual(mainWindow.decorations, true, 'shared main window config should keep native macOS traffic lights'); +assertEqual(mainWindow.decorations, true, 'windows main window should keep native decorations'); assertEqual(mainWindow.visible, false, 'windows main window should stay hidden until the intended first show point'); assertMatch( libRs, - /#\[cfg\(target_os = "windows"\)\][\s\S]*?main\.set_decorations\(false\)/, - 'windows runtime should disable native chrome before the first show', + /fn apply_windows_caption_theme[\s\S]*?DWMWA_USE_IMMERSIVE_DARK_MODE[\s\S]*?DWMWA_CAPTION_COLOR[\s\S]*?DWMWA_TEXT_COLOR[\s\S]*?DWMWA_BORDER_COLOR/, + 'windows runtime should sync immersive dark mode and caption/text/border colors', +); + +assertMatch( + libRs, + /#\[tauri::command\][\s\S]*?fn set_windows_caption_theme/, + 'windows caption theme should be exposed as a Tauri command', +); + +assertMatch( + themeModeTs, + /export function applyThemeMode[\s\S]*?syncWindowsCaptionTheme/, + 'applyThemeMode should sync Windows native caption theme', +); + +assertMatch( + platformTs, + /export async function syncWindowsCaptionTheme[\s\S]*?set_windows_caption_theme/, + 'platform IPC wrapper should invoke set_windows_caption_theme', ); assertMatch( @@ -49,10 +68,18 @@ assertMatch( 'macOS capsule should show without taking the key window', ); -if (!/function WindowsResizeHandles\(\)/.test(windowChromeTsx)) { - throw new Error('windows frameless shell should expose explicit resize handles'); +const tokensCss = await readFile(new URL('../src/styles/tokens.css', import.meta.url), 'utf-8'); + +if (!/os === 'win' \|\| os === 'android' \? 0 : 14/.test(windowChromeTsx)) { + throw new Error('windows main shell should rely on native decorations instead of a frameless chrome shell'); } +assertMatch( + windowChromeTsx, + /\/\/ Windows: decorations:true 时外层不画圆角/, + 'windows WindowChrome should defer chrome to native decorations', +); + assertMatch( windowChromeTsx, /const MAC_TITLEBAR_HEIGHT = 28;/, @@ -66,8 +93,8 @@ assertMatch( if (/standardWindowButton|setFrameOrigin: origin|tune_macos_main_window_controls/.test(libRs)) { throw new Error('macOS traffic lights should not be manually repositioned; keep native AppKit button frames visible'); } -if (!/action=\"close\"/.test(windowChromeTsx) || !/tone=\"danger\"/.test(windowChromeTsx)) { - throw new Error('windows titlebar should keep the close button and danger hover treatment'); +if (!/className=\"ol-linux-close-btn\"/.test(windowChromeTsx)) { + throw new Error('linux titlebar should keep the close button treatment'); } assertMatch( tokensCss, @@ -75,12 +102,14 @@ assertMatch( 'shared motion tokens should drive shell animations and transitions', ); -if (!/startResizeDragging\(direction\)/.test(windowChromeTsx)) { - throw new Error('windows resize handles should delegate edge dragging to Tauri'); -} +assertMatch( + windowChromeTsx, + /function LinuxTitlebar\(\)/, + 'linux should keep the custom ol-linux-titlebar shell', +); -if (!/borderRadius:\s*'var\(--ol-window-console-radius\)'/.test(floatingShellTsx)) { - throw new Error('floating shell should consume the shared window-console radius'); +if (!/borderRadius:\s*'var\(--ol-r-lg\)'/.test(floatingShellTsx)) { + throw new Error('floating shell should consume the shared radius token'); } assertMatch( diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 021ee9fa..b08e0f2e 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -281,6 +281,7 @@ macro_rules! app_invoke_handler_desktop { commands::sherpa_onnx_asr_reveal_model_dir, commands::export_error_log, restart_app, + set_windows_caption_theme, ] }; } @@ -507,9 +508,9 @@ fn run_desktop() { if let Err(e) = apply_mica(&main, None) { log::warn!("[main] mica failed: {e}"); } - // Win11 22H2+: 把原生标题栏底色调成白色,与应用 sidebar 视觉统一。 + // Win11 22H2+: 同步原生标题栏主题;前端就绪后会再调 set_windows_caption_theme。 // 老版 Windows 静默失败,不阻塞。 - apply_windows_caption_color(&main); + apply_windows_caption_theme(&main, false); } // 静默启动开关:prefs.start_minimized = true → 不弹主窗口, // 用户从菜单栏 / 托盘点击访问。开机自启时尤其有用,避免每次 @@ -957,41 +958,84 @@ pub(crate) fn refresh_tray_microphone_menu(_app: &AppHandle) -> tauri::Result<() Ok(()) } -/// 把 Win11 原生标题栏底色刷成白色,与应用 sidebar 视觉统一。需要 Win11 22H2+ -/// (Build 22621+) 才支持 `DWMWA_CAPTION_COLOR`(35);老 Windows 上 DwmSetWindowAttribute -/// 返回错误,仅打 warn 不阻塞启动。 +/// Win11 22H2+ (Build 22621+) 同步原生标题栏沉浸式暗色 / caption / text / border 色。 +/// 老 Windows 上 DwmSetWindowAttribute 返回错误,仅打 warn 不阻塞启动。 #[cfg(target_os = "windows")] -fn apply_windows_caption_color(window: &tauri::WebviewWindow) { +fn apply_windows_caption_theme(window: &tauri::WebviewWindow, dark: bool) { use raw_window_handle::{HasWindowHandle, RawWindowHandle}; use windows::Win32::Foundation::HWND; - use windows::Win32::Graphics::Dwm::{DwmSetWindowAttribute, DWMWA_CAPTION_COLOR}; + use windows::Win32::Graphics::Dwm::{ + DwmSetWindowAttribute, DWMWA_BORDER_COLOR, DWMWA_CAPTION_COLOR, DWMWA_TEXT_COLOR, + DWMWA_USE_IMMERSIVE_DARK_MODE, + }; let handle = match window.window_handle().map(|h| h.as_raw()) { Ok(RawWindowHandle::Win32(handle)) => handle, Ok(other) => { - log::warn!("[main] unexpected raw window handle for caption color: {other:?}"); + log::warn!("[main] unexpected raw window handle for caption theme: {other:?}"); return; } Err(e) => { - log::warn!("[main] read raw window handle for caption color failed: {e}"); + log::warn!("[main] read raw window handle for caption theme failed: {e}"); return; } }; let hwnd = HWND(handle.hwnd.get() as *mut core::ffi::c_void); - // COLORREF 0x00BBGGRR 编码——选用 rgb(245,245,247) 跟 WindowChrome 的 glass linear-gradient - // 起始色一致,减小原生 caption bar 跟应用磨砂玻璃的色差(用户反馈:纯白 caption + 半透灰 glass - // 色差很丑)。R=0xF5 G=0xF5 B=0xF7 → COLORREF = 0x00F7F5F5。 - let glass_match: u32 = 0x00F7F5F5; + // COLORREF 0x00BBGGRR — light 对齐 WindowChrome glass 起始色 rgb(245,245,247); + // dark 对齐 tokens.css --ol-surface (#141922) / --ol-ink (#f4f7fb) / --ol-surface-2 (#1a202b)。 + let immersive_dark: i32 = i32::from(dark); + let caption_color: u32 = if dark { 0x0022_1914 } else { 0x00F7_F5F5 }; + let text_color: u32 = if dark { 0x00FB_F7F4 } else { 0x002A_170F }; + let border_color: u32 = if dark { 0x002B_201A } else { 0x00E8_E8E8 }; + unsafe { - if let Err(e) = DwmSetWindowAttribute( + set_dwm_window_attribute( + hwnd, + DWMWA_USE_IMMERSIVE_DARK_MODE, + &immersive_dark, + "immersive dark mode", + ); + set_dwm_window_attribute( hwnd, DWMWA_CAPTION_COLOR, - &glass_match as *const _ as *const core::ffi::c_void, - std::mem::size_of_val(&glass_match) as u32, - ) { - log::warn!("[main] set caption color failed (likely pre-22H2 Win): {e}"); - } + &caption_color, + "caption color", + ); + set_dwm_window_attribute(hwnd, DWMWA_TEXT_COLOR, &text_color, "text color"); + set_dwm_window_attribute(hwnd, DWMWA_BORDER_COLOR, &border_color, "border color"); + } +} + +#[cfg(target_os = "windows")] +unsafe fn set_dwm_window_attribute( + hwnd: windows::Win32::Foundation::HWND, + attribute: windows::Win32::Graphics::Dwm::DWMWINDOWATTRIBUTE, + value: &T, + label: &str, +) { + use windows::Win32::Graphics::Dwm::DwmSetWindowAttribute; + + if let Err(e) = DwmSetWindowAttribute( + hwnd, + attribute, + value as *const _ as *const core::ffi::c_void, + std::mem::size_of_val(value) as u32, + ) { + log::warn!("[main] set {label} failed (likely pre-22H2 Win): {e}"); + } +} + +/// 前端主题切换时同步主窗口原生标题栏;非 Windows 为 no-op。 +#[tauri::command] +fn set_windows_caption_theme(app: AppHandle, dark: bool) { + #[cfg(target_os = "windows")] + if let Some(main) = app.get_webview_window("main") { + apply_windows_caption_theme(&main, dark); + } + #[cfg(not(target_os = "windows"))] + { + let _ = (app, dark); } } diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index f2b7323a..091dc104 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -114,10 +114,10 @@ function CircleButton({ variant, enabled, onClick }: CircleButtonProps) { width: 28, height: 28, borderRadius: 'var(--ol-pill-radius)', - background: isCancel ? 'var(--ol-control-muted-strong)' : 'var(--ol-control-solid)', + background: isCancel ? 'var(--ol-capsule-cancel-bg)' : 'var(--ol-capsule-confirm-bg)', backdropFilter: useBackdrop ? 'blur(12px) saturate(160%)' : 'none', WebkitBackdropFilter: useBackdrop ? 'blur(12px) saturate(160%)' : 'none', - color: 'var(--ol-ink)', + color: isCancel ? 'var(--ol-capsule-cancel-ink)' : 'var(--ol-capsule-confirm-ink)', border: '0.8px solid var(--ol-glass-border)', display: 'inline-flex', alignItems: 'center', diff --git a/openless-all/app/src/lib/platform.ts b/openless-all/app/src/lib/platform.ts index 03badcdb..3e91c6d2 100644 --- a/openless-all/app/src/lib/platform.ts +++ b/openless-all/app/src/lib/platform.ts @@ -120,3 +120,21 @@ export async function getPlatformCapabilities(): Promise { export function getCachedPlatformCapabilities(): PlatformCapabilities | null { return cachedCapabilities; } + +/** Win11 原生标题栏暗色同步;非 Windows / 非 Tauri 为 no-op。 */ +export async function syncWindowsCaptionTheme(dark: boolean): Promise { + if (typeof window === 'undefined') return; + if (detectOS() !== 'win') return; + + const isTauri = + globalThis.window !== undefined && + '__TAURI_INTERNALS__' in globalThis.window; + if (!isTauri) return; + + try { + const { invoke } = await import('@tauri-apps/api/core'); + await invoke('set_windows_caption_theme', { dark }); + } catch (err) { + console.warn('[platform] set_windows_caption_theme failed', err); + } +} diff --git a/openless-all/app/src/lib/themeMode.ts b/openless-all/app/src/lib/themeMode.ts index 14f2ba65..5c1138a5 100644 --- a/openless-all/app/src/lib/themeMode.ts +++ b/openless-all/app/src/lib/themeMode.ts @@ -1,6 +1,9 @@ // 主题模式 — localStorage 持久化,通过 html[data-ol-theme] 切换暗色 token 集。 +import { syncWindowsCaptionTheme } from './platform'; + export type ThemeMode = 'system' | 'light' | 'dark'; +export type ResolvedThemeMode = 'light' | 'dark'; const THEME_MODE_KEY = 'ol-theme-mode'; @@ -8,6 +11,12 @@ function systemPrefersDark(): boolean { return window.matchMedia('(prefers-color-scheme: dark)').matches; } +export function resolveThemeMode(mode: ThemeMode): ResolvedThemeMode { + if (mode === 'dark') return 'dark'; + if (mode === 'light') return 'light'; + return systemPrefersDark() ? 'dark' : 'light'; +} + export function readThemeMode(): ThemeMode { try { const v = window.localStorage.getItem(THEME_MODE_KEY); @@ -17,12 +26,14 @@ export function readThemeMode(): ThemeMode { } export function applyThemeMode(mode: ThemeMode): void { + const resolved = resolveThemeMode(mode); const root = document.documentElement; - if (mode === 'dark' || (mode === 'system' && systemPrefersDark())) { + if (resolved === 'dark') { root.dataset.olTheme = 'dark'; } else { delete root.dataset.olTheme; } + void syncWindowsCaptionTheme(resolved === 'dark'); } export function setThemeMode(mode: ThemeMode): ThemeMode { diff --git a/openless-all/app/src/pages/settings/shared.tsx b/openless-all/app/src/pages/settings/shared.tsx index 78e556fa..952e3739 100644 --- a/openless-all/app/src/pages/settings/shared.tsx +++ b/openless-all/app/src/pages/settings/shared.tsx @@ -137,7 +137,9 @@ export const inputStyle: CSSProperties = { fontSize: 12.5, fontFamily: "inherit", outline: "none", - background: "var(--ol-surface-2)", + background: "var(--ol-input-bg)", + color: "var(--ol-input-ink)", + caretColor: "var(--ol-input-ink)", width: "100%", maxWidth: 360, transition: diff --git a/openless-all/app/src/styles/global.css b/openless-all/app/src/styles/global.css index 3d975d52..bc085cc8 100644 --- a/openless-all/app/src/styles/global.css +++ b/openless-all/app/src/styles/global.css @@ -66,6 +66,13 @@ input, textarea { font-family: inherit; user-select: text; -webkit-user-select: text; + color: var(--ol-input-ink); + caret-color: var(--ol-input-ink); +} + +input::placeholder, +textarea::placeholder { + color: var(--ol-input-placeholder); } a { color: inherit; text-decoration: none; } @@ -145,14 +152,7 @@ a { color: inherit; text-decoration: none; } .ol-frost { position: relative; isolation: isolate; - /* 体渐变 + 左上高光扫面 —— 半透明白底(~0.9),给磨砂面体积感而不是死平。 */ - background: - radial-gradient(135% 86% at 15% -10%, - rgba(255, 255, 255, 0.97) 0%, - rgba(255, 255, 255, 0) 56%), - linear-gradient(161deg, - rgba(255, 255, 255, 0.93) 0%, - rgba(244, 247, 252, 0.88) 100%); + background: var(--ol-frost-bg); } .ol-frost::before { @@ -161,12 +161,12 @@ a { color: inherit; text-decoration: none; } inset: 0; z-index: -1; border-radius: inherit; - /* 不透明灰度噪点,normal 混合 —— 这是"磨砂颗粒"的来源。opacity 直接等于颗粒 - 强度:0.1 几乎看不见,0.32 能明显看出磨砂质感又不脏。要更糙往 0.45 调。 */ + /* 不透明灰度噪点,normal 混合 —— 这是"磨砂颗粒"的来源。opacity 由 + --ol-frost-grain-opacity 控制颗粒强度。 */ background-image: var(--ol-frost-grain); /* tile 与 SVG 视口同尺寸(100),stitchTiles 的无缝拼接才成立 */ background-size: 100px 100px; - opacity: 0.32; + opacity: var(--ol-frost-grain-opacity); pointer-events: none; } diff --git a/openless-all/app/src/styles/tokens.css b/openless-all/app/src/styles/tokens.css index a19679e6..d822cfec 100755 --- a/openless-all/app/src/styles/tokens.css +++ b/openless-all/app/src/styles/tokens.css @@ -56,6 +56,25 @@ /* Frost grain texture used by the .ol-frost helpers in global.css. */ --ol-frost-grain: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='100'%20height='100'%3E%3Cfilter%20id='g'%3E%3CfeTurbulence%20type='fractalNoise'%20baseFrequency='0.95'%20numOctaves='1'%20stitchTiles='stitch'/%3E%3CfeColorMatrix%20type='matrix'%20values='0.34%200.34%200.34%200%200%200.34%200.34%200.34%200%200%200.34%200.34%200.34%200%200%200%200%200%200%201'/%3E%3C/filter%3E%3Crect%20width='100'%20height='100'%20filter='url(%23g)'/%3E%3C/svg%3E"); + --ol-frost-bg: + radial-gradient(135% 86% at 15% -10%, + rgba(255, 255, 255, 0.97) 0%, + rgba(255, 255, 255, 0) 56%), + linear-gradient(161deg, + rgba(255, 255, 255, 0.93) 0%, + rgba(244, 247, 252, 0.88) 100%); + --ol-frost-grain-opacity: 0.32; + + /* Capsule circle buttons */ + --ol-capsule-cancel-bg: var(--ol-control-muted-strong); + --ol-capsule-cancel-ink: var(--ol-ink); + --ol-capsule-confirm-bg: var(--ol-control-solid); + --ol-capsule-confirm-ink: var(--ol-ink); + + /* Settings / form inputs */ + --ol-input-bg: var(--ol-surface-2); + --ol-input-ink: var(--ol-ink); + --ol-input-placeholder: var(--ol-placeholder); /* Motion */ --ol-motion-spring: cubic-bezier(0.16, 1, 0.3, 1); @@ -240,6 +259,24 @@ --ol-glass-border: rgba(255, 255, 255, 0.10); --ol-aura-glass-border: rgba(255, 255, 255, 0.10); + --ol-frost-bg: + radial-gradient(135% 86% at 15% -10%, + rgba(255, 255, 255, 0.10) 0%, + rgba(255, 255, 255, 0) 56%), + linear-gradient(161deg, + rgba(22, 28, 38, 0.88) 0%, + rgba(14, 18, 26, 0.82) 100%); + --ol-frost-grain-opacity: 0.28; + + --ol-capsule-cancel-bg: rgba(255, 255, 255, 0.10); + --ol-capsule-cancel-ink: var(--ol-ink-2); + --ol-capsule-confirm-bg: var(--ol-accent-solid-bg); + --ol-capsule-confirm-ink: var(--ol-accent-solid-ink); + + --ol-input-bg: rgba(255, 255, 255, 0.06); + --ol-input-ink: var(--ol-ink); + --ol-input-placeholder: var(--ol-ink-4); + --ol-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.34), 0 0 0 0.5px rgba(255, 255, 255, 0.04); --ol-shadow-md: 0 10px 30px -20px rgba(0, 0, 0, 0.42), 0 0 0 0.5px rgba(255, 255, 255, 0.05); --ol-shadow-lg: 0 24px 72px -32px rgba(0, 0, 0, 0.58), 0 0 0 0.5px rgba(255, 255, 255, 0.05); From 356e8ad480337c16d3dbf5380666fcd6cf0541d5 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sat, 13 Jun 2026 00:47:53 +0800 Subject: [PATCH 4/4] Fix Style page dark mode readability with style tokens. Replace hardcoded light card backgrounds with --ol-style-* tokens and strengthen dark subtle/input contrast. Co-authored-by: Cursor --- .../app/scripts/aura-skin-contract.test.mjs | 33 +++++++- openless-all/app/src/pages/Style.tsx | 76 ++++++++++--------- openless-all/app/src/styles/tokens.css | 8 +- 3 files changed, 75 insertions(+), 42 deletions(-) diff --git a/openless-all/app/scripts/aura-skin-contract.test.mjs b/openless-all/app/scripts/aura-skin-contract.test.mjs index 4e6b1c99..5228a23e 100644 --- a/openless-all/app/scripts/aura-skin-contract.test.mjs +++ b/openless-all/app/scripts/aura-skin-contract.test.mjs @@ -186,7 +186,7 @@ assertUsesClassName( 'sample should accept className usage', ); -const [tokens, globalCss, shell, settingsModal, overview, settingsTabs, themeMode, sourceFiles, remoteStyle] = await Promise.all([ +const [tokens, globalCss, shell, settingsModal, overview, settingsTabs, themeMode, stylePage, sourceFiles, remoteStyle] = await Promise.all([ read('src/styles/tokens.css'), read('src/styles/global.css'), read('src/components/FloatingShell.tsx'), @@ -194,6 +194,7 @@ const [tokens, globalCss, shell, settingsModal, overview, settingsTabs, themeMod read('src/pages/Overview.tsx'), read('src/pages/settings/tabs.tsx'), read('src/lib/themeMode.ts'), + read('src/pages/Style.tsx'), walkSourceFiles(srcRoot), read('src-tauri/src/remote_server/assets/style.css'), ]); @@ -397,6 +398,36 @@ assert.match( 'themeMode.ts must apply theme via data-ol-theme / dataset.olTheme', ); +const forbiddenStyleCardLightBackgrounds = [ + /rgba\(\s*255\s*,\s*255\s*,\s*255/i, + /rgba\(\s*248\s*,\s*250\s*,\s*252/i, + /rgba\(\s*239\s*,\s*246\s*,\s*255/i, +]; + +for (const pattern of forbiddenStyleCardLightBackgrounds) { + assert.doesNotMatch( + stylePage, + pattern, + 'Style.tsx must not hardcode light style-card backgrounds (use --ol-style-* tokens)', + ); +} + +assert.match( + stylePage, + /--ol-style-card-bg/, + 'Style.tsx must reference --ol-style-card-bg for style pack surfaces', +); +assert.match( + stylePage, + /--ol-style-card-ink/, + 'Style.tsx must reference --ol-style-card-ink for style pack text', +); +assert.match( + stylePage, + /--ol-style-subtle-bg/, + 'Style.tsx must reference --ol-style-subtle-bg for editor subtle surfaces', +); + const illegalCssStringPatterns = [ /color:\s*'var\([^)]+\)';/, /background:\s*'var\([^)]+\)';/, diff --git a/openless-all/app/src/pages/Style.tsx b/openless-all/app/src/pages/Style.tsx index 7e282a5d..43cabc5c 100644 --- a/openless-all/app/src/pages/Style.tsx +++ b/openless-all/app/src/pages/Style.tsx @@ -613,15 +613,11 @@ export function Style() { textAlign: 'left', position: 'relative', border: '0.5px solid', - borderColor: pack.active ? 'var(--ol-blue)' : 'var(--ol-line)', - background: pack.active - ? 'linear-gradient(180deg, rgba(239,246,255,0.92), rgba(255,255,255,0.98))' - : isBuiltin - ? 'linear-gradient(180deg, rgba(248,250,252,0.92), rgba(241,245,249,0.85))' - : 'linear-gradient(180deg, rgba(255,255,255,0.98), rgba(248,250,252,0.92))', + borderColor: pack.active ? 'var(--ol-style-card-border-active)' : 'var(--ol-style-card-border)', + background: pack.active ? 'var(--ol-style-card-bg-active)' : 'var(--ol-style-card-bg)', borderRadius: 'var(--ol-modal-radius)', padding: 16, - boxShadow: pack.active ? '0 0 0 3px var(--ol-blue-ring)' : 'none', + boxShadow: pack.active ? 'var(--ol-style-card-shadow-active)' : 'var(--ol-style-card-shadow)', cursor: 'default', minHeight: 204, }} @@ -629,7 +625,7 @@ export function Style() {
-
+
{pack.name}
@@ -646,7 +642,7 @@ export function Style() {
@@ -683,8 +679,8 @@ export function Style() { width: 36, height: 36, borderRadius: 'var(--ol-r-lg)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, - border: '0.5px solid rgba(239,68,68,0.32)', - background: 'rgba(254,242,242,0.6)', + border: '0.5px solid var(--ol-style-delete-border)', + background: 'var(--ol-style-delete-bg)', color: 'var(--ol-red, #ef4444)', cursor: busy === 'deleting' ? 'wait' : 'pointer', opacity: busy === 'deleting' ? 0.55 : 1, @@ -755,11 +751,11 @@ export function Style() { justifyContent: 'center', gap: 8, textAlign: 'center', - border: '0.5px dashed var(--ol-line-strong)', + border: '0.5px dashed var(--ol-style-add-tile-border)', borderRadius: 'var(--ol-modal-radius)', padding: 16, - background: 'transparent', - color: 'var(--ol-ink-3)', + background: 'var(--ol-style-add-tile-bg)', + color: 'var(--ol-style-card-ink-3)', cursor: busy === 'creating' ? 'wait' : 'pointer', opacity: busy === 'creating' ? 0.55 : 1, minHeight: 204, @@ -770,14 +766,14 @@ export function Style() { style={{ width: 44, height: 44, borderRadius: 'var(--ol-pill-radius)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - background: 'rgba(15,23,42,0.04)', - color: 'var(--ol-ink-2)', + background: 'var(--ol-style-add-tile-icon-bg)', + color: 'var(--ol-style-card-ink-2)', }} >
-
{t('style.pack.addPackTileTitle')}
-
{t('style.pack.addPackTileHint')}
+
{t('style.pack.addPackTileTitle')}
+
{t('style.pack.addPackTileHint')}
@@ -990,8 +986,9 @@ export function Style() {
@@ -1058,8 +1055,9 @@ export function Style() { style={{ padding: 14, borderRadius: 'var(--ol-bubble-radius)', - background: 'linear-gradient(180deg, rgba(248,250,252,0.98), rgba(241,245,249,0.95))', - border: '0.5px solid var(--ol-line)', + background: 'var(--ol-style-subtle-bg)', + border: '0.5px solid var(--ol-style-subtle-border)', + boxShadow: 'var(--ol-style-subtle-shadow)', }} >
@@ -1095,7 +1093,9 @@ export function Style() { key={`${draft.id}-example-${index}`} padding={16} style={{ - background: 'linear-gradient(180deg, rgba(255,255,255,0.98), rgba(248,250,252,0.98))', + background: 'var(--ol-style-card-bg)', + border: '0.5px solid var(--ol-style-card-border)', + boxShadow: 'var(--ol-style-card-shadow)', }} >
@@ -1129,8 +1129,8 @@ export function Style() {
@@ -1147,8 +1147,8 @@ export function Style() {
@@ -1181,8 +1181,8 @@ function MetaItem({ label, value }: { label: string; value: string }) {
@@ -1218,8 +1218,8 @@ function DirectiveRow({ gap: 12, padding: '10px 12px', borderRadius: 'var(--ol-r-lg)', - border: '0.5px solid rgba(148,163,184,0.2)', - background: 'var(--ol-glass-bg-strong)', + border: '0.5px solid var(--ol-style-subtle-border)', + background: 'var(--ol-style-card-bg)', }} >
@@ -1239,8 +1239,9 @@ const inputStyle: CSSProperties = { minHeight: 38, padding: '9px 11px', borderRadius: 'var(--ol-r-md)', - border: '0.5px solid var(--ol-line-strong)', - background: 'var(--ol-control-solid)', + border: '0.5px solid var(--ol-style-input-border)', + background: 'var(--ol-style-input-bg)', + boxShadow: 'var(--ol-style-input-shadow)', color: 'var(--ol-ink)', font: 'inherit', fontSize: 12.5, @@ -1251,8 +1252,9 @@ const textareaStyle: CSSProperties = { boxSizing: 'border-box', padding: '11px 12px', borderRadius: 'var(--ol-r-lg)', - border: '0.5px solid var(--ol-line-strong)', - background: 'var(--ol-control-solid)', + border: '0.5px solid var(--ol-style-input-border)', + background: 'var(--ol-style-input-bg)', + boxShadow: 'var(--ol-style-input-shadow)', color: 'var(--ol-ink)', font: 'inherit', fontSize: 12.5, diff --git a/openless-all/app/src/styles/tokens.css b/openless-all/app/src/styles/tokens.css index d822cfec..48ce77e6 100755 --- a/openless-all/app/src/styles/tokens.css +++ b/openless-all/app/src/styles/tokens.css @@ -373,11 +373,11 @@ --ol-style-add-tile-bg: rgba(255,255,255,0.02); --ol-style-add-tile-icon-bg: rgba(255,255,255,0.10); --ol-style-add-tile-border: rgba(255,255,255,0.10); - --ol-style-input-bg: rgba(255,255,255,0.04); - --ol-style-input-border: rgba(255,255,255,0.08); + --ol-style-input-bg: rgba(255,255,255,0.06); + --ol-style-input-border: rgba(255,255,255,0.10); --ol-style-input-shadow: none; - --ol-style-subtle-bg: rgba(255,255,255,0.03); - --ol-style-subtle-border: rgba(255,255,255,0.06); + --ol-style-subtle-bg: rgba(255,255,255,0.05); + --ol-style-subtle-border: rgba(255,255,255,0.09); --ol-style-subtle-shadow: none; --ol-style-editor-bg: linear-gradient(180deg, rgba(27,27,30,0.96), rgba(23,23,26,0.94)); --ol-style-editor-border: rgba(255,255,255,0.07);