feat: add dark mode foundation
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
43
docs/adr/0081-internal-light-dark-theme-runtime.md
Normal file
43
docs/adr/0081-internal-light-dark-theme-runtime.md
Normal 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.
|
||||
@@ -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 |
|
||||
|
||||
45
index.html
45
index.html
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">Couldn’t 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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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}`,
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
28
src/components/propertyDropdownUtils.test.ts
Normal file
28
src/components/propertyDropdownUtils.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
57
src/components/propertyDropdownUtils.ts
Normal file
57
src/components/propertyDropdownUtils.ts
Normal 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])
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)' },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
29
src/hooks/useDocumentThemeMode.test.ts
Normal file
29
src/hooks/useDocumentThemeMode.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
33
src/hooks/useDocumentThemeMode.ts
Normal file
33
src/hooks/useDocumentThemeMode.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
49
src/hooks/useThemeMode.test.ts
Normal file
49
src/hooks/useThemeMode.test.ts
Normal 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
30
src/hooks/useThemeMode.ts
Normal 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])
|
||||
}
|
||||
360
src/index.css
360
src/index.css
@@ -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
66
src/lib/themeMode.test.ts
Normal 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
66
src/lib/themeMode.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -167,6 +167,7 @@ describe('mockHandlers coverage', () => {
|
||||
analytics_enabled: true,
|
||||
anonymous_id: 'anon-1',
|
||||
release_channel: 'alpha',
|
||||
theme_mode: null,
|
||||
default_ai_agent: 'codex',
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"lists": {
|
||||
"bulletSymbol": "•",
|
||||
"bulletSize": 24,
|
||||
"bulletColor": "#177bfd",
|
||||
"bulletColor": "var(--accent-blue)",
|
||||
"indentSize": 24,
|
||||
"itemSpacing": 4,
|
||||
"paddingLeft": 8,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user