diff --git a/README.md b/README.md index aaa11d1..7b983da 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,16 @@ Right now, that means a VSCode + Web UI flow that works without extra Git config - IDE integration via extension commands, status bar notifications, and file conflict discovery. - Extremely active development (issues and feedback are always welcome, and feel free to email me!). +- Light and dark themes (the demo above needs to be updated!): + +
+
+ Light theme +
+
+ Dark theme +
+
> [!IMPORTANT] > MergeNB is currently **not compatible with nbdime** in the same merge flow. diff --git a/esbuild.js b/esbuild.js index 95a8c25..41a4abc 100644 --- a/esbuild.js +++ b/esbuild.js @@ -67,6 +67,21 @@ function copyKatex() { } } +/** + * Copy local web fonts to dist/web/fonts directory + */ +function copyWebFonts() { + const srcDir = path.join(__dirname, 'src', 'web', 'client', 'fonts'); + const destDir = path.join(__dirname, 'dist', 'web', 'fonts'); + + if (fs.existsSync(srcDir)) { + copyDir(srcDir, destDir); + console.log('[fonts] Copied bundled web fonts'); + } else { + console.warn(`[fonts] Warning: ${srcDir} not found`); + } +} + async function main() { // Extension bundle (Node.js / VSCode) const extensionCtx = await esbuild.context({ @@ -119,6 +134,7 @@ async function main() { // Copy KaTeX after build copyKatex(); + copyWebFonts(); } main().catch(e => { diff --git a/package-lock.json b/package-lock.json index ea49609..ff54ed0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -984,6 +984,7 @@ "integrity": "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -994,6 +995,7 @@ "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1077,6 +1079,7 @@ "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", @@ -1318,6 +1321,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2304,6 +2308,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4725,6 +4730,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4734,6 +4740,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5554,6 +5561,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5738,6 +5746,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 2e06b24..c2c9afc 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,12 @@ "type": "boolean", "default": false, "description": "Show the base (common ancestor) branch column in the conflict resolution view. When false, only current and incoming columns are shown." + }, + "mergeNB.ui.theme": { + "type": "string", + "enum": ["dark", "light"], + "default": "dark", + "description": "Visual theme for the conflict resolution UI. 'light' provides a light, beige theme inspired by the MergeNB logo. 'dark' provides an inverse dark theme." } } } diff --git a/readme-assets/dark-theme.png b/readme-assets/dark-theme.png new file mode 100644 index 0000000..dedfdb6 Binary files /dev/null and b/readme-assets/dark-theme.png differ diff --git a/readme-assets/light-theme.png b/readme-assets/light-theme.png new file mode 100644 index 0000000..2494aca Binary files /dev/null and b/readme-assets/light-theme.png differ diff --git a/src/resolver.ts b/src/resolver.ts index d67ccc4..72a5d14 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -272,7 +272,8 @@ export class NotebookConflictResolver { autoResolveResult: autoResolveResult, hideNonConflictOutputs: settings.hideNonConflictOutputs, enableUndoRedoHotkeys: settings.enableUndoRedoHotkeys, - showBaseColumn: settings.showBaseColumn + showBaseColumn: settings.showBaseColumn, + theme: settings.theme }; const resolutionCallback = async (resolution: UnifiedResolution): Promise => { diff --git a/src/settings.ts b/src/settings.ts index 828aa1e..fa5c23f 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -10,6 +10,7 @@ * - hideNonConflictOutputs: Hide outputs for non-conflicted cells in UI (default: true) * - enableUndoRedoHotkeys: Enable Ctrl+Z / Ctrl+Shift+Z in web UI (default: true) * - showBaseColumn: Show base branch column in 3-column view (default: false, true in headless/testing) + * - theme: UI theme selection ('dark' | 'light', default: 'light') * * These reduce manual conflict resolution for common non-semantic differences. */ @@ -30,6 +31,7 @@ export interface MergeNBSettings { hideNonConflictOutputs: boolean; enableUndoRedoHotkeys: boolean; showBaseColumn: boolean; + theme: 'dark' | 'light'; } /** Default settings used in headless mode */ @@ -40,7 +42,8 @@ const DEFAULT_SETTINGS: MergeNBSettings = { autoResolveWhitespace: true, hideNonConflictOutputs: true, enableUndoRedoHotkeys: true, - showBaseColumn: true + showBaseColumn: true, + theme: 'light' }; /** @@ -64,6 +67,7 @@ export function getSettings(): MergeNBSettings { hideNonConflictOutputs: true, enableUndoRedoHotkeys: true, showBaseColumn: false, + theme: 'light', }; const config = vscode.workspace.getConfiguration('mergeNB'); @@ -76,13 +80,17 @@ export function getSettings(): MergeNBSettings { hideNonConflictOutputs: config.get('ui.hideNonConflictOutputs', defaults.hideNonConflictOutputs), enableUndoRedoHotkeys: config.get('ui.enableUndoRedoHotkeys', defaults.enableUndoRedoHotkeys), showBaseColumn: config.get('ui.showBaseColumn', defaults.showBaseColumn), + theme: config.get<'dark' | 'light'>('ui.theme', defaults.theme), }; } /** - * Check if a specific auto-resolve setting is enabled + * Check if a specific auto-resolve setting is enabled. + * Only checks actual auto-resolve settings, not UI settings. */ -export function isAutoResolveEnabled(setting: keyof MergeNBSettings): boolean { +export function isAutoResolveEnabled( + setting: 'autoResolveExecutionCount' | 'autoResolveKernelVersion' | 'stripOutputs' | 'autoResolveWhitespace' +): boolean { const settings = getSettings(); return settings[setting]; } diff --git a/src/tests/logicRegression.test.ts b/src/tests/logicRegression.test.ts index c93d627..ee11091 100644 --- a/src/tests/logicRegression.test.ts +++ b/src/tests/logicRegression.test.ts @@ -68,6 +68,7 @@ export async function run(): Promise { hideNonConflictOutputs: true, enableUndoRedoHotkeys: true, showBaseColumn: true, + theme: 'light', }); assert.ok( conflicts.some(c => c.type === 'metadata-changed'), diff --git a/src/web/WebConflictPanel.ts b/src/web/WebConflictPanel.ts index df96acc..c102f06 100644 --- a/src/web/WebConflictPanel.ts +++ b/src/web/WebConflictPanel.ts @@ -92,7 +92,8 @@ export class WebConflictPanel { void server.openSession( this._sessionId, '', // No HTML content needed - server generates shell - (message: unknown) => this._handleMessage(message) + (message: unknown) => this._handleMessage(message), + this._conflict?.theme ?? 'light' ).then(() => { // Send conflict data to browser once connected this._sendConflictData(); @@ -120,6 +121,7 @@ export class WebConflictPanel { hideNonConflictOutputs: this._conflict.hideNonConflictOutputs, enableUndoRedoHotkeys: this._conflict.enableUndoRedoHotkeys, showBaseColumn: this._conflict.showBaseColumn, + theme: this._conflict.theme, currentBranch: this._conflict.semanticConflict?.currentBranch, incomingBranch: this._conflict.semanticConflict?.incomingBranch, }; diff --git a/src/web/client/App.tsx b/src/web/client/App.tsx index 96e33df..1078d64 100644 --- a/src/web/client/App.tsx +++ b/src/web/client/App.tsx @@ -3,14 +3,22 @@ * @description Root React component for the conflict resolver. */ -import React from 'react'; +import React, { useEffect } from 'react'; import { useWebSocket } from './useWebSocket'; import { ConflictResolver } from './ConflictResolver'; +import { injectStyles } from './styles'; import type { ConflictChoice, ResolvedRow } from './types'; export function App(): React.ReactElement { const { connected, conflictData, sendMessage, resolutionStatus, resolutionMessage } = useWebSocket(); + // Inject theme styles when conflict data loads + useEffect(() => { + if (conflictData?.theme) { + injectStyles(conflictData.theme); + } + }, [conflictData?.theme]); + const handleResolve = (resolutions: ConflictChoice[], markAsResolved: boolean, renumberExecutionCounts: boolean, resolvedRows: ResolvedRow[]) => { sendMessage({ command: 'resolve', diff --git a/src/web/client/ConflictResolver.tsx b/src/web/client/ConflictResolver.tsx index feace59..4c2bafa 100644 --- a/src/web/client/ConflictResolver.tsx +++ b/src/web/client/ConflictResolver.tsx @@ -541,7 +541,14 @@ export function ConflictResolver({
- MergeNB +
+
+
+
+
+ Merge + NB +
{fileName}
diff --git a/src/web/client/fonts/dm-sans-700-latin.woff2 b/src/web/client/fonts/dm-sans-700-latin.woff2 new file mode 100644 index 0000000..26edc56 Binary files /dev/null and b/src/web/client/fonts/dm-sans-700-latin.woff2 differ diff --git a/src/web/client/fonts/playfair-display-italic-500-latin.woff2 b/src/web/client/fonts/playfair-display-italic-500-latin.woff2 new file mode 100644 index 0000000..8e3e680 Binary files /dev/null and b/src/web/client/fonts/playfair-display-italic-500-latin.woff2 differ diff --git a/src/web/client/index.tsx b/src/web/client/index.tsx index fe375f4..30e09c4 100644 --- a/src/web/client/index.tsx +++ b/src/web/client/index.tsx @@ -8,8 +8,18 @@ import { createRoot } from 'react-dom/client'; import { App } from './App'; import { injectStyles } from './styles'; -// Inject styles into the document -injectStyles(); +declare global { + interface Window { + __MERGENB_INITIAL_THEME?: 'dark' | 'light'; + } +} + +// Use server-provided theme first so loading and app boot with the same palette. +const initialTheme = + window.__MERGENB_INITIAL_THEME ?? + (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); + +injectStyles(initialTheme); // Mount the React app const container = document.getElementById('root'); diff --git a/src/web/client/styles.ts b/src/web/client/styles.ts index 2be9eb6..fce2bf7 100644 --- a/src/web/client/styles.ts +++ b/src/web/client/styles.ts @@ -3,27 +3,128 @@ * @description Shared styles for the conflict resolver UI. */ -export const styles = ` +export function getStyles(theme: 'dark' | 'light' = 'light'): string { + const isDark = theme === 'dark'; + + // Checkered background gradients + const DARK_GRID_GRADIENT = `linear-gradient(to right, rgba(255,255,255,0.04) 1px, transparent 1px), + linear-gradient(to bottom, rgba(255,255,255,0.04) 1px, transparent 1px)`; + + // Checkered background gradient for light theme + const LIGHT_GRID_GRADIENT = `linear-gradient(to right, rgba(0,0,0,0.05) 1px, transparent 1px), + linear-gradient(to bottom, rgba(0,0,0,0.05) 1px, transparent 1px)`; + + // Color palette based on theme + const colors = isDark ? { + bgPrimary: '#25201b', + bgSecondary: '#2c2620', + bgTertiary: '#373027', + bgQuarternary: '#373027', + borderColor: 'rgba(255, 255, 255, 0.12)', + textPrimary: '#EFE7DB', + textSecondary: '#B5A998', + accentBlue: '#7FB9C7', + accentGreen: '#9FA8DD', + currentBg: 'rgba(127, 185, 199, 0.24)', + currentBorder: '#7FB9C7', + currentRgb: '127, 185, 199', + incomingBg: 'rgba(159, 168, 221, 0.24)', + incomingBorder: '#9FA8DD', + incomingRgb: '159, 168, 221', + baseBg: 'rgba(181, 169, 152, 0.18)', + baseBorder: '#B5A998', + diffAdd: 'rgba(159, 168, 221, 0.28)', + diffRemove: 'rgba(220, 130, 115, 0.3)', + diffChange: 'rgba(224, 180, 82, 0.28)', + cellSurface: 'rgba(55, 48, 39, 0.82)', + cellSurfaceSoft: 'rgba(55, 48, 39, 0.64)', + cellPlaceholderBg: 'rgba(55, 48, 39, 0.54)', + outputBg: 'rgba(55, 48, 39, 0.72)', + bodyBackground: '#1D1915', + bodyBackgroundImage: DARK_GRID_GRADIENT, + logoLeft: '#7FB9C7', + logoRight: '#9FA8DD', + logoBlendMode: 'normal', + } : { + // LIGHT theme - inspired by MergeNB logo + bgPrimary: '#f1ece3', + bgSecondary: '#ebe3d8', + bgTertiary: '#e2d8ca', + bgQuarternary: '#ebe3d8b7', + borderColor: 'rgba(0, 0, 0, 0.1)', + textPrimary: '#1A202C', + textSecondary: '#6B7280', + accentBlue: '#569cd6', + accentGreen: '#4ec9b0', + currentBg: 'rgba(164, 212, 222, 0.25)', + currentBorder: '#A4D4DE', + currentRgb: '164, 212, 222', + incomingBg: 'rgba(159, 168, 221, 0.30)', + incomingBorder: '#9FA8DD', + incomingRgb: '159, 168, 221', + baseBg: 'rgba(128, 128, 128, 0.18)', + baseBorder: '#8b7f70', + diffAdd: 'rgba(195, 201, 242, 0.4)', + diffRemove: 'rgba(244, 135, 113, 0.35)', + diffChange: 'rgba(255, 193, 7, 0.35)', + cellSurface: 'rgba(226, 216, 202, 0.78)', + cellSurfaceSoft: 'rgba(226, 216, 202, 0.62)', + cellPlaceholderBg: 'rgba(226, 216, 202, 0.48)', + outputBg: 'rgba(226, 216, 202, 0.66)', + bodyBackground: '#EAE2D5', + bodyBackgroundImage: LIGHT_GRID_GRADIENT, + logoLeft: '#A4D4DE', + logoRight: '#C3C9F2', + logoBlendMode: 'multiply', + }; + + const hasBackgroundImage = colors.bodyBackgroundImage !== 'none'; + + return ` +@font-face { + font-family: 'DM Sans'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('/fonts/dm-sans-700-latin.woff2') format('woff2'); +} + +@font-face { + font-family: 'Playfair Display'; + font-style: italic; + font-weight: 500; + font-display: swap; + src: url('/fonts/playfair-display-italic-500-latin.woff2') format('woff2'); +} + :root { - --bg-primary: #1e1e1e; - --bg-secondary: #252526; - --bg-tertiary: #2d2d2d; - --border-color: #3c3c3c; - --text-primary: #f3f3f3; - --text-secondary: #808080; - --accent-blue: #007acc; - --accent-green: #4ec9b0; - --current-bg: rgba(64, 164, 223, 0.15); - --current-border: #40a4df; - --current-rgb: 64, 164, 223; - --incoming-bg: rgba(78, 201, 176, 0.15); - --incoming-border: #4ec9b0; - --incoming-rgb: 78, 201, 176; - --base-bg: rgba(128, 128, 128, 0.15); - --base-border: #808080; - --diff-add: rgba(78, 201, 176, 0.3); - --diff-remove: rgba(244, 135, 113, 0.3); - --diff-change: rgba(255, 213, 79, 0.3); + --bg-primary: ${colors.bgPrimary}; + --bg-secondary: ${colors.bgSecondary}; + --bg-tertiary: ${colors.bgTertiary}; + --bg-quarternary: ${colors.bgQuarternary}; + --border-color: ${colors.borderColor}; + --text-primary: ${colors.textPrimary}; + --text-secondary: ${colors.textSecondary}; + --accent-blue: ${colors.accentBlue}; + --accent-green: ${colors.accentGreen}; + --current-bg: ${colors.currentBg}; + --current-border: ${colors.currentBorder}; + --current-rgb: ${colors.currentRgb}; + --incoming-bg: ${colors.incomingBg}; + --incoming-border: ${colors.incomingBorder}; + --incoming-rgb: ${colors.incomingRgb}; + --base-bg: ${colors.baseBg}; + --base-border: ${colors.baseBorder}; + --diff-add: ${colors.diffAdd}; + --diff-remove: ${colors.diffRemove}; + --diff-change: ${colors.diffChange}; + --cell-surface: ${colors.cellSurface}; + --cell-surface-soft: ${colors.cellSurfaceSoft}; + --cell-placeholder-bg: ${colors.cellPlaceholderBg}; + --output-bg: ${colors.outputBg}; + --logo-left: ${colors.logoLeft}; + --logo-right: ${colors.logoRight}; + --logo-blend-mode: ${colors.logoBlendMode}; } * { @@ -34,7 +135,9 @@ export const styles = ` body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - background: var(--bg-primary); + background: ${colors.bodyBackground}; + ${hasBackgroundImage ? `background-image: ${colors.bodyBackgroundImage};` : ''} + ${hasBackgroundImage ? 'background-size: 20px 20px;' : ''} color: var(--text-primary); line-height: 1.5; } @@ -64,9 +167,60 @@ body { gap: 16px; } +/* Logo icon */ +.logo-icon { + position: relative; + width: 60px; + height: 40px; + display: flex; + justify-content: center; + align-items: center; +} + +.logo-card { + position: absolute; + width: 30px; + height: 36px; + border-radius: 6px; + mix-blend-mode: var(--logo-blend-mode); + opacity: 0.9; + transform-origin: 50% 90%; + top: 0; +} + +.logo-card-left { + background-color: var(--logo-left); + left: 6px; + transform: rotate(-25deg); +} + +.logo-card-right { + background-color: var(--logo-right); + right: 6px; + transform: rotate(25deg); +} + .header-title { - font-size: 14px; - font-weight: 600; + display: flex; + align-items: baseline; + gap: 0; + color: var(--text-primary); + line-height: 1; + letter-spacing: -0.03em; +} + +.header-title-merge { + font-family: 'Playfair Display', serif; + font-style: italic; + font-weight: 500; + font-size: 24px; + letter-spacing: -0.02em; +} + +.header-title-nb { + font-family: 'DM Sans', sans-serif; + font-weight: 700; + font-size: 24px; } .file-path { @@ -227,7 +381,7 @@ body { margin-bottom: 12px; position: sticky; top: 0; - background: var(--bg-primary); + background: transparent; padding: 8px 0; z-index: 50; } @@ -246,9 +400,9 @@ body { border-radius: 4px; } -.column-label.base { background: var(--base-bg); color: var(--base-border); } -.column-label.current { background: var(--current-bg); color: var(--current-border); } -.column-label.incoming { background: var(--incoming-bg); color: var(--incoming-border); } +.column-label.base { background: var(--base-bg); color: var(--text-primary); } +.column-label.current { background: var(--current-bg); color: var(--text-primary); } +.column-label.incoming { background: var(--incoming-bg); color: var(--text-primary); } /* Merge rows */ .merge-row { @@ -258,20 +412,26 @@ body { position: relative; } +/* Conflict row - consolidated styling: subtle red background with + a consistent 3px border on top/right/bottom and a 4px left accent */ .merge-row.conflict-row { - border: 2px solid var(--border-color); - background: var(--bg-secondary); + background: rgba(244, 135, 113, 0.05); + border-top: 3px solid rgba(244, 135, 113, 0.6); + border-right: 3px solid rgba(244, 135, 113, 0.6); + border-bottom: 3px solid rgba(244, 135, 113, 0.6); + border-left: 4px solid #f48771; + border-radius: 6px; } .merge-row.identical-row { - opacity: 0.7; + /* No opacity reduction - keep text readable */ } .cell-columns { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1px; - background: var(--border-color); + background: transparent; } .cell-columns.two-column { @@ -279,7 +439,7 @@ body { } .cell-column { - background: var(--bg-primary); + background: transparent; padding: 12px; min-height: 60px; display: flex; @@ -317,7 +477,7 @@ body { .cell-content pre { margin: 0; padding: 12px; - background: var(--bg-tertiary); + background: var(--cell-surface); border-radius: 4px; overflow-x: auto; white-space: pre-wrap; @@ -330,7 +490,7 @@ body { .markdown-cell .cell-content { padding: 12px; - background: var(--bg-tertiary); + background: var(--cell-surface); border-radius: 4px; border-left: 3px solid var(--accent-green); } @@ -344,7 +504,7 @@ body { color: var(--text-primary); font-style: italic; font-size: 12px; - background: var(--bg-tertiary); + background: var(--cell-placeholder-bg); border-radius: 4px; border: 1px dashed var(--border-color); } @@ -357,7 +517,7 @@ body { .metadata-cell pre { margin: 0; padding: 12px; - background: var(--bg-tertiary); + background: var(--cell-surface); border-radius: 4px; overflow-x: auto; white-space: pre-wrap; @@ -372,7 +532,7 @@ body { justify-content: center; gap: 8px; padding: 12px; - background: var(--bg-secondary); + background: var(--bg-quarternary); border-top: 1px solid var(--border-color); } @@ -461,7 +621,7 @@ body { .cell-outputs { margin-top: 8px; padding: 8px; - background: var(--bg-tertiary); + background: var(--output-bg); border-radius: 4px; font-size: 12px; } @@ -475,7 +635,7 @@ body { color: var(--text-secondary); font-style: italic; padding: 8px; - background: var(--bg-secondary); + background: var(--cell-surface-soft); border: 1px dashed var(--border-color); border-radius: 4px; font-family: "SF Mono", Monaco, "Cascadia Code", "Courier New", monospace; @@ -657,7 +817,7 @@ body { width: 100%; min-height: 200px; padding: 12px; - background: var(--bg-primary); + background: var(--cell-surface); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 4px; @@ -670,7 +830,7 @@ body { .resolved-cell { margin-top: 12px; padding: 12px; - background: rgba(78, 201, 176, 0.1); + background: var(--cell-surface); border: 2px solid var(--accent-green); border-radius: 6px; } @@ -714,7 +874,7 @@ body { width: 100%; min-height: 120px; padding: 12px; - background: var(--bg-primary); + background: var(--cell-surface); color: var(--text-primary); border: 1px solid rgba(78, 201, 176, 0.4); border-radius: 4px; @@ -896,12 +1056,8 @@ body { cursor: grabbing; } -/* Conflict row - red border for actual conflicts */ -.merge-row.conflict-row { - background: rgba(244, 135, 113, 0.05); - border-left: 4px solid #f48771; - border-radius: 4px; -} +/* Conflict row - red border for actual conflicts + (consolidated rule moved earlier to avoid duplicate definitions) */ /* Unmatched row - yellow border for cells that couldn't be matched */ .merge-row.unmatched-row { @@ -956,7 +1112,7 @@ body { font-style: italic; border: 2px dashed var(--border-color); border-radius: 4px; - background: rgba(128, 128, 128, 0.05); + background: var(--cell-placeholder-bg); } .cell-placeholder.drop-target { @@ -1022,15 +1178,24 @@ body { color: var(--text-primary); } `; +} -export function injectStyles(): void { +export function injectStyles(theme: 'dark' | 'light' = 'light'): void { if (typeof document !== 'undefined') { const existing = document.getElementById('mergenb-styles'); - if (existing) return; + if (existing) { + existing.textContent = getStyles(theme); + return; + } const style = document.createElement('style'); style.id = 'mergenb-styles'; - style.textContent = styles; + style.textContent = getStyles(theme); document.head.appendChild(style); } } + +// Keep backward compatibility +// Do not generate a default light stylesheet at module import time — +// this prevents a light-theme flash on first render when the app wants dark. +export const styles = ''; diff --git a/src/web/client/types.ts b/src/web/client/types.ts index ef3071d..e53cb2f 100644 --- a/src/web/client/types.ts +++ b/src/web/client/types.ts @@ -56,6 +56,7 @@ export interface UnifiedConflictData { incomingBranch?: string; enableUndoRedoHotkeys?: boolean; showBaseColumn?: boolean; + theme?: 'dark' | 'light'; } /** diff --git a/src/web/webServer.ts b/src/web/webServer.ts index b22dfa4..842a036 100644 --- a/src/web/webServer.ts +++ b/src/web/webServer.ts @@ -47,6 +47,7 @@ export interface PendingConnection { /** Session data stored for each active conflict resolution */ export interface SessionData { htmlContent: string; + theme: 'dark' | 'light'; conflictData?: unknown; onMessage: (message: unknown) => void; } @@ -224,11 +225,13 @@ export class ConflictResolverWebServer { public async openSession( sessionId: string, htmlContent: string, - onMessage: (message: unknown) => void + onMessage: (message: unknown) => void, + theme: 'dark' | 'light' = 'light' ): Promise { // Store session data this.sessions.set(sessionId, { htmlContent, + theme, onMessage }); @@ -329,7 +332,7 @@ export class ConflictResolverWebServer { if (session) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(this.getHtmlShell(sessionId || 'default')); + res.end(this.getHtmlShell(sessionId || 'default', session.theme)); } else { res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(` @@ -352,6 +355,9 @@ export class ConflictResolverWebServer { } else if (pathname === '/client.js' || pathname === '/client.js.map') { // Serve bundled React app from dist/web/ this.serveStaticFile(res, pathname); + } else if (pathname.startsWith('/fonts/')) { + // Serve bundled web fonts from dist/web/fonts/ + this.serveStaticFile(res, pathname); } else if (pathname.startsWith('/katex/')) { // Serve KaTeX files from dist/web/katex/ this.serveStaticFile(res, pathname); @@ -431,7 +437,13 @@ export class ConflictResolverWebServer { /** * Generate minimal HTML shell that loads the React app. */ - private getHtmlShell(sessionId: string): string { + private getHtmlShell(sessionId: string, theme: 'dark' | 'light' = 'light'): string { + const isDark = theme === 'dark'; + const loadingBg = isDark ? '#1D1915' : '#EAE2D5'; + const loadingText = isDark ? '#EFE7DB' : '#1A202C'; + const spinnerBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.1)'; + const spinnerAccent = isDark ? '#7FB9C7' : '#569cd6'; + return ` @@ -440,7 +452,7 @@ export class ConflictResolverWebServer { MergeNB - Conflict Resolver