From 5d2b4962077f73ffcb11e77d4d50602e0d005646 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Tue, 7 Jul 2026 14:22:43 -0600 Subject: [PATCH 1/8] Add light mode following the OS color scheme --- src/App.tsx | 4 +++- src/styles.css | 39 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 632758e..6f7f08a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1247,7 +1247,9 @@ function DiffView() { const options = useMemo>( () => ({ - theme: 'github-dark-default', + // Dual theme: the library emits light-dark() CSS and resolves it from the + // page's color-scheme (see body in styles.css), so this follows the OS. + theme: { light: 'github-light-default', dark: 'github-dark-default' }, diffStyle, // The library lays out collapsed files (and unmeasured estimates) from // these metrics rather than the DOM. Its diffHeaderHeight default (44) diff --git a/src/styles.css b/src/styles.css index 90a0516..578c152 100644 --- a/src/styles.css +++ b/src/styles.css @@ -59,6 +59,41 @@ Consolas, 'Liberation Mono', monospace; } +/* Light scheme, following the OS preference. Same token structure with the + elevation ramp inverted: on a light base, raised chrome steps DOWN in + lightness (white page, gray bars). --bg-code stays pure white to match the + github-light-default syntax theme background the diff bodies render with. */ +@media (prefers-color-scheme: light) { + :root { + --bg-code: oklch(100% 0 0); + --bg-canvas: oklch(100% 0 0); + --bg-panel: oklch(97% 0.005 256); + --bg-muted: oklch(95.5% 0.006 256); + --bg-header: oklch(96% 0.006 256); + --bg-control: oklch(94% 0.007 256); + --bg-control-hover: oklch(90.5% 0.009 256); + + --border: oklch(88% 0.01 256); + --border-soft: oklch(89.5% 0.009 256); + --border-strong: oklch(80% 0.013 256); + --border-muted: oklch(65% 0.016 256); + + --fg: oklch(32% 0.02 250); + --fg-bright: oklch(21% 0.02 250); + --fg-dim: oklch(44% 0.02 250); + --fg-muted: oklch(52% 0.02 250); + + /* Accent text/hover variants re-pinned darker for contrast on white; + base --blue/--green (used as fills under white text) work as-is. */ + --blue-bright: oklch(52% 0.2 258); + --blue-text: oklch(47% 0.18 258); + --green-text: oklch(44% 0.14 146); + --red: oklch(53% 0.19 27); + --amber: oklch(58% 0.12 80); + --amber-deep: oklch(52% 0.11 78); + } +} + body { margin: 0; background: var(--bg-canvas); @@ -67,7 +102,9 @@ body { -apple-system, sans-serif; color: var(--fg); - color-scheme: dark; + /* Drives both native widget rendering and the diff library's light-dark() + theme resolution inside its shadow roots. */ + color-scheme: light dark; } /* Full-height layout so CodeView's single scroll container fills the viewport From 57d3c49c3bb4df228868e64e813a8bcc7b2db29c Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Tue, 7 Jul 2026 14:49:12 -0600 Subject: [PATCH 2/8] Add --theme flag to force light or dark regardless of OS --- cli.ts | 9 ++- server/main.ts | 5 +- shared/types.ts | 7 +++ src/App.tsx | 20 ++++++- src/styles.css | 146 +++++++++++++++++++++++++----------------------- 5 files changed, 111 insertions(+), 76 deletions(-) diff --git a/cli.ts b/cli.ts index 73ec4a4..3ca6228 100644 --- a/cli.ts +++ b/cli.ts @@ -10,7 +10,7 @@ import { spawn, execFileSync } from 'child_process' import { existsSync } from 'node:fs' import { dirname, join } from 'node:path' -import { Command } from '@commander-js/extra-typings' +import { Command, Option } from '@commander-js/extra-typings' import { startServer } from './server/main.ts' import type { DiffArgs, DiffEndpoints } from './shared/types.ts' @@ -23,6 +23,11 @@ const program = new Command() .option('--git', 'force git mode (skip jj detection)') .option('--dev', 'run with Vite dev server for development') .option('--host
', 'address to bind the HTTP server to', 'localhost') + .addOption( + new Option('--theme ', 'color scheme (e.g. light diffs on a dark desktop)') + .choices(['light', 'dark', 'system'] as const) + .default('system' as const), + ) .argument('[files...]', 'Limit diff to these paths (passed through to jj/git)') .parse() @@ -223,7 +228,7 @@ process.on('SIGINT', () => cleanup()) process.on('SIGTERM', () => cleanup()) const checkoutRoot = opts.dev ? requireCheckoutRoot() : undefined -const apiPort = await startServer({ diffSource, cwd, hostname }) +const apiPort = await startServer({ diffSource, cwd, hostname, theme: opts.theme }) function urlOpenCommand(url: string): { cmd: string; args: string[] } { switch (process.platform) { diff --git a/server/main.ts b/server/main.ts index dbe1295..5612f14 100644 --- a/server/main.ts +++ b/server/main.ts @@ -32,6 +32,7 @@ import type { FileContentsResponse, OkResponse, ErrorResponse, + ThemeMode, } from '../shared/types.ts' export async function startServer(opts: { @@ -39,8 +40,9 @@ export async function startServer(opts: { port?: number hostname?: string cwd: string + theme?: ThemeMode }): Promise { - const { diffSource, port = 0, hostname = 'localhost', cwd } = opts + const { diffSource, port = 0, hostname = 'localhost', cwd, theme = 'system' } = opts await validateDiffArgs(diffSource) const app = new Hono() @@ -60,6 +62,7 @@ export async function startServer(opts: { fileHashes, viewed, commentSyntaxes, + theme, } satisfies DiffResponse) }) diff --git a/shared/types.ts b/shared/types.ts index 8fdf925..5a2b385 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -48,6 +48,12 @@ export type ViewedRequest = z.infer export type CommentRequest = z.infer export type CommentDeleteRequest = z.infer +// --- UI types --- + +/** Color scheme from --theme. 'system' follows the OS preference; 'light' and + * 'dark' force it (e.g. light diffs on a dark desktop). */ +export type ThemeMode = 'light' | 'dark' | 'system' + // --- VCS types --- /** @@ -92,6 +98,7 @@ export interface DiffResponse { * file type wasn't recognized. Both get bare review tags on insert. Always * empty when comments are disabled. */ commentSyntaxes: Record + theme: ThemeMode error?: string } diff --git a/src/App.tsx b/src/App.tsx index 6f7f08a..8803c8c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -759,6 +759,16 @@ function DiffView() { if (data?.revset) document.title = `skepsis | ${data.revset}` }, [data?.revset]) + // --theme light/dark: pin color-scheme on the root, overriding the + // OS-following `light dark` from styles.css so our light-dark() tokens + // resolve to the forced side. The diff shadow roots are pinned separately + // via the CodeView themeType option. + useEffect(() => { + if (data?.theme && data.theme !== 'system') { + document.documentElement.style.colorScheme = data.theme + } + }, [data?.theme]) + // Seed collapse state from the first successful fetch: viewed files start collapsed useEffect(() => { if (seededFromInitialLoad.current || !data?.fileHashes) return @@ -965,6 +975,7 @@ function DiffView() { ]) const commentsEnabled = data?.commentsEnabled ?? false + const theme = data?.theme ?? 'system' const [showHelp, setShowHelp] = useState(false) const [showCommentsInfo, setShowCommentsInfo] = useState(false) @@ -1247,9 +1258,12 @@ function DiffView() { const options = useMemo>( () => ({ - // Dual theme: the library emits light-dark() CSS and resolves it from the - // page's color-scheme (see body in styles.css), so this follows the OS. + // Dual theme: the library emits light-dark() CSS. themeType pins the + // shadow roots' color-scheme for forced themes — their :host base style + // is `color-scheme: light dark`, which re-resolves from the OS + // preference rather than inheriting the page's forced value. theme: { light: 'github-light-default', dark: 'github-dark-default' }, + themeType: theme, diffStyle, // The library lays out collapsed files (and unmeasured estimates) from // these metrics rather than the DOM. Its diffHeaderHeight default (44) @@ -1299,7 +1313,7 @@ function DiffView() { } }, }), - [diffStyle, commentsEnabled, markSeen], + [diffStyle, commentsEnabled, markSeen, theme], ) // Keyboard shortcuts. File navigation (n/p) and the focused-file actions diff --git a/src/styles.css b/src/styles.css index 578c152..94726ac 100644 --- a/src/styles.css +++ b/src/styles.css @@ -7,48 +7,92 @@ */ /* - * Color tokens. Neutrals share a fixed hue (256) and chroma (.015) and vary - * only in OKLCH lightness, so surfaces form one perceptually-even elevation - * ramp: darker = recessed, lighter = raised. Controls (--bg-control) sit ABOVE - * the header bar (--bg-header) so a button reads as resting on the bar rather - * than punched into it. Borders are a separate low→high contrast ramp; accents - * are four fixed hues with bright/text variants. + * Color tokens. Neutrals share a fixed hue (256) and low chroma and vary only + * in OKLCH lightness, so surfaces form one perceptually-even elevation ramp. + * Each token that differs by scheme is a light-dark(light, dark) pair; which + * side applies is driven by color-scheme (see :root below), so 'system' + * follows the OS and --theme light/dark forces it by setting an inline + * color-scheme on the root — the same property the diff library resolves its + * own light-dark() theme CSS from inside its shadow roots. + * + * Elevation runs opposite ways per scheme: dark = raised chrome steps LIGHTER + * (dark base, lifted bars); light = raised chrome steps DARKER (white page, + * gray bars, GitHub-style). Controls (--bg-control) sit one step past the + * header bar (--bg-header) so a button reads as resting on the bar rather + * than punched into it. Borders are a separate low→high contrast ramp; + * accents are four fixed hues with bright/text variants. */ :root { + /* Drives light-dark() resolution here and native widget rendering, and is + inherited into the diff library's shadow roots. Forced themes override + this with an inline style (see App). */ + color-scheme: light dark; + /* Surfaces, ascending elevation. The page (--bg-canvas) matches the diff/code - base (--bg-code) so the code area doesn't read as an odd dark inset — the - whole app sits on one dark base and only chrome (header, controls) lifts off - it. Steps are small so raised elements stay dark rather than pasty-gray. */ - --bg-code: oklch(17.5% 0.015 256); /* diff/code content, textarea — the base */ - --bg-canvas: oklch(17.5% 0.015 256); /* page — same as the code base */ - --bg-panel: oklch(22% 0.015 256); /* modal, comment form, separator band */ - --bg-muted: oklch(24% 0.015 256); /* progress track, kbd, inline code chip */ - --bg-header: oklch(26% 0.015 256); /* file header bar */ - --bg-control: oklch(28.5% 0.015 256); /* buttons — just above the header */ - --bg-control-hover: oklch(33% 0.015 256); + base (--bg-code) so the code area doesn't read as an odd inset — the whole + app sits on one base and only chrome (header, controls) steps off it. + Steps are small so raised elements stay close to the base rather than + pasty-gray. Light --bg-code is pure white to match the + github-light-default syntax theme background the diff bodies render with. */ + --bg-code: light-dark( + oklch(100% 0 0), + oklch(17.5% 0.015 256) + ); /* diff/code, textarea — the base */ + --bg-canvas: light-dark( + oklch(100% 0 0), + oklch(17.5% 0.015 256) + ); /* page — same as the code base */ + --bg-panel: light-dark( + oklch(97% 0.005 256), + oklch(22% 0.015 256) + ); /* modal, comment form, separator band */ + --bg-muted: light-dark( + oklch(95.5% 0.006 256), + oklch(24% 0.015 256) + ); /* progress track, kbd, inline code chip */ + --bg-header: light-dark(oklch(96% 0.006 256), oklch(26% 0.015 256)); /* file header bar */ + --bg-control: light-dark( + oklch(94% 0.007 256), + oklch(28.5% 0.015 256) + ); /* buttons — one step past the header */ + --bg-control-hover: light-dark(oklch(90.5% 0.009 256), oklch(33% 0.015 256)); /* Borders, low→high contrast against the surface they sit on. */ - --border: oklch(33% 0.013 256); /* default, on dark surfaces */ - --border-soft: oklch(32% 0.015 256); /* header strip — low contrast on the bar */ - --border-strong: oklch(42% 0.016 256); /* control outlines */ - --border-muted: oklch(56% 0.018 256); /* hover / emphasis */ + --border: light-dark(oklch(88% 0.01 256), oklch(33% 0.013 256)); /* default */ + --border-soft: light-dark( + oklch(89.5% 0.009 256), + oklch(32% 0.015 256) + ); /* header strip — low contrast on the bar */ + --border-strong: light-dark( + oklch(80% 0.013 256), + oklch(42% 0.016 256) + ); /* control outlines */ + --border-muted: light-dark( + oklch(65% 0.016 256), + oklch(56% 0.018 256) + ); /* hover / emphasis */ /* Text, hue 250. */ - --fg: oklch(86% 0.014 250); - --fg-bright: oklch(94% 0.012 250); - --fg-dim: oklch(77% 0.016 250); /* between muted and fg: de-emphasized but interactive */ - --fg-muted: oklch(66% 0.018 250); - - /* Accents: base / brighter (hover) / lighter (text-on-dark). */ + --fg: light-dark(oklch(32% 0.02 250), oklch(86% 0.014 250)); + --fg-bright: light-dark(oklch(21% 0.02 250), oklch(94% 0.012 250)); + --fg-dim: light-dark( + oklch(44% 0.02 250), + oklch(77% 0.016 250) + ); /* between muted and fg: de-emphasized but interactive */ + --fg-muted: light-dark(oklch(52% 0.02 250), oklch(66% 0.018 250)); + + /* Accents: base / bright (hover) / text (on the scheme's surfaces). Bases + used as fills under white text (--blue, --green) hold in both schemes; + hover and text variants swing toward the scheme's contrast direction. */ --blue: oklch(57% 0.2 260); - --blue-bright: oklch(65% 0.19 257); - --blue-text: oklch(72% 0.15 253); + --blue-bright: light-dark(oklch(52% 0.2 258), oklch(65% 0.19 257)); + --blue-text: light-dark(oklch(47% 0.18 258), oklch(72% 0.15 253)); --green: oklch(55% 0.15 146); --green-bright: oklch(62% 0.17 146); - --green-text: oklch(69% 0.18 146); - --red: oklch(66% 0.2 27); - --amber: oklch(72% 0.14 80); - --amber-deep: oklch(56% 0.12 78); + --green-text: light-dark(oklch(44% 0.14 146), oklch(69% 0.18 146)); + --red: light-dark(oklch(53% 0.19 27), oklch(66% 0.2 27)); + --amber: light-dark(oklch(58% 0.12 80), oklch(72% 0.14 80)); + --amber-deep: light-dark(oklch(52% 0.11 78), oklch(56% 0.12 78)); /* Monospace stack. Berkeley Mono first for those who have it, then each OS's best system mono (ui-monospace → SF Mono on macOS, Consolas/Cascadia on @@ -59,41 +103,6 @@ Consolas, 'Liberation Mono', monospace; } -/* Light scheme, following the OS preference. Same token structure with the - elevation ramp inverted: on a light base, raised chrome steps DOWN in - lightness (white page, gray bars). --bg-code stays pure white to match the - github-light-default syntax theme background the diff bodies render with. */ -@media (prefers-color-scheme: light) { - :root { - --bg-code: oklch(100% 0 0); - --bg-canvas: oklch(100% 0 0); - --bg-panel: oklch(97% 0.005 256); - --bg-muted: oklch(95.5% 0.006 256); - --bg-header: oklch(96% 0.006 256); - --bg-control: oklch(94% 0.007 256); - --bg-control-hover: oklch(90.5% 0.009 256); - - --border: oklch(88% 0.01 256); - --border-soft: oklch(89.5% 0.009 256); - --border-strong: oklch(80% 0.013 256); - --border-muted: oklch(65% 0.016 256); - - --fg: oklch(32% 0.02 250); - --fg-bright: oklch(21% 0.02 250); - --fg-dim: oklch(44% 0.02 250); - --fg-muted: oklch(52% 0.02 250); - - /* Accent text/hover variants re-pinned darker for contrast on white; - base --blue/--green (used as fills under white text) work as-is. */ - --blue-bright: oklch(52% 0.2 258); - --blue-text: oklch(47% 0.18 258); - --green-text: oklch(44% 0.14 146); - --red: oklch(53% 0.19 27); - --amber: oklch(58% 0.12 80); - --amber-deep: oklch(52% 0.11 78); - } -} - body { margin: 0; background: var(--bg-canvas); @@ -102,9 +111,6 @@ body { -apple-system, sans-serif; color: var(--fg); - /* Drives both native widget rendering and the diff library's light-dark() - theme resolution inside its shadow roots. */ - color-scheme: light dark; } /* Full-height layout so CodeView's single scroll container fills the viewport From deeee18a1ba0fa8150b25a28523b7c1f770cd875 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Tue, 7 Jul 2026 14:54:45 -0600 Subject: [PATCH 3/8] Trim the token header comment per review --- src/styles.css | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/src/styles.css b/src/styles.css index 94726ac..f8e3745 100644 --- a/src/styles.css +++ b/src/styles.css @@ -7,20 +7,15 @@ */ /* - * Color tokens. Neutrals share a fixed hue (256) and low chroma and vary only - * in OKLCH lightness, so surfaces form one perceptually-even elevation ramp. - * Each token that differs by scheme is a light-dark(light, dark) pair; which - * side applies is driven by color-scheme (see :root below), so 'system' - * follows the OS and --theme light/dark forces it by setting an inline - * color-scheme on the root — the same property the diff library resolves its - * own light-dark() theme CSS from inside its shadow roots. - * - * Elevation runs opposite ways per scheme: dark = raised chrome steps LIGHTER - * (dark base, lifted bars); light = raised chrome steps DARKER (white page, - * gray bars, GitHub-style). Controls (--bg-control) sit one step past the - * header bar (--bg-header) so a button reads as resting on the bar rather - * than punched into it. Borders are a separate low→high contrast ramp; - * accents are four fixed hues with bright/text variants. + * Color tokens, a light-dark(light, dark) pair per token, resolved by + * color-scheme on :root: 'light dark' follows the OS, and --theme pins it + * with an inline style (see App) — the diff library's shadow roots resolve + * their theme CSS from the same property. Neutrals share one hue and vary + * only in OKLCH lightness, so surfaces form an elevation ramp: raised chrome + * steps away from the page background, with controls (--bg-control) one step + * past the header bar (--bg-header) so a button reads as resting on the bar. + * Borders are a separate low→high contrast ramp; accents are four fixed hues + * with bright/text variants. */ :root { /* Drives light-dark() resolution here and native widget rendering, and is From ebde487537f3769d94b2ecf2b5bc2a37d50a36e8 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Tue, 7 Jul 2026 15:23:58 -0600 Subject: [PATCH 4/8] Deliver forced theme on the URL, not the diff response The theme is known at server start but was riding on /api/diff, so a forced scheme applied only after the slowest request resolved (OS-scheme flash for the whole diff run) and never applied at all when the fetch failed. Carry it as ?theme= on the URL the CLI opens and read it at module load, before first paint. Deletes the server and DiffResponse plumbing; THEME_MODES in shared/types.ts now drives the CLI choices and the client's param validation from one list. A forced theme now also passes a single CodeView theme instead of the dual object: Shiki tokenizes once instead of once per theme (the dual path runs a full grammar pass per theme and ships both themes' token styles even though a pinned color-scheme can only ever render one). Also reorder the error guard above the loading guard: a failed fetch settles with data undefined, so the loading-first check showed 'Loading...' forever and the error branch was unreachable. --- cli.ts | 14 ++++++++++---- server/main.ts | 5 +---- shared/types.ts | 7 ++++--- src/App.tsx | 48 ++++++++++++++++++++++++++++++------------------ 4 files changed, 45 insertions(+), 29 deletions(-) diff --git a/cli.ts b/cli.ts index 3ca6228..d934c26 100644 --- a/cli.ts +++ b/cli.ts @@ -12,6 +12,7 @@ import { existsSync } from 'node:fs' import { dirname, join } from 'node:path' import { Command, Option } from '@commander-js/extra-typings' import { startServer } from './server/main.ts' +import { THEME_MODES } from './shared/types.ts' import type { DiffArgs, DiffEndpoints } from './shared/types.ts' const program = new Command() @@ -25,7 +26,7 @@ const program = new Command() .option('--host
', 'address to bind the HTTP server to', 'localhost') .addOption( new Option('--theme ', 'color scheme (e.g. light diffs on a dark desktop)') - .choices(['light', 'dark', 'system'] as const) + .choices(THEME_MODES) .default('system' as const), ) .argument('[files...]', 'Limit diff to these paths (passed through to jj/git)') @@ -228,7 +229,7 @@ process.on('SIGINT', () => cleanup()) process.on('SIGTERM', () => cleanup()) const checkoutRoot = opts.dev ? requireCheckoutRoot() : undefined -const apiPort = await startServer({ diffSource, cwd, hostname, theme: opts.theme }) +const apiPort = await startServer({ diffSource, cwd, hostname }) function urlOpenCommand(url: string): { cmd: string; args: string[] } { switch (process.platform) { @@ -246,9 +247,14 @@ function urlOpenCommand(url: string): { cmd: string; args: string[] } { // — opening locally would just spawn an unwanted browser. const shouldAutoOpen = hostname === 'localhost' +// A forced theme rides on the URL so the client can apply it before first +// paint, with no dependency on the (slow, fallible) diff fetch. A hand-typed +// bare URL falls back to the OS scheme. +const themeQuery = opts.theme === 'system' ? '' : `/?theme=${opts.theme}` + if (opts.dev) { const viteArgs = ['vite', '--host', hostname] - if (shouldAutoOpen) viteArgs.push('--open') + if (shouldAutoOpen) viteArgs.push('--open', themeQuery || '/') const vite = spawn('npx', viteArgs, { cwd: checkoutRoot, stdio: 'inherit', @@ -256,7 +262,7 @@ if (opts.dev) { }) children.push(vite) } else if (shouldAutoOpen) { - const url = `http://localhost:${apiPort}` + const url = `http://localhost:${apiPort}${themeQuery}` const { cmd, args } = urlOpenCommand(url) const opener = spawn(cmd, args, { detached: true, stdio: 'ignore' }) opener.on('error', (err) => { diff --git a/server/main.ts b/server/main.ts index 5612f14..dbe1295 100644 --- a/server/main.ts +++ b/server/main.ts @@ -32,7 +32,6 @@ import type { FileContentsResponse, OkResponse, ErrorResponse, - ThemeMode, } from '../shared/types.ts' export async function startServer(opts: { @@ -40,9 +39,8 @@ export async function startServer(opts: { port?: number hostname?: string cwd: string - theme?: ThemeMode }): Promise { - const { diffSource, port = 0, hostname = 'localhost', cwd, theme = 'system' } = opts + const { diffSource, port = 0, hostname = 'localhost', cwd } = opts await validateDiffArgs(diffSource) const app = new Hono() @@ -62,7 +60,6 @@ export async function startServer(opts: { fileHashes, viewed, commentSyntaxes, - theme, } satisfies DiffResponse) }) diff --git a/shared/types.ts b/shared/types.ts index 5a2b385..e3e7e36 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -51,8 +51,10 @@ export type CommentDeleteRequest = z.infer // --- UI types --- /** Color scheme from --theme. 'system' follows the OS preference; 'light' and - * 'dark' force it (e.g. light diffs on a dark desktop). */ -export type ThemeMode = 'light' | 'dark' | 'system' + * 'dark' force it (e.g. light diffs on a dark desktop). One list drives both + * the CLI's --theme choices and the client's URL-param validation. */ +export const THEME_MODES = ['light', 'dark', 'system'] as const +export type ThemeMode = (typeof THEME_MODES)[number] // --- VCS types --- @@ -98,7 +100,6 @@ export interface DiffResponse { * file type wasn't recognized. Both get bare review tags on insert. Always * empty when comments are disabled. */ commentSyntaxes: Record - theme: ThemeMode error?: string } diff --git a/src/App.tsx b/src/App.tsx index 8803c8c..b6ccf49 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -37,10 +37,12 @@ import type { LineAnnotation, SelectedLineRange, } from '@pierre/diffs' +import { THEME_MODES } from '../shared/types.ts' import type { DiffResponse, ErrorResponse, FileContentsResponse, + ThemeMode, ViewedMap, FileHashes, } from '../shared/types.ts' @@ -54,6 +56,18 @@ import { const queryClient = new QueryClient() +// --theme rides on the URL the CLI opens (?theme=light|dark) so a forced +// scheme applies at module load — before first paint, and independent of the +// diff fetch (which is slow and can fail). Pinning color-scheme on the root +// overrides the OS-following `light dark` from styles.css so our light-dark() +// tokens resolve to the forced side; the diff shadow roots are pinned +// separately via the CodeView theme/themeType options. +const themeParam = new URLSearchParams(location.search).get('theme') +const theme: ThemeMode = (THEME_MODES as readonly (string | null)[]).includes(themeParam) + ? (themeParam as ThemeMode) + : 'system' +if (theme !== 'system') document.documentElement.style.colorScheme = theme + async function apiFetch( url: string, opts: { method: string; body?: unknown } = { method: 'GET' }, @@ -759,16 +773,6 @@ function DiffView() { if (data?.revset) document.title = `skepsis | ${data.revset}` }, [data?.revset]) - // --theme light/dark: pin color-scheme on the root, overriding the - // OS-following `light dark` from styles.css so our light-dark() tokens - // resolve to the forced side. The diff shadow roots are pinned separately - // via the CodeView themeType option. - useEffect(() => { - if (data?.theme && data.theme !== 'system') { - document.documentElement.style.colorScheme = data.theme - } - }, [data?.theme]) - // Seed collapse state from the first successful fetch: viewed files start collapsed useEffect(() => { if (seededFromInitialLoad.current || !data?.fileHashes) return @@ -975,7 +979,6 @@ function DiffView() { ]) const commentsEnabled = data?.commentsEnabled ?? false - const theme = data?.theme ?? 'system' const [showHelp, setShowHelp] = useState(false) const [showCommentsInfo, setShowCommentsInfo] = useState(false) @@ -1258,11 +1261,18 @@ function DiffView() { const options = useMemo>( () => ({ - // Dual theme: the library emits light-dark() CSS. themeType pins the - // shadow roots' color-scheme for forced themes — their :host base style - // is `color-scheme: light dark`, which re-resolves from the OS - // preference rather than inheriting the page's forced value. - theme: { light: 'github-light-default', dark: 'github-dark-default' }, + // In system mode the dual theme makes the library emit light-dark() + // CSS that follows the OS (the shadow roots' :host base style is + // `color-scheme: light dark`; they don't inherit the page's value). A + // forced theme passes a single theme instead: Shiki then tokenizes once + // rather than once per theme, and the theme's own type pins the shadow + // roots' color-scheme. + theme: + theme === 'system' + ? { light: 'github-light-default', dark: 'github-dark-default' } + : theme === 'light' + ? 'github-light-default' + : 'github-dark-default', themeType: theme, diffStyle, // The library lays out collapsed files (and unmeasured estimates) from @@ -1313,7 +1323,7 @@ function DiffView() { } }, }), - [diffStyle, commentsEnabled, markSeen, theme], + [diffStyle, commentsEnabled, markSeen], ) // Keyboard shortcuts. File navigation (n/p) and the focused-file actions @@ -1541,8 +1551,10 @@ function DiffView() { markProgrammaticScroll, ]) - if (isLoading || !data) return
Loading...
+ // Error before loading: on a failed fetch react-query settles with `data` + // undefined, so a loading-first guard would show "Loading..." forever. if (error) return
{String(error)}
+ if (isLoading || !data) return
Loading...
if (data.error) return
{data.error}
if (!patch) return ( From 208064e46cf61a39aed08d8c42762a4a2b774796 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Tue, 7 Jul 2026 15:24:24 -0600 Subject: [PATCH 5/8] Give --green-bright and --amber light arms that darken Both were left fixed at their dark values in the light-dark() conversion. In light mode the primary button hover lightened under white text (contrast dropping to about 3.6:1, opposite hover direction from --blue-bright) and the stale viewed label rendered about 3.6:1 on the control surface. Darken both light arms to 50% L, in line with the other accent text variants. --- src/styles.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/styles.css b/src/styles.css index f8e3745..9e3a10c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -83,10 +83,10 @@ --blue-bright: light-dark(oklch(52% 0.2 258), oklch(65% 0.19 257)); --blue-text: light-dark(oklch(47% 0.18 258), oklch(72% 0.15 253)); --green: oklch(55% 0.15 146); - --green-bright: oklch(62% 0.17 146); + --green-bright: light-dark(oklch(50% 0.15 146), oklch(62% 0.17 146)); --green-text: light-dark(oklch(44% 0.14 146), oklch(69% 0.18 146)); --red: light-dark(oklch(53% 0.19 27), oklch(66% 0.2 27)); - --amber: light-dark(oklch(58% 0.12 80), oklch(72% 0.14 80)); + --amber: light-dark(oklch(50% 0.12 80), oklch(72% 0.14 80)); --amber-deep: light-dark(oklch(52% 0.11 78), oklch(56% 0.12 78)); /* Monospace stack. Berkeley Mono first for those who have it, then each OS's From cb56bcc1b2820b3ea64f65769b7eb0795af3e2a0 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Tue, 7 Jul 2026 18:19:19 -0600 Subject: [PATCH 6/8] Keep light-dark() untransformed in the production CSS Vite's default cssTarget predates light-dark(), so esbuild lowered every token pair to a prefers-color-scheme polyfill that follows the OS and ignores the color-scheme --theme pins. Dev serves the raw CSS, which is why the bug only showed in the built package: forced light rendered the diff bodies light (the library generates its CSS at runtime) but left the page chrome dark. Raise cssTarget to the first versions with light-dark() support; not a new floor in practice, since the diff library already emits light-dark() at runtime. --- vite.config.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/vite.config.ts b/vite.config.ts index b0ffdef..f11baa7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -19,6 +19,12 @@ export default defineConfig({ plugins: [react()], build: { outDir: 'dist/web', + // Vite's default cssTarget predates light-dark(), so esbuild lowers it + // to a prefers-color-scheme polyfill that follows the OS and ignores the + // forced color-scheme --theme sets. Target the first versions with + // light-dark() support so it ships untransformed. Not a new floor in + // practice: the diff library already emits light-dark() at runtime. + cssTarget: ['chrome123', 'firefox120', 'safari17.5'], }, server: { proxy: { From 4a129b8f178801f02066556d8d3421b56d2c58e6 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 8 Jul 2026 11:14:17 -0500 Subject: [PATCH 7/8] Theme toggle with server-side persistence (replaces --theme flag) --- CLAUDE.md | 6 +- cli.ts | 17 +---- index.html | 5 ++ server/main.ts | 21 +++++ server/settings.test.ts | 67 ++++++++++++++++ server/settings.ts | 49 ++++++++++++ shared/types.ts | 12 ++- src/App.tsx | 165 ++++++++++++++++++++++++++++++++++------ src/styles.css | 75 +++++++++++++++--- vite.config.ts | 6 -- 10 files changed, 365 insertions(+), 58 deletions(-) create mode 100644 server/settings.test.ts create mode 100644 server/settings.ts diff --git a/CLAUDE.md b/CLAUDE.md index 9090721..7e84ec6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,11 @@ cli.ts CLI entry (commander). Detects jj/git, builds DiffArgs starts the API server, spawns Vite in --dev mode. server/ main.ts Hono server. GET /api/diff; POST/DELETE /api/viewed; - POST/DELETE /api/comment; serves dist/web statics. + POST/DELETE /api/comment; GET/POST /api/theme; + GET /api/theme.js (pre-first-paint theme boot script + loaded by index.html); serves dist/web statics. + settings.ts Global (not per-repo) UI settings — currently just the + theme — in ~/.local/share/skepsis/settings.json. diff.ts Runs `jj diff --git`/`git diff`, extracts per-file blob hashes from index lines. viewed.ts Viewed state, content-addressed by git blob ID — files diff --git a/cli.ts b/cli.ts index d934c26..73ec4a4 100644 --- a/cli.ts +++ b/cli.ts @@ -10,9 +10,8 @@ import { spawn, execFileSync } from 'child_process' import { existsSync } from 'node:fs' import { dirname, join } from 'node:path' -import { Command, Option } from '@commander-js/extra-typings' +import { Command } from '@commander-js/extra-typings' import { startServer } from './server/main.ts' -import { THEME_MODES } from './shared/types.ts' import type { DiffArgs, DiffEndpoints } from './shared/types.ts' const program = new Command() @@ -24,11 +23,6 @@ const program = new Command() .option('--git', 'force git mode (skip jj detection)') .option('--dev', 'run with Vite dev server for development') .option('--host
', 'address to bind the HTTP server to', 'localhost') - .addOption( - new Option('--theme ', 'color scheme (e.g. light diffs on a dark desktop)') - .choices(THEME_MODES) - .default('system' as const), - ) .argument('[files...]', 'Limit diff to these paths (passed through to jj/git)') .parse() @@ -247,14 +241,9 @@ function urlOpenCommand(url: string): { cmd: string; args: string[] } { // — opening locally would just spawn an unwanted browser. const shouldAutoOpen = hostname === 'localhost' -// A forced theme rides on the URL so the client can apply it before first -// paint, with no dependency on the (slow, fallible) diff fetch. A hand-typed -// bare URL falls back to the OS scheme. -const themeQuery = opts.theme === 'system' ? '' : `/?theme=${opts.theme}` - if (opts.dev) { const viteArgs = ['vite', '--host', hostname] - if (shouldAutoOpen) viteArgs.push('--open', themeQuery || '/') + if (shouldAutoOpen) viteArgs.push('--open') const vite = spawn('npx', viteArgs, { cwd: checkoutRoot, stdio: 'inherit', @@ -262,7 +251,7 @@ if (opts.dev) { }) children.push(vite) } else if (shouldAutoOpen) { - const url = `http://localhost:${apiPort}${themeQuery}` + const url = `http://localhost:${apiPort}` const { cmd, args } = urlOpenCommand(url) const opener = spawn(cmd, args, { detached: true, stdio: 'ignore' }) opener.on('error', (err) => { diff --git a/index.html b/index.html index 38379e7..57ea737 100644 --- a/index.html +++ b/index.html @@ -12,6 +12,11 @@ skepsis + +
diff --git a/server/main.ts b/server/main.ts index dbe1295..36613ac 100644 --- a/server/main.ts +++ b/server/main.ts @@ -19,6 +19,7 @@ import { getFileContents } from './fileContents.ts' import { loadViewed, markViewed, unmarkViewed, unmarkViewedAll } from './viewed.ts' import { insertComment, removeComment } from './comment.ts' import { getCommentSyntaxes } from './commentSyntax.ts' +import { loadTheme, saveTheme, themeBootScript } from './settings.ts' import { viewedRequestSchema, viewedDeleteSchema, @@ -26,12 +27,14 @@ import { commentRequestSchema, commentDeleteSchema, fileContentsQuerySchema, + themeRequestSchema, } from '../shared/types.ts' import type { DiffResponse, FileContentsResponse, OkResponse, ErrorResponse, + ThemeResponse, } from '../shared/types.ts' export async function startServer(opts: { @@ -99,6 +102,24 @@ export async function startServer(opts: { return c.json({ ok: true } satisfies OkResponse) }) + app.get('/api/theme', async (c) => { + return c.json({ theme: await loadTheme() } satisfies ThemeResponse) + }) + + // Loaded by a render-blocking