diff --git a/e2e/sharing.spec.mjs b/e2e/sharing.spec.mjs index 9c0c27f..88a12aa 100644 --- a/e2e/sharing.spec.mjs +++ b/e2e/sharing.spec.mjs @@ -108,7 +108,11 @@ test('two peers exchange keys, share a sealed diff, and import it', async () => await mgr.locator('input.rename').fill('Bob — laptop') await mgr.locator('input.rename').press('Enter') await expect(mgr.getByText('Bob — laptop')).toBeVisible() + // Removal is guarded by a confirmation dialog. await mgr.getByTitle('Remove').click() + const confirm = pageA.getByRole('dialog', { name: 'Remove trusted key?' }) + await expect(confirm).toBeVisible() + await confirm.getByRole('button', { name: 'Remove' }).click() await expect(mgr.getByText(/No trusted keys yet/i)).toBeVisible() } finally { await appA.close() diff --git a/e2e/trusted-keys.spec.mjs b/e2e/trusted-keys.spec.mjs index 0355ee9..1f6a95d 100644 --- a/e2e/trusted-keys.spec.mjs +++ b/e2e/trusted-keys.spec.mjs @@ -51,11 +51,18 @@ test('the trusted-keys manager stays stable with 15 keys', async () => { await expect(mgr.getByText('Teammate 08 — laptop')).toBeVisible() await expect(mgr.locator('li.key')).toHaveCount(N) - // Remove two different rows: the count drops, the removed ones vanish, the - // rest are untouched. + // Remove two different rows (each behind a confirmation): the count drops, + // the removed ones vanish, the rest are untouched. + const confirmRemove = () => + page + .getByRole('dialog', { name: 'Remove trusted key?' }) + .getByRole('button', { name: 'Remove' }) + .click() await mgr.locator('li.key', { hasText: 'Teammate 03' }).getByTitle('Remove').click() + await confirmRemove() await expect(mgr.locator('li.key')).toHaveCount(N - 1) await mgr.locator('li.key', { hasText: 'Teammate 12' }).getByTitle('Remove').click() + await confirmRemove() await expect(mgr.locator('li.key')).toHaveCount(N - 2) await expect(mgr.getByText('Teammate 03', { exact: true })).toBeHidden() await expect(mgr.getByText('Teammate 12', { exact: true })).toBeHidden() diff --git a/electron-builder.yml b/electron-builder.yml index 3b78ba4..a9d96cd 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -108,3 +108,15 @@ dmg: nsis: oneClick: false allowToChangeInstallationDirectory: true + # Assisted-installer branding. Set explicitly rather than relying on + # buildResources auto-detection, for the same reason dmg.background is (a + # silent fallback to NSIS's stock art ships a wrong-looking installer). Both + # are 24-bit BMPs rendered from the SVGs in resources/ — regenerate with + # `npm run render:brand` (scripts/render-brand-assets.mjs), never hand-edit + # the .bmp (it also re-renders icon.png from logo.svg). installerHeader + # (150x57) rides the inner-page header; installerSidebar (164x314) is the + # welcome/finish panel. The installer/uninstaller and header icons default to + # the app icon (icon.png), which is correct — no need to restate them. + installerHeader: resources/installerHeader.bmp + installerSidebar: resources/installerSidebar.bmp + uninstallerSidebar: resources/installerSidebar.bmp diff --git a/package.json b/package.json index e7714a0..2e20deb 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "build:linux": "npm run build && electron-builder --linux --publish never", "docker:up": "docker compose up --build", "docker:down": "docker compose down", - "render:dmg-bg": "electron scripts/render-dmg-background.mjs" + "render:dmg-bg": "electron scripts/render-dmg-background.mjs", + "render:brand": "electron scripts/render-brand-assets.mjs" }, "dependencies": { "chardet": "^2.0.0", diff --git a/resources/background.png b/resources/background.png index 85d0b7b..3de37b2 100644 Binary files a/resources/background.png and b/resources/background.png differ diff --git a/resources/background@2x.png b/resources/background@2x.png index 8ef050e..43f9204 100644 Binary files a/resources/background@2x.png and b/resources/background@2x.png differ diff --git a/resources/dmg-background.svg b/resources/dmg-background.svg index 35e5365..c1d8c43 100644 --- a/resources/dmg-background.svg +++ b/resources/dmg-background.svg @@ -3,17 +3,14 @@ 1. LIGHT, not dark. The DMG window renders in the light appearance even when the user's system is in dark mode, so Finder draws the two icon captions ("Diff Bro" and "Applications") in black. A dark backdrop - makes them unreadable. + makes them unreadable. The brand's warmth therefore comes from the + app icon's own dark tile and the amber drag-arrow — the field itself + stays a light warm paper so the captions read. 2. The canvas is 1600x1000 but the LAYOUT lives in the top-left 640x400, which is the window size set in electron-builder.yml. Finder draws this - image at natural size anchored top-left and does not scale it, so a - user resizing the window past 640x400 would otherwise expose the bare - white window background. The extra canvas is filled pure white and the - artwork fades to pure white before the 640x400 edge, so the seam - between image and window background is invisible at any window size. - (A Finder window cannot be made non-resizable — there is no such - property in the .DS_Store or the AppleScript Finder dictionary — so - making the resize harmless is the available fix.) + image at natural size anchored top-left and does not scale it, so the + artwork fades to pure white before the 640x400 edge — a user resizing + the window past 640x400 only ever exposes more white, never a seam. Never edit background.png / background@2x.png directly: run `npm run render:dmg-bg` (scripts/render-dmg-background.mjs), which @@ -22,58 +19,56 @@ below (centres 180,208 and 460,208). --> - - - - + + + + - - + + - + - - - - - - + + + + + - Diff Bro - Install Diff Bro + Drag the app into Applications to install + font-size="13.5">Drag the app into your Applications folder - - + + - + - Offline only · nothing leaves this machine diff --git a/resources/icon.png b/resources/icon.png index 7dd9fb8..b445c03 100644 Binary files a/resources/icon.png and b/resources/icon.png differ diff --git a/resources/installer-header.svg b/resources/installer-header.svg new file mode 100644 index 0000000..162b125 --- /dev/null +++ b/resources/installer-header.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/installer-sidebar.svg b/resources/installer-sidebar.svg new file mode 100644 index 0000000..b2f97e5 --- /dev/null +++ b/resources/installer-sidebar.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + DIFF BRO + + + + Offline diff viewer + + NOTHING LEAVES THIS MACHINE + diff --git a/resources/installerHeader.bmp b/resources/installerHeader.bmp new file mode 100644 index 0000000..71df818 Binary files /dev/null and b/resources/installerHeader.bmp differ diff --git a/resources/installerSidebar.bmp b/resources/installerSidebar.bmp new file mode 100644 index 0000000..2aa767f Binary files /dev/null and b/resources/installerSidebar.bmp differ diff --git a/resources/logo.svg b/resources/logo.svg index fa73328..767aa3b 100644 --- a/resources/logo.svg +++ b/resources/logo.svg @@ -1,43 +1,29 @@ - + - - - + + + + + + + - - - - - - - - - - - - - + + + - - - - - - + + + + + - - - - - - - - diff --git a/scripts/check-theme-depth.mjs b/scripts/check-theme-depth.mjs index 85c9763..6d10d23 100644 --- a/scripts/check-theme-depth.mjs +++ b/scripts/check-theme-depth.mjs @@ -56,7 +56,22 @@ const tokenBlocks = blocks(tokensCss) const themeBlocks = blocks(themesCss) const structuralDefaults = tokenBlocks[':root'] || {} -const THEMES = ['light', 'dark', 'solar', 'neon', 'nord', 'sepia', 'nyan', 'matrix', 'contrast'] +const THEMES = [ + 'light', + 'dark', + 'solar', + 'neon', + 'nord', + 'sepia', + 'dim', + 'beacon', + 'meridian', + 'linen', + 'bloom', + 'nyan', + 'matrix', + 'contrast' +] // Effective token map for a theme: structural defaults (tokens.css) < the light // palette on the bare :root < the theme's own overrides. Light additionally diff --git a/scripts/render-brand-assets.mjs b/scripts/render-brand-assets.mjs new file mode 100644 index 0000000..ef302ca --- /dev/null +++ b/scripts/render-brand-assets.mjs @@ -0,0 +1,113 @@ +// Rasterize the SVG brand sources in resources/ to the binaries the packagers +// consume: +// +// npm run render:brand +// +// logo.svg -> icon.png (1024, macOS/Win/Linux app icon) +// installer-sidebar.svg -> installerSidebar.bmp (164x314, NSIS welcome panel) +// installer-header.svg -> installerHeader.bmp (150x57, NSIS inner header) +// +// Run this after editing any of those SVGs. The binaries are what actually +// ship, so leaving them stale silently reverts the artwork (the same trap the +// DMG backdrop documents). The DMG backdrop has its own renderer, +// scripts/render-dmg-background.mjs. +// +// Rendering goes through the Electron already in devDependencies — no added +// rasterizer (CLAUDE.md rule 2), same engine that draws the UI. BMP is encoded +// inline (24-bit, bottom-up) because NSIS needs BMP and there is no dependency +// that would produce one. If this exits with "Cannot read properties of +// undefined (reading 'whenReady')", ELECTRON_RUN_AS_NODE=1 is set — run +// `env -u ELECTRON_RUN_AS_NODE npm run render:brand`. +import { readFileSync, writeFileSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { createRequire } from 'module' +import { fileURLToPath, pathToFileURL } from 'url' +import { dirname, join } from 'path' + +const { app, BrowserWindow } = createRequire(import.meta.url)('electron') +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const res = (f) => join(root, 'resources', f) + +const JOBS = [ + { src: 'logo.svg', w: 1024, h: 1024, out: 'icon.png', format: 'png' }, + { src: 'installer-sidebar.svg', w: 164, h: 314, out: 'installerSidebar.bmp', format: 'bmp' }, + { src: 'installer-header.svg', w: 150, h: 57, out: 'installerHeader.bmp', format: 'bmp' } +] + +// A 24-bit BGR, bottom-up BMP from Electron's top-down BGRA bitmap buffer. +function encodeBmp(bgra, w, h) { + const rowSize = (w * 3 + 3) & ~3 + const pad = rowSize - w * 3 + const pixels = rowSize * h + const buf = Buffer.alloc(54 + pixels) + buf.write('BM', 0) + buf.writeUInt32LE(54 + pixels, 2) + buf.writeUInt32LE(54, 10) + buf.writeUInt32LE(40, 14) + buf.writeInt32LE(w, 18) + buf.writeInt32LE(h, 22) + buf.writeUInt16LE(1, 26) + buf.writeUInt16LE(24, 28) + buf.writeUInt32LE(pixels, 34) + buf.writeInt32LE(2835, 38) + buf.writeInt32LE(2835, 42) + let off = 54 + for (let sy = h - 1; sy >= 0; sy--) { + for (let x = 0; x < w; x++) { + const si = (sy * w + x) * 4 + buf[off++] = bgra[si] + buf[off++] = bgra[si + 1] + buf[off++] = bgra[si + 2] + } + for (let p = 0; p < pad; p++) buf[off++] = 0 + } + return buf +} + +async function render(win, { src, w, h, out, format }) { + // The source SVG carries its own intrinsic width/height; override them so it + // scales to the target (icon.png is 1024 from a 256 master). Loaded from a + // file:// URL — a data: URL is treated as an image document Chromium aborts + // before capturePage can read it, and a comment in the XML prolog fails the + // same loader, so strip comments too. + const svg = readFileSync(res(src), 'utf8') + .replace(//g, '') + .replace(/(]*?)\swidth="[^"]*"/, `$1 width="${w}"`) + .replace(/(]*?)\sheight="[^"]*"/, `$1 height="${h}"`) + const tmp = join(tmpdir(), `diffbro-brand-${process.pid}-${out}.svg`) + writeFileSync(tmp, svg) + try { + win.setContentSize(w, h) + await win.loadURL(pathToFileURL(tmp).href) + await new Promise((r) => setTimeout(r, 450)) + let image = await win.webContents.capturePage({ x: 0, y: 0, width: w, height: h }) + if (image.getSize().width !== w) image = image.resize({ width: w, height: h, quality: 'best' }) + writeFileSync(res(out), format === 'bmp' ? encodeBmp(image.toBitmap(), w, h) : image.toPNG()) + process.stdout.write(`wrote resources/${out} (${w}x${h})\n`) + } finally { + rmSync(tmp, { force: true }) + } +} + +app.whenReady().then(async () => { + // One reusable offscreen window: creating a second one in the same process + // after destroying the first tears down the offscreen surface and the next + // load fails, so resize this one per job instead. + const win = new BrowserWindow({ + width: 1024, + height: 1024, + show: false, + frame: false, + transparent: true, + backgroundColor: '#00000000', + webPreferences: { javascript: false, offscreen: true } + }) + try { + for (const job of JOBS) await render(win, job) + } catch (e) { + console.error(e) + process.exitCode = 1 + } + win.destroy() + app.quit() +}) diff --git a/src/main/quickLook.js b/src/main/quickLook.js index 0b28705..29d9169 100644 --- a/src/main/quickLook.js +++ b/src/main/quickLook.js @@ -107,6 +107,16 @@ export function toggleQuickLook() { else reveal() } +// Global-shortcut entry point only (menu/IPC toggle unconditionally): skip +// revealing when you're already in the app, e.g. capturing a new shortcut in +// Settings — the keypress would otherwise pop the launcher over the field. +function onShortcut() { + const w = ensure() + const main = BrowserWindow.getAllWindows().find((x) => x !== w) + if (!w.isVisible() && main?.isFocused()) return + toggleQuickLook() +} + // Hand a chosen result to the main window on its own channel (menu:action stays // a plain-string channel, so the id travels separately). function openInMain(payload) { @@ -127,7 +137,7 @@ function registerShortcut(accel) { currentAccelerator = null } try { - if (globalShortcut.register(accel, toggleQuickLook)) { + if (globalShortcut.register(accel, onShortcut)) { currentAccelerator = accel return { ok: true } } diff --git a/src/renderer/src/App.vue b/src/renderer/src/App.vue index e693e29..2238fef 100644 --- a/src/renderer/src/App.vue +++ b/src/renderer/src/App.vue @@ -75,12 +75,16 @@ const { - -
+ +
+ diff --git a/src/renderer/src/components/MatrixRain.vue b/src/renderer/src/components/MatrixRain.vue index 61d0083..faa4c2c 100644 --- a/src/renderer/src/components/MatrixRain.vue +++ b/src/renderer/src/components/MatrixRain.vue @@ -1,46 +1,14 @@ diff --git a/src/renderer/src/components/RemoveTrustedKeyDialog.vue b/src/renderer/src/components/RemoveTrustedKeyDialog.vue new file mode 100644 index 0000000..67d7e09 --- /dev/null +++ b/src/renderer/src/components/RemoveTrustedKeyDialog.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/src/renderer/src/components/TrustedKeysDialog.vue b/src/renderer/src/components/TrustedKeysDialog.vue index 7c3baa3..859a42f 100644 --- a/src/renderer/src/components/TrustedKeysDialog.vue +++ b/src/renderer/src/components/TrustedKeysDialog.vue @@ -14,13 +14,10 @@ async function refresh() { } onMounted(refresh) -// Re-fetch after the "name this key" dialog closes. -watch( - () => diff.pendingTrustedKey, - (v) => { - if (!v) refresh() - } -) +// Re-fetch after the "name this key" or "remove key?" dialog closes. +watch([() => diff.pendingTrustedKey, () => diff.pendingUntrust], ([a, b]) => { + if (!a && !b) refresh() +}) function startRename(k) { editingFp.value = k.fingerprint @@ -34,11 +31,6 @@ async function commitRename() { await refresh() } } -async function remove(k) { - if (diff.lastAddedTrustedFp === k.fingerprint) diff.lastAddedTrustedFp = null - await window.api.removeTrusted(k.fingerprint) - await refresh() -} function addKey() { // Opens the OS file picker, then the naming dialog on top of this one. diff.addTrustedKey() @@ -92,7 +84,9 @@ function close() { - + diff --git a/src/renderer/src/components/styles/App.css b/src/renderer/src/components/styles/App.css index 001ea62..fa961ae 100644 --- a/src/renderer/src/components/styles/App.css +++ b/src/renderer/src/components/styles/App.css @@ -35,8 +35,11 @@ min-height: 0; display: flex; flex-direction: column; - /* Anchors the floating ShortcutBar to the diff area, not the window. */ + /* Anchors the floating ShortcutBar to the diff area, not the window. Isolation + keeps the Matrix theme's z-index:-1 rain layer behind the content, not the + whole app. */ position: relative; + isolation: isolate; } .empty { height: 100%; diff --git a/src/renderer/src/components/styles/MatrixRain.css b/src/renderer/src/components/styles/MatrixRain.css index 497581d..090ae18 100644 --- a/src/renderer/src/components/styles/MatrixRain.css +++ b/src/renderer/src/components/styles/MatrixRain.css @@ -1,56 +1,20 @@ -/* A flat black CRT track for the falling glyphs — same height as the Nyan lane - so the chrome doesn't jump when switching themes. */ +/* CRT rain lane under the toolbar — tall enough for the cascade to actually + read (the 26px Nyan-lane height was too short for it). */ .matrix-rain { - position: relative; - height: 26px; + display: block; + width: 100%; + height: 48px; flex: none; - overflow: hidden; background: var(--bg); border-bottom: 1px solid var(--border); } -/* Each column is a short vertical stack of glyphs that translates downward on a - loop (composited transform, like the cat's glide). The mask fades the trailing - (upper) glyphs out so only the leading head stays bright. */ -.mr-col { +/* Full-area background behind the empty state / diff area (no lane chrome). */ +.matrix-rain.fill { position: absolute; - top: 0; - display: flex; - flex-direction: column; - line-height: 1; - will-change: transform; - animation-name: mr-fall; - animation-timing-function: linear; - animation-iteration-count: infinite; - -webkit-mask-image: linear-gradient(180deg, transparent, #000 70%); - mask-image: linear-gradient(180deg, transparent, #000 70%); -} -@keyframes mr-fall { - from { - transform: translateY(-120%); - } - to { - transform: translateY(120%); - } -} -.mr-ch { - font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; - font-size: var(--font-sm); - color: var(--accent); - opacity: 0.75; -} -/* The leading glyph is brightest, with a phosphor glow. */ -.mr-ch.head { - color: var(--text); - opacity: 1; - text-shadow: 0 0 6px var(--accent); -} - -/* Reduced motion: hold a static field of glyphs so the theme still reads as - "rain" without any movement. */ -@media (prefers-reduced-motion: reduce) { - .mr-col { - animation: none; - transform: translateY(0); - } + inset: 0; + width: 100%; + height: 100%; + z-index: -1; + border-bottom: none; } diff --git a/src/renderer/src/components/styles/RemoveTrustedKeyDialog.css b/src/renderer/src/components/styles/RemoveTrustedKeyDialog.css new file mode 100644 index 0000000..ec925c1 --- /dev/null +++ b/src/renderer/src/components/styles/RemoveTrustedKeyDialog.css @@ -0,0 +1,8 @@ +/* Opens on top of the trusted-keys manager, which is itself a dialog. */ +.dialog-backdrop { + z-index: 40; +} +code { + color: var(--text); + font-size: var(--font-xs); +} diff --git a/src/renderer/src/composables/useMatrixRain.js b/src/renderer/src/composables/useMatrixRain.js new file mode 100644 index 0000000..9cfb164 --- /dev/null +++ b/src/renderer/src/composables/useMatrixRain.js @@ -0,0 +1,116 @@ +import { onBeforeUnmount, onMounted } from 'vue' + +// Canvas "digital rain" for the Matrix theme. One glyph per cell as each column's +// head steps down at its own speed; a translucent overlay fades the trail in +// place. No katakana (those tofu on fonts that lack them). +const GLYPHS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ<>*+=/#' +const rnd = (n) => Math.floor(Math.random() * n) +const glyph = () => GLYPHS[rnd(GLYPHS.length)] + +function hexToRgba(hex, a) { + const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim()) + if (!m) return `rgba(2,10,4,${a})` + const n = parseInt(m[1], 16) + return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})` +} + +/** + * @param {import('vue').Ref} canvas + * @param {{ fill: boolean }} props + */ +export function useMatrixRain(canvas, props) { + const FONT = props.fill ? 16 : 11 + let ctx = null + let raf = null + let ro = null + let last = 0 + let cols = 0 + let w = 0 + let h = 0 + let ys = [] + let speeds = [] + let lastCell = [] + let headGlyph = [] + let green = '#00ff41' + let bright = '#c6ffd2' + let fade = 'rgba(2,10,4,0.05)' + + function palette() { + const s = getComputedStyle(document.documentElement) + green = s.getPropertyValue('--accent').trim() || green + bright = s.getPropertyValue('--text').trim() || bright + // Lower alpha = the trail lingers longer before fading to black. + fade = hexToRgba(s.getPropertyValue('--bg'), 0.035) + } + + function resize() { + const parent = canvas.value.parentElement + const dpr = window.devicePixelRatio || 1 + w = parent.clientWidth + h = parent.clientHeight + canvas.value.width = Math.max(1, Math.round(w * dpr)) + canvas.value.height = Math.max(1, Math.round(h * dpr)) + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.font = `${FONT}px Menlo, Consolas, monospace` + ctx.textBaseline = 'top' + const next = Math.max(1, Math.ceil(w / FONT)) + if (next === cols) return + cols = next + ys = Array.from({ length: cols }, () => rnd(Math.ceil(h / FONT) + 1)) + speeds = Array.from({ length: cols }, () => 4 + Math.random() * 7) + lastCell = Array.from({ length: cols }, () => -1) + headGlyph = Array.from({ length: cols }, () => '') + } + + function step(t) { + const dt = last ? Math.min(0.05, (t - last) / 1000) : 0 + last = t + ctx.fillStyle = fade + ctx.fillRect(0, 0, w, h) + const rows = Math.ceil(h / FONT) + for (let i = 0; i < cols; i++) { + ys[i] += speeds[i] * dt + const cell = Math.floor(ys[i]) + if (cell === lastCell[i]) continue + // The old head drops back to green; the new head is the bright leader. + if (lastCell[i] >= 0) { + ctx.fillStyle = green + ctx.fillText(headGlyph[i], i * FONT, lastCell[i] * FONT) + } + lastCell[i] = cell + headGlyph[i] = glyph() + ctx.fillStyle = bright + ctx.fillText(headGlyph[i], i * FONT, cell * FONT) + // Wrap well above the top so the column stays dark for a beat before the + // next stream falls in — that gap keeps the field from filling to a wall. + if (cell > rows && Math.random() > 0.9) ys[i] = -rnd(rows * 3) + } + raf = requestAnimationFrame(step) + } + + function drawStatic() { + ctx.fillStyle = fade.replace(/[\d.]+\)$/, '1)') + ctx.fillRect(0, 0, w, h) + ctx.fillStyle = green + for (let i = 0; i < cols; i++) + for (let y = 0; y < h; y += FONT) if (Math.random() < 0.5) ctx.fillText(glyph(), i * FONT, y) + } + + onMounted(() => { + ctx = canvas.value.getContext('2d') + palette() + resize() + const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches + ro = new ResizeObserver(() => { + resize() + if (reduce) drawStatic() + }) + ro.observe(canvas.value.parentElement) + if (reduce) drawStatic() + else raf = requestAnimationFrame(step) + }) + onBeforeUnmount(() => { + cancelAnimationFrame(raf) + ro?.disconnect() + }) +} diff --git a/src/renderer/src/stores/diffStore.js b/src/renderer/src/stores/diffStore.js index b107846..f2825ad 100644 --- a/src/renderer/src/stores/diffStore.js +++ b/src/renderer/src/stores/diffStore.js @@ -160,6 +160,8 @@ export const useDiffStore = defineStore('diff', { // entry id currently in the share dialog (null = closed) shareEntryId: null, pendingTrustedKey: null, // { key, fingerprint, label } while the name dialog is open + // { fingerprint, label } while the "remove this key?" confirmation is open. + pendingUntrust: null, // So the manager can highlight the just-added key; cleared when it closes. lastAddedTrustedFp: null, // Trusted-keys management dialog visibility. diff --git a/src/renderer/src/styles/themes.css b/src/renderer/src/styles/themes.css index 3ca226e..befde6a 100644 --- a/src/renderer/src/styles/themes.css +++ b/src/renderer/src/styles/themes.css @@ -183,6 +183,128 @@ --text-on-accent: #ffffff; } +/* Dim — the warm counterpart to the cool darks (Dark/Neon/Nord): a warm + graphite ground with soft warm-white ink and a single muted amber-gold + accent. Lower glare for long night sessions. */ +:root[data-theme='dim'] { + --bg: #1b1917; + --bg-panel: #232019; + --bg-hover: #2c2820; + --border: #3a352b; + --text: #ece5da; + --text-dim: #9a8f7f; + --text-hint: #cdbfab; + --accent: #d9a441; + --warning-bg: #e0a52e; + --warning-border: #b07d18; + --warning-text: #201700; + --danger-bg: #e0705f; + --danger-border: #c25445; + --danger-text: #2a1712; + --success-text: #9ccd78; + --scrim: rgba(10, 8, 4, 0.55); + --favorite: #e6b34d; + --text-on-accent: #201a10; +} + +/* Beacon — the dark sibling of Contrast: an accessibility-grade dark aimed at + WCAG-AAA. Pure-black ground, pure-white ink, saturated accent, bright hard + borders. */ +:root[data-theme='beacon'] { + --bg: #000000; + --bg-panel: #0b0b0b; + --bg-hover: #1a1a1a; + --border: #e0e0e0; + --text: #ffffff; + --text-dim: #cccccc; + --text-hint: #e6e6e6; + --accent: #4cc2ff; + --warning-bg: #ffd23f; + --warning-border: #b38f00; + --warning-text: #000000; + --danger-bg: #ff5b57; + --danger-border: #d63a36; + --danger-text: #000000; + --success-text: #3fe07a; + --scrim: rgba(0, 0, 0, 0.6); + --favorite: #ffd23f; + --text-on-accent: #000000; +} + +/* Meridian — a low-glare daytime coding light (the Solarized-Light lane): a + soft blue-grey off-white instead of pure white, teal ink, muted cyan accent. + Calmer than the crisp Light for hours of reading. */ +:root[data-theme='meridian'] { + --bg: #f5f7f4; + --bg-panel: #eef2ee; + --bg-hover: #e2e8e2; + --border: #c7d0c8; + --text: #263238; + --text-dim: #5c6b70; + --text-hint: #3d4a4f; + --accent: #0e8a8a; + --warning-bg: #e8a317; + --warning-border: #b3790a; + --warning-text: #2a1c00; + --danger-bg: #c0392b; + --danger-border: #9e2f24; + --danger-text: #ffffff; + --success-text: #217a38; + --scrim: rgba(20, 30, 30, 0.42); + --favorite: #b3780a; + --text-on-accent: #ffffff; + --shadow-rgb: 20 40 45; +} + +/* Linen — a quiet warm light between Solar's vivid sun and Sepia's saturated + parchment: a restrained warm ivory with near-black ink and a cool slate-blue + accent, for an e-ink / print reading feel. */ +:root[data-theme='linen'] { + --bg: #faf7f0; + --bg-panel: #efe9dc; + --bg-hover: #e6dfce; + --border: #d8cfba; + --text: #221f1a; + --text-dim: #6f685b; + --text-hint: #4a4638; + --accent: #3f5b8a; + --warning-bg: #d99a1f; + --warning-border: #a86f10; + --warning-text: #2a1c00; + --danger-bg: #a8432f; + --danger-border: #8a3626; + --danger-text: #ffffff; + --success-text: #3d6b2f; + --scrim: rgba(40, 34, 20, 0.42); + --favorite: #a3760a; + --text-on-accent: #ffffff; + --shadow-rgb: 40 34 20; +} + +/* Bloom — the only rose/plum accent in the set, kept low-saturation and + high-clarity so it stays a serious reading light, not a novelty theme. */ +:root[data-theme='bloom'] { + --bg: #f9f4f5; + --bg-panel: #efe4e7; + --bg-hover: #e7d8dd; + --border: #ddccd2; + --text: #2a1f26; + --text-dim: #7a6570; + --text-hint: #4f414a; + --accent: #b0446e; + --warning-bg: #d99a1f; + --warning-border: #a86f10; + --warning-text: #2a1c00; + --danger-bg: #c33a54; + --danger-border: #9e2f44; + --danger-text: #ffffff; + --success-text: #357a4c; + --scrim: rgba(50, 30, 40, 0.42); + --favorite: #b07a1a; + --text-on-accent: #ffffff; + --shadow-rgb: 50 30 40; +} + /* Nyan — playful but usable: a readable deep-violet base with hot-pink accent and lime/rose diff colors. The chaos (the rainbow lane + reward cat) lives in non-content chrome (see NyanLane), so diffs stay legible. */ diff --git a/src/renderer/src/utils/themes.js b/src/renderer/src/utils/themes.js index f8afe6d..44dca82 100644 --- a/src/renderer/src/utils/themes.js +++ b/src/renderer/src/utils/themes.js @@ -44,6 +44,36 @@ export const THEMES = [ dark: false, swatch: { bg: '#dfcea6', accent: '#9c4f1f', add: '#5a6f28', del: '#933a22' } }, + { + id: 'dim', + label: 'Dim', + dark: true, + swatch: { bg: '#232019', accent: '#d9a441', add: '#8fbf6b', del: '#e0705f' } + }, + { + id: 'beacon', + label: 'Beacon', + dark: true, + swatch: { bg: '#0b0b0b', accent: '#4cc2ff', add: '#3fe07a', del: '#ff5b57' } + }, + { + id: 'meridian', + label: 'Meridian', + dark: false, + swatch: { bg: '#eef2ee', accent: '#0e8a8a', add: '#2f9e44', del: '#c0392b' } + }, + { + id: 'linen', + label: 'Linen', + dark: false, + swatch: { bg: '#efe9dc', accent: '#3f5b8a', add: '#4a7c3a', del: '#a8432f' } + }, + { + id: 'bloom', + label: 'Bloom', + dark: false, + swatch: { bg: '#efe4e7', accent: '#b0446e', add: '#3f9160', del: '#c33a54' } + }, { id: 'nyan', label: 'Nyan', diff --git a/tests/renderer/utils/themes.test.js b/tests/renderer/utils/themes.test.js index 5a6d0ca..62d3773 100644 --- a/tests/renderer/utils/themes.test.js +++ b/tests/renderer/utils/themes.test.js @@ -18,6 +18,11 @@ describe('themes registry', () => { 'contrast', 'nord', 'sepia', + 'dim', + 'beacon', + 'meridian', + 'linen', + 'bloom', 'nyan', 'matrix' ]) @@ -27,7 +32,7 @@ describe('themes registry', () => { it('marks each theme dark- or light-ground (drives the editor/diagram theme)', () => { const dark = THEMES.filter((t) => t.dark).map((t) => t.id) - expect(dark).toEqual(['dark', 'neon', 'nord', 'nyan', 'matrix']) + expect(dark).toEqual(['dark', 'neon', 'nord', 'dim', 'beacon', 'nyan', 'matrix']) expect(isDarkTheme('neon')).toBe(true) expect(isDarkTheme('solar')).toBe(false) expect(isDarkTheme('nope')).toBe(false)