feat: add dark mode foundation

This commit is contained in:
lucaronin
2026-04-24 22:26:07 +02:00
parent bdde0c8943
commit 54c0efa3e4
72 changed files with 2203 additions and 711 deletions

View File

@@ -535,10 +535,11 @@ Typed ASCII arrow sequences are normalized consistently in both editor modes:
## Styling
The app uses a single light theme — the vault-based theming system was removed (see [ADR-0013](adr/0013-remove-theming-system.md)). Styling is defined in two layers:
The app uses internal light and dark themes owned by Tolaria (see [ADR-0081](adr/0081-internal-light-dark-theme-runtime.md)). The previous vault-authored theming system remains removed; theme mode is an installation-local app preference.
1. **Global CSS variables** (`src/index.css`): App-wide colors via `:root`, bridged to Tailwind v4
1. **Global CSS variables** (`src/index.css`): Semantic app colors, borders, surfaces, and interaction states via `:root` / `[data-theme]`, bridged to Tailwind v4
2. **Editor theme** (`src/theme.json`): BlockNote typography, flattened to CSS vars by `useEditorTheme`
3. **Runtime theme bridge**: Applies `data-theme` and `.dark` for shadcn/ui, while CodeMirror and editor-specific consumers derive any non-CSS-variable values from the same semantic contract
## Inspector Abstraction
@@ -648,11 +649,12 @@ interface Settings {
analytics_enabled: boolean | null
anonymous_id: string | null
release_channel: string | null // null = stable default, "alpha" = every-push prerelease feed
theme_mode: 'light' | 'dark' | null
default_ai_agent: 'claude_code' | 'codex' | null
}
```
Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
## Telemetry

View File

@@ -406,10 +406,11 @@ flowchart TD
## Styling
The app uses a single light theme with no user-configurable theming (see [ADR-0013](adr/0013-remove-theming-system.md)).
The app uses internal app-owned light and dark themes (see [ADR-0081](adr/0081-internal-light-dark-theme-runtime.md)). This is not the old vault-authored theming system from ADR-0013: users choose a mode, but themes are owned by the app.
1. **Global CSS variables** (`src/index.css`): App-wide colors, borders, backgrounds. Bridged to Tailwind v4 via `@theme inline`.
2. **Editor theme** (`src/theme.json`): BlockNote-specific typography. Flattened to CSS vars by `useEditorTheme`.
1. **Global CSS variables** (`src/index.css`): Semantic app colors, borders, surfaces, and interaction states. Bridged to Tailwind v4 via `@theme inline`.
2. **Editor theme** (`src/theme.json`): BlockNote-specific typography. Flattened to CSS vars by `useEditorTheme`; editor colors resolve through the same semantic app variables.
3. **Theme runtime**: Applies `data-theme` and the shadcn-compatible `.dark` class before React consumers render, with a localStorage mirror to avoid startup flash when dark mode is selected.
## Vault Management
@@ -751,14 +752,14 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| `frontmatterOps` | — (pure functions) | Frontmatter CRUD: key→VaultEntry mapping, mock/Tauri dispatch |
| `useTabManagement` | Navigation history, note switching | Note navigation lifecycle |
| `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching |
| `useTheme` | Editor theme CSS vars | Editor typography theme |
| `useTheme` | Editor theme CSS vars and theme-mode bridge | Editor typography and app theme runtime |
| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation |
| `useAutoSync` | Sync interval, pull/push state | Git auto-sync |
| `useAutoGit` | Last activity timestamp, idle/inactive checkpoint triggers | Automatic commit/push checkpoints |
| `useCommitFlow` | Commit dialog state, shared manual/automatic checkpoint runner | Git commit/push orchestration |
| `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI |
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
| `useSettings` | App settings (telemetry, release channel, auto-sync interval, AutoGit thresholds, default AI agent) | Persistent settings |
| `useSettings` | App settings (telemetry, release channel, theme mode, auto-sync interval, AutoGit thresholds, default AI agent) | Persistent settings |
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |
| `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands |

View File

@@ -63,8 +63,8 @@ tolaria/
│ ├── App.css # App shell layout styles
│ ├── types.ts # Shared TS types (VaultEntry, Settings, etc.)
│ ├── mock-tauri.ts # Mock Tauri layer for browser testing
│ ├── theme.json # Editor theme configuration
│ ├── index.css # Global CSS variables + Tailwind setup
│ ├── theme.json # Editor typography theme configuration
│ ├── index.css # Semantic app theme variables + Tailwind setup
│ │
│ ├── components/ # UI components (~98 files)
│ │ ├── Sidebar.tsx # Left panel: filters + type groups
@@ -287,14 +287,14 @@ tolaria/
| File | Why it matters |
|------|---------------|
| `src/index.css` | All CSS custom properties. The design token source of truth. |
| `src/theme.json` | Editor-specific theme (fonts, headings, lists, code blocks). |
| `src/index.css` | Semantic CSS custom properties for app-owned light/dark themes. |
| `src/theme.json` | Editor-specific typography theme (fonts, headings, lists, code blocks). |
### Settings & Config
| File | Why it matters |
|------|---------------|
| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, auto-sync interval, default AI agent). |
| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, theme mode, auto-sync interval, default AI agent). |
| `src/lib/releaseChannel.ts` | Normalizes persisted updater-channel values (`stable` default, optional `alpha`). |
| `src/lib/appUpdater.ts` | Frontend wrapper for channel-aware updater commands. |
| `src/hooks/useMainWindowSizeConstraints.ts` | Derives the main-window minimum width from the visible panes and asks Tauri to grow back to fit wider layouts. |
@@ -404,7 +404,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
### Modify styling
1. **Global CSS variables**: Edit `src/index.css`
1. **Global app/theme variables**: Edit `src/index.css`
2. **Editor typography**: Edit `src/theme.json`
### Work with the AI agent

View File

@@ -2,8 +2,9 @@
type: ADR
id: "0013"
title: "Remove vault-based theming system"
status: active
status: superseded
date: 2026-03-23
superseded_by: "0081"
---
## Context

View File

@@ -0,0 +1,43 @@
---
type: ADR
id: "0081"
title: "Internal light and dark theme runtime"
status: active
date: 2026-04-24
supersedes: "0013"
---
## Context
ADR-0013 removed the vault-authored theming system and made Tolaria light-only. That kept the app simpler, but dark mode has become a product requirement for long writing sessions and accessibility.
The previous theming system should not return in its old form: vault notes, live user-authored themes, and broad runtime editing created too much maintenance burden. Tolaria still needs a small app-owned theme architecture because the UI spans Tailwind/shadcn variables, BlockNote/Mantine surfaces, CodeMirror raw editing, syntax highlighting, and product-specific states such as selected rows, badges, warnings, and diff lines.
## Decision
**Tolaria will support internal app-owned light and dark themes through a semantic CSS-variable contract, with the user's theme mode persisted as installation-local app settings.**
The v1 theme runtime is deliberately smaller than a general theming system:
- Themes are defined by the app, not by vault-authored notes.
- CSS custom properties remain the public runtime contract for product components, Tailwind v4, and shadcn/ui.
- Typed TypeScript helpers may derive values for consumers that cannot read CSS variables directly, such as CodeMirror extensions.
- Existing CSS variables stay available as compatibility aliases while the UI migrates toward semantic names.
- The first persisted choices are `light` and `dark`; system-follow, high-contrast variants, custom themes, and per-vault themes are deferred.
## Options considered
- **Internal light/dark runtime with semantic tokens** (chosen): ships dark mode while keeping the product-owned theme surface small, testable, and compatible with existing CSS-variable usage.
- **Reintroduce vault-authored theme notes**: flexible, but repeats the complexity removed by ADR-0013 and makes dark mode dependent on user-editable data.
- **Ad hoc `.dark` overrides in components**: fastest initially, but would scatter color logic across the app and make future theme variants expensive.
- **Single TypeScript theme object as source of truth**: attractive for validation, but the current app already relies on CSS variables for Tailwind, shadcn/ui, BlockNote CSS overrides, and many product components.
## Consequences
- `src/index.css` owns the stable CSS custom-property contract for app chrome and shared states.
- `src/theme.json` continues to describe editor typography, but editor-facing colors should resolve through the same semantic CSS variables used by the app shell.
- `useTheme` remains responsible for editor theme flattening and can grow into the bridge between app theme mode and editor consumers.
- App settings, not vault frontmatter, store the selected theme mode because it is an installation-local comfort preference.
- Startup must avoid a light-mode flash when dark mode is selected, so the runtime needs a pre-React localStorage mirror and a minimal `index.html` prepaint style in addition to persisted Tauri settings.
- Domain tokens should be introduced only when a surface needs a role that generic semantic tokens cannot express clearly.
- Re-evaluate if Tolaria decides to support user-authored custom themes, per-vault themes, or system-synchronized mode as a first-class product requirement.

View File

@@ -68,7 +68,7 @@ proposed → active → superseded
| [0010](0010-dynamic-wikilink-relationship-detection.md) | Dynamic wikilink relationship detection | active |
| [0011](0011-mcp-server-for-ai-integration.md) | MCP server for AI tool integration | superseded → [0074](0074-explicit-external-ai-tool-setup-and-least-privilege-desktop-scope.md) |
| [0012](0012-claude-cli-for-ai-agent.md) | Claude CLI subprocess for AI agent | active |
| [0013](0013-remove-theming-system.md) | Remove vault-based theming system | active |
| [0013](0013-remove-theming-system.md) | Remove vault-based theming system | superseded -> [0081](0081-internal-light-dark-theme-runtime.md) |
| [0014](0014-git-based-vault-cache.md) | Git-based incremental vault cache | active |
| [0015](0015-auto-save-with-debounce.md) | Auto-save with 500ms debounce | active |
| [0016](0016-sentry-posthog-telemetry.md) | Sentry + PostHog telemetry with consent | active |
@@ -136,3 +136,4 @@ proposed → active → superseded
| [0078](0078-scoped-unsigned-fallback-for-app-managed-git-commits.md) | Scoped unsigned fallback for app-managed git commits | active |
| [0079](0079-linux-window-chrome-and-menu-reuse.md) | Linux window chrome and menu reuse | active |
| [0080](0080-cross-platform-desktop-release-artifacts-and-portable-vault-names.md) | Cross-platform desktop release artifacts and portable vault names | active |
| [0081](0081-internal-light-dark-theme-runtime.md) | Internal light and dark theme runtime | active |

View File

@@ -7,17 +7,48 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&family=JetBrains+Mono&display=swap" rel="stylesheet" />
<style>
:root {
color-scheme: light;
background: #FFFFFF;
color: #37352F;
}
:root[data-theme="dark"] {
color-scheme: dark;
background: #1F1E1B;
color: #E6E1D8;
}
body {
background: inherit;
color: inherit;
}
</style>
<title>Tolaria</title>
</head>
<body>
<script>
// Apply saved theme before React mounts to prevent flash
var t = localStorage.getItem('tolaria-theme');
if (t === null) {
t = localStorage.getItem('laputa-theme');
if (t !== null) localStorage.setItem('tolaria-theme', t);
}
if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
(function () {
var key = 'tolaria-theme';
var legacyKey = 'laputa-theme';
function normalizeTheme(value) {
return value === 'dark' || value === 'light' ? value : null;
}
function applyTheme(value) {
var mode = normalizeTheme(value) || 'light';
document.documentElement.setAttribute('data-theme', mode);
document.documentElement.classList.toggle('dark', mode === 'dark');
}
try {
var mode = normalizeTheme(localStorage.getItem(key));
if (mode === null) {
mode = normalizeTheme(localStorage.getItem(legacyKey));
if (mode !== null) localStorage.setItem(key, mode);
}
applyTheme(mode);
} catch (err) {
applyTheme('light');
}
})();
</script>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>

View File

@@ -5,7 +5,7 @@ use std::path::PathBuf;
const APP_CONFIG_DIR: &str = "com.tolaria.app";
const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Settings {
pub auto_pull_interval_minutes: Option<u32>,
pub autogit_enabled: Option<bool>,
@@ -17,6 +17,7 @@ pub struct Settings {
pub analytics_enabled: Option<bool>,
pub anonymous_id: Option<String>,
pub release_channel: Option<String>,
pub theme_mode: Option<String>,
pub initial_h1_auto_rename_enabled: Option<bool>,
pub default_ai_agent: Option<String>,
}
@@ -53,6 +54,13 @@ pub fn normalize_default_ai_agent(value: Option<&str>) -> Option<String> {
}
}
pub fn normalize_theme_mode(value: Option<&str>) -> Option<String> {
match value.map(|candidate| candidate.trim().to_ascii_lowercase()) {
Some(mode) if mode == "light" || mode == "dark" => Some(mode),
_ => None,
}
}
fn normalize_settings(settings: Settings) -> Settings {
Settings {
auto_pull_interval_minutes: settings.auto_pull_interval_minutes,
@@ -69,6 +77,7 @@ fn normalize_settings(settings: Settings) -> Settings {
analytics_enabled: settings.analytics_enabled,
anonymous_id: normalize_optional_string(settings.anonymous_id),
release_channel: normalize_release_channel(settings.release_channel.as_deref()),
theme_mode: normalize_theme_mode(settings.theme_mode.as_deref()),
initial_h1_auto_rename_enabled: settings.initial_h1_auto_rename_enabled,
default_ai_agent: normalize_default_ai_agent(settings.default_ai_agent.as_deref()),
}
@@ -166,43 +175,8 @@ pub fn set_last_vault(vault_path: &str) -> Result<(), String> {
mod tests {
use super::*;
type SettingsSnapshot<'a> = (
Option<u32>,
Option<bool>,
Option<u32>,
Option<u32>,
Option<bool>,
Option<bool>,
Option<bool>,
Option<bool>,
Option<&'a str>,
Option<&'a str>,
Option<bool>,
Option<&'a str>,
);
fn settings_snapshot(settings: &Settings) -> SettingsSnapshot<'_> {
(
settings.auto_pull_interval_minutes,
settings.autogit_enabled,
settings.autogit_idle_threshold_seconds,
settings.autogit_inactive_threshold_seconds,
settings.auto_advance_inbox_after_organize,
settings.telemetry_consent,
settings.crash_reporting_enabled,
settings.analytics_enabled,
settings.anonymous_id.as_deref(),
settings.release_channel.as_deref(),
settings.initial_h1_auto_rename_enabled,
settings.default_ai_agent.as_deref(),
)
}
fn assert_empty_settings(settings: &Settings) {
assert_eq!(
settings_snapshot(settings),
(None, None, None, None, None, None, None, None, None, None, None, None)
);
assert_eq!(settings, &Settings::default());
}
/// Helper: save settings to a temp file and reload them.
@@ -244,12 +218,13 @@ mod tests {
analytics_enabled: Some(false),
anonymous_id: Some("abc-123-uuid".to_string()),
release_channel: Some("alpha".to_string()),
theme_mode: Some("dark".to_string()),
initial_h1_auto_rename_enabled: Some(false),
default_ai_agent: Some("codex".to_string()),
};
let json = serde_json::to_string(&settings).unwrap();
let parsed: Settings = serde_json::from_str(&json).unwrap();
assert_eq!(settings_snapshot(&parsed), settings_snapshot(&settings));
assert_eq!(parsed, settings);
}
#[test]
@@ -269,6 +244,7 @@ mod tests {
autogit_inactive_threshold_seconds: Some(30),
auto_advance_inbox_after_organize: Some(true),
release_channel: Some("alpha".to_string()),
theme_mode: Some("dark".to_string()),
initial_h1_auto_rename_enabled: Some(false),
default_ai_agent: Some("codex".to_string()),
..Default::default()
@@ -279,6 +255,7 @@ mod tests {
assert_eq!(loaded.autogit_inactive_threshold_seconds, Some(30));
assert_eq!(loaded.auto_advance_inbox_after_organize, Some(true));
assert_eq!(loaded.release_channel.as_deref(), Some("alpha"));
assert_eq!(loaded.theme_mode.as_deref(), Some("dark"));
assert_eq!(loaded.initial_h1_auto_rename_enabled, Some(false));
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
}
@@ -288,11 +265,13 @@ mod tests {
let loaded = save_and_reload(Settings {
anonymous_id: Some(" test-uuid ".to_string()),
release_channel: Some(" alpha ".to_string()),
theme_mode: Some(" dark ".to_string()),
default_ai_agent: Some(" codex ".to_string()),
..Default::default()
});
assert_eq!(loaded.anonymous_id.as_deref(), Some("test-uuid"));
assert_eq!(loaded.release_channel.as_deref(), Some("alpha"));
assert_eq!(loaded.theme_mode.as_deref(), Some("dark"));
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
}
@@ -334,6 +313,15 @@ mod tests {
assert!(loaded.default_ai_agent.is_none());
}
#[test]
fn test_invalid_theme_mode_is_filtered() {
let loaded = save_and_reload(Settings {
theme_mode: Some("system".to_string()),
..Default::default()
});
assert!(loaded.theme_mode.is_none());
}
#[test]
fn test_get_settings_normalizes_legacy_beta_channel() {
let dir = tempfile::TempDir::new().unwrap();
@@ -384,21 +372,14 @@ mod tests {
..Default::default()
});
assert_eq!(
settings_snapshot(&loaded),
(
None,
None,
None,
None,
None,
Some(true),
Some(true),
Some(false),
Some("test-uuid-v4"),
None,
None,
None,
)
loaded,
Settings {
telemetry_consent: Some(true),
crash_reporting_enabled: Some(true),
analytics_enabled: Some(false),
anonymous_id: Some("test-uuid-v4".to_string()),
..Default::default()
}
);
}

View File

@@ -68,9 +68,9 @@
/* AI highlight animation — applied by useAiActivity when MCP calls ui_highlight */
@keyframes ai-highlight-glow {
0% { box-shadow: inset 0 0 0 2px rgba(99, 102, 241, 0.8); }
50% { box-shadow: inset 0 0 8px 2px rgba(99, 102, 241, 0.4); }
100% { box-shadow: inset 0 0 0 2px rgba(99, 102, 241, 0); }
0% { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent-blue) 80%, transparent); }
50% { box-shadow: inset 0 0 8px 2px color-mix(in srgb, var(--accent-blue) 40%, transparent); }
100% { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent-blue) 0%, transparent); }
}
.ai-highlight {

View File

@@ -31,6 +31,7 @@ import { useAutoGit } from './hooks/useAutoGit'
import { useVaultLoader } from './hooks/useVaultLoader'
import { useAiAgentPreferences } from './hooks/useAiAgentPreferences'
import { useSettings } from './hooks/useSettings'
import { useThemeMode } from './hooks/useThemeMode'
import { useNoteActions } from './hooks/useNoteActions'
import { planNewTypeCreation } from './hooks/useNoteCreation'
import { useCommitFlow } from './hooks/useCommitFlow'
@@ -367,6 +368,7 @@ function App() {
})
}, [updateConfig, vaultConfig.inbox?.noteListProperties])
const { settings, loaded: settingsLoaded, saveSettings } = useSettings()
useThemeMode(settings.theme_mode, settingsLoaded)
const aiAgentPreferences = useAiAgentPreferences({
settings,
saveSettings,

View File

@@ -69,13 +69,13 @@ describe('AiActionCard', () => {
it('uses lighter background for open_note tool', () => {
render(<AiActionCard {...defaults} tool="open_note" label="Opening note" />)
const card = screen.getByTestId('ai-action-card')
expect(card.style.background).toContain('0.06')
expect(card.style.background).toBe('var(--accent-blue-light)')
})
it('uses standard background for vault tools', () => {
render(<AiActionCard {...defaults} />)
const card = screen.getByTestId('ai-action-card')
expect(card.style.background).toContain('0.1')
expect(card.style.background).toBe('var(--accent-blue-bg)')
})
// --- Expand / collapse ---

View File

@@ -20,6 +20,10 @@ export interface AiActionCardProps {
}
const MAX_DETAIL_LENGTH = 800
const DEFAULT_ACTION_CARD_BACKGROUND = 'var(--accent-blue-bg)'
const TOOL_BACKGROUND_MAP: Record<string, string> = {
open_note: 'var(--accent-blue-light)',
}
type IconRenderer = (size: number) => ReactNode
@@ -66,6 +70,71 @@ function formatInputForDisplay(raw: string): string {
}
}
function hasActionDetails(input?: string, output?: string): boolean {
return Boolean(input || output)
}
function resolveDirectOpenPath({
hasDetails,
onOpenNote,
path,
}: Pick<AiActionCardProps, 'onOpenNote' | 'path'> & {
hasDetails: boolean
}): string | null {
if (hasDetails || !path || !onOpenNote) return null
return path
}
function ActionCardHeader({
expanded,
hasDetails,
label,
onClick,
onKeyDown,
renderIcon,
status,
}: {
expanded: boolean
hasDetails: boolean
label: string
onClick: () => void
onKeyDown: (event: KeyboardEvent) => void
renderIcon: IconRenderer
status: AiActionStatus
}) {
return (
<div
className="flex items-center gap-2"
style={{ padding: '6px 10px', cursor: 'pointer' }}
role="button"
tabIndex={0}
aria-expanded={expanded}
onClick={onClick}
onKeyDown={onKeyDown}
data-testid="action-card-header"
>
<span className="shrink-0 text-muted-foreground" style={{ width: 14, display: 'flex' }}>
<ActionIcon expanded={expanded} hasDetails={hasDetails} renderIcon={renderIcon} />
</span>
<span className="flex-1 truncate">{label}</span>
<StatusIndicator status={status} />
</div>
)
}
function ActionIcon({
expanded,
hasDetails,
renderIcon,
}: {
expanded: boolean
hasDetails: boolean
renderIcon: IconRenderer
}) {
if (!hasDetails) return <>{renderIcon(14)}</>
return expanded ? <CaretDown size={12} /> : <CaretRight size={12} />
}
function DetailBlock({ label, content, isError }: {
label: string; content: string; isError?: boolean
}) {
@@ -100,16 +169,41 @@ function DetailBlock({ label, content, isError }: {
)
}
/** Whether this tool is a Tolaria UI-only tool (lighter styling). */
function isUiOnlyTool(tool: string): boolean {
return tool === 'open_note'
function ActionCardDetails({
expanded,
hasDetails,
input,
output,
status,
}: {
expanded: boolean
hasDetails: boolean
input?: string
output?: string
status: AiActionStatus
}) {
if (!expanded || !hasDetails) return null
const formattedInput = input ? formatInputForDisplay(input) : undefined
return (
<div
data-testid="action-card-details"
style={{ padding: '0 10px 8px 10px' }}
>
{formattedInput && <DetailBlock label="Input" content={formattedInput} />}
{output && (
<DetailBlock label="Output" content={output} isError={status === 'error'} />
)}
</div>
)
}
export function AiActionCard({
tool, label, path, status, input, output, expanded, onToggle, onOpenNote,
}: AiActionCardProps) {
const renderIcon = TOOL_ICON_MAP[tool] ?? DEFAULT_ICON
const hasDetails = !!(input || output)
const hasDetails = hasActionDetails(input, output)
const directOpenPath = resolveDirectOpenPath({ path, onOpenNote, hasDetails })
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -122,14 +216,13 @@ export function AiActionCard({
}, [onToggle, expanded])
const handleClick = useCallback(() => {
if (path && onOpenNote && !hasDetails) {
onOpenNote(path)
} else {
onToggle()
if (directOpenPath && onOpenNote) {
onOpenNote(directOpenPath)
return
}
}, [path, onOpenNote, hasDetails, onToggle])
const formattedInput = input ? formatInputForDisplay(input) : undefined
onToggle()
}, [directOpenPath, onOpenNote, onToggle])
return (
<div
@@ -137,38 +230,25 @@ export function AiActionCard({
className="rounded"
style={{
fontSize: 12,
background: isUiOnlyTool(tool) ? 'rgba(74, 158, 255, 0.06)' : 'rgba(74, 158, 255, 0.1)',
background: TOOL_BACKGROUND_MAP[tool] ?? DEFAULT_ACTION_CARD_BACKGROUND,
}}
>
<div
className="flex items-center gap-2"
style={{ padding: '6px 10px', cursor: 'pointer' }}
role="button"
tabIndex={0}
aria-expanded={expanded}
<ActionCardHeader
expanded={expanded}
hasDetails={hasDetails}
label={label}
onClick={handleClick}
onKeyDown={handleKeyDown}
data-testid="action-card-header"
>
<span className="shrink-0 text-muted-foreground" style={{ width: 14, display: 'flex' }}>
{hasDetails
? (expanded ? <CaretDown size={12} /> : <CaretRight size={12} />)
: renderIcon(14)}
</span>
<span className="flex-1 truncate">{label}</span>
<StatusIndicator status={status} />
</div>
{expanded && hasDetails && (
<div
data-testid="action-card-details"
style={{ padding: '0 10px 8px 10px' }}
>
{formattedInput && <DetailBlock label="Input" content={formattedInput} />}
{output && (
<DetailBlock label="Output" content={output} isError={status === 'error'} />
)}
</div>
)}
renderIcon={renderIcon}
status={status}
/>
<ActionCardDetails
expanded={expanded}
hasDetails={hasDetails}
input={input}
output={output}
status={status}
/>
</div>
)
}

View File

@@ -19,7 +19,7 @@ interface AiAgentsOnboardingPromptProps {
function getPromptCopy(statuses: AiAgentsStatus) {
if (isAiAgentsStatusChecking(statuses)) {
return {
accentClassName: 'bg-slate-100 text-slate-600',
accentClassName: 'bg-muted text-muted-foreground',
description: 'Checking which AI agents are available on this machine.',
icon: <Loader2 className="size-7 animate-spin" />,
title: 'Checking AI agents',
@@ -28,7 +28,7 @@ function getPromptCopy(statuses: AiAgentsStatus) {
if (!hasAnyInstalledAiAgent(statuses)) {
return {
accentClassName: 'bg-amber-100 text-amber-700',
accentClassName: 'bg-[var(--feedback-warning-bg)] text-[var(--feedback-warning-text)]',
description: 'Tolaria works best with a local CLI AI agent installed.',
icon: <Bot className="size-7" />,
title: 'No AI agents detected',
@@ -36,7 +36,7 @@ function getPromptCopy(statuses: AiAgentsStatus) {
}
return {
accentClassName: 'bg-emerald-100 text-emerald-700',
accentClassName: 'bg-[var(--feedback-success-bg)] text-[var(--feedback-success-text)]',
description: 'Your AI agents are ready to use in Tolaria.',
icon: <CheckCircle2 className="size-7" />,
title: 'AI agents ready',
@@ -63,7 +63,7 @@ function AgentStatusList({ statuses }: { statuses: AiAgentsStatus }) {
</div>
</div>
<span
className={`rounded-full px-2 py-1 text-[11px] font-medium ${ready ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'}`}
className={`rounded-full px-2 py-1 text-[11px] font-medium ${ready ? 'bg-[var(--feedback-success-bg)] text-[var(--feedback-success-text)]' : 'bg-[var(--feedback-warning-bg)] text-[var(--feedback-warning-text)]'}`}
>
{ready ? 'Installed' : 'Missing'}
</span>
@@ -106,11 +106,11 @@ export function AiAgentsOnboardingPrompt({
<CardContent className="space-y-4">
{showLegacyClaudeCompatibility ? (
<div
className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-left"
className="rounded-lg border border-[var(--feedback-warning-border)] bg-[var(--feedback-warning-bg)] px-4 py-3 text-left"
data-testid="claude-onboarding-screen"
>
<div className="text-sm font-medium text-amber-900">Claude Code not detected</div>
<p className="mt-1 text-xs leading-5 text-amber-800">
<div className="text-sm font-medium text-[var(--feedback-warning-text)]">Claude Code not detected</div>
<p className="mt-1 text-xs leading-5 text-[var(--feedback-warning-text)]">
Install Claude Code or continue without it.
</p>
</div>

View File

@@ -89,7 +89,7 @@ export function AiPanelView({
style={{
outline: 'none',
borderLeft: isActive
? '2px solid var(--accent-blue, #3b82f6)'
? '2px solid var(--accent-blue)'
: '1px solid var(--border)',
animation: isActive ? 'ai-border-pulse 2s ease-in-out infinite' : undefined,
transition: 'border-color 0.3s ease',

View File

@@ -223,7 +223,7 @@ export function AiPanelComposer({
const placeholder = getComposerPlaceholder(agentLabel, agentReady, legacyCopy, hasContext)
const sendButtonStyle = {
background: canSend ? 'var(--primary)' : 'var(--muted)',
color: canSend ? 'white' : 'var(--muted-foreground)',
color: canSend ? 'var(--primary-foreground)' : 'var(--muted-foreground)',
borderRadius: 8,
width: 32,
height: 34,

View File

@@ -185,7 +185,7 @@ function FavoriteAction({ favorite, onToggleFavorite }: { favorite: boolean; onT
return (
<ToggleIconAction
active={favorite}
activeClassName="text-yellow-500"
activeClassName="text-[var(--accent-yellow)]"
activeLabel="Remove from favorites"
inactiveLabel="Add to favorites"
onClick={onToggleFavorite}
@@ -207,7 +207,7 @@ function OrganizedAction({
return (
<ToggleIconAction
active={organized}
activeClassName="text-green-600"
activeClassName="text-[var(--accent-green)]"
activeLabel="Set note as not organized"
inactiveLabel="Set note as organized"
onClick={onToggleOrganized}

View File

@@ -28,8 +28,8 @@ function BulkActionButton({ ariaLabel, children, destructive = false, onClick, t
variant={destructive ? 'destructive' : 'ghost'}
className={
destructive
? 'h-8 w-8 rounded-lg bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/30'
: 'h-8 w-8 rounded-lg bg-white/10 text-background hover:bg-white/20 focus-visible:ring-white/35 disabled:bg-white/5 disabled:text-white/35'
? 'h-8 w-8 rounded-lg bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/30'
: 'h-8 w-8 rounded-lg bg-background/10 text-background hover:bg-background/20 focus-visible:ring-background/35 disabled:bg-background/5 disabled:text-background/35'
}
onClick={onClick}
disabled={!onClick}
@@ -89,7 +89,7 @@ function BulkActionBarInner({ count, isArchivedView, onOrganize, onArchive, onDe
type="button"
size="icon-sm"
variant="ghost"
className="h-8 w-8 rounded-lg text-white/55 hover:bg-white/10 hover:text-background focus-visible:ring-white/30"
className="h-8 w-8 rounded-lg text-background/55 hover:bg-background/10 hover:text-background focus-visible:ring-background/30"
onClick={onClear}
aria-label="Clear selection"
title="Clear selection"

View File

@@ -14,7 +14,7 @@ interface ClaudeCodeOnboardingPromptProps {
function getPromptCopy(status: ClaudeCodeStatus) {
if (status === 'installed') {
return {
accentClassName: 'bg-emerald-100 text-emerald-700',
accentClassName: 'bg-[var(--feedback-success-bg)] text-[var(--feedback-success-text)]',
description: "Tolaria's AI features are ready to use.",
icon: <CheckCircle2 className="size-7" />,
title: 'Claude Code detected',
@@ -23,7 +23,7 @@ function getPromptCopy(status: ClaudeCodeStatus) {
if (status === 'missing') {
return {
accentClassName: 'bg-amber-100 text-amber-700',
accentClassName: 'bg-[var(--feedback-warning-bg)] text-[var(--feedback-warning-text)]',
description: 'Tolaria works best with an AI coding agent installed.',
icon: <Bot className="size-7" />,
title: 'Claude Code not detected',
@@ -31,7 +31,7 @@ function getPromptCopy(status: ClaudeCodeStatus) {
}
return {
accentClassName: 'bg-slate-100 text-slate-600',
accentClassName: 'bg-muted text-muted-foreground',
description: 'Checking whether Claude Code is available on this machine.',
icon: <Loader2 className="size-7 animate-spin" />,
title: 'Checking for Claude Code',

View File

@@ -1,8 +1,44 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { AlertTriangle, FileText, Check, Loader2 } from 'lucide-react'
import type { ConflictFileState } from '../hooks/useConflictResolver'
import { cn } from '@/lib/utils'
type ConflictResolutionStrategy = 'ours' | 'theirs'
const BINARY_FILE_EXTENSIONS = [
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.ico',
'.pdf',
'.zip',
'.tar',
'.gz',
'.mp3',
'.mp4',
'.wav',
'.ogg',
'.woff',
'.woff2',
'.ttf',
'.otf',
'.eot',
]
const RESOLUTION_LABELS: Record<NonNullable<ConflictFileState['resolution']>, string> = {
manual: 'Edited manually',
ours: 'Keeping mine',
theirs: 'Keeping theirs',
}
const RESOLUTION_SHORTCUTS: Record<string, ConflictResolutionStrategy | undefined> = {
k: 'ours',
t: 'theirs',
}
interface ConflictResolverModalProps {
open: boolean
@@ -17,8 +53,8 @@ interface ConflictResolverModalProps {
}
function isBinaryFile(file: string): boolean {
const binaryExts = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.pdf', '.zip', '.tar', '.gz', '.mp3', '.mp4', '.wav', '.ogg', '.woff', '.woff2', '.ttf', '.otf', '.eot']
return binaryExts.some(ext => file.toLowerCase().endsWith(ext))
const normalizedFile = file.toLowerCase()
return BINARY_FILE_EXTENSIONS.some(ext => normalizedFile.endsWith(ext))
}
function fileName(path: string): string {
@@ -27,10 +63,9 @@ function fileName(path: string): string {
function ResolutionLabel({ resolution }: { resolution: ConflictFileState['resolution'] }) {
if (!resolution) return null
const labels = { ours: 'Keeping mine', theirs: 'Keeping theirs', manual: 'Edited manually' }
return (
<span className="flex items-center gap-1 text-xs text-green-600">
<Check size={12} />{labels[resolution]}
<span className="flex items-center gap-1 text-xs text-[var(--feedback-success-text)]">
<Check size={12} />{RESOLUTION_LABELS[resolution]}
</span>
)
}
@@ -62,9 +97,11 @@ function ConflictFileRow({
role="row"
tabIndex={0}
onFocus={onFocus}
className={`flex items-center justify-between gap-2 rounded-md border px-3 py-2 transition-colors ${
focused ? 'border-ring bg-accent/50' : 'border-border bg-background'
} ${resolved ? 'opacity-70' : ''}`}
className={cn(
'flex items-center justify-between gap-2 rounded-md border px-3 py-2 transition-colors',
focused ? 'border-ring bg-accent/50' : 'border-border bg-background',
resolved && 'opacity-70',
)}
data-testid={`conflict-file-${state.file}`}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
@@ -118,8 +155,214 @@ function ConflictFileRow({
)
}
function clampFocusIndex(index: number, fileCount: number): number {
if (fileCount === 0) return 0
return Math.min(Math.max(index, 0), fileCount - 1)
}
function useConflictFocus(fileCount: number) {
const [focusIdx, setFocusIdx] = useState(0)
const focusIdxRef = useRef(0)
const visibleFocusIdx = clampFocusIndex(focusIdx, fileCount)
const syncFocusIdx = useCallback((nextIndex: number) => {
const clampedIndex = clampFocusIndex(nextIndex, fileCount)
setFocusIdx(clampedIndex)
focusIdxRef.current = clampedIndex
}, [fileCount])
const moveFocus = useCallback((offset: number) => {
const currentIndex = clampFocusIndex(focusIdxRef.current, fileCount)
syncFocusIdx(currentIndex + offset)
}, [fileCount, syncFocusIdx])
return {
focusIdx: visibleFocusIdx,
focusIdxRef,
moveFocus,
syncFocusIdx,
}
}
function hasCommandModifier(event: KeyboardEvent): boolean {
return event.metaKey || event.ctrlKey
}
function isNextRowKey(event: KeyboardEvent): boolean {
if (event.key === 'ArrowDown') return true
return event.key === 'Tab' && !event.shiftKey
}
function isPreviousRowKey(event: KeyboardEvent): boolean {
if (event.key === 'ArrowUp') return true
return event.key === 'Tab' && event.shiftKey
}
function handleNavigationKey(event: KeyboardEvent, moveFocus: (offset: number) => void): boolean {
if (isNextRowKey(event)) {
event.preventDefault()
moveFocus(1)
return true
}
if (isPreviousRowKey(event)) {
event.preventDefault()
moveFocus(-1)
return true
}
return false
}
function handleResolutionShortcut(
event: KeyboardEvent,
file: ConflictFileState | undefined,
onResolveFile: ConflictResolverModalProps['onResolveFile'],
): boolean {
const strategy = RESOLUTION_SHORTCUTS[event.key.toLowerCase()]
if (!strategy || !file || file.resolving || hasCommandModifier(event)) return false
event.preventDefault()
onResolveFile(file.file, strategy)
return true
}
function handleOpenShortcut(
event: KeyboardEvent,
file: ConflictFileState | undefined,
onOpenInEditor: ConflictResolverModalProps['onOpenInEditor'],
): boolean {
if (event.key.toLowerCase() !== 'o' || !file || file.resolving || hasCommandModifier(event)) return false
if (isBinaryFile(file.file)) return false
event.preventDefault()
onOpenInEditor(file.file)
return true
}
function handleCommitShortcut({
allResolved,
committing,
event,
onCommit,
}: {
allResolved: boolean
committing: boolean
event: KeyboardEvent
onCommit: ConflictResolverModalProps['onCommit']
}): boolean {
if (event.key !== 'Enter' || !allResolved || committing) return false
event.preventDefault()
onCommit()
return true
}
function ConflictDialogHeader({ fileCount }: { fileCount: number }) {
return (
<DialogHeader>
<div className="flex items-center gap-2">
<AlertTriangle size={18} className="text-[var(--accent-orange)]" />
<DialogTitle>Resolve Merge Conflicts</DialogTitle>
</div>
<DialogDescription>
{fileCount} file{fileCount !== 1 ? 's have' : ' has'} merge conflicts. Choose how to resolve each file.
</DialogDescription>
</DialogHeader>
)
}
function ConflictFileList({
fileStates,
focusIdx,
onFocusRow,
onOpenInEditor,
onResolveFile,
}: {
fileStates: ConflictFileState[]
focusIdx: number
onFocusRow: (index: number) => void
onOpenInEditor: (file: string) => void
onResolveFile: (file: string, strategy: ConflictResolutionStrategy) => void
}) {
return (
<div
className="flex flex-col gap-2 max-h-[300px] overflow-y-auto"
role="grid"
data-testid="conflict-file-list"
>
{fileStates.map((state, index) => (
<ConflictFileRow
key={state.file}
state={state}
focused={index === focusIdx}
onResolve={(strategy) => onResolveFile(state.file, strategy)}
onOpenInEditor={() => onOpenInEditor(state.file)}
onFocus={() => onFocusRow(index)}
/>
))}
</div>
)
}
function CommitButtonContent({ committing }: { committing: boolean }) {
if (!committing) return 'Commit & continue'
return (
<>
<Loader2 size={14} className="animate-spin mr-1" />Committing
</>
)
}
function ConflictDialogFooter({
allResolved,
committing,
onClose,
onCommit,
}: {
allResolved: boolean
committing: boolean
onClose: () => void
onCommit: () => void
}) {
return (
<DialogFooter className="flex-row items-center justify-between sm:justify-between">
<span className="text-[11px] text-muted-foreground">
K = keep mine · T = keep theirs · O = open · Enter = commit
</span>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button
onClick={onCommit}
disabled={!allResolved || committing}
data-testid="conflict-commit-btn"
>
<CommitButtonContent committing={committing} />
</Button>
</div>
</DialogFooter>
)
}
export function ConflictResolverModal({
open,
onClose,
...contentProps
}: ConflictResolverModalProps) {
return (
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
{open ? (
<ConflictResolverDialogContent
open={open}
onClose={onClose}
{...contentProps}
/>
) : null}
</Dialog>
)
}
function ConflictResolverDialogContent({
fileStates,
allResolved,
committing,
@@ -129,118 +372,54 @@ export function ConflictResolverModal({
onCommit,
onClose,
}: ConflictResolverModalProps) {
const [focusIdx, setFocusIdx] = useState(0)
const focusIdxRef = useRef(0)
const listRef = useRef<HTMLDivElement>(null)
const {
focusIdx,
focusIdxRef,
moveFocus,
syncFocusIdx,
} = useConflictFocus(fileStates.length)
useEffect(() => {
if (open) {
setFocusIdx(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
focusIdxRef.current = 0
}
}, [open])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
return
}
const idx = focusIdxRef.current
const file = fileStates[idx]
if (handleNavigationKey(e, moveFocus)) return
if (e.key === 'ArrowDown' || (e.key === 'Tab' && !e.shiftKey)) {
e.preventDefault()
const next = Math.min(idx + 1, fileStates.length - 1)
setFocusIdx(next)
focusIdxRef.current = next
return
}
if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
e.preventDefault()
const prev = Math.max(idx - 1, 0)
setFocusIdx(prev)
focusIdxRef.current = prev
return
}
if ((e.key === 'k' || e.key === 'K') && file && !file.resolving && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
onResolveFile(file.file, 'ours')
} else if ((e.key === 't' || e.key === 'T') && file && !file.resolving && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
onResolveFile(file.file, 'theirs')
} else if ((e.key === 'o' || e.key === 'O') && file && !isBinaryFile(file.file) && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
onOpenInEditor(file.file)
} else if (e.key === 'Enter' && allResolved && !committing) {
e.preventDefault()
onCommit()
}
}, [fileStates, allResolved, committing, onResolveFile, onOpenInEditor, onCommit, onClose])
const focusedIndex = clampFocusIndex(focusIdxRef.current, fileStates.length)
const file = fileStates[focusedIndex]
if (handleResolutionShortcut(e, file, onResolveFile)) return
if (handleOpenShortcut(e, file, onOpenInEditor)) return
handleCommitShortcut({ allResolved, committing, event: e, onCommit })
}, [allResolved, committing, fileStates, focusIdxRef, moveFocus, onClose, onCommit, onOpenInEditor, onResolveFile])
return (
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
<DialogContent
showCloseButton={false}
className="sm:max-w-[520px]"
onKeyDown={handleKeyDown}
>
<DialogHeader>
<div className="flex items-center gap-2">
<AlertTriangle size={18} className="text-orange-500" />
<DialogTitle>Resolve Merge Conflicts</DialogTitle>
</div>
<DialogDescription>
{fileStates.length} file{fileStates.length !== 1 ? 's have' : ' has'} merge conflicts. Choose how to resolve each file.
</DialogDescription>
</DialogHeader>
<DialogContent
showCloseButton={false}
className="sm:max-w-[520px]"
onKeyDown={handleKeyDown}
>
<ConflictDialogHeader fileCount={fileStates.length} />
<div
ref={listRef}
className="flex flex-col gap-2 max-h-[300px] overflow-y-auto"
role="grid"
data-testid="conflict-file-list"
>
{fileStates.map((state, i) => (
<ConflictFileRow
key={state.file}
state={state}
focused={i === focusIdx}
onResolve={(strategy) => onResolveFile(state.file, strategy)}
onOpenInEditor={() => onOpenInEditor(state.file)}
onFocus={() => {
setFocusIdx(i)
focusIdxRef.current = i
}}
/>
))}
</div>
<ConflictFileList
fileStates={fileStates}
focusIdx={focusIdx}
onFocusRow={syncFocusIdx}
onOpenInEditor={onOpenInEditor}
onResolveFile={onResolveFile}
/>
{error && (
<p className="text-xs text-destructive" data-testid="conflict-error">{error}</p>
)}
{error && (
<p className="text-xs text-destructive" data-testid="conflict-error">{error}</p>
)}
<DialogFooter className="flex-row items-center justify-between sm:justify-between">
<span className="text-[11px] text-muted-foreground">
K = keep mine · T = keep theirs · O = open · Enter = commit
</span>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button
onClick={onCommit}
disabled={!allResolved || committing}
data-testid="conflict-commit-btn"
>
{committing ? (
<><Loader2 size={14} className="animate-spin mr-1" />Committing</>
) : (
'Commit & continue'
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<ConflictDialogFooter
allResolved={allResolved}
committing={committing}
onClose={onClose}
onCommit={onCommit}
/>
</DialogContent>
)
}

View File

@@ -97,7 +97,7 @@ export function CreateNoteDialog({ open, onClose, onCreate, defaultType, customT
className={cn(
"rounded-full text-xs",
type === t
? "bg-[var(--accent-blue)] text-white"
? "bg-[var(--accent-blue)] text-primary-foreground"
: "border-[var(--accent-blue)] text-[var(--accent-blue)]"
)}
onClick={() => setType(t)}

View File

@@ -19,16 +19,18 @@ describe('DiffView', () => {
it('applies green styling to added lines', () => {
const diff = '+added line'
const { container } = render(<DiffView diff={diff} />)
const addedLine = container.querySelector('.text-\\[\\#4caf50\\]')
render(<DiffView diff={diff} />)
const addedLine = screen.getByText('+added line').closest('div')
expect(addedLine).toBeInTheDocument()
expect(addedLine).toHaveClass('text-[var(--diff-added-text)]')
})
it('applies red styling to removed lines', () => {
const diff = '-removed line'
const { container } = render(<DiffView diff={diff} />)
const removedLine = container.querySelector('.text-\\[\\#f44336\\]')
render(<DiffView diff={diff} />)
const removedLine = screen.getByText('-removed line').closest('div')
expect(removedLine).toBeInTheDocument()
expect(removedLine).toHaveClass('text-[var(--diff-removed-text)]')
})
it('applies hunk header styling to @@ lines', () => {

View File

@@ -7,9 +7,9 @@ interface DiffViewProps {
const DIFF_HEADER_PREFIXES = ['diff', 'index', '---', '+++', 'new file']
function classifyDiffLine(line: string): string {
if (line.startsWith('+') && !line.startsWith('+++')) return 'bg-[rgba(76,175,80,0.12)] text-[#4caf50]'
if (line.startsWith('-') && !line.startsWith('---')) return 'bg-[rgba(244,67,54,0.12)] text-[#f44336]'
if (line.startsWith('@@')) return 'bg-[rgba(33,150,243,0.08)] text-primary italic'
if (line.startsWith('+') && !line.startsWith('+++')) return 'bg-[var(--diff-added-bg)] text-[var(--diff-added-text)]'
if (line.startsWith('-') && !line.startsWith('---')) return 'bg-[var(--diff-removed-bg)] text-[var(--diff-removed-text)]'
if (line.startsWith('@@')) return 'bg-[var(--diff-hunk-bg)] text-primary italic'
if (DIFF_HEADER_PREFIXES.some((p) => line.startsWith(p))) return 'bg-muted text-muted-foreground font-semibold'
return 'text-secondary-foreground'
}

View File

@@ -68,7 +68,7 @@
/* Drag-over state: subtle border highlight */
.editor__blocknote-container--drag-over {
outline: 2px dashed var(--primary, #155DFF);
outline: 2px dashed var(--border-focus);
outline-offset: -2px;
}
@@ -80,18 +80,18 @@
display: flex;
align-items: center;
justify-content: center;
background: rgba(21, 93, 255, 0.06);
background: var(--state-drag-target);
pointer-events: none;
}
.editor__drop-overlay-label {
padding: 10px 20px;
border-radius: 8px;
background: var(--background, #fff);
color: var(--primary, #155DFF);
background: var(--surface-popover);
color: var(--primary);
font-size: 14px;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
box-shadow: 0 2px 8px var(--shadow-dialog);
}
.editor__blocknote-container .bn-container {
@@ -246,7 +246,7 @@
display: flex;
align-items: center;
gap: 4px;
color: var(--text-faint, #999);
color: var(--text-faint);
font-size: 13px;
opacity: 0;
transition: opacity 0.15s;
@@ -274,9 +274,9 @@
flex-direction: column;
min-width: 140px;
border-radius: 8px;
border: 1px solid var(--border-dialog, var(--border));
border: 1px solid var(--border-dialog);
background: var(--popover);
box-shadow: 0 4px 16px var(--shadow-dialog, rgba(0,0,0,0.1));
box-shadow: 0 4px 16px var(--shadow-dialog);
padding: 4px;
}
@@ -297,7 +297,7 @@
}
.note-icon-menu__item--danger {
color: var(--destructive, #ef4444);
color: var(--destructive);
}
/* =============================================

View File

@@ -67,7 +67,7 @@
margin-top: var(--headings-h1-margin-top) !important;
margin-bottom: var(--headings-h1-margin-bottom) !important;
padding-bottom: 16px !important;
border-bottom: 1px solid var(--border-primary, rgba(0,0,0,0.1));
border-bottom: 1px solid var(--border-primary);
}
.editor__blocknote-container [data-content-type="heading"]:not([data-level]) .bn-inline-content,
.editor__blocknote-container [data-content-type="heading"][data-level="1"] .bn-inline-content {

View File

@@ -133,10 +133,17 @@ function LinkFallbackBanner({ linkFallback }: { linkFallback: LinkFallback | nul
if (!linkFallback) return null
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
<div
className="rounded-lg border px-4 py-3 text-sm"
style={{
background: 'var(--feedback-warning-bg)',
borderColor: 'var(--feedback-warning-border)',
color: 'var(--feedback-warning-text)',
}}
>
<p className="font-medium">Couldnt open {linkFallback.label} automatically.</p>
<p className="mt-1 text-amber-900">Open this URL manually instead:</p>
<p className="mt-2 break-all rounded-md bg-white/80 px-3 py-2 font-mono text-xs">
<p className="mt-1">Open this URL manually instead:</p>
<p className="mt-2 break-all rounded-md bg-popover px-3 py-2 font-mono text-xs text-foreground">
{linkFallback.url}
</p>
</div>
@@ -171,7 +178,7 @@ function BugReportActions({
<p className="text-xs font-medium text-foreground">Diagnostics copied.</p>
) : null}
{copyState === 'failed' ? (
<p className="text-xs font-medium text-amber-700">
<p className="text-xs font-medium text-[var(--feedback-warning-text)]">
Clipboard access is unavailable right now. You can still open GitHub Issues directly.
</p>
) : null}

View File

@@ -163,7 +163,7 @@ function TitlebarButton({
aria-label={ariaLabel}
className={[
'h-full w-[46px] rounded-none text-foreground/70 hover:text-foreground',
close ? 'hover:bg-red-500 hover:text-white' : 'hover:bg-foreground/10',
close ? 'hover:bg-destructive hover:text-destructive-foreground' : 'hover:bg-foreground/10',
].join(' ')}
onClick={onClick}
data-no-drag

View File

@@ -60,9 +60,9 @@ const STATUS_ICON = {
} as const
const STATUS_COLOR = {
added: 'var(--accent-green, #16a34a)',
modified: 'var(--accent-orange, #ea580c)',
deleted: 'var(--destructive, #dc2626)',
added: 'var(--accent-green)',
modified: 'var(--accent-orange)',
deleted: 'var(--destructive)',
} as const
const PULSE_ROW_FOCUS_CLASS_NAME = 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/70 focus-visible:ring-inset'

View File

@@ -31,29 +31,119 @@ export interface RawEditorViewProps {
const DEBOUNCE_MS = 500
const DROPDOWN_MAX_HEIGHT = 200
export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath }: RawEditorViewProps) {
const containerRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pathRef = useRef(path)
const onContentChangeRef = useRef(onContentChange)
const onSaveRef = useRef(onSave)
const latestDocRef = useRef(content)
useEffect(() => { pathRef.current = path }, [path])
// Expose latest doc content to parent via ref
useEffect(() => { if (latestContentRef) latestContentRef.current = content }, [latestContentRef, content])
useEffect(() => { onContentChangeRef.current = onContentChange }, [onContentChange])
useEffect(() => { onSaveRef.current = onSave }, [onSave])
type PendingChangeRefs = {
debounceRef: React.MutableRefObject<ReturnType<typeof setTimeout> | null>
latestDocRef: React.MutableRefObject<string>
onContentChangeRef: React.MutableRefObject<RawEditorViewProps['onContentChange']>
pathRef: React.MutableRefObject<string>
}
const [autocomplete, setAutocomplete] = useState<RawEditorAutocompleteState | null>(null)
function useLatestRef<T>(value: T): React.MutableRefObject<T> {
const ref = useRef(value)
useEffect(() => { ref.current = value }, [value])
return ref
}
function flushPendingRawEditorChange({
debounceRef,
latestDocRef,
onContentChangeRef,
pathRef,
}: PendingChangeRefs): void {
if (!debounceRef.current) return
clearTimeout(debounceRef.current)
debounceRef.current = null
onContentChangeRef.current(pathRef.current, latestDocRef.current)
}
function moveRawEditorAutocompleteSelection(
autocomplete: RawEditorAutocompleteState,
direction: 'next' | 'previous',
): RawEditorAutocompleteState {
const selectedIndex = direction === 'next'
? Math.min(autocomplete.selectedIndex + 1, autocomplete.items.length - 1)
: Math.max(autocomplete.selectedIndex - 1, 0)
return { ...autocomplete, selectedIndex }
}
function RawEditorYamlErrorBanner({ error }: { error: string | null }) {
if (!error) return null
return (
<div
className="flex items-center gap-2 px-4 py-2 text-xs border-b shrink-0"
style={{
background: 'var(--feedback-warning-bg)',
borderColor: 'var(--feedback-warning-border)',
color: 'var(--feedback-warning-text)',
}}
role="alert"
data-testid="raw-editor-yaml-error"
>
<span style={{ fontWeight: 600 }}>YAML error:</span>
<span>{error}</span>
</div>
)
}
function RawEditorAutocompleteDropdown({
autocomplete,
onItemHover,
position,
}: {
autocomplete: RawEditorAutocompleteState | null
onItemHover: (index: number) => void
position: { top: number; left: number }
}) {
if (!autocomplete || autocomplete.items.length === 0) return null
return (
<div
className="fixed z-50 min-w-64 max-w-xs overflow-auto rounded-md border shadow-[0_12px_30px_var(--shadow-dialog)]"
style={{
top: position.top,
left: position.left,
maxHeight: DROPDOWN_MAX_HEIGHT,
background: 'var(--popover)',
borderColor: 'var(--border)',
}}
data-testid="raw-editor-wikilink-dropdown"
>
<NoteSearchList
items={autocomplete.items}
selectedIndex={autocomplete.selectedIndex}
getItemKey={(item, i) => `${item.title}-${item.path ?? i}`}
onItemClick={(item) => item.onItemClick()}
onItemHover={onItemHover}
/>
</div>
)
}
type RawEditorPendingChanges = PendingChangeRefs & {
handleDocChange: (doc: string) => void
handleSave: () => void
yamlError: string | null
}
function useRawEditorPendingChanges({
content,
latestContentRef,
onContentChange,
onSave,
path,
}: Pick<RawEditorViewProps, 'content' | 'latestContentRef' | 'onContentChange' | 'onSave' | 'path'>): RawEditorPendingChanges {
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pathRef = useLatestRef(path)
const onContentChangeRef = useLatestRef(onContentChange)
const onSaveRef = useLatestRef(onSave)
const latestContentRefStable = useRef(latestContentRef)
const latestDocRef = useRef(content)
const [yamlError, setYamlError] = useState<string | null>(() => detectYamlError(content))
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const baseItems = useMemo(() => buildRawEditorBaseItems(entries), [entries])
const insertWikilinkRef = useRef<(target: string) => void>(() => {})
const latestContentRefStable = useRef(latestContentRef)
useEffect(() => { if (latestContentRef) latestContentRef.current = content }, [latestContentRef, content])
useEffect(() => { latestContentRefStable.current = latestContentRef }, [latestContentRef])
const handleDocChange = useCallback((doc: string) => {
@@ -64,48 +154,149 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
debounceRef.current = setTimeout(() => {
onContentChangeRef.current(pathRef.current, doc)
}, DEBOUNCE_MS)
}, [])
const handleCursorActivity = useCallback((view: EditorView) => {
const doc = view.state.doc.toString()
const cursor = view.state.selection.main.head
const query = extractWikilinkQuery(doc, cursor)
if (query === null || query.length < MIN_QUERY_LENGTH) {
setAutocomplete(null)
return
}
const nextAutocomplete = buildRawEditorAutocompleteState({
view,
baseItems,
query,
typeEntryMap,
onInsertTarget: (target: string) => insertWikilinkRef.current(target),
vaultPath: vaultPath ?? '',
})
setAutocomplete(nextAutocomplete)
}, [baseItems, typeEntryMap, vaultPath])
}, [latestContentRefStable, onContentChangeRef, pathRef])
const handleSave = useCallback(() => {
if (debounceRef.current) {
clearTimeout(debounceRef.current)
debounceRef.current = null
onContentChangeRef.current(pathRef.current, latestDocRef.current)
}
flushPendingRawEditorChange({ debounceRef, latestDocRef, onContentChangeRef, pathRef })
onSaveRef.current()
}, [])
}, [onContentChangeRef, onSaveRef, pathRef])
const handleEscape = useCallback(() => {
useEffect(() => {
return () => {
flushPendingRawEditorChange({ debounceRef, latestDocRef, onContentChangeRef, pathRef })
}
}, [onContentChangeRef, pathRef])
return {
debounceRef,
handleDocChange,
handleSave,
latestDocRef,
onContentChangeRef,
pathRef,
yamlError,
}
}
type RawEditorAutocompleteDirection = 'next' | 'previous'
type RawEditorSetAutocomplete = React.Dispatch<React.SetStateAction<RawEditorAutocompleteState | null>>
type RawEditorTypeEntryMap = ReturnType<typeof buildTypeEntryMap>
function getRawEditorAutocompleteDirection(key: string): RawEditorAutocompleteDirection | null {
if (key === 'ArrowDown') return 'next'
if (key === 'ArrowUp') return 'previous'
return null
}
function buildNextRawEditorAutocomplete({
baseItems,
insertWikilinkRef,
typeEntryMap,
vaultPath,
view,
}: {
baseItems: ReturnType<typeof buildRawEditorBaseItems>
insertWikilinkRef: React.MutableRefObject<(target: string) => void>
typeEntryMap: RawEditorTypeEntryMap
vaultPath?: string
view: EditorView
}): RawEditorAutocompleteState | null {
const doc = view.state.doc.toString()
const cursor = view.state.selection.main.head
const query = extractWikilinkQuery(doc, cursor)
if (query === null || query.length < MIN_QUERY_LENGTH) return null
return buildRawEditorAutocompleteState({
view,
baseItems,
query,
typeEntryMap,
onInsertTarget: (target: string) => insertWikilinkRef.current(target),
vaultPath: vaultPath ?? '',
})
}
function useRawEditorAutocompleteEscape(
autocomplete: RawEditorAutocompleteState | null,
setAutocomplete: RawEditorSetAutocomplete,
) {
return useCallback(() => {
if (autocomplete) { setAutocomplete(null); return true }
return false
}, [autocomplete])
}, [autocomplete, setAutocomplete])
}
const viewRef = useCodeMirror(containerRef, content, {
onDocChange: handleDocChange,
onCursorActivity: handleCursorActivity,
onSave: handleSave,
onEscape: handleEscape,
})
function useRawEditorAutocompleteKeyboard(
autocomplete: RawEditorAutocompleteState | null,
setAutocomplete: RawEditorSetAutocomplete,
) {
return useCallback((e: React.KeyboardEvent) => {
if (!autocomplete) return
if (e.key === 'Enter') {
e.preventDefault()
autocomplete.items[autocomplete.selectedIndex]?.onItemClick()
return
}
const direction = getRawEditorAutocompleteDirection(e.key)
if (!direction) return
e.preventDefault()
setAutocomplete(prev => prev ? moveRawEditorAutocompleteSelection(prev, direction) : null)
}, [autocomplete, setAutocomplete])
}
function useRawEditorAutocompleteController({
entries,
vaultPath,
}: Pick<RawEditorViewProps, 'entries' | 'vaultPath'>) {
const [autocomplete, setAutocomplete] = useState<RawEditorAutocompleteState | null>(null)
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const baseItems = useMemo(() => buildRawEditorBaseItems(entries), [entries])
const insertWikilinkRef = useRef<(target: string) => void>(() => {})
const handleCursorActivity = useCallback((view: EditorView) => {
setAutocomplete(buildNextRawEditorAutocomplete({
baseItems,
insertWikilinkRef,
typeEntryMap,
vaultPath,
view,
}))
}, [baseItems, typeEntryMap, vaultPath])
const handleItemHover = useCallback((index: number) => {
setAutocomplete(prev => prev ? { ...prev, selectedIndex: index } : null)
}, [])
const handleEscape = useRawEditorAutocompleteEscape(autocomplete, setAutocomplete)
const handleAutocompleteKey = useRawEditorAutocompleteKeyboard(autocomplete, setAutocomplete)
return {
autocomplete,
handleAutocompleteKey,
handleCursorActivity,
handleEscape,
handleItemHover,
insertWikilinkRef,
setAutocomplete,
}
}
function useRawEditorWikilinkInsertion({
debounceRef,
insertWikilinkRef,
latestDocRef,
onContentChangeRef,
pathRef,
setAutocomplete,
viewRef,
}: PendingChangeRefs & {
insertWikilinkRef: React.MutableRefObject<(target: string) => void>
setAutocomplete: RawEditorSetAutocomplete
viewRef: React.MutableRefObject<EditorView | null>
}) {
const insertWikilink = useCallback((target: string) => {
const view = viewRef.current
if (!view) return
@@ -127,81 +318,48 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
onContentChangeRef.current(pathRef.current, replacement.text)
view.focus()
}, [viewRef])
}, [debounceRef, latestDocRef, onContentChangeRef, pathRef, setAutocomplete, viewRef])
useEffect(() => { insertWikilinkRef.current = insertWikilink }, [insertWikilink])
useEffect(() => { insertWikilinkRef.current = insertWikilink }, [insertWikilinkRef, insertWikilink])
}
const handleAutocompleteKey = useCallback((e: React.KeyboardEvent) => {
if (!autocomplete) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setAutocomplete(prev => prev
? { ...prev, selectedIndex: Math.min(prev.selectedIndex + 1, prev.items.length - 1) }
: null)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setAutocomplete(prev => prev
? { ...prev, selectedIndex: Math.max(prev.selectedIndex - 1, 0) }
: null)
} else if (e.key === 'Enter') {
e.preventDefault()
const item = autocomplete.items[autocomplete.selectedIndex]
if (item) item.onItemClick()
}
}, [autocomplete])
export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath }: RawEditorViewProps) {
const containerRef = useRef<HTMLDivElement>(null)
const pendingChanges = useRawEditorPendingChanges({ content, latestContentRef, onContentChange, onSave, path })
const autocompleteController = useRawEditorAutocompleteController({ entries, vaultPath })
const viewRef = useCodeMirror(containerRef, content, {
onDocChange: pendingChanges.handleDocChange,
onCursorActivity: autocompleteController.handleCursorActivity,
onSave: pendingChanges.handleSave,
onEscape: autocompleteController.handleEscape,
})
// Flush pending debounce on unmount
useEffect(() => {
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current)
onContentChangeRef.current(pathRef.current, latestDocRef.current)
}
}
}, [])
useRawEditorWikilinkInsertion({
debounceRef: pendingChanges.debounceRef,
insertWikilinkRef: autocompleteController.insertWikilinkRef,
latestDocRef: pendingChanges.latestDocRef,
onContentChangeRef: pendingChanges.onContentChangeRef,
pathRef: pendingChanges.pathRef,
setAutocomplete: autocompleteController.setAutocomplete,
viewRef,
})
const dropdownPosition = getRawEditorDropdownPosition(autocomplete, DROPDOWN_MAX_HEIGHT, window)
const dropdownPosition = getRawEditorDropdownPosition(autocompleteController.autocomplete, DROPDOWN_MAX_HEIGHT, window)
return (
<div className="flex flex-1 flex-col min-h-0 relative" style={{ background: 'var(--background)' }} onKeyDown={handleAutocompleteKey} role="presentation">
{yamlError && (
<div
className="flex items-center gap-2 px-4 py-2 text-xs border-b shrink-0"
style={{ background: '#fef3c7', borderColor: '#d97706', color: '#92400e' }}
role="alert"
data-testid="raw-editor-yaml-error"
>
<span style={{ fontWeight: 600 }}>YAML error:</span>
<span>{yamlError}</span>
</div>
)}
<div className="flex flex-1 flex-col min-h-0 relative" style={{ background: 'var(--background)' }} onKeyDown={autocompleteController.handleAutocompleteKey} role="presentation">
<RawEditorYamlErrorBanner error={pendingChanges.yamlError} />
<div
ref={containerRef}
className="flex flex-1 min-h-0"
data-testid="raw-editor-codemirror"
aria-label="Raw editor"
/>
{autocomplete && autocomplete.items.length > 0 && (
<div
className="fixed z-50 min-w-64 max-w-xs rounded-md border shadow-lg overflow-auto"
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
maxHeight: DROPDOWN_MAX_HEIGHT,
background: 'var(--popover)',
borderColor: 'var(--border)',
}}
data-testid="raw-editor-wikilink-dropdown"
>
<NoteSearchList
items={autocomplete.items}
selectedIndex={autocomplete.selectedIndex}
getItemKey={(item, i) => `${item.title}-${item.path ?? i}`}
onItemClick={(item) => item.onItemClick()}
onItemHover={(i) => setAutocomplete(prev => prev ? { ...prev, selectedIndex: i } : null)}
/>
</div>
)}
<RawEditorAutocompleteDropdown
autocomplete={autocompleteController.autocomplete}
onItemHover={autocompleteController.handleItemHover}
position={dropdownPosition}
/>
</div>
)
}

View File

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { SettingsPanel } from './SettingsPanel'
import type { Settings } from '../types'
import { THEME_MODE_STORAGE_KEY } from '../lib/themeMode'
const emptySettings: Settings = {
auto_pull_interval_minutes: null,
@@ -14,6 +15,7 @@ const emptySettings: Settings = {
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
theme_mode: null,
}
function installPointerCapturePolyfill() {
@@ -28,12 +30,27 @@ function installPointerCapturePolyfill() {
}
}
function createStorageMock(): Storage {
const values = new Map<string, string>()
return {
get length() { return values.size },
clear: vi.fn(() => { values.clear() }),
getItem: vi.fn((key: string) => values.get(key) ?? null),
key: vi.fn((index: number) => Array.from(values.keys())[index] ?? null),
removeItem: vi.fn((key: string) => { values.delete(key) }),
setItem: vi.fn((key: string, value: string) => { values.set(key, value) }),
}
}
describe('SettingsPanel', () => {
const onSave = vi.fn()
const onClose = vi.fn()
const localStorageMock = createStorageMock()
beforeEach(() => {
vi.clearAllMocks()
Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true })
window.localStorage.clear()
installPointerCapturePolyfill()
})
@@ -65,10 +82,62 @@ describe('SettingsPanel', () => {
autogit_idle_threshold_seconds: 90,
autogit_inactive_threshold_seconds: 30,
release_channel: null,
theme_mode: 'light',
}))
expect(onClose).toHaveBeenCalled()
})
it('defaults the color mode control to light', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
expect(screen.getByTestId('settings-theme-mode')).toBeInTheDocument()
expect(screen.getByRole('radio', { name: 'Light' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByRole('radio', { name: 'Dark' })).toHaveAttribute('aria-checked', 'false')
})
it('uses the stored color mode mirror when settings have no saved mode', () => {
window.localStorage.setItem(THEME_MODE_STORAGE_KEY, 'dark')
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
expect(screen.getByRole('radio', { name: 'Dark' })).toHaveAttribute('aria-checked', 'true')
})
it('saves the selected dark color mode', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.click(screen.getByRole('radio', { name: 'Dark' }))
fireEvent.click(screen.getByTestId('settings-save'))
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
theme_mode: 'dark',
}))
})
it('preserves a saved dark color mode until changed', () => {
render(
<SettingsPanel
open={true}
settings={{ ...emptySettings, theme_mode: 'dark' }}
onSave={onSave}
onClose={onClose}
/>
)
expect(screen.getByRole('radio', { name: 'Dark' })).toHaveAttribute('aria-checked', 'true')
fireEvent.click(screen.getByTestId('settings-save'))
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
theme_mode: 'dark',
}))
})
it('defaults the release channel trigger to stable', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />

View File

@@ -15,8 +15,13 @@ import {
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from 'react'
import { X } from '@phosphor-icons/react'
import { Moon, Sun, X } from '@phosphor-icons/react'
import type { Settings } from '../types'
import {
DEFAULT_THEME_MODE,
readStoredThemeMode,
type ThemeMode,
} from '../lib/themeMode'
import { normalizeReleaseChannel, serializeReleaseChannel, type ReleaseChannel } from '../lib/releaseChannel'
import { trackEvent } from '../lib/telemetry'
import { Button } from './ui/button'
@@ -50,6 +55,7 @@ interface SettingsDraft {
autoAdvanceInboxAfterOrganize: boolean
defaultAiAgent: AiAgentId
releaseChannel: ReleaseChannel
themeMode: ThemeMode
initialH1AutoRename: boolean
crashReporting: boolean
analytics: boolean
@@ -73,6 +79,8 @@ interface SettingsBodyProps {
setDefaultAiAgent: (value: AiAgentId) => void
releaseChannel: ReleaseChannel
setReleaseChannel: (value: ReleaseChannel) => void
themeMode: ThemeMode
setThemeMode: (value: ThemeMode) => void
initialH1AutoRename: boolean
setInitialH1AutoRename: (value: boolean) => void
explicitOrganization: boolean
@@ -109,6 +117,7 @@ function createSettingsDraft(
autoAdvanceInboxAfterOrganize: settings.auto_advance_inbox_after_organize ?? false,
defaultAiAgent: resolveDefaultAiAgent(settings.default_ai_agent),
releaseChannel: normalizeReleaseChannel(settings.release_channel),
themeMode: resolveSettingsDraftThemeMode(settings.theme_mode),
initialH1AutoRename: settings.initial_h1_auto_rename_enabled ?? true,
crashReporting: settings.crash_reporting_enabled ?? false,
analytics: settings.analytics_enabled ?? false,
@@ -116,6 +125,12 @@ function createSettingsDraft(
}
}
function resolveSettingsDraftThemeMode(themeMode: Settings['theme_mode']): ThemeMode {
if (themeMode) return themeMode
if (typeof window === 'undefined') return DEFAULT_THEME_MODE
return readStoredThemeMode(window.localStorage) ?? DEFAULT_THEME_MODE
}
function resolveTelemetryConsent(settings: Settings, draft: SettingsDraft): boolean | null {
if (draft.crashReporting || draft.analytics) return true
return settings.telemetry_consent === null ? null : false
@@ -141,6 +156,7 @@ function buildSettingsFromDraft(settings: Settings, draft: SettingsDraft): Setti
analytics_enabled: draft.analytics,
anonymous_id: resolveAnonymousId(settings, draft),
release_channel: serializeReleaseChannel(draft.releaseChannel),
theme_mode: draft.themeMode,
initial_h1_auto_rename_enabled: draft.initialH1AutoRename,
default_ai_agent: draft.defaultAiAgent,
}
@@ -251,14 +267,14 @@ function SettingsPanelInner({
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.4)' }}
style={{ background: 'var(--shadow-overlay)' }}
onClick={handleBackdropClick}
onKeyDown={handleKeyDown}
data-testid="settings-panel"
>
<div
ref={panelRef}
className="bg-background border border-border rounded-lg shadow-xl"
className="rounded-lg border border-border bg-background shadow-[0_18px_55px_var(--shadow-dialog)]"
style={{ width: 520, maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}
>
<SettingsHeader onClose={onClose} />
@@ -279,6 +295,8 @@ function SettingsPanelInner({
setDefaultAiAgent={(value) => updateDraft('defaultAiAgent', value)}
releaseChannel={draft.releaseChannel}
setReleaseChannel={(value) => updateDraft('releaseChannel', value)}
themeMode={draft.themeMode}
setThemeMode={(value) => updateDraft('themeMode', value)}
initialH1AutoRename={draft.initialH1AutoRename}
setInitialH1AutoRename={(value) => updateDraft('initialH1AutoRename', value)}
explicitOrganization={draft.explicitOrganization}
@@ -331,6 +349,8 @@ function SettingsBody({
setDefaultAiAgent,
releaseChannel,
setReleaseChannel,
themeMode,
setThemeMode,
initialH1AutoRename,
setInitialH1AutoRename,
explicitOrganization,
@@ -363,6 +383,13 @@ function SettingsBody({
/>
</SettingsSection>
<SettingsSection>
<AppearanceSettingsSection
themeMode={themeMode}
setThemeMode={setThemeMode}
/>
</SettingsSection>
<SettingsSection>
<TitleSettingsSection
initialH1AutoRename={initialH1AutoRename}
@@ -438,6 +465,81 @@ function SyncAndUpdatesSection({
)
}
function AppearanceSettingsSection({
themeMode,
setThemeMode,
}: Pick<SettingsBodyProps, 'themeMode' | 'setThemeMode'>) {
return (
<>
<SectionHeading
title="Appearance"
description="Choose the app color mode used for Tolaria chrome, editor surfaces, menus, and dialogs."
/>
<ThemeModeControl value={themeMode} onChange={setThemeMode} />
</>
)
}
function ThemeModeControl({
value,
onChange,
}: {
value: ThemeMode
onChange: (value: ThemeMode) => void
}) {
return (
<div
className="inline-flex w-full rounded-md border border-border bg-muted p-1"
role="radiogroup"
aria-label="Theme"
data-testid="settings-theme-mode"
>
<ThemeModeButton label="Light" selected={value === 'light'} value="light" onSelect={onChange}>
<Sun size={14} />
</ThemeModeButton>
<ThemeModeButton label="Dark" selected={value === 'dark'} value="dark" onSelect={onChange}>
<Moon size={14} />
</ThemeModeButton>
</div>
)
}
function ThemeModeButton({
children,
label,
selected,
value,
onSelect,
}: {
children: ReactNode
label: string
selected: boolean
value: ThemeMode
onSelect: (value: ThemeMode) => void
}) {
return (
<Button
type="button"
variant="ghost"
size="sm"
role="radio"
aria-checked={selected}
aria-label={label}
data-testid={`settings-theme-${value}`}
className={
selected
? 'h-7 flex-1 border border-border bg-background text-foreground shadow-xs hover:bg-background'
: 'h-7 flex-1 text-muted-foreground hover:text-foreground'
}
onClick={() => onSelect(value)}
>
{children}
{label}
</Button>
)
}
function autoGitSectionDescription(isGitVault: boolean): string {
return isGitVault
? 'Automatically create conservative Git checkpoints after editing pauses or when the app is no longer active.'

View File

@@ -4,6 +4,7 @@ import { cn } from '@/lib/utils'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { type IconProps } from '@phosphor-icons/react'
import { SIDEBAR_ITEM_PADDING } from './sidebar/sidebarStyles'
import { Button } from './ui/button'
const SIDEBAR_COUNT_PILL_STYLE = {
borderRadius: 9999,
@@ -359,6 +360,19 @@ function getSectionContextMenuHandler(
return onContextMenu
}
function resolveInlineRenameHandlers({
isRenaming,
onRenameCancel,
onRenameSubmit,
}: {
isRenaming?: boolean
onRenameCancel?: () => void
onRenameSubmit?: (value: string) => void
}): { onRenameCancel: () => void; onRenameSubmit: (value: string) => void } | null {
if (!isRenaming || !onRenameSubmit || !onRenameCancel) return null
return { onRenameCancel, onRenameSubmit }
}
function SectionHeaderLabel({
type,
label,
@@ -378,13 +392,19 @@ function SectionHeaderLabel({
onRenameSubmit?: (value: string) => void
onRenameCancel?: () => void
}) {
if (isRenaming && onRenameSubmit && onRenameCancel) {
const inlineRenameHandlers = resolveInlineRenameHandlers({
isRenaming,
onRenameCancel,
onRenameSubmit,
})
if (inlineRenameHandlers) {
return (
<InlineRenameInput
key={`rename-${type}`}
initialValue={renameInitialValue ?? label}
onSubmit={onRenameSubmit}
onCancel={onRenameCancel}
onSubmit={inlineRenameHandlers.onRenameSubmit}
onCancel={inlineRenameHandlers.onRenameCancel}
/>
)
}
@@ -406,7 +426,7 @@ function SectionHeaderCountPill({
<SidebarCountPill
count={itemCount}
className={!isActive ? 'text-muted-foreground' : undefined}
style={isActive ? { background: sectionColor, color: 'white' } : { background: 'var(--muted)' }}
style={isActive ? { background: sectionColor, color: 'var(--text-inverse)' } : { background: 'var(--muted)' }}
/>
)
}
@@ -458,9 +478,11 @@ function VisibilityPopoverItem({
const { sectionColor } = resolveSectionColors(type, customColor)
return (
<button
<Button
type="button"
className="flex w-full cursor-pointer items-center border-none bg-transparent transition-colors hover:bg-accent"
variant="ghost"
size="sm"
className="h-auto w-full justify-start rounded-none px-3 py-1.5"
style={{ padding: '6px 12px', gap: 8 }}
onClick={() => onToggle(type)}
aria-label={`Toggle ${label}`}
@@ -468,7 +490,7 @@ function VisibilityPopoverItem({
<Icon size={14} style={{ color: sectionColor }} />
<span className="flex-1 text-left text-[13px] text-foreground">{label}</span>
<ToggleSwitch on={isVisible} />
</button>
</Button>
)
}
@@ -482,7 +504,7 @@ export function VisibilityPopover({ sections, isSectionVisible, onToggle }: {
return (
<div
className="border border-border bg-popover text-popover-foreground"
style={{ position: 'absolute', top: '100%', left: 6, right: 6, zIndex: 50, borderRadius: 8, padding: '8px 0', boxShadow: '0 4px 12px rgba(0,0,0,0.12)' }}
style={{ position: 'absolute', top: '100%', left: 6, right: 6, zIndex: 50, borderRadius: 8, padding: '8px 0', boxShadow: '0 4px 12px var(--shadow-dialog)' }}
>
<div className="text-[12px] font-semibold text-muted-foreground" style={{ padding: '0 12px 4px' }}>Show in sidebar</div>
{sections.map((group) => (
@@ -500,7 +522,7 @@ export function VisibilityPopover({ sections, isSectionVisible, onToggle }: {
function ToggleSwitch({ on }: { on: boolean }) {
return (
<div className="flex items-center" style={{ width: 32, height: 18, borderRadius: 9, padding: 2, backgroundColor: on ? 'var(--primary)' : 'var(--muted)', justifyContent: on ? 'flex-end' : 'flex-start', transition: 'background-color 150ms' }}>
<div style={{ width: 14, height: 14, borderRadius: 7, backgroundColor: 'white', transition: 'transform 150ms' }} />
<div style={{ width: 14, height: 14, borderRadius: 7, backgroundColor: 'var(--background)', transition: 'transform 150ms' }} />
</div>
)
}

View File

@@ -26,6 +26,7 @@ vi.mock('@blocknote/react', () => ({
slashMenu?: boolean
sideMenu?: boolean
onChange?: () => void
theme?: string
}) => {
const {
children,
@@ -205,6 +206,8 @@ describe('SingleEditorView', () => {
state.capturedBlockNoteOnChange = null
state.imageDropState.isDragOver = false
state.wikilinkEntriesRef.current = []
document.documentElement.removeAttribute('data-theme')
document.documentElement.classList.remove('dark')
delete window.__laputaTest
})
@@ -306,6 +309,22 @@ describe('SingleEditorView', () => {
expect(onMentionItemClick).toHaveBeenCalledOnce()
})
it('passes the active document theme to BlockNote', () => {
document.documentElement.setAttribute('data-theme', 'dark')
document.documentElement.classList.add('dark')
render(
<SingleEditorView
editor={createEditor() as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('theme', 'dark')
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-mantine-color-scheme', 'dark')
})
it('defers rich-editor change propagation until IME composition ends', async () => {
const editor = createEditor()
const onChange = vi.fn()

View File

@@ -9,6 +9,7 @@ import {
} from '@blocknote/react'
import { components } from '@blocknote/mantine'
import { MantineContext, MantineProvider } from '@mantine/core'
import { useDocumentThemeMode } from '../hooks/useDocumentThemeMode'
import { useEditorTheme } from '../hooks/useTheme'
import { useImageDrop } from '../hooks/useImageDrop'
import { buildTypeEntryMap } from '../utils/typeColors'
@@ -331,6 +332,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
editable?: boolean
}) {
const { cssVars } = useEditorTheme()
const themeMode = useDocumentThemeMode()
const containerRef = useRef<HTMLDivElement>(null)
const handleContainerClick = useEditorContainerClickHandler({ editable, editor })
const handleEditorChange = useCompositionAwareEditorChange({ containerRef, onChange })
@@ -369,7 +371,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
)}
<SharedContextBlockNoteView
editor={editor}
theme="light"
theme={themeMode}
onChange={handleEditorChange}
editable={editable}
formattingToolbar={false}

View File

@@ -1,7 +1,17 @@
import { useState, useRef, useEffect, useLayoutEffect, useCallback, useMemo } from 'react'
import { useState, useRef, useCallback, useMemo } from 'react'
import { createPortal } from 'react-dom'
import { getStatusStyle, SUGGESTED_STATUSES, setStatusColor, getStatusColorKey } from '../utils/statusStyles'
import { ACCENT_COLORS } from '../utils/typeColors'
import {
getNextHighlightIndex,
getPreviousHighlightIndex,
isCreateOptionVisible,
useAnchoredDropdownPosition,
useAutoFocus,
} from './propertyDropdownUtils'
const PROPERTY_DROPDOWN_WIDTH = 208
const SELECTED_SWATCH_CHECK_STYLE = { color: 'var(--text-inverse)', fontSize: 8, lineHeight: 1 } as const
export function StatusPill({ status, className }: { status: string; className?: string }) {
const style = getStatusStyle(status)
@@ -40,7 +50,7 @@ function ColorPickerRow({ status, onColorChange }: { status: string; onColorChan
data-testid={`color-option-${c.key}`}
>
{currentKey === c.key && (
<span style={{ color: 'white', fontSize: 8, lineHeight: 1 }}>{'\u2713'}</span>
<span style={SELECTED_SWATCH_CHECK_STYLE}>{'\u2713'}</span>
)}
</button>
))}
@@ -162,6 +172,25 @@ interface KeyboardNavOptions {
listRef: React.RefObject<HTMLDivElement | null>
}
interface StatusSelectionOptions {
highlightIndex: number
allFiltered: string[]
showCreateOption: boolean
query: string
}
function getStatusValueToSave({
highlightIndex,
allFiltered,
showCreateOption,
query,
}: StatusSelectionOptions) {
const trimmed = query.trim()
if (highlightIndex >= 0 && highlightIndex < allFiltered.length) return allFiltered[highlightIndex]
if (showCreateOption && highlightIndex === allFiltered.length) return trimmed
return trimmed || null
}
function useStatusKeyboard(opts: KeyboardNavOptions) {
const { allFiltered, totalOptions, showCreateOption, query, onSave, onCancel, listRef } = opts
const [highlightIndex, setHighlightIndex] = useState(-1)
@@ -173,29 +202,32 @@ function useStatusKeyboard(opts: KeyboardNavOptions) {
items[index]?.scrollIntoView({ block: 'nearest' })
}, [listRef])
const moveHighlight = useCallback((nextIndex: number) => {
setHighlightIndex(nextIndex)
scrollIntoView(nextIndex)
}, [scrollIntoView])
const submitHighlightedStatus = useCallback(() => {
const value = getStatusValueToSave({ highlightIndex, allFiltered, showCreateOption, query })
if (value) onSave(value)
}, [highlightIndex, allFiltered, showCreateOption, query, onSave])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown': {
e.preventDefault()
const next = highlightIndex < totalOptions - 1 ? highlightIndex + 1 : 0
setHighlightIndex(next)
scrollIntoView(next)
moveHighlight(getNextHighlightIndex(highlightIndex, totalOptions))
break
}
case 'ArrowUp': {
e.preventDefault()
const prev = highlightIndex > 0 ? highlightIndex - 1 : totalOptions - 1
setHighlightIndex(prev)
scrollIntoView(prev)
moveHighlight(getPreviousHighlightIndex(highlightIndex, totalOptions))
break
}
case 'Enter': {
e.preventDefault()
const trimmed = query.trim()
if (highlightIndex >= 0 && highlightIndex < allFiltered.length) onSave(allFiltered[highlightIndex])
else if (showCreateOption && highlightIndex === allFiltered.length) onSave(trimmed)
else if (trimmed) onSave(trimmed)
submitHighlightedStatus()
break
}
case 'Escape':
@@ -204,7 +236,7 @@ function useStatusKeyboard(opts: KeyboardNavOptions) {
break
}
},
[highlightIndex, totalOptions, allFiltered, showCreateOption, query, onSave, onCancel, scrollIntoView],
[highlightIndex, totalOptions, moveHighlight, submitHighlightedStatus, onCancel],
)
const resetHighlight = useCallback(() => setHighlightIndex(-1), [])
@@ -226,31 +258,15 @@ export function StatusDropdown({
const [colorEditingStatus, setColorEditingStatus] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLSpanElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
const node = dropdownRef.current
if (!node) return
const anchor = anchorRef.current?.parentElement
if (!anchor) return
const rect = anchor.getBoundingClientRect()
const dropW = 208
let left = rect.right - dropW
if (left < 8) left = 8
if (left + dropW > window.innerWidth - 8) left = window.innerWidth - dropW - 8
node.style.top = `${rect.bottom + 4}px`
node.style.left = `${left}px`
}, [])
useEffect(() => { inputRef.current?.focus() }, [])
useAnchoredDropdownPosition({ anchorRef, dropdownRef, width: PROPERTY_DROPDOWN_WIDTH })
useAutoFocus(inputRef)
const { suggestedFiltered, vaultFiltered, allFiltered } = useStatusFiltering(query, vaultStatuses)
const showCreateOption = useMemo(() => {
if (!query.trim()) return false
return !allFiltered.some(s => s.toLowerCase() === query.trim().toLowerCase())
}, [query, allFiltered])
const showCreateOption = useMemo(() => isCreateOptionVisible(query, allFiltered), [query, allFiltered])
const totalOptions = allFiltered.length + (showCreateOption ? 1 : 0)

View File

@@ -1,7 +1,17 @@
import { useState, useRef, useEffect, useLayoutEffect, useCallback, useMemo } from 'react'
import { useState, useRef, useCallback, useMemo } from 'react'
import { createPortal } from 'react-dom'
import { getTagStyle, setTagColor, getTagColorKey } from '../utils/tagStyles'
import { ACCENT_COLORS } from '../utils/typeColors'
import {
getNextHighlightIndex,
getPreviousHighlightIndex,
isCreateOptionVisible,
useAnchoredDropdownPosition,
useAutoFocus,
} from './propertyDropdownUtils'
const PROPERTY_DROPDOWN_WIDTH = 208
const SELECTED_SWATCH_CHECK_STYLE = { color: 'var(--text-inverse)', fontSize: 8, lineHeight: 1 } as const
export function TagPill({ tag, className }: { tag: string; className?: string }) {
const style = getTagStyle(tag)
@@ -40,7 +50,7 @@ function ColorPickerRow({ tag, onColorChange }: { tag: string; onColorChange: (t
data-testid={`tag-color-option-${c.key}`}
>
{currentKey === c.key && (
<span style={{ color: 'white', fontSize: 8, lineHeight: 1 }}>{'\u2713'}</span>
<span style={SELECTED_SWATCH_CHECK_STYLE}>{'\u2713'}</span>
)}
</button>
))}
@@ -111,6 +121,28 @@ function useTagFiltering(query: string, vaultTags: string[]) {
}, [query, vaultTags])
}
interface TagSelectionOptions {
highlightIndex: number
filtered: string[]
showCreateOption: boolean
query: string
selectedTags: Set<string>
}
function getTagValueToToggle({
highlightIndex,
filtered,
showCreateOption,
query,
selectedTags,
}: TagSelectionOptions) {
const trimmed = query.trim()
if (highlightIndex >= 0 && highlightIndex < filtered.length) return filtered[highlightIndex]
if (showCreateOption && highlightIndex === filtered.length && trimmed) return trimmed
if (trimmed && !selectedTags.has(trimmed)) return trimmed
return null
}
function useTagKeyboard(opts: {
filtered: string[]; totalOptions: number; showCreateOption: boolean
query: string; selectedTags: Set<string>
@@ -127,33 +159,32 @@ function useTagKeyboard(opts: {
items[index]?.scrollIntoView({ block: 'nearest' })
}, [listRef])
const moveHighlight = useCallback((nextIndex: number) => {
setHighlightIndex(nextIndex)
scrollIntoView(nextIndex)
}, [scrollIntoView])
const submitHighlightedTag = useCallback(() => {
const value = getTagValueToToggle({ highlightIndex, filtered, showCreateOption, query, selectedTags })
if (value) onToggle(value)
}, [highlightIndex, filtered, showCreateOption, query, selectedTags, onToggle])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown': {
e.preventDefault()
const next = highlightIndex < totalOptions - 1 ? highlightIndex + 1 : 0
setHighlightIndex(next)
scrollIntoView(next)
moveHighlight(getNextHighlightIndex(highlightIndex, totalOptions))
break
}
case 'ArrowUp': {
e.preventDefault()
const prev = highlightIndex > 0 ? highlightIndex - 1 : totalOptions - 1
setHighlightIndex(prev)
scrollIntoView(prev)
moveHighlight(getPreviousHighlightIndex(highlightIndex, totalOptions))
break
}
case 'Enter': {
e.preventDefault()
const trimmed = query.trim()
if (highlightIndex >= 0 && highlightIndex < filtered.length) {
onToggle(filtered[highlightIndex])
} else if (showCreateOption && highlightIndex === filtered.length && trimmed) {
onToggle(trimmed)
} else if (trimmed && !selectedTags.has(trimmed)) {
onToggle(trimmed)
}
submitHighlightedTag()
break
}
case 'Escape':
@@ -162,7 +193,7 @@ function useTagKeyboard(opts: {
break
}
},
[highlightIndex, totalOptions, filtered, showCreateOption, query, selectedTags, onToggle, onClose, scrollIntoView],
[highlightIndex, totalOptions, moveHighlight, submitHighlightedTag, onClose],
)
const resetHighlight = useCallback(() => setHighlightIndex(-1), [])
@@ -180,33 +211,17 @@ export function TagsDropdown({
const [colorEditingTag, setColorEditingTag] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLSpanElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const selectedSet = useMemo(() => new Set(selectedTags), [selectedTags])
useLayoutEffect(() => {
const node = dropdownRef.current
if (!node) return
const anchor = anchorRef.current?.parentElement
if (!anchor) return
const rect = anchor.getBoundingClientRect()
const dropW = 208
let left = rect.right - dropW
if (left < 8) left = 8
if (left + dropW > window.innerWidth - 8) left = window.innerWidth - dropW - 8
node.style.top = `${rect.bottom + 4}px`
node.style.left = `${left}px`
}, [])
useEffect(() => { inputRef.current?.focus() }, [])
useAnchoredDropdownPosition({ anchorRef, dropdownRef, width: PROPERTY_DROPDOWN_WIDTH })
useAutoFocus(inputRef)
const { filtered } = useTagFiltering(query, vaultTags)
const showCreateOption = useMemo(() => {
if (!query.trim()) return false
return !filtered.some(t => t.toLowerCase() === query.trim().toLowerCase())
}, [query, filtered])
const showCreateOption = useMemo(() => isCreateOptionVisible(query, filtered), [query, filtered])
const totalOptions = filtered.length + (showCreateOption ? 1 : 0)
@@ -250,46 +265,25 @@ export function TagsDropdown({
/>
</div>
<div ref={listRef} className="max-h-52 overflow-y-auto py-1">
{filtered.length > 0 && (
<div>
<SectionLabel>From vault</SectionLabel>
{filtered.map((tag, i) => (
<TagOption
key={tag} tag={tag}
selected={selectedSet.has(tag)}
highlighted={highlightIndex === i}
onToggle={onToggle}
onMouseEnter={() => setHighlightIndex(i)}
colorEditing={colorEditingTag === tag}
onToggleColor={handleToggleColor}
onColorChange={handleColorChange}
/>
))}
</div>
)}
{showCreateOption && (
<>
{filtered.length > 0 && <div className="my-1 h-px bg-border" />}
<button
className="flex w-full items-center gap-1.5 border-none bg-transparent px-2 py-1 text-left text-[11px] transition-colors"
style={{
borderRadius: 4,
backgroundColor: highlightIndex === filtered.length ? 'var(--muted)' : 'transparent',
color: 'var(--muted-foreground)',
}}
onClick={() => onToggle(query.trim())}
onMouseEnter={() => setHighlightIndex(filtered.length)}
data-testid="tag-create-option"
>
Create <TagPill tag={query.trim()} />
</button>
</>
)}
{filtered.length === 0 && !showCreateOption && (
<div className="px-2 py-2 text-center text-[11px] text-muted-foreground">
No matching tags
</div>
)}
<VaultTagSection
tags={filtered}
selectedTags={selectedSet}
highlightIndex={highlightIndex}
colorEditingTag={colorEditingTag}
onToggle={onToggle}
onHighlight={setHighlightIndex}
onToggleColor={handleToggleColor}
onColorChange={handleColorChange}
/>
<CreateTagSection
show={showCreateOption}
query={query}
showDivider={filtered.length > 0}
highlighted={highlightIndex === filtered.length}
onToggle={onToggle}
onMouseEnter={() => setHighlightIndex(filtered.length)}
/>
<EmptyTagMessage show={filtered.length === 0 && !showCreateOption} />
</div>
</div>
</>,
@@ -298,3 +292,80 @@ export function TagsDropdown({
</span>
)
}
interface VaultTagSectionProps {
tags: string[]
selectedTags: Set<string>
highlightIndex: number
colorEditingTag: string | null
onToggle: (tag: string) => void
onHighlight: (index: number) => void
onToggleColor: (tag: string) => void
onColorChange: (tag: string, colorKey: string) => void
}
function VaultTagSection({
tags,
selectedTags,
highlightIndex,
colorEditingTag,
onToggle,
onHighlight,
onToggleColor,
onColorChange,
}: VaultTagSectionProps) {
if (tags.length === 0) return null
return (
<div>
<SectionLabel>From vault</SectionLabel>
{tags.map((tag, i) => (
<TagOption
key={tag}
tag={tag}
selected={selectedTags.has(tag)}
highlighted={highlightIndex === i}
onToggle={onToggle}
onMouseEnter={() => onHighlight(i)}
colorEditing={colorEditingTag === tag}
onToggleColor={onToggleColor}
onColorChange={onColorChange}
/>
))}
</div>
)
}
function CreateTagSection({ show, query, showDivider, highlighted, onToggle, onMouseEnter }: {
show: boolean; query: string; showDivider: boolean; highlighted: boolean
onToggle: (tag: string) => void; onMouseEnter: () => void
}) {
if (!show) return null
const trimmed = query.trim()
return (
<>
{showDivider && <div className="my-1 h-px bg-border" />}
<button
className="flex w-full items-center gap-1.5 border-none bg-transparent px-2 py-1 text-left text-[11px] transition-colors"
style={{
borderRadius: 4,
backgroundColor: highlighted ? 'var(--muted)' : 'transparent',
color: 'var(--muted-foreground)',
}}
onClick={() => onToggle(trimmed)}
onMouseEnter={onMouseEnter}
data-testid="tag-create-option"
>
Create <TagPill tag={trimmed} />
</button>
</>
)
}
function EmptyTagMessage({ show }: { show: boolean }) {
if (!show) return null
return (
<div className="px-2 py-2 text-center text-[11px] text-muted-foreground">
No matching tags
</div>
)
}

View File

@@ -1,5 +1,6 @@
import { ShieldCheck } from '@phosphor-icons/react'
import { OnboardingShell } from './OnboardingShell'
import { Button } from './ui/button'
interface TelemetryConsentDialogProps {
onAccept: () => void
@@ -10,8 +11,8 @@ export function TelemetryConsentDialog({ onAccept, onDecline }: TelemetryConsent
return (
<OnboardingShell
className="fixed inset-0 z-50"
contentClassName="w-full rounded-lg border border-border bg-background shadow-xl"
style={{ background: 'rgba(0,0,0,0.4)' }}
contentClassName="w-full rounded-lg border border-border bg-background shadow-[0_18px_55px_var(--shadow-dialog)]"
style={{ background: 'var(--shadow-overlay)' }}
contentStyle={{
width: 'min(440px, 100%)',
padding: 32,
@@ -49,23 +50,24 @@ export function TelemetryConsentDialog({ onAccept, onDecline }: TelemetryConsent
</div>
<div style={{ display: 'flex', gap: 12, width: '100%', marginTop: 4 }}>
<button
className="border border-border bg-transparent text-foreground rounded cursor-pointer hover:bg-accent"
<Button
type="button"
variant="outline"
style={{ flex: 1, fontSize: 13, padding: '10px 16px' }}
onClick={onDecline}
data-testid="telemetry-decline"
autoFocus
>
No thanks
</button>
<button
className="border-none rounded cursor-pointer"
style={{ flex: 1, fontSize: 13, padding: '10px 16px', background: 'var(--primary)', color: 'white', fontWeight: 500 }}
</Button>
<Button
type="button"
style={{ flex: 1, fontSize: 13, padding: '10px 16px', fontWeight: 500 }}
onClick={onAccept}
data-testid="telemetry-accept"
>
Allow anonymous reporting
</button>
</Button>
</div>
<p style={{ fontSize: 11, color: 'var(--muted-foreground)', margin: 0, textAlign: 'center' }}>

View File

@@ -132,7 +132,7 @@ function MissingTypeWarning({
variant="ghost"
size="icon-xs"
className={cn(
'h-6 w-6 shrink-0 rounded-md border border-amber-300/80 bg-amber-50 p-0 text-amber-700 shadow-none hover:bg-amber-100 hover:text-amber-800',
'h-6 w-6 shrink-0 rounded-md border border-[var(--feedback-warning-border)] bg-[var(--feedback-warning-bg)] p-0 text-[var(--feedback-warning-text)] shadow-none hover:brightness-95',
!canCreateMissingType && 'cursor-default',
)}
data-testid="missing-type-warning"

View File

@@ -16,29 +16,29 @@ const bannerStyle = {
alignItems: 'center',
gap: 10,
padding: '6px 12px',
background: '#1a56db',
background: 'var(--accent-blue)',
borderBottom: 'none',
fontSize: 13,
color: '#fff',
color: 'var(--text-inverse)',
flexShrink: 0,
} satisfies CSSProperties
const iconStyle = {
color: '#fff',
color: 'var(--text-inverse)',
flexShrink: 0,
} satisfies CSSProperties
const primaryActionStyle = {
marginLeft: 'auto',
padding: '3px 10px',
background: 'var(--primary)',
color: '#fff',
background: 'var(--text-inverse)',
color: 'var(--accent-blue)',
fontSize: 12,
fontWeight: 500,
} satisfies CSSProperties
const dismissButtonStyle = {
color: '#fff',
color: 'var(--text-inverse)',
display: 'flex',
padding: 2,
} satisfies CSSProperties
@@ -47,18 +47,18 @@ const progressTrackStyle = {
flex: 1,
maxWidth: 200,
height: 4,
background: 'rgba(255,255,255,0.3)',
background: 'color-mix(in srgb, var(--text-inverse) 30%, transparent)',
borderRadius: 2,
overflow: 'hidden',
} satisfies CSSProperties
const progressTextStyle = {
fontSize: 11,
color: 'rgba(255,255,255,0.85)',
color: 'color-mix(in srgb, var(--text-inverse) 85%, transparent)',
} satisfies CSSProperties
const readyIconStyle = {
color: 'var(--accent-green, #0F7B0F)',
color: 'var(--accent-green)',
flexShrink: 0,
} satisfies CSSProperties
@@ -75,7 +75,7 @@ function renderAvailableContent(status: Extract<VisibleUpdateStatus, { state: 'a
size="xs"
data-testid="update-release-notes"
onClick={actions.openReleaseNotes}
style={{ color: '#fff', padding: 0, height: 'auto' }}
style={{ color: 'var(--text-inverse)', padding: 0, height: 'auto' }}
>
Release Notes <ExternalLink size={11} />
</Button>
@@ -114,7 +114,7 @@ function renderDownloadingContent(status: Extract<VisibleUpdateStatus, { state:
style={{
width: `${Math.round(status.progress * 100)}%`,
height: '100%',
background: 'var(--primary)',
background: 'var(--text-inverse)',
borderRadius: 2,
transition: 'width 0.2s ease',
}}
@@ -139,7 +139,8 @@ function renderReadyContent(status: Extract<VisibleUpdateStatus, { state: 'ready
onClick={restartApp}
style={{
...primaryActionStyle,
background: 'var(--accent-green, #0F7B0F)',
background: 'var(--accent-green)',
color: 'var(--text-inverse)',
}}
>
Restart Now

View File

@@ -175,7 +175,7 @@ const OPTION_DESC_STYLE: React.CSSProperties = {
const ERROR_STYLE: React.CSSProperties = {
fontSize: 13,
color: 'var(--destructive, #e03e3e)',
color: 'var(--destructive)',
textAlign: 'center',
margin: 0,
}
@@ -286,7 +286,7 @@ function getWelcomeScreenPresentation(
}
return {
heroBackground: 'var(--accent-yellow-light, #FFF3E0)',
heroBackground: 'var(--accent-yellow-light)',
heroIcon: <AlertTriangle size={28} style={{ color: 'var(--accent-orange)' }} />,
openFolderLabel: 'Choose a different folder',
subtitle: 'The vault folder could not be found on disk.\nIt may have been moved or deleted.',
@@ -419,7 +419,7 @@ export function WelcomeScreen({
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 10 }}>
<OptionButton
icon={<Rocket size={18} style={{ color: 'var(--accent-purple)' }} />}
iconBg="var(--accent-purple-light, #F3E8FF)"
iconBg="var(--accent-purple-light)"
label="Get started with a template"
description={presentation.templateDescription}
loadingLabel="Downloading template…"
@@ -434,7 +434,7 @@ export function WelcomeScreen({
<OptionButton
icon={<Plus size={18} style={{ color: 'var(--accent-blue)' }} />}
iconBg="var(--accent-blue-light, #EBF4FF)"
iconBg="var(--accent-blue-light)"
label="Create empty vault"
description="Start fresh in an empty folder with Tolaria defaults"
loadingLabel="Creating vault…"
@@ -448,7 +448,7 @@ export function WelcomeScreen({
<OptionButton
icon={<FolderOpen size={18} style={{ color: 'var(--accent-green)' }} />}
iconBg="var(--accent-green-light, #E8F5E9)"
iconBg="var(--accent-green-light)"
label={presentation.openFolderLabel}
description="Point to a folder you already have"
onClick={onOpenFolder}

View File

@@ -1,9 +1,9 @@
/* Wikilink autocomplete — floating menu container (positioned by BlockNote) */
.wikilink-menu {
background: var(--bg-primary, #fff);
border: 1px solid var(--border, rgba(0, 0, 0, 0.1));
background: var(--popover);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
box-shadow: 0 4px 12px var(--shadow-dialog);
max-height: 320px;
overflow: hidden;
min-width: 260px;
@@ -24,7 +24,7 @@
.wikilink-menu__item:hover,
.wikilink-menu__item--selected {
background: hsl(var(--accent));
background: var(--accent);
}
.wikilink-menu__title {

View File

@@ -52,7 +52,7 @@ export function FolderItemRow({
className={cn(
'group relative flex items-center gap-1 rounded transition-colors',
isSelected
? 'bg-[var(--accent-blue-light,rgba(0,100,255,0.08))] text-primary'
? 'bg-[var(--accent-blue-light)] text-primary'
: 'text-foreground hover:bg-accent',
)}
style={{ paddingLeft: depthIndent, borderRadius: 4 }}

View File

@@ -11,11 +11,11 @@ type ChangeStatsEntry = VaultEntry & {
}
const CHANGE_STATUS_DISPLAY: Record<ChangeStatus, { label: string; color: string; symbol: string }> = {
modified: { label: 'Modified', color: 'var(--accent-orange, #f59e0b)', symbol: '·' },
added: { label: 'Added', color: 'var(--accent-green, #22c55e)', symbol: '+' },
untracked: { label: 'Added', color: 'var(--accent-green, #22c55e)', symbol: '+' },
deleted: { label: 'Deleted', color: 'var(--destructive, #ef4444)', symbol: '' },
renamed: { label: 'Renamed', color: 'var(--accent-orange, #f59e0b)', symbol: 'R' },
modified: { label: 'Modified', color: 'var(--accent-orange)', symbol: '·' },
added: { label: 'Added', color: 'var(--accent-green)', symbol: '+' },
untracked: { label: 'Added', color: 'var(--accent-green)', symbol: '+' },
deleted: { label: 'Deleted', color: 'var(--destructive)', symbol: '' },
renamed: { label: 'Renamed', color: 'var(--accent-orange)', symbol: 'R' },
}
function readChangeStats(entry: VaultEntry): Required<Pick<ChangeStatsEntry, '__changeAddedLines' | '__changeDeletedLines' | '__changeBinary'>> {
@@ -72,7 +72,7 @@ function buildChangeStatBadges(
if (shouldShowAddedStat(status, addedLines, deletedLines)) {
badges.push({
className: 'text-[var(--accent-green,#22c55e)]',
className: 'text-[var(--accent-green)]',
testId: 'change-stat-added',
value: `+${addedLines ?? 0}`,
})
@@ -80,7 +80,7 @@ function buildChangeStatBadges(
if (shouldShowDeletedStat(status, addedLines, deletedLines)) {
badges.push({
className: 'text-[var(--destructive,#ef4444)]',
className: 'text-[var(--destructive)]',
testId: 'change-stat-deleted',
value: `-${deletedLines ?? 0}`,
})

View File

@@ -13,7 +13,7 @@ const PILLS: { value: NoteListFilter; label: string }[] = [
{ value: 'archived', label: 'Archived' },
]
const BOTTOM_GRADIENT = 'linear-gradient(to bottom, transparent 0%, var(--card, #fff) 30%, var(--card, #fff) 100%)'
const BOTTOM_GRADIENT = 'linear-gradient(to bottom, transparent 0%, var(--card) 30%, var(--card) 100%)'
function FilterPillsInner({ active, counts, onChange, position = 'top' }: FilterPillsProps) {
const isBottom = position === 'bottom'

View File

@@ -14,7 +14,7 @@ const PILLS: { value: InboxPeriod; label: string }[] = [
{ value: 'all', label: 'All' },
]
const BOTTOM_GRADIENT = 'linear-gradient(to bottom, transparent 0%, var(--card, #fff) 30%, var(--card, #fff) 100%)'
const BOTTOM_GRADIENT = 'linear-gradient(to bottom, transparent 0%, var(--card) 30%, var(--card) 100%)'
function InboxFilterPillsInner({ active, counts, onChange, position = 'top' }: InboxFilterPillsProps) {
const isBottom = position === 'bottom'

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import {
getAnchoredDropdownLeft,
getNextHighlightIndex,
getPreviousHighlightIndex,
isCreateOptionVisible,
} from './propertyDropdownUtils'
describe('propertyDropdownUtils', () => {
it('wraps highlight movement across the available option range', () => {
expect(getNextHighlightIndex(-1, 3)).toBe(0)
expect(getNextHighlightIndex(2, 3)).toBe(0)
expect(getPreviousHighlightIndex(0, 3)).toBe(2)
expect(getPreviousHighlightIndex(-1, 0)).toBe(-1)
})
it('hides create options for blank or duplicate values', () => {
expect(isCreateOptionVisible('', ['Draft'])).toBe(false)
expect(isCreateOptionVisible(' draft ', ['Draft'])).toBe(false)
expect(isCreateOptionVisible('Review', ['Draft'])).toBe(true)
})
it('keeps an anchored dropdown within the viewport margins', () => {
expect(getAnchoredDropdownLeft(300, 208, 800)).toBe(92)
expect(getAnchoredDropdownLeft(100, 208, 800)).toBe(8)
expect(getAnchoredDropdownLeft(900, 208, 800)).toBe(584)
})
})

View File

@@ -0,0 +1,57 @@
import { useEffect, useLayoutEffect, type RefObject } from 'react'
const DEFAULT_DROPDOWN_MARGIN = 8
export function getAnchoredDropdownLeft(
anchorRight: number,
dropdownWidth: number,
viewportWidth: number,
margin = DEFAULT_DROPDOWN_MARGIN,
) {
const rightAlignedLeft = anchorRight - dropdownWidth
const minLeft = margin
const maxLeft = viewportWidth - dropdownWidth - margin
return Math.min(Math.max(rightAlignedLeft, minLeft), maxLeft)
}
export function getNextHighlightIndex(current: number, total: number) {
if (total <= 0) return 0
return current < total - 1 ? current + 1 : 0
}
export function getPreviousHighlightIndex(current: number, total: number) {
if (total <= 0) return -1
return current > 0 ? current - 1 : total - 1
}
export function isCreateOptionVisible(query: string, options: string[]) {
const trimmed = query.trim()
if (!trimmed) return false
return !options.some((option) => option.toLowerCase() === trimmed.toLowerCase())
}
export function useAnchoredDropdownPosition({
anchorRef,
dropdownRef,
width,
}: {
anchorRef: RefObject<HTMLElement | null>
dropdownRef: RefObject<HTMLElement | null>
width: number
}) {
useLayoutEffect(() => {
const node = dropdownRef.current
const anchor = anchorRef.current?.parentElement
if (!node || !anchor) return
const rect = anchor.getBoundingClientRect()
node.style.top = `${rect.bottom + 4}px`
node.style.left = `${getAnchoredDropdownLeft(rect.right, width, window.innerWidth)}px`
}, [anchorRef, dropdownRef, width])
}
export function useAutoFocus<T extends HTMLElement>(ref: RefObject<T | null>) {
useEffect(() => {
ref.current?.focus()
}, [ref])
}

View File

@@ -254,7 +254,7 @@ function GitStatusPopup({
borderRadius: 6,
padding: 8,
minWidth: 220,
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
boxShadow: '0 4px 12px var(--shadow-dialog)',
zIndex: 1000,
fontSize: 12,
color: 'var(--foreground)',
@@ -310,8 +310,8 @@ export function OfflineBadge({ isOffline }: { isOffline?: boolean }) {
<span
style={{
...ICON_STYLE,
color: 'var(--destructive, #e03e3e)',
background: 'rgba(224, 62, 62, 0.12)',
color: 'var(--destructive)',
background: 'var(--feedback-error-bg)',
borderRadius: 999,
padding: '2px 6px',
fontWeight: 600,
@@ -443,7 +443,7 @@ export function ConflictBadge({ count, onClick }: { count: number; onClick?: ()
copy={{ label: 'Resolve merge conflicts' }}
onClick={onClick}
testId="status-conflict-count"
className="text-[var(--destructive,#e03e3e)]"
className="text-[var(--destructive)]"
>
<span style={ICON_STYLE}>
<AlertTriangle size={13} />
@@ -469,7 +469,7 @@ export function ChangesBadge({ count, onClick }: { count: number; onClick?: () =
alignItems: 'center',
justifyContent: 'center',
background: 'var(--accent-orange)',
color: '#fff',
color: 'var(--text-inverse)',
borderRadius: 9,
padding: '0 5px',
fontSize: 10,

View File

@@ -229,7 +229,7 @@ export function VaultMenu({
borderRadius: 6,
padding: 4,
minWidth: 200,
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
boxShadow: '0 4px 12px var(--shadow-dialog)',
zIndex: 1000,
}}
>

View File

@@ -13,7 +13,7 @@ const badgeVariants = cva(
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
"bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",

View File

@@ -11,7 +11,7 @@ const buttonVariants = cva(
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
"bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:

View File

@@ -73,12 +73,9 @@ export const frontmatterHighlightPlugin = ViewPlugin.fromClass(
)
export function frontmatterHighlightTheme() {
const keyColor = '#c9383e'
const valueColor = '#2a7e4f'
const delimiterColor = '#c9383e'
return EditorView.baseTheme({
'.cm-frontmatter-delimiter': { color: delimiterColor, fontWeight: '600' },
'.cm-frontmatter-key': { color: keyColor },
'.cm-frontmatter-value': { color: valueColor },
'.cm-frontmatter-delimiter': { color: 'var(--syntax-frontmatter-key)', fontWeight: '600' },
'.cm-frontmatter-key': { color: 'var(--syntax-frontmatter-key)' },
'.cm-frontmatter-value': { color: 'var(--syntax-frontmatter-value)' },
})
}

View File

@@ -4,23 +4,31 @@ import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { tags } from '@lezer/highlight'
import type { Extension } from '@codemirror/state'
const SYNTAX_COLORS = {
heading: 'var(--syntax-heading)',
link: 'var(--syntax-link)',
monospace: 'var(--syntax-monospace)',
monospaceBackground: 'var(--syntax-monospace-bg)',
muted: 'var(--syntax-muted)',
}
const markdownHighlightStyle = HighlightStyle.define([
{ tag: tags.heading1, color: '#0969da', fontWeight: '700', fontSize: '1.4em' },
{ tag: tags.heading2, color: '#0969da', fontWeight: '700', fontSize: '1.25em' },
{ tag: tags.heading3, color: '#0969da', fontWeight: '600', fontSize: '1.1em' },
{ tag: tags.heading4, color: '#0969da', fontWeight: '600' },
{ tag: tags.heading5, color: '#0969da', fontWeight: '600' },
{ tag: tags.heading6, color: '#0969da', fontWeight: '600' },
{ tag: tags.heading1, color: SYNTAX_COLORS.heading, fontWeight: '700', fontSize: '1.4em' },
{ tag: tags.heading2, color: SYNTAX_COLORS.heading, fontWeight: '700', fontSize: '1.25em' },
{ tag: tags.heading3, color: SYNTAX_COLORS.heading, fontWeight: '600', fontSize: '1.1em' },
{ tag: tags.heading4, color: SYNTAX_COLORS.heading, fontWeight: '600' },
{ tag: tags.heading5, color: SYNTAX_COLORS.heading, fontWeight: '600' },
{ tag: tags.heading6, color: SYNTAX_COLORS.heading, fontWeight: '600' },
{ tag: tags.strong, fontWeight: '700' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strikethrough, textDecoration: 'line-through' },
{ tag: tags.link, color: '#0969da', textDecoration: 'underline' },
{ tag: tags.url, color: '#0969da' },
{ tag: tags.monospace, color: '#c9383e', backgroundColor: 'rgba(175,184,193,0.15)', borderRadius: '3px' },
{ tag: tags.quote, color: '#636c76', fontStyle: 'italic' },
{ tag: tags.separator, color: '#636c76' },
{ tag: tags.processingInstruction, color: '#c9383e', fontWeight: '600' },
{ tag: tags.contentSeparator, color: '#c9383e', fontWeight: '600' },
{ tag: tags.link, color: SYNTAX_COLORS.link, textDecoration: 'underline' },
{ tag: tags.url, color: SYNTAX_COLORS.link },
{ tag: tags.monospace, color: SYNTAX_COLORS.monospace, backgroundColor: SYNTAX_COLORS.monospaceBackground, borderRadius: '3px' },
{ tag: tags.quote, color: SYNTAX_COLORS.muted, fontStyle: 'italic' },
{ tag: tags.separator, color: SYNTAX_COLORS.muted },
{ tag: tags.processingInstruction, color: SYNTAX_COLORS.monospace, fontWeight: '600' },
{ tag: tags.contentSeparator, color: SYNTAX_COLORS.monospace, fontWeight: '600' },
])
export function markdownLanguage(): Extension {

View File

@@ -8,6 +8,14 @@ import { resolveArrowLigatureInput } from '../utils/arrowLigatures'
import { zoomCursorFix } from '../extensions/zoomCursorFix'
const FONT_FAMILY = '"JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const RAW_EDITOR_COLORS = {
activeLineBackground: 'var(--state-hover-subtle)',
background: 'var(--surface-editor)',
foreground: 'var(--text-primary)',
gutterBackground: 'var(--surface-editor)',
gutterBorder: 'var(--border-subtle)',
gutterText: 'var(--text-muted)',
}
export interface CodeMirrorCallbacks {
onDocChange: (doc: string) => void
@@ -17,19 +25,12 @@ export interface CodeMirrorCallbacks {
}
function buildBaseTheme() {
const bg = '#ffffff'
const fg = '#1e1e1e'
const gutterBg = '#ffffff'
const gutterColor = '#aaa'
const activeLineBg = 'rgba(0,100,255,0.06)'
const gutterBorder = '#eee'
return EditorView.theme({
'&': {
fontSize: '13px',
fontFamily: FONT_FAMILY,
backgroundColor: bg,
color: fg,
backgroundColor: RAW_EDITOR_COLORS.background,
color: RAW_EDITOR_COLORS.foreground,
flex: '1',
minHeight: '0',
},
@@ -41,12 +42,12 @@ function buildBaseTheme() {
},
'.cm-content': {
padding: '0 32px 0 16px',
caretColor: fg,
caretColor: RAW_EDITOR_COLORS.foreground,
},
'.cm-gutters': {
backgroundColor: gutterBg,
color: gutterColor,
borderRight: `1px solid ${gutterBorder}`,
backgroundColor: RAW_EDITOR_COLORS.gutterBackground,
color: RAW_EDITOR_COLORS.gutterText,
borderRight: `1px solid ${RAW_EDITOR_COLORS.gutterBorder}`,
paddingLeft: '16px',
},
'.cm-lineNumbers .cm-gutterElement': {
@@ -55,10 +56,10 @@ function buildBaseTheme() {
textAlign: 'right',
},
'.cm-activeLine': {
backgroundColor: activeLineBg,
backgroundColor: RAW_EDITOR_COLORS.activeLineBackground,
},
'.cm-activeLineGutter': {
backgroundColor: activeLineBg,
backgroundColor: RAW_EDITOR_COLORS.activeLineBackground,
},
'&.cm-focused': { outline: 'none' },
'.cm-line': { padding: '0' },

View File

@@ -0,0 +1,29 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { useDocumentThemeMode } from './useDocumentThemeMode'
describe('useDocumentThemeMode', () => {
beforeEach(() => {
document.documentElement.removeAttribute('data-theme')
document.documentElement.classList.remove('dark')
})
it('defaults to light when no document theme is applied', () => {
const { result } = renderHook(() => useDocumentThemeMode())
expect(result.current).toBe('light')
})
it('updates when the document theme changes', async () => {
const { result } = renderHook(() => useDocumentThemeMode())
act(() => {
document.documentElement.setAttribute('data-theme', 'dark')
document.documentElement.classList.add('dark')
})
await waitFor(() => {
expect(result.current).toBe('dark')
})
})
})

View File

@@ -0,0 +1,33 @@
import { useSyncExternalStore } from 'react'
import {
DEFAULT_THEME_MODE,
normalizeThemeMode,
type ThemeMode,
} from '../lib/themeMode'
function readDocumentThemeMode(): ThemeMode {
if (typeof document === 'undefined') return DEFAULT_THEME_MODE
return normalizeThemeMode(document.documentElement.getAttribute('data-theme')) ?? DEFAULT_THEME_MODE
}
function subscribeDocumentThemeMode(onChange: () => void): () => void {
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') {
return () => {}
}
const observer = new MutationObserver(onChange)
observer.observe(document.documentElement, {
attributeFilter: ['class', 'data-theme'],
attributes: true,
})
return () => observer.disconnect()
}
export function useDocumentThemeMode(): ThemeMode {
return useSyncExternalStore(
subscribeDocumentThemeMode,
readDocumentThemeMode,
() => DEFAULT_THEME_MODE,
)
}

View File

@@ -14,6 +14,7 @@ const defaultSettings: Settings = {
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
theme_mode: null,
default_ai_agent: null,
}
@@ -28,6 +29,7 @@ const savedSettings: Settings = {
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
theme_mode: null,
default_ai_agent: null,
}
@@ -111,6 +113,7 @@ describe('useSettings', () => {
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
theme_mode: null,
default_ai_agent: null,
}

View File

@@ -3,6 +3,7 @@ import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { normalizeStoredAiAgent } from '../lib/aiAgents'
import { normalizeReleaseChannel, serializeReleaseChannel } from '../lib/releaseChannel'
import { normalizeThemeMode } from '../lib/themeMode'
import type { Settings } from '../types'
function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockArgs?: Record<string, unknown>): Promise<T> {
@@ -20,6 +21,7 @@ const EMPTY_SETTINGS: Settings = {
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
theme_mode: null,
default_ai_agent: null,
}
@@ -29,6 +31,7 @@ function normalizeSettings(settings: Settings): Settings {
release_channel: serializeReleaseChannel(
normalizeReleaseChannel(settings.release_channel),
),
theme_mode: normalizeThemeMode(settings.theme_mode),
default_ai_agent: normalizeStoredAiAgent(settings.default_ai_agent),
}
}

View File

@@ -0,0 +1,49 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { THEME_MODE_STORAGE_KEY } from '../lib/themeMode'
import { useThemeMode } from './useThemeMode'
function createStorageMock(): Storage {
const values = new Map<string, string>()
return {
get length() { return values.size },
clear: vi.fn(() => { values.clear() }),
getItem: vi.fn((key: string) => values.get(key) ?? null),
key: vi.fn((index: number) => Array.from(values.keys())[index] ?? null),
removeItem: vi.fn((key: string) => { values.delete(key) }),
setItem: vi.fn((key: string, value: string) => { values.set(key, value) }),
}
}
describe('useThemeMode', () => {
const localStorageMock = createStorageMock()
beforeEach(() => {
Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true })
document.documentElement.removeAttribute('data-theme')
document.documentElement.classList.remove('dark')
window.localStorage.clear()
})
it('waits until settings have loaded', () => {
renderHook(() => useThemeMode('dark', false))
expect(document.documentElement).not.toHaveAttribute('data-theme')
})
it('applies and mirrors the loaded settings mode', () => {
renderHook(() => useThemeMode('dark', true))
expect(document.documentElement).toHaveAttribute('data-theme', 'dark')
expect(document.documentElement).toHaveClass('dark')
expect(window.localStorage.getItem(THEME_MODE_STORAGE_KEY)).toBe('dark')
})
it('uses the storage mirror when persisted settings are empty', () => {
window.localStorage.setItem(THEME_MODE_STORAGE_KEY, 'dark')
renderHook(() => useThemeMode(null, true))
expect(document.documentElement).toHaveAttribute('data-theme', 'dark')
})
})

30
src/hooks/useThemeMode.ts Normal file
View File

@@ -0,0 +1,30 @@
import { useEffect } from 'react'
import {
applyThemeModeToDocument,
DEFAULT_THEME_MODE,
readStoredThemeMode,
writeStoredThemeMode,
type ThemeMode,
} from '../lib/themeMode'
function resolveRuntimeThemeMode(themeMode: ThemeMode | null | undefined): ThemeMode {
if (themeMode) return themeMode
if (typeof window === 'undefined') return DEFAULT_THEME_MODE
return readStoredThemeMode(window.localStorage) ?? DEFAULT_THEME_MODE
}
export function useThemeMode(
themeMode: ThemeMode | null | undefined,
loaded: boolean,
): void {
useEffect(() => {
if (!loaded || typeof document === 'undefined') return
const resolvedMode = resolveRuntimeThemeMode(themeMode)
applyThemeModeToDocument(document, resolvedMode)
if (typeof window !== 'undefined') {
writeStoredThemeMode(window.localStorage, resolvedMode)
}
}, [loaded, themeMode])
}

View File

@@ -2,7 +2,7 @@
@import "tw-animate-css";
/* ============================================================
Theme Variables — Light mode only (dark mode removed for now)
Theme Variables — app-owned light/dark contract
============================================================ */
:root {
@@ -16,67 +16,65 @@
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* --- shadcn theme variables (light mode) --- */
--radius: 0.5rem;
--background: #FFFFFF;
--foreground: #37352F;
--card: #FFFFFF;
--card-foreground: #37352F;
--popover: #FFFFFF;
--popover-foreground: #37352F;
--primary: #155DFF;
--primary-foreground: #FFFFFF;
--secondary: #EBEBEA;
--secondary-foreground: #37352F;
--muted: #F0F0EF;
--muted-foreground: #787774;
--accent: #EBEBEA;
--accent-foreground: #37352F;
--destructive: #E03E3E;
--destructive-foreground: #FFFFFF;
--border: #E9E9E7;
--input: #E9E9E7;
--ring: #155DFF;
--sidebar: #F7F6F3;
--sidebar-foreground: #37352F;
--sidebar-primary: #155DFF;
--sidebar-primary-foreground: #FFFFFF;
--sidebar-accent: #EBEBEA;
--sidebar-accent-foreground: #37352F;
--sidebar-border: #E9E9E7;
--sidebar-ring: #155DFF;
/* --- App-specific variables (light mode) --- */
:root,
[data-theme="light"] {
color-scheme: light;
--bg-primary: #FFFFFF;
--bg-sidebar: #F7F6F3;
--bg-card: #FFFFFF;
--bg-hover: #EBEBEA;
--bg-hover-subtle: #F0F0EF;
--bg-selected: #E8F4FE;
--bg-input: #FFFFFF;
--bg-button: #EBEBEA;
--bg-dialog: #FFFFFF;
/* --- Semantic surfaces (light mode) --- */
--surface-app: #FFFFFF;
--surface-sidebar: #F7F6F3;
--surface-panel: #FFFFFF;
--surface-card: #FFFFFF;
--surface-popover: #FFFFFF;
--surface-input: #FFFFFF;
--surface-button: #EBEBEA;
--surface-dialog: #FFFFFF;
--surface-editor: #FFFFFF;
--surface-overlay: rgba(0, 0, 0, 0.3);
/* --- Semantic text (light mode) --- */
--text-primary: #37352F;
--text-secondary: #787774;
--text-tertiary: #787774;
--text-muted: #B4B4B4;
--text-faint: #B4B4B4;
--text-heading: #37352F;
--text-inverse: #FFFFFF;
/* --- Semantic borders (light mode) --- */
--border-default: #E9E9E7;
--border-subtle: #E9E9E7;
--border-strong: #D9D9D6;
--border-input: #E9E9E7;
--border-dialog: #E9E9E7;
--border-focus: #155DFF;
/* --- Interaction states (light mode) --- */
--state-hover: #EBEBEA;
--state-hover-subtle: #F0F0EF;
--state-selected: #E8F4FE;
--state-selected-strong: #D8ECFE;
--state-active: #E8F4FE;
--state-focus-ring: #155DFF;
--state-drag-target: #155DFF18;
--state-disabled: #F0F0EF;
/* --- Accent roles (light mode) --- */
--accent-blue: #155DFF;
--accent-blue-bg: #155DFF18;
--accent-blue-hover: #0D4AD6;
--accent-blue-light: #155DFF14;
--accent-green: #38A169;
--accent-green-light: rgba(56, 161, 105, 0.1);
--accent-orange: #D9730D;
--accent-orange-light: rgba(217, 115, 13, 0.1);
--accent-red: #E53E3E;
--accent-purple: #805AD5;
--accent-yellow: #D69E2E;
--accent-blue-light: #155DFF14;
--accent-green-light: rgba(56, 161, 105, 0.1);
--accent-purple-light: rgba(128, 90, 213, 0.1);
--accent-red-light: rgba(229, 62, 62, 0.1);
--accent-purple: #805AD5;
--accent-purple-light: rgba(128, 90, 213, 0.1);
--accent-yellow: #D69E2E;
--accent-yellow-light: rgba(214, 158, 46, 0.1);
--accent-teal: #319795;
--accent-teal-light: rgba(49, 151, 149, 0.1);
@@ -84,17 +82,236 @@
--accent-pink-light: rgba(213, 63, 140, 0.1);
--accent-gray: #718096;
--accent-gray-light: rgba(113, 128, 150, 0.1);
--border-primary: #E9E9E7;
--border-subtle: #E9E9E7;
--border-input: #E9E9E7;
--border-dialog: #E9E9E7;
--link-color: #155DFF;
--link-hover: #0D4AD6;
--shadow-overlay: rgba(0, 0, 0, 0.3);
/* --- Feedback roles (light mode) --- */
--feedback-info-text: var(--accent-blue);
--feedback-info-bg: var(--accent-blue-light);
--feedback-success-text: var(--accent-green);
--feedback-success-bg: var(--accent-green-light);
--feedback-warning-text: #92400E;
--feedback-warning-bg: #FEF3C7;
--feedback-warning-border: #D97706;
--feedback-error-text: var(--accent-red);
--feedback-error-bg: var(--accent-red-light);
/* --- Syntax and diff roles (light mode) --- */
--syntax-heading: #0969DA;
--syntax-link: #0969DA;
--syntax-monospace: #C9383E;
--syntax-monospace-bg: rgba(175, 184, 193, 0.15);
--syntax-muted: #636C76;
--syntax-frontmatter-key: #C9383E;
--syntax-frontmatter-value: #2A7E4F;
--syntax-highlight-comment: #6A737D;
--syntax-highlight-keyword: #D73A49;
--syntax-highlight-string: #032F62;
--syntax-highlight-number: #005CC5;
--syntax-highlight-title: #6F42C1;
--syntax-highlight-type: #E36209;
--syntax-highlight-deletion: #B31D28;
--syntax-highlight-deletion-bg: #FFEEF0;
--diff-added-text: #4CAF50;
--diff-added-bg: rgba(76, 175, 80, 0.12);
--diff-removed-text: #F44336;
--diff-removed-bg: rgba(244, 67, 54, 0.12);
--diff-hunk-bg: rgba(33, 150, 243, 0.08);
/* --- Overlays and shadows (light mode) --- */
--shadow-overlay: var(--surface-overlay);
--shadow-dialog: rgba(0, 0, 0, 0.15);
/* --- shadcn theme aliases (light mode) --- */
--radius: 0.5rem;
--background: var(--surface-app);
--foreground: var(--text-primary);
--card: var(--surface-card);
--card-foreground: var(--text-primary);
--popover: var(--surface-popover);
--popover-foreground: var(--text-primary);
--primary: var(--accent-blue);
--primary-foreground: var(--text-inverse);
--secondary: var(--state-hover);
--secondary-foreground: var(--text-primary);
--muted: var(--state-hover-subtle);
--muted-foreground: var(--text-secondary);
--accent: var(--state-hover);
--accent-foreground: var(--text-primary);
--destructive: var(--accent-red);
--destructive-foreground: var(--text-inverse);
--border: var(--border-default);
--input: var(--border-input);
--ring: var(--state-focus-ring);
--sidebar: var(--surface-sidebar);
--sidebar-foreground: var(--text-primary);
--sidebar-primary: var(--accent-blue);
--sidebar-primary-foreground: var(--text-inverse);
--sidebar-accent: var(--state-hover);
--sidebar-accent-foreground: var(--text-primary);
--sidebar-border: var(--border-default);
--sidebar-ring: var(--state-focus-ring);
/* --- Compatibility aliases for existing product code --- */
--bg-primary: var(--surface-app);
--bg-sidebar: var(--surface-sidebar);
--bg-card: var(--surface-card);
--bg-hover: var(--state-hover);
--bg-hover-subtle: var(--state-hover-subtle);
--bg-selected: var(--state-selected);
--bg-input: var(--surface-input);
--bg-button: var(--surface-button);
--bg-dialog: var(--surface-dialog);
--border-primary: var(--border-default);
--hover: var(--state-hover);
--link-color: var(--accent-blue);
--link-hover: var(--accent-blue-hover);
}
/* Dark mode variables removed — light mode only for now */
:root.dark,
[data-theme="dark"] {
color-scheme: dark;
/* --- Semantic surfaces (dark mode) --- */
--surface-app: #1F1E1B;
--surface-sidebar: #191814;
--surface-panel: #23221F;
--surface-card: #23221F;
--surface-popover: #292823;
--surface-input: #1F1E1B;
--surface-button: #34322D;
--surface-dialog: #272622;
--surface-editor: #1F1E1B;
--surface-overlay: rgba(8, 8, 7, 0.58);
/* --- Semantic text (dark mode) --- */
--text-primary: #E6E1D8;
--text-secondary: #B8B1A6;
--text-tertiary: #9C9488;
--text-muted: #7F776D;
--text-faint: #625B53;
--text-heading: #F1ECE3;
--text-inverse: #151411;
/* --- Semantic borders (dark mode) --- */
--border-default: #34322D;
--border-subtle: #2A2925;
--border-strong: #46433B;
--border-input: #3A3832;
--border-dialog: #3A3832;
--border-focus: #78A4FF;
/* --- Interaction states (dark mode) --- */
--state-hover: #2D2B27;
--state-hover-subtle: #262520;
--state-selected: #1E344C;
--state-selected-strong: #25415F;
--state-active: #203A55;
--state-focus-ring: #78A4FF;
--state-drag-target: rgba(120, 164, 255, 0.2);
--state-disabled: #292824;
/* --- Accent roles (dark mode) --- */
--accent-blue: #78A4FF;
--accent-blue-bg: rgba(120, 164, 255, 0.2);
--accent-blue-hover: #9BBEFF;
--accent-blue-light: rgba(120, 164, 255, 0.16);
--accent-green: #79D89D;
--accent-green-light: rgba(121, 216, 157, 0.16);
--accent-orange: #F3A15B;
--accent-orange-light: rgba(243, 161, 91, 0.17);
--accent-red: #FF8A86;
--accent-red-light: rgba(255, 138, 134, 0.16);
--accent-purple: #B69CFF;
--accent-purple-light: rgba(182, 156, 255, 0.17);
--accent-yellow: #F2C86B;
--accent-yellow-light: rgba(242, 200, 107, 0.18);
--accent-teal: #64D1C8;
--accent-teal-light: rgba(100, 209, 200, 0.16);
--accent-pink: #F28AC3;
--accent-pink-light: rgba(242, 138, 195, 0.16);
--accent-gray: #A7B0BD;
--accent-gray-light: rgba(167, 176, 189, 0.14);
/* --- Feedback roles (dark mode) --- */
--feedback-info-text: var(--accent-blue);
--feedback-info-bg: var(--accent-blue-light);
--feedback-success-text: var(--accent-green);
--feedback-success-bg: var(--accent-green-light);
--feedback-warning-text: #F5C36B;
--feedback-warning-bg: rgba(245, 195, 107, 0.16);
--feedback-warning-border: #D99B3D;
--feedback-error-text: var(--accent-red);
--feedback-error-bg: var(--accent-red-light);
/* --- Syntax and diff roles (dark mode) --- */
--syntax-heading: #83B2FF;
--syntax-link: #83B2FF;
--syntax-monospace: #FFA6A3;
--syntax-monospace-bg: rgba(167, 176, 189, 0.16);
--syntax-muted: #9C9488;
--syntax-frontmatter-key: #FFA6A3;
--syntax-frontmatter-value: #8EDFAE;
--syntax-highlight-comment: #8E877D;
--syntax-highlight-keyword: #FF9BA0;
--syntax-highlight-string: #A9D6FF;
--syntax-highlight-number: #8BB7FF;
--syntax-highlight-title: #C2AAFF;
--syntax-highlight-type: #F3B175;
--syntax-highlight-deletion: #FF9C9A;
--syntax-highlight-deletion-bg: rgba(255, 138, 134, 0.16);
--diff-added-text: #79D89D;
--diff-added-bg: rgba(121, 216, 157, 0.14);
--diff-removed-text: #FF8A86;
--diff-removed-bg: rgba(255, 138, 134, 0.14);
--diff-hunk-bg: rgba(120, 164, 255, 0.13);
/* --- Overlays and shadows (dark mode) --- */
--shadow-overlay: var(--surface-overlay);
--shadow-dialog: rgba(0, 0, 0, 0.42);
/* --- shadcn theme aliases (dark mode) --- */
--background: var(--surface-app);
--foreground: var(--text-primary);
--card: var(--surface-card);
--card-foreground: var(--text-primary);
--popover: var(--surface-popover);
--popover-foreground: var(--text-primary);
--primary: var(--accent-blue);
--primary-foreground: var(--text-inverse);
--secondary: var(--state-hover);
--secondary-foreground: var(--text-primary);
--muted: var(--state-hover-subtle);
--muted-foreground: var(--text-secondary);
--accent: var(--state-hover);
--accent-foreground: var(--text-primary);
--destructive: var(--accent-red);
--destructive-foreground: var(--text-inverse);
--border: var(--border-default);
--input: var(--border-input);
--ring: var(--state-focus-ring);
--sidebar: var(--surface-sidebar);
--sidebar-foreground: var(--text-primary);
--sidebar-primary: var(--accent-blue);
--sidebar-primary-foreground: var(--text-inverse);
--sidebar-accent: var(--state-hover);
--sidebar-accent-foreground: var(--text-primary);
--sidebar-border: var(--border-default);
--sidebar-ring: var(--state-focus-ring);
/* --- Compatibility aliases for existing product code --- */
--bg-primary: var(--surface-app);
--bg-sidebar: var(--surface-sidebar);
--bg-card: var(--surface-card);
--bg-hover: var(--state-hover);
--bg-hover-subtle: var(--state-hover-subtle);
--bg-selected: var(--state-selected);
--bg-input: var(--surface-input);
--bg-button: var(--surface-button);
--bg-dialog: var(--surface-dialog);
--border-primary: var(--border-default);
--hover: var(--state-hover);
--link-color: var(--accent-blue);
--link-hover: var(--accent-blue-hover);
}
/* --- Tailwind v4 theme inline: register colors + radii --- */
@theme inline {
@@ -125,6 +342,19 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-surface-app: var(--surface-app);
--color-surface-sidebar: var(--surface-sidebar);
--color-surface-panel: var(--surface-panel);
--color-surface-card: var(--surface-card);
--color-surface-popover: var(--surface-popover);
--color-surface-editor: var(--surface-editor);
--color-state-hover: var(--state-hover);
--color-state-selected: var(--state-selected);
--color-border-default: var(--border-default);
--color-border-subtle: var(--border-subtle);
--color-text-primary: var(--text-primary);
--color-text-secondary: var(--text-secondary);
--color-text-muted: var(--text-muted);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
@@ -296,28 +526,28 @@
.ai-markdown .hljs { color: var(--foreground); }
.ai-markdown .hljs-comment,
.ai-markdown .hljs-quote { color: #6a737d; font-style: italic; }
.ai-markdown .hljs-quote { color: var(--syntax-highlight-comment); font-style: italic; }
.ai-markdown .hljs-keyword,
.ai-markdown .hljs-selector-tag { color: #d73a49; }
.ai-markdown .hljs-selector-tag { color: var(--syntax-highlight-keyword); }
.ai-markdown .hljs-string,
.ai-markdown .hljs-addition { color: #032f62; }
.ai-markdown .hljs-addition { color: var(--syntax-highlight-string); }
.ai-markdown .hljs-number,
.ai-markdown .hljs-literal { color: #005cc5; }
.ai-markdown .hljs-literal { color: var(--syntax-highlight-number); }
.ai-markdown .hljs-title,
.ai-markdown .hljs-section { color: #6f42c1; }
.ai-markdown .hljs-section { color: var(--syntax-highlight-title); }
.ai-markdown .hljs-built_in,
.ai-markdown .hljs-type { color: #e36209; }
.ai-markdown .hljs-type { color: var(--syntax-highlight-type); }
.ai-markdown .hljs-attr,
.ai-markdown .hljs-name,
.ai-markdown .hljs-attribute { color: #005cc5; }
.ai-markdown .hljs-deletion { color: #b31d28; background: #ffeef0; }
.ai-markdown .hljs-meta { color: #6a737d; }
.ai-markdown .hljs-attribute { color: var(--syntax-highlight-number); }
.ai-markdown .hljs-deletion { color: var(--syntax-highlight-deletion); background: var(--syntax-highlight-deletion-bg); }
.ai-markdown .hljs-meta { color: var(--syntax-highlight-comment); }
/* --- AI panel working border animation --- */
@keyframes ai-border-pulse {
0%, 100% { border-color: var(--accent-blue, #3b82f6); opacity: 1; }
50% { border-color: var(--accent-blue, #93c5fd); opacity: 0.6; }
0%, 100% { border-color: var(--accent-blue); opacity: 1; }
50% { border-color: var(--accent-blue-hover); opacity: 0.6; }
}
/* --- Typing indicator dots --- */

66
src/lib/themeMode.test.ts Normal file
View File

@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from 'vitest'
import {
applyStoredThemeMode,
applyThemeModeToDocument,
LEGACY_THEME_MODE_STORAGE_KEY,
normalizeThemeMode,
readStoredThemeMode,
resolveThemeMode,
THEME_MODE_STORAGE_KEY,
writeStoredThemeMode,
} from './themeMode'
function makeStorage(initial: Record<string, string> = {}): Storage {
const values = new Map(Object.entries(initial))
return {
get length() { return values.size },
clear: vi.fn(() => values.clear()),
getItem: vi.fn((key: string) => values.get(key) ?? null),
key: vi.fn((index: number) => Array.from(values.keys())[index] ?? null),
removeItem: vi.fn((key: string) => { values.delete(key) }),
setItem: vi.fn((key: string, value: string) => { values.set(key, value) }),
}
}
describe('themeMode', () => {
it('normalizes only supported theme modes', () => {
expect(normalizeThemeMode('light')).toBe('light')
expect(normalizeThemeMode('dark')).toBe('dark')
expect(normalizeThemeMode('system')).toBeNull()
expect(resolveThemeMode('system')).toBe('light')
})
it('reads and writes the current storage key', () => {
const storage = makeStorage()
writeStoredThemeMode(storage, 'dark')
expect(readStoredThemeMode(storage)).toBe('dark')
expect(storage.setItem).toHaveBeenCalledWith(THEME_MODE_STORAGE_KEY, 'dark')
})
it('migrates the legacy storage key', () => {
const storage = makeStorage({ [LEGACY_THEME_MODE_STORAGE_KEY]: 'dark' })
expect(readStoredThemeMode(storage)).toBe('dark')
expect(storage.setItem).toHaveBeenCalledWith(THEME_MODE_STORAGE_KEY, 'dark')
})
it('applies theme attributes and shadcn dark class', () => {
applyThemeModeToDocument(document, 'dark')
expect(document.documentElement).toHaveAttribute('data-theme', 'dark')
expect(document.documentElement).toHaveClass('dark')
applyThemeModeToDocument(document, 'light')
expect(document.documentElement).toHaveAttribute('data-theme', 'light')
expect(document.documentElement).not.toHaveClass('dark')
})
it('bootstraps stored theme mode onto the document', () => {
const storage = makeStorage({ [THEME_MODE_STORAGE_KEY]: 'dark' })
expect(applyStoredThemeMode(document, storage)).toBe('dark')
expect(document.documentElement).toHaveAttribute('data-theme', 'dark')
expect(document.documentElement).toHaveClass('dark')
})
})

66
src/lib/themeMode.ts Normal file
View File

@@ -0,0 +1,66 @@
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS } from '../constants/appStorage'
export const THEME_MODE_STORAGE_KEY = APP_STORAGE_KEYS.theme
export const LEGACY_THEME_MODE_STORAGE_KEY = LEGACY_APP_STORAGE_KEYS.theme
export const DEFAULT_THEME_MODE = 'light'
const THEME_MODES = new Set(['light', 'dark'])
export type ThemeMode = 'light' | 'dark'
type ThemeStorage = Pick<Storage, 'getItem' | 'setItem'>
type ThemeDocument = Pick<Document, 'documentElement'>
export function normalizeThemeMode(value: unknown): ThemeMode | null {
return typeof value === 'string' && THEME_MODES.has(value) ? value as ThemeMode : null
}
export function resolveThemeMode(value: unknown): ThemeMode {
return normalizeThemeMode(value) ?? DEFAULT_THEME_MODE
}
function safeGetThemeMode(storage: ThemeStorage, key: string): ThemeMode | null {
try {
return normalizeThemeMode(storage.getItem(key))
} catch {
return null
}
}
function safeSetThemeMode(storage: ThemeStorage, key: string, mode: ThemeMode): void {
try {
storage.setItem(key, mode)
} catch {
// Storage can be unavailable in restricted browser contexts.
}
}
export function readStoredThemeMode(storage: ThemeStorage): ThemeMode | null {
const storedMode = safeGetThemeMode(storage, THEME_MODE_STORAGE_KEY)
if (storedMode) return storedMode
const legacyMode = safeGetThemeMode(storage, LEGACY_THEME_MODE_STORAGE_KEY)
if (!legacyMode) return null
safeSetThemeMode(storage, THEME_MODE_STORAGE_KEY, legacyMode)
return legacyMode
}
export function writeStoredThemeMode(storage: ThemeStorage, mode: ThemeMode): void {
safeSetThemeMode(storage, THEME_MODE_STORAGE_KEY, mode)
}
export function applyThemeModeToDocument(documentObject: ThemeDocument, mode: ThemeMode): void {
const root = documentObject.documentElement
root.setAttribute('data-theme', mode)
root.classList.toggle('dark', mode === 'dark')
}
export function applyStoredThemeMode(
documentObject: ThemeDocument,
storage: ThemeStorage,
): ThemeMode {
const mode = readStoredThemeMode(storage) ?? DEFAULT_THEME_MODE
applyThemeModeToDocument(documentObject, mode)
return mode
}

View File

@@ -5,6 +5,7 @@ import { TooltipProvider } from '@/components/ui/tooltip'
import './index.css'
import App from './App.tsx'
import { LinuxTitlebar } from './components/LinuxTitlebar'
import { applyStoredThemeMode } from './lib/themeMode'
import {
APP_COMMAND_EVENT_NAME,
isAppCommandId,
@@ -29,6 +30,8 @@ if (shouldUseLinuxWindowChrome()) {
document.body.classList.add('linux-chrome')
}
applyStoredThemeMode(document, window.localStorage)
function dispatchDeterministicShortcutEvent(init: AppCommandShortcutEventInit) {
const target =
document.activeElement instanceof HTMLElement

View File

@@ -167,6 +167,7 @@ describe('mockHandlers coverage', () => {
analytics_enabled: true,
anonymous_id: 'anon-1',
release_channel: 'alpha',
theme_mode: null,
default_ai_agent: 'codex',
})

View File

@@ -152,6 +152,22 @@ describe('mockHandlers additional coverage', () => {
expect(mockHandlers.repair_vault()).toBe('Vault repaired')
})
it('persists theme mode through the mock settings backend', async () => {
const { mockHandlers } = await loadHandlers()
const settings = mockHandlers.get_settings()
mockHandlers.save_settings({
settings: {
...settings,
theme_mode: 'dark',
},
})
expect(mockHandlers.get_settings()).toEqual(expect.objectContaining({
theme_mode: 'dark',
}))
})
it('surfaces the simple command handlers for git, conflicts, trash, and telemetry', async () => {
const { mockHandlers } = await loadHandlers()

View File

@@ -111,6 +111,7 @@ let mockSettings: Settings = {
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
theme_mode: null,
default_ai_agent: 'claude_code',
}
@@ -416,6 +417,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
analytics_enabled: s.analytics_enabled,
anonymous_id: s.anonymous_id,
release_channel: s.release_channel,
theme_mode: s.theme_mode ?? null,
default_ai_agent: s.default_ai_agent ?? null,
}
return null

View File

@@ -49,7 +49,7 @@
"lists": {
"bulletSymbol": "•",
"bulletSize": 24,
"bulletColor": "#177bfd",
"bulletColor": "var(--accent-blue)",
"indentSize": 24,
"itemSpacing": 4,
"paddingLeft": 8,

View File

@@ -1,4 +1,5 @@
import type { AiAgentId } from './lib/aiAgents'
import type { ThemeMode } from './lib/themeMode'
export interface VaultEntry {
path: string
@@ -89,6 +90,7 @@ export interface Settings {
analytics_enabled: boolean | null
anonymous_id: string | null
release_channel: string | null
theme_mode?: ThemeMode | null
initial_h1_auto_rename_enabled?: boolean | null
default_ai_agent?: AiAgentId | null
}

View File

@@ -58,6 +58,9 @@ describe('buildStableDownloadRedirectPage', () => {
expect(html).toContain('Download Tolaria for Windows')
expect(html).toContain('Download Tolaria for macOS')
expect(html).toContain('window.location.replace')
expect(html).toContain('color-scheme: light dark')
expect(html).toContain('@media (prefers-color-scheme: dark)')
expect(html).toContain('background: var(--download-surface-page)')
})
it('builds a fallback page when no stable downloads exist yet', () => {

View File

@@ -62,8 +62,35 @@ const PLATFORM_ORDER: StablePlatformKey[] = [
const REDIRECT_PAGE_STYLES = `
:root {
color-scheme: light;
color-scheme: light dark;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--download-surface-page: #f7f6f3;
--download-surface-card: #ffffff;
--download-text-primary: #37352f;
--download-border-default: #e9e9e7;
--download-accent: #155dff;
--download-accent-hover: #1248cc;
--download-secondary-bg: #eef2ff;
--download-secondary-hover-bg: #dbe4ff;
--download-secondary-text: #1d4ed8;
--download-text-on-accent: #ffffff;
--download-shadow-card: rgba(15, 23, 42, 0.08);
}
@media (prefers-color-scheme: dark) {
:root {
--download-surface-page: #1f1e1b;
--download-surface-card: #23221f;
--download-text-primary: #e6e1d8;
--download-border-default: #34322d;
--download-accent: #78a4ff;
--download-accent-hover: #9bbeff;
--download-secondary-bg: #34322d;
--download-secondary-hover-bg: #46433b;
--download-secondary-text: #e6e1d8;
--download-text-on-accent: #151411;
--download-shadow-card: rgba(0, 0, 0, 0.35);
}
}
* {
@@ -76,17 +103,17 @@ const REDIRECT_PAGE_STYLES = `
display: grid;
place-items: center;
padding: 24px;
background: #f7f6f3;
color: #37352f;
background: var(--download-surface-page);
color: var(--download-text-primary);
}
main {
width: min(100%, 520px);
background: #ffffff;
border: 1px solid #e9e9e7;
background: var(--download-surface-card);
border: 1px solid var(--download-border-default);
border-radius: 16px;
padding: 24px;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
box-shadow: 0 16px 40px var(--download-shadow-card);
}
h1 {
@@ -114,25 +141,25 @@ const REDIRECT_PAGE_STYLES = `
min-height: 44px;
padding: 0 16px;
border-radius: 10px;
background: #155dff;
color: #ffffff;
background: var(--download-accent);
color: var(--download-text-on-accent);
text-decoration: none;
font-weight: 600;
}
a[data-secondary="true"] {
background: #eef2ff;
color: #1d4ed8;
background: var(--download-secondary-bg);
color: var(--download-secondary-text);
}
a:hover,
a:focus-visible {
background: #1248cc;
background: var(--download-accent-hover);
}
a[data-secondary="true"]:hover,
a[data-secondary="true"]:focus-visible {
background: #dbe4ff;
background: var(--download-secondary-hover-bg);
}
`

View File

@@ -39,6 +39,9 @@ describe('buildReleaseHistoryPage', () => {
expect(html).toContain('id="tab-stable"')
expect(html).toContain('aria-selected="true"')
expect(html).toContain('data-release-panel="alpha" hidden')
expect(html).toContain('color-scheme: light dark')
expect(html).toContain('@media (prefers-color-scheme: dark)')
expect(html).toContain('background: var(--release-surface-page)')
expect(html).toContain('<h2>Highlights</h2>')
expect(html).toContain('<li>Faster startup</li>')
expect(html).toContain('<strong>Alpha</strong> notes')

View File

@@ -36,8 +36,51 @@ type ReleaseSections = Record<ReleaseChannel, ReleaseEntry[]>
const RELEASE_HISTORY_PAGE_STYLES = `
:root {
color-scheme: light;
color-scheme: light dark;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--release-surface-page: #f7f6f3;
--release-surface-card: #ffffff;
--release-surface-muted: #f4f4f2;
--release-surface-tab-hover: rgba(21, 93, 255, 0.06);
--release-surface-secondary-action: #ebeef5;
--release-text-primary: #37352f;
--release-text-secondary: #787774;
--release-text-muted: #5e5c57;
--release-text-body: #44403c;
--release-text-blockquote: #57534e;
--release-text-on-accent: #ffffff;
--release-border-default: #e9e9e7;
--release-border-strong: #d6d3d1;
--release-border-accent: #d8e3ff;
--release-accent: #155dff;
--release-alpha: #f59e0b;
--release-alpha-bg: #fff3d6;
--release-alpha-text: #b45309;
--release-shadow-card: rgba(15, 23, 42, 0.05);
}
@media (prefers-color-scheme: dark) {
:root {
--release-surface-page: #1f1e1b;
--release-surface-card: #23221f;
--release-surface-muted: #2d2b27;
--release-surface-tab-hover: rgba(120, 164, 255, 0.16);
--release-surface-secondary-action: #34322d;
--release-text-primary: #e6e1d8;
--release-text-secondary: #b8b1a6;
--release-text-muted: #c8c1b6;
--release-text-body: #d8d1c6;
--release-text-blockquote: #b8b1a6;
--release-text-on-accent: #151411;
--release-border-default: #34322d;
--release-border-strong: #46433b;
--release-border-accent: #25415f;
--release-accent: #78a4ff;
--release-alpha: #f3a15b;
--release-alpha-bg: rgba(243, 161, 91, 0.17);
--release-alpha-text: #f3c27f;
--release-shadow-card: rgba(0, 0, 0, 0.35);
}
}
* {
@@ -47,8 +90,8 @@ const RELEASE_HISTORY_PAGE_STYLES = `
body {
margin: 0;
min-height: 100vh;
background: #f7f6f3;
color: #37352f;
background: var(--release-surface-page);
color: var(--release-text-primary);
padding: 32px 20px 48px;
}
@@ -70,7 +113,7 @@ const RELEASE_HISTORY_PAGE_STYLES = `
.subtitle,
.keyboard-hint {
margin: 0;
color: #787774;
color: var(--release-text-secondary);
line-height: 1.6;
}
@@ -81,7 +124,7 @@ const RELEASE_HISTORY_PAGE_STYLES = `
.channel-tabs {
margin-bottom: 24px;
border-bottom: 1px solid #e9e9e7;
border-bottom: 1px solid var(--release-border-default);
}
.channel-tablist {
@@ -97,7 +140,7 @@ const RELEASE_HISTORY_PAGE_STYLES = `
border-bottom: none;
border-radius: 12px 12px 0 0;
background: transparent;
color: #5e5c57;
color: var(--release-text-muted);
cursor: pointer;
font: inherit;
font-weight: 600;
@@ -105,24 +148,24 @@ const RELEASE_HISTORY_PAGE_STYLES = `
}
.channel-tab:hover {
background: rgba(21, 93, 255, 0.06);
color: #155dff;
background: var(--release-surface-tab-hover);
color: var(--release-accent);
}
.channel-tab:focus-visible {
outline: 2px solid #155dff;
outline: 2px solid var(--release-accent);
outline-offset: 2px;
}
.channel-tab[aria-selected="true"] {
background: #ffffff;
border-color: #d8e3ff;
color: #155dff;
box-shadow: 0 -1px 0 #ffffff inset;
background: var(--release-surface-card);
border-color: var(--release-border-accent);
color: var(--release-accent);
box-shadow: 0 -1px 0 var(--release-surface-card) inset;
}
.tab-count {
color: #787774;
color: var(--release-text-secondary);
font-weight: 500;
margin-left: 6px;
}
@@ -138,19 +181,19 @@ const RELEASE_HISTORY_PAGE_STYLES = `
}
.release-card {
background: #ffffff;
border: 1px solid #e9e9e7;
background: var(--release-surface-card);
border: 1px solid var(--release-border-default);
border-radius: 18px;
padding: 20px 22px;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.05);
box-shadow: 0 16px 40px var(--release-shadow-card);
}
.release-card--alpha {
border-left: 4px solid #f59e0b;
border-left: 4px solid var(--release-alpha);
}
.release-card--stable {
border-left: 4px solid #155dff;
border-left: 4px solid var(--release-accent);
}
.release-header {
@@ -169,15 +212,15 @@ const RELEASE_HISTORY_PAGE_STYLES = `
.release-meta {
margin: 4px 0 0;
color: #787774;
color: var(--release-text-secondary);
font-size: 0.9375rem;
}
.release-channel {
align-self: start;
background: #f1f5ff;
background: var(--release-surface-tab-hover);
border-radius: 999px;
color: #155dff;
color: var(--release-accent);
font-size: 0.8125rem;
font-weight: 700;
letter-spacing: 0.02em;
@@ -186,12 +229,12 @@ const RELEASE_HISTORY_PAGE_STYLES = `
}
.release-card--alpha .release-channel {
background: #fff3d6;
color: #b45309;
background: var(--release-alpha-bg);
color: var(--release-alpha-text);
}
.release-notes {
color: #44403c;
color: var(--release-text-body);
font-size: 0.98rem;
line-height: 1.7;
}
@@ -225,14 +268,14 @@ const RELEASE_HISTORY_PAGE_STYLES = `
}
.release-notes code {
background: #f4f4f2;
background: var(--release-surface-muted);
border-radius: 6px;
padding: 0.12em 0.35em;
}
.release-notes pre {
overflow-x: auto;
background: #f4f4f2;
background: var(--release-surface-muted);
border-radius: 12px;
padding: 14px;
}
@@ -243,14 +286,14 @@ const RELEASE_HISTORY_PAGE_STYLES = `
}
.release-notes blockquote {
border-left: 3px solid #d6d3d1;
color: #57534e;
border-left: 3px solid var(--release-border-strong);
color: var(--release-text-blockquote);
padding-left: 14px;
}
.release-notes a,
.release-downloads a {
color: #155dff;
color: var(--release-accent);
text-decoration-thickness: 0.08em;
text-underline-offset: 0.18em;
}
@@ -269,15 +312,15 @@ const RELEASE_HISTORY_PAGE_STYLES = `
min-height: 42px;
padding: 0 14px;
border-radius: 10px;
background: #155dff;
color: #ffffff;
background: var(--release-accent);
color: var(--release-text-on-accent);
font-weight: 600;
text-decoration: none;
}
.release-downloads a[data-secondary="true"] {
background: #ebeef5;
color: #37352f;
background: var(--release-surface-secondary-action);
color: var(--release-text-primary);
}
.release-downloads a:hover,
@@ -286,15 +329,15 @@ const RELEASE_HISTORY_PAGE_STYLES = `
}
.release-downloads a:focus-visible {
outline: 2px solid #155dff;
outline: 2px solid var(--release-accent);
outline-offset: 2px;
}
.empty-state {
background: #ffffff;
border: 1px dashed #d6d3d1;
background: var(--release-surface-card);
border: 1px dashed var(--release-border-strong);
border-radius: 18px;
color: #787774;
color: var(--release-text-secondary);
padding: 28px 24px;
text-align: center;
}