diff --git a/docs/ui-interaction-and-motion.md b/docs/ui-interaction-and-motion.md index 0adfb2ea8..5bf1fef7f 100644 --- a/docs/ui-interaction-and-motion.md +++ b/docs/ui-interaction-and-motion.md @@ -127,6 +127,10 @@ keyboard navigation, outside dismissal, scroll locking, and focus return. Keep the current workstation as the compatibility default and gate compact desktop density behind both sufficient width and a fine pointer so a visual profile never reduces touch usability. +- A style profile may declare an optional recommended day/night palette pair. + Selecting the style must never apply that pair automatically: Settings shows + it as an explicit preview and action so users can adopt the complete look + without silently losing their existing color choices. - Delete superseded event plumbing during migration. A component is not migrated if its old global Escape/outside-click/focus-loop implementation is still running beside the primitive. diff --git a/ui/index.html b/ui/index.html index 270663800..55764f2e4 100644 --- a/ui/index.html +++ b/ui/index.html @@ -17,7 +17,7 @@ var t = storedTheme === 'day' || storedTheme === 'night' || storedTheme === 'auto' ? storedTheme : storedTheme === 'light' ? 'day' : storedTheme === 'dark' ? 'night' : 'auto'; - var palettes = ['paper', 'porcelain', 'linen', 'graphite', 'midnight', 'moss', 'iris']; + var palettes = ['paper', 'porcelain', 'linen', 'windows-classic', 'graphite', 'midnight', 'moss', 'iris']; var storedDay = palettes.indexOf(state.dayPalette) >= 0 ? state.dayPalette : state.lightPalette; var storedNight = palettes.indexOf(state.nightPalette) >= 0 ? state.nightPalette : state.darkPalette; var day = palettes.indexOf(storedDay) >= 0 ? storedDay : 'paper'; diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 08d4ff577..7aae68fa3 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -287,6 +287,10 @@ export const en = { description: 'Color and layout preferences. Stored locally on this device.', interfaceStyle: 'Interface style', interfaceStyleDescription: 'Changes component shape, density, typography, and motion without changing your colors.', + recommendedPalette: '{{style}} recommended colors', + recommendedPaletteDescription: 'Optional — applies {{palette}} to both Day and Night. Choosing a style never changes colors by itself.', + applyRecommendedPalette: 'Use recommended colors', + recommendedPaletteApplied: 'Recommended colors applied', colorMode: 'Color mode', colorModeDescription: 'Auto follows the system between Day and Night slots; Day and Night pin one slot.', dayPalette: 'Day palette', @@ -1223,13 +1227,14 @@ export const en = { 'broker-classic': 'Dense TWS-inspired trading workstation', }, palette: { - paper: 'Paper', porcelain: 'Porcelain', linen: 'Linen', + paper: 'Paper', porcelain: 'Porcelain', linen: 'Linen', 'windows-classic': 'Windows Classic', graphite: 'Graphite', midnight: 'Midnight', moss: 'Moss', iris: 'Iris', }, paletteDescription: { paper: 'Warm, editorial neutrals', porcelain: 'Cool, crisp workspace', linen: 'Oat, ink, and terracotta', + 'windows-classic': 'System silver with navy selection', graphite: 'Neutral low-glare dark', midnight: 'Deep blue night palette', moss: 'Forest ink with soft amber', diff --git a/ui/src/i18n/locales/ja.ts b/ui/src/i18n/locales/ja.ts index 3f34b800c..170e6bea3 100644 --- a/ui/src/i18n/locales/ja.ts +++ b/ui/src/i18n/locales/ja.ts @@ -276,6 +276,10 @@ export const ja: Resources = { description: '色とレイアウトの設定。この端末にのみ保存されます。', interfaceStyle: 'インターフェーススタイル', interfaceStyleDescription: '選択中の色を変えずに、形状、密度、書体、モーションを変更します。', + recommendedPalette: '{{style}} のおすすめ配色', + recommendedPaletteDescription: '任意 — {{palette}}を昼と夜の両方に適用します。スタイルを選ぶだけでは色は変わりません。', + applyRecommendedPalette: 'おすすめ配色を使う', + recommendedPaletteApplied: 'おすすめ配色を適用済み', colorMode: 'カラーモード', colorModeDescription: '自動はシステムに追従して昼と夜のスロットを切り替え、昼と夜は対応するスロットに固定します。', dayPalette: '昼用パレット', @@ -1212,13 +1216,14 @@ export const ja: Resources = { 'broker-classic': 'TWS に着想を得た高密度な取引画面', }, palette: { - paper: 'ペーパー', porcelain: 'ポーセリン', linen: 'リネン', + paper: 'ペーパー', porcelain: 'ポーセリン', linen: 'リネン', 'windows-classic': 'Windows クラシック', graphite: 'グラファイト', midnight: 'ミッドナイト', moss: 'モス', iris: 'アイリス', }, paletteDescription: { paper: '温かみのある編集向け中間色', porcelain: '涼しく鮮明なワークスペース', linen: 'オート麦色、墨色、テラコッタ', + 'windows-classic': 'システムシルバーと濃紺の選択色', graphite: '低反射のニュートラルダーク', midnight: '深いブルーの夜間パレット', moss: '森の墨色と柔らかな琥珀色', diff --git a/ui/src/i18n/locales/zh-Hant.ts b/ui/src/i18n/locales/zh-Hant.ts index 3257da400..73fa9cd9d 100644 --- a/ui/src/i18n/locales/zh-Hant.ts +++ b/ui/src/i18n/locales/zh-Hant.ts @@ -283,6 +283,10 @@ export const zhHant: Resources = { description: '色彩與版面偏好,僅儲存在本機。', interfaceStyle: '介面樣式', interfaceStyleDescription: '調整元件外形、密度、字體與動效,不會改變你選擇的色卡。', + recommendedPalette: '{{style}}推薦配色', + recommendedPaletteDescription: '可選——將{{palette}}同時用於日間與暗夜。單獨選擇樣式不會改變目前色卡。', + applyRecommendedPalette: '套用推薦配色', + recommendedPaletteApplied: '已套用推薦配色', colorMode: '色彩模式', colorModeDescription: '自動模式跟隨系統在日間與暗夜槽位間切換;日間與暗夜模式會固定對應槽位。', dayPalette: '日間色卡', @@ -1219,13 +1223,14 @@ export const zhHant: Resources = { 'broker-classic': '受 TWS 啟發的高密度交易工作台', }, palette: { - paper: '紙張', porcelain: '白瓷', linen: '亞麻', + paper: '紙張', porcelain: '白瓷', linen: '亞麻', 'windows-classic': 'Windows 經典', graphite: '石墨', midnight: '午夜', moss: '苔色', iris: '鳶尾', }, paletteDescription: { paper: '溫暖的編輯風中性色', porcelain: '清爽的冷調工作區', linen: '燕麥紙、墨棕與陶土色', + 'windows-classic': '系統銀灰與深藍選取色', graphite: '低眩光的中性深色', midnight: '深藍調暗夜色卡', moss: '墨綠底色與柔和琥珀', diff --git a/ui/src/i18n/locales/zh.ts b/ui/src/i18n/locales/zh.ts index c38c5e70f..765d6fd4a 100644 --- a/ui/src/i18n/locales/zh.ts +++ b/ui/src/i18n/locales/zh.ts @@ -275,6 +275,10 @@ export const zh: Resources = { description: '色彩与布局偏好,仅保存在本设备。', interfaceStyle: '界面样式', interfaceStyleDescription: '调整组件外形、密度、字体与动效,不会改变你选择的色卡。', + recommendedPalette: '{{style}}推荐配色', + recommendedPaletteDescription: '可选——将{{palette}}同时用于日间和暗夜。单独选择样式不会改变当前色卡。', + applyRecommendedPalette: '应用推荐配色', + recommendedPaletteApplied: '已应用推荐配色', colorMode: '色彩模式', colorModeDescription: '自动模式跟随系统在日间和暗夜槽位间切换;日间和暗夜模式会固定对应槽位。', dayPalette: '日间色卡', @@ -1211,13 +1215,14 @@ export const zh: Resources = { 'broker-classic': '受 TWS 启发的高密度交易工作台', }, palette: { - paper: '纸张', porcelain: '白瓷', linen: '亚麻', + paper: '纸张', porcelain: '白瓷', linen: '亚麻', 'windows-classic': 'Windows 经典', graphite: '石墨', midnight: '午夜', moss: '苔色', iris: '鸢尾', }, paletteDescription: { paper: '温暖的编辑风中性色', porcelain: '清爽的冷调工作区', linen: '燕麦纸、墨棕与陶土色', + 'windows-classic': '系统银灰与深蓝选择色', graphite: '低眩光的中性深色', midnight: '深蓝调暗夜色卡', moss: '墨绿底色与柔和琥珀', diff --git a/ui/src/pages/SettingsPage.appearance.spec.tsx b/ui/src/pages/SettingsPage.appearance.spec.tsx index 420516045..8dccda6a4 100644 --- a/ui/src/pages/SettingsPage.appearance.spec.tsx +++ b/ui/src/pages/SettingsPage.appearance.spec.tsx @@ -52,6 +52,22 @@ describe('AppearanceSection palette pair editor', () => { expect(useThemeStore.getState().dayPalette).toBe('paper') expect(useThemeStore.getState().nightPalette).toBe('graphite') expect(screen.getByRole('radio', { name: 'Windows 98' }).getAttribute('aria-checked')).toBe('true') + expect(screen.getByRole('button', { name: 'Use recommended colors' })).toBeTruthy() + }) + + it('applies the selected style recommended palette only after explicit confirmation', () => { + render() + + fireEvent.click(screen.getByRole('radio', { name: 'Windows 98' })) + expect(useThemeStore.getState().dayPalette).toBe('paper') + expect(useThemeStore.getState().nightPalette).toBe('graphite') + + fireEvent.click(screen.getByRole('button', { name: 'Use recommended colors' })) + + expect(useThemeStore.getState().theme).toBe('auto') + expect(useThemeStore.getState().dayPalette).toBe('windows-classic') + expect(useThemeStore.getState().nightPalette).toBe('windows-classic') + expect(screen.getByRole('button', { name: 'Recommended colors applied' }).hasAttribute('disabled')).toBe(true) }) it('does not expose the retired editor tab strip preference', () => { diff --git a/ui/src/pages/SettingsPage.tsx b/ui/src/pages/SettingsPage.tsx index b4d476706..12826eafc 100644 --- a/ui/src/pages/SettingsPage.tsx +++ b/ui/src/pages/SettingsPage.tsx @@ -20,7 +20,11 @@ import { type ThemePreferenceSlot, } from '../theme/palettes' import { useThemeStore, type AppTheme } from '../theme/store' -import { UI_STYLE_PROFILES, type UiStyleProfileId } from '../theme/styleProfiles' +import { + UI_STYLE_PROFILES, + type UiStyleProfileDefinition, + type UiStyleProfileId, +} from '../theme/styleProfiles' import { useEffectivePreferenceSlot } from '../theme/useEffectiveTheme' import { AboutOpenAliceSection } from '../components/settings/AboutOpenAliceSection' @@ -50,6 +54,13 @@ export function AppearanceSection() { const modes: readonly AppTheme[] = ['auto', 'day', 'night'] const activePalette = effectiveSlot === 'day' ? dayPalette : nightPalette const activePaletteDefinition = paletteDefinition(activePalette) + const activeStyleDefinition: UiStyleProfileDefinition = UI_STYLE_PROFILES.find( + (profile) => profile.id === uiStyle, + )! + const recommendedPalettePair = activeStyleDefinition.recommendedPalettePair + const recommendedPaletteApplied = recommendedPalettePair != null + && dayPalette === recommendedPalettePair.day + && nightPalette === recommendedPalettePair.night const editingPalette = editingSlot === 'day' ? dayPalette : nightPalette const recommendedAppearance = editingSlot === 'day' ? 'light' : 'dark' const visiblePalettes = paletteFilter === 'recommended' @@ -82,6 +93,12 @@ export function AppearanceSection() { setPaletteFilter('recommended') } + const applyRecommendedPalette = () => { + if (!recommendedPalettePair) return + setDayPalette(recommendedPalettePair.day) + setNightPalette(recommendedPalettePair.night) + } + return (
@@ -109,6 +126,45 @@ export function AppearanceSection() { /> ))}
+ {recommendedPalettePair && ( +
+ + + + + + + + + + +
+ + {t('settings.appearance.recommendedPalette', { + style: t(activeStyleDefinition.labelKey), + })} + +

+ {t('settings.appearance.recommendedPaletteDescription', { + palette: t(paletteDefinition(recommendedPalettePair.day).labelKey), + })} +

+
+ +
+ )}
diff --git a/ui/src/theme/noFlashTheme.spec.ts b/ui/src/theme/noFlashTheme.spec.ts index 851d236fd..5a513b599 100644 --- a/ui/src/theme/noFlashTheme.spec.ts +++ b/ui/src/theme/noFlashTheme.spec.ts @@ -64,4 +64,20 @@ describe('no-flash theme bootstrap', () => { expect(applyNoFlashTheme({ uiStyle: 'win98' }, false).uiStyle).toBe('win98') expect(applyNoFlashTheme({ uiStyle: 'aqua' }, false).uiStyle).toBe('default') }) + + it('restores the Windows Classic palette before first paint', () => { + const state = { + theme: 'auto', + dayPalette: 'windows-classic', + nightPalette: 'windows-classic', + uiStyle: 'win98', + } + expect(applyNoFlashTheme(state, false)).toEqual({ + theme: 'auto', + dayPalette: 'windows-classic', + nightPalette: 'windows-classic', + uiStyle: 'win98', + palette: 'windows-classic', + }) + }) }) diff --git a/ui/src/theme/palette.css b/ui/src/theme/palette.css index a2949e624..de9e298e4 100644 --- a/ui/src/theme/palette.css +++ b/ui/src/theme/palette.css @@ -284,6 +284,97 @@ ); } +/* Windows 98's application chrome: system silver, navy selection, white + document fields, and a black console. The paired Win98 style profile owns + bevels and square geometry; this card remains independently selectable. */ +:root[data-palette="windows-classic"], +[data-palette-preview="windows-classic"] { + color-scheme: light; + + --background: #c0c0c0; + --foreground: #000000; + --card: #c0c0c0; + --card-foreground: #000000; + --popover: #c0c0c0; + --popover-foreground: #000000; + --primary: #000080; + --primary-foreground: #ffffff; + --secondary: #d4d0c8; + --secondary-foreground: #000000; + --muted: #a0a0a0; + --muted-foreground: #3f3f3f; + --accent: rgba(0, 0, 128, 0.12); + --accent-foreground: #000000; + --destructive: #800000; + --destructive-foreground: #ffffff; + --border: #808080; + --input: #808080; + --ring: #000080; + + --accent-strong: rgba(0, 0, 128, 0.2); + --primary-muted: rgba(0, 0, 128, 0.18); + --success: #006400; + --success-foreground: #ffffff; + --warning: #7f4f00; + --warning-foreground: #ffffff; + --warning-background: #ffffe1; + --warning-border: #808000; + --info: #000080; + --info-foreground: #ffffff; + --ai-action: #800080; + --ai-action-foreground: #ffffff; + --backdrop: rgba(0, 0, 0, 0.48); + --code-background: rgba(255, 255, 255, 0.42); + --report-canvas: #ffffff; + --shadow-color: #000000; + + --terminal-background: #000000; + --terminal-foreground: #c0c0c0; + --terminal-cursor: #ffffff; + --terminal-cursor-accent: #000000; + --terminal-selection-background: #000080; + --terminal-selection-foreground: #ffffff; + --terminal-black: #000000; + --terminal-red: #ff5f5f; + --terminal-green: #00aa00; + --terminal-yellow: #ffff00; + --terminal-blue: #6f6fff; + --terminal-magenta: #ff5fff; + --terminal-cyan: #00aaaa; + --terminal-white: #c0c0c0; + --terminal-bright-black: #808080; + --terminal-bright-red: #ff0000; + --terminal-bright-green: #00ff00; + --terminal-bright-yellow: #ffff00; + --terminal-bright-blue: #8080ff; + --terminal-bright-magenta: #ff00ff; + --terminal-bright-cyan: #00ffff; + --terminal-bright-white: #ffffff; + + --chart-1: #000080; + --chart-2: #006400; + --chart-3: #7f4f00; + --chart-4: #800080; + --chart-5: #008080; + --chart-positive: #006400; + --chart-negative: #800000; + --chart-positive-muted: rgba(0, 100, 0, 0.32); + --chart-negative-muted: rgba(128, 0, 0, 0.32); + --chart-axis: #3f3f3f; + --chart-grid: #808080; + + --sidebar: #c0c0c0; + --sidebar-foreground: #000000; + --sidebar-primary: #000080; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #000080; + --sidebar-accent-foreground: #ffffff; + --sidebar-border: #808080; + --sidebar-ring: #000080; + + --app-background-wash: none; +} + :root[data-palette="graphite"], [data-palette-preview="graphite"] { color-scheme: dark; diff --git a/ui/src/theme/palettes.ts b/ui/src/theme/palettes.ts index 7ae884fd5..96f862ccb 100644 --- a/ui/src/theme/palettes.ts +++ b/ui/src/theme/palettes.ts @@ -2,6 +2,7 @@ export type ThemePaletteId = | 'paper' | 'porcelain' | 'linen' + | 'windows-classic' | 'graphite' | 'midnight' | 'moss' @@ -29,6 +30,7 @@ export const THEME_PALETTES = [ { id: 'paper', appearance: 'light', labelKey: 'theme.palette.paper', descriptionKey: 'theme.paletteDescription.paper' }, { id: 'porcelain', appearance: 'light', labelKey: 'theme.palette.porcelain', descriptionKey: 'theme.paletteDescription.porcelain' }, { id: 'linen', appearance: 'light', labelKey: 'theme.palette.linen', descriptionKey: 'theme.paletteDescription.linen' }, + { id: 'windows-classic', appearance: 'light', labelKey: 'theme.palette.windows-classic', descriptionKey: 'theme.paletteDescription.windows-classic' }, { id: 'graphite', appearance: 'dark', labelKey: 'theme.palette.graphite', descriptionKey: 'theme.paletteDescription.graphite' }, { id: 'midnight', appearance: 'dark', labelKey: 'theme.palette.midnight', descriptionKey: 'theme.paletteDescription.midnight' }, { id: 'moss', appearance: 'dark', labelKey: 'theme.palette.moss', descriptionKey: 'theme.paletteDescription.moss' }, diff --git a/ui/src/theme/semanticColors.spec.ts b/ui/src/theme/semanticColors.spec.ts index c8a8b6ea9..2c9ee0873 100644 --- a/ui/src/theme/semanticColors.spec.ts +++ b/ui/src/theme/semanticColors.spec.ts @@ -39,7 +39,7 @@ const CORE_TOKENS = [ ] as const const PALETTE_IDS: readonly ThemePaletteId[] = [ - 'paper', 'porcelain', 'linen', 'graphite', 'midnight', 'moss', 'iris', + 'paper', 'porcelain', 'linen', 'windows-classic', 'graphite', 'midnight', 'moss', 'iris', ] const ALLOWED_LITERAL_COLOR_FILES = new Set([ @@ -98,12 +98,12 @@ function hexContrast(a: string, b: string): number { } describe('semantic color contract', () => { - it('ships one universal library with seven complete semantic cards', () => { + it('ships one universal library with eight complete semantic cards', () => { expect(THEME_PALETTES.map(({ id }) => id)).toEqual(PALETTE_IDS) expect(THEME_PALETTES.map(({ appearance }) => appearance)).toEqual([ - 'light', 'light', 'light', 'dark', 'dark', 'dark', 'dark', + 'light', 'light', 'light', 'light', 'dark', 'dark', 'dark', 'dark', ]) - expect(new Set(PALETTE_IDS).size).toBe(7) + expect(new Set(PALETTE_IDS).size).toBe(8) for (const id of PALETTE_IDS) { const block = paletteBlock(id) @@ -114,7 +114,7 @@ describe('semantic color contract', () => { } }) - it('keeps every semantic token symmetric across all seven cards', () => { + it('keeps every semantic token symmetric across all eight cards', () => { const tokens = [...paletteBlock('paper').matchAll(/^\s*(--[\w-]+):/gm)].map((match) => match[1]) expect(tokens.length).toBeGreaterThan(CORE_TOKENS.length) @@ -149,7 +149,7 @@ describe('semantic color contract', () => { it('keeps the new product and ANSI text roles at normal-text contrast', () => { const ansiTokens = ['red', 'green', 'yellow', 'blue', 'magenta', 'cyan'] as const - for (const id of ['linen', 'moss', 'iris'] as const) { + for (const id of ['linen', 'windows-classic', 'moss', 'iris'] as const) { expect(hexContrast(paletteToken(id, 'foreground'), paletteToken(id, 'background')), id).toBeGreaterThanOrEqual(4.5) expect(hexContrast(paletteToken(id, 'muted-foreground'), paletteToken(id, 'background')), id).toBeGreaterThanOrEqual(4.5) expect(hexContrast(paletteToken(id, 'primary-foreground'), paletteToken(id, 'primary')), id).toBeGreaterThanOrEqual(4.5) @@ -177,6 +177,7 @@ describe('semantic color contract', () => { expect(paletteAppearance('midnight')).toBe('dark') expect(paletteAppearance('paper')).toBe('light') expect(paletteAppearance('linen')).toBe('light') + expect(paletteAppearance('windows-classic')).toBe('light') expect(paletteAppearance('moss')).toBe('dark') expect(paletteAppearance('iris')).toBe('dark') expect(palette).not.toMatch(/data-theme|prefers-color-scheme/) diff --git a/ui/src/theme/store.spec.ts b/ui/src/theme/store.spec.ts index aeaed8440..077e0260e 100644 --- a/ui/src/theme/store.spec.ts +++ b/ui/src/theme/store.spec.ts @@ -60,4 +60,18 @@ describe('theme preference persistence', () => { expect(normalizeThemePreferences({ uiStyle: 'aqua' }).uiStyle).toBe('default') }) + + it('accepts Windows Classic in either palette slot', () => { + expect(normalizeThemePreferences({ + theme: 'auto', + dayPalette: 'windows-classic', + nightPalette: 'windows-classic', + uiStyle: 'win98', + })).toEqual({ + theme: 'auto', + dayPalette: 'windows-classic', + nightPalette: 'windows-classic', + uiStyle: 'win98', + }) + }) }) diff --git a/ui/src/theme/styleProfiles.ts b/ui/src/theme/styleProfiles.ts index 82a993526..35b41a53c 100644 --- a/ui/src/theme/styleProfiles.ts +++ b/ui/src/theme/styleProfiles.ts @@ -1,9 +1,15 @@ +import type { ThemePaletteId } from './palettes' + export type UiStyleProfileId = 'default' | 'win98' | 'broker-classic' export interface UiStyleProfileDefinition { readonly id: UiStyleProfileId readonly labelKey: `theme.uiStyle.${UiStyleProfileId}` readonly descriptionKey: `theme.uiStyleDescription.${UiStyleProfileId}` + readonly recommendedPalettePair?: Readonly<{ + day: ThemePaletteId + night: ThemePaletteId + }> } export const DEFAULT_UI_STYLE_PROFILE: UiStyleProfileId = 'default' @@ -18,6 +24,10 @@ export const UI_STYLE_PROFILES = [ id: 'win98', labelKey: 'theme.uiStyle.win98', descriptionKey: 'theme.uiStyleDescription.win98', + recommendedPalettePair: { + day: 'windows-classic', + night: 'windows-classic', + }, }, { id: 'broker-classic',