diff --git a/HANDOFF.md b/HANDOFF.md
index a5ce9a43..26eb7833 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -14,7 +14,7 @@ You're picking up GitStudio. This doc is the context you need so you don't relit
1. **One flagship extension, not a swarm.** GitStudio is `gitstudio.gitstudio` — a single extension that grows pillar by pillar (the GitLens model). Merge Studio stays a separate, focused product. They **share an engine, not a listing**.
2. **Publisher = brand, extension = product.** The publisher id `gitstudio` is the umbrella; products are extensions under it. (This distinction cost us a day of confusion — see the war-story; don't repeat it.)
3. **Reuse before rebuild.** The merge engine and webview ribbon stack are proven. Extract, don't fork-and-drift.
-4. **AI (GitBrain) is optional + BYO-key.** Don't make the core depend on it.
+4. **AI is optional + BYO-key.** Don't make the core depend on it.
## Suggested first moves (highest leverage first)
@@ -22,7 +22,7 @@ You're picking up GitStudio. This doc is the context you need so you don't relit
2. **Scaffold the extension** from `../vscode-extension-starter` (esbuild bundling, `vscode:prepublish`, `.vscodeignore`, CI + release workflows). You get a publishable shell in minutes.
3. **Ship pillar #1: Blame & history.** It's the highest-value, lowest-risk feature after merge — ambient (decorations + hovers, no custom editor needed), and it makes the extension immediately useful. Resist starting with the commit graph (high effort, easy to get visually wrong).
4. **Then the commit graph / log**, reusing the webview muscle.
-5. **GitBrain last**, once there's a surface to attach it to (commit-message gen on the staging view, "explain this diff" on the diff/history view).
+5. **AI features last**, once there's a surface to attach it to (commit-message gen on the staging view, "explain this diff" on the diff/history view).
## How to work with the codebases
@@ -50,7 +50,7 @@ Don't hand-publish. Use the pipeline:
- License for GitStudio (Merge Studio is MIT — match it, or go source-available?).
- Monorepo (merge-studio + gitstudio + shared engine) vs. separate repos with a published `@gitstudio/engine`?
-- Free vs. freemium for the suite (GitBrain/AI is the natural paid tier).
+- Free vs. freemium for the suite (AI is the natural paid tier).
- How much of Merge Studio folds *into* GitStudio vs. stays standalone long-term.
Start with the engine extraction and blame pillar. Everything else builds on those.
diff --git a/README.md b/README.md
index 14221b34..43a63fea 100644
--- a/README.md
+++ b/README.md
@@ -123,7 +123,7 @@ Everything ships in one extension (`gitstudio.gitstudio`) and one desktop app, b
| **Rewrite** | Drag-to-reorder interactive rebase (pick · reword · edit · squash · fixup · drop); a universal, reflog-powered **Undo** safety net (never hijacks `Ctrl/Cmd+Z`). |
| **Manage** | Branches (live ↑/↓ badges, fetch-in-place, pull without checkout), remotes, tags, first-class stashes, worktrees; GitHub-style branch compare; status-bar sync with in-view Push/Pull. |
| **Collaborate** | In-editor GitHub pull-request review — list, check out, diff, comment inline, submit, merge, create. |
-| **Assist** | GitBrain — optional, bring-your-own-key (Anthropic or any OpenAI-compatible endpoint, including local Ollama / LM Studio) or zero-key (Copilot): AI commit messages, explain-diff, summaries. With Copilot it works with nothing to set up, otherwise it is off until you connect a provider; keys live in SecretStorage; AI never gates a Git operation. |
+| **Assist** | AI features — optional, bring-your-own-key (Anthropic or any OpenAI-compatible endpoint, including local Ollama / LM Studio) or zero-key (Copilot): AI commit messages, explain-diff, summaries. With Copilot it works with nothing to set up, otherwise it is off until you connect a provider; keys stay on your machine in GitStudio's own encrypted store; AI never gates a Git operation. |
## The desktop app
@@ -203,7 +203,7 @@ Issues and pull requests are welcome at [GitStudioHQ/gitstudio](https://github.c
npm test && npm run check-types && npm run check-purity
```
-[CONTRIBUTING.md](CONTRIBUTING.md) has the rest, and [`docs/merge-studio.md`](docs/merge-studio.md) explains how the merge code is shared with Merge Studio and how a pull request opened on its repository comes back here.
+[CONTRIBUTING.md](CONTRIBUTING.md) has the rest (and the [code of conduct](CODE_OF_CONDUCT.md); report security problems privately, as [SECURITY.md](SECURITY.md) says), and [`docs/merge-studio.md`](docs/merge-studio.md) explains how the merge code is shared with Merge Studio and how a pull request opened on its repository comes back here.
## Facts
@@ -213,6 +213,8 @@ npm test && npm run check-types && npm run check-purity
| **Desktop** | [GitHub Releases](https://github.com/GitStudioHQ/gitstudio/releases) — `.dmg` (arm64 + x64), `.exe`, `.AppImage`, `.deb`, `.rpm`, `.tar.gz` |
| **Website** | [gitstudio.dev](https://gitstudio.dev) |
| **License** | **Apache-2.0** |
+| **Privacy** | What each product sends, and how to turn it off: [desktop](apps/desktop/PRIVACY.md) · [extension](apps/extension/PRIVACY.md) · [Merge Studio](apps/merge-studio/PRIVACY.md) |
+| **Security** | [SECURITY.md](SECURITY.md) — report privately, through the repository's Security tab |
| **Sibling product** | [Merge Studio](https://marketplace.visualstudio.com/items?itemName=gitstudio.merge-studio) — `gitstudio.merge-studio`, the merge-only extension. Built from `apps/merge-studio` and the shared packages here, exported to [GitStudioHQ/merge-studio](https://github.com/GitStudioHQ/merge-studio). Shares its merge editor, not a listing. |
## Architecture notes
diff --git a/apps/desktop/CHANGELOG.md b/apps/desktop/CHANGELOG.md
index 40756685..68779723 100644
--- a/apps/desktop/CHANGELOG.md
+++ b/apps/desktop/CHANGELOG.md
@@ -9,9 +9,17 @@ The VS Code / Cursor extension has its own changelog at
separately — desktop releases are tagged `app-v*`, extension releases `ext-v*` —
but they share the same engine, so most Git behaviour lands in both at once.
-## [Unreleased]
+## [2.4.0] - 2026-09-29
### Added
+- **Dark style: Graphite or Neon.** Settings ▸ Appearance has a new *Dark
+ style* choice, shown as each style's Dock icon beside a swatch of its
+ window, and it sets both at once. **Graphite** (the default) is the grey
+ icon macOS shows while GitStudio is closed, with the familiar dark theme, so
+ opening the app no longer changes the Dock icon. **Neon** is the
+ near-black icon with a deeper, more violet dark theme; its Dock icon applies
+ while GitStudio runs, and macOS still shows the Graphite icon while it is
+ closed. The launch screen opens in the chosen style from its first frame.
- **A launch screen.** GitStudio's window now opens straight onto the
whole GitStudio mark — the app icon's cube, its commit graph and the name,
settling gently into place — instead of appearing late and painting itself
@@ -71,6 +79,28 @@ but they share the same engine, so most Git behaviour lands in both at once.
Dock doesn't get your shell's PATH, and a Windows app keeps the PATH it
started with.
+- **Push no longer re-creates a branch someone deleted on the remote.** When a
+ branch's remote copy was deleted but it still tracked it, Push published it
+ again. It now stops and says the branch it tracks is gone.
+- **A repository with no commits yet shows "No commits yet"** in the graph,
+ not "Couldn't load history".
+- **A failed clone says why.** The clone sheet showed git's last line — often
+ a fragment like "and the repository exists." — instead of the reason.
+- **On Windows, repositories are grouped inside the folders they sit in.**
+ The folders you track came from the folder picker with backslashes, and
+ every repository landed outside them.
+- **Release tags show their date.** Annotated tags — most releases — came
+ back with no date.
+- **A reword keeps its `#` lines after the rebase pauses**, a rebase git
+ refuses up front is no longer shown as stopped on conflicts, and Stash &
+ Retry is not offered for a branch or commit git doesn't know.
+- **Merge editor: a conflict is written where it happened**, and an add/add
+ conflict no longer carries an extra blank line on each side.
+- **The Assistant:** cancelling a chat just as its message was sent could
+ crash the app's main process; an Azure OpenAI address ending in `/` before
+ its `?api-version=` produced a broken URL; and the first connection added on
+ a fresh install could leak into later ones.
+
### Security
- **Crash reports no longer carry a commit's subject, or a branch name git
prints without quotes.** When a rebase, cherry-pick, revert or `git am`
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index 68093e7d..21a704bb 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -57,7 +57,15 @@ when their secrets are absent.
## Privacy
-No accounts, no usage tracking. During the beta the app sends **anonymous,
-scrubbed crash reports** (on by default; toggle via **Help → Send Anonymous
-Crash Reports**) to the same collector as the extension. Details in
-[PRIVACY.md](PRIVACY.md).
+No accounts, no usage tracking. What the app does send:
+
+- **anonymous, scrubbed crash reports**, on by default (**Help → Send
+ Anonymous Crash Reports** turns them off), to the same collector as the
+ extension;
+- **update checks** to GitHub Releases, which ask before downloading;
+- **commit authors' pictures** from Gravatar (**Settings ▸ Appearance** turns
+ it off) or GitHub;
+- **GitHub requests** while you're signed in, and your diff to the **AI
+ provider** you connect, only when you ask.
+
+Every connection, in full: [PRIVACY.md](PRIVACY.md).
diff --git a/apps/desktop/build/compile-icon.sh b/apps/desktop/build/compile-icon.sh
index da983f2a..cd6b5204 100755
--- a/apps/desktop/build/compile-icon.sh
+++ b/apps/desktop/build/compile-icon.sh
@@ -29,8 +29,12 @@
# Apple's grid expects — at dock size the old framing read as a small mark
# floating in a large empty square.
#
-# NOTE: build/icon.icns comes from brand/gitstudio-dock-1024.png, NOT from this
-# SVG, so it does not inherit the zoom. It is only used on macOS 11-15.
+# NOTE: build/icon.icns comes from brand/gitstudio-dock-graphite-1024.png — the
+# Graphite tile, which is this icon as macOS 26 draws it (see
+# brand/gitstudio-icon-graphite.svg) — so a closed GitStudio looks the same on
+# 11-15 as on 26, and the same as the running app in the default dark style
+# (Settings ▸ Appearance ▸ Dark style; main.ts setDockIcon leaves the bundle's
+# icon alone for Graphite). It is only used on macOS 11-15.
set -e
cd "$(dirname "$0")"
@@ -49,7 +53,7 @@ ISET="$OUT/icon.iconset"; mkdir -p "$ISET"
python3 - "$ISET" <<'PY'
import sys
from PIL import Image
-src = Image.open("../../../brand/gitstudio-dock-1024.png").convert("RGBA")
+src = Image.open("../../../brand/gitstudio-dock-graphite-1024.png").convert("RGBA")
for n in (16, 32, 128, 256, 512):
src.resize((n, n), Image.LANCZOS).save(f"{sys.argv[1]}/icon_{n}x{n}.png")
src.resize((n * 2, n * 2), Image.LANCZOS).save(f"{sys.argv[1]}/icon_{n}x{n}@2x.png")
diff --git a/apps/desktop/build/icon.icns b/apps/desktop/build/icon.icns
index e2e927f4..81e20358 100644
Binary files a/apps/desktop/build/icon.icns and b/apps/desktop/build/icon.icns differ
diff --git a/apps/desktop/esbuild.js b/apps/desktop/esbuild.js
index 6af89f19..50c7b39b 100644
--- a/apps/desktop/esbuild.js
+++ b/apps/desktop/esbuild.js
@@ -163,6 +163,11 @@ function copyStaticAssets() {
// dockIconPath) so a runtime theme swap never shrinks the icon.
"brand/gitstudio-dock-1024.png": "dock.png",
"brand/gitstudio-dock-light-1024.png": "dock-light.png",
+ // Dark style ▸ Graphite (shared/darkStyle.ts): the tile macOS draws for
+ // the closed app, for a dev build's Dock (its bundle icon is Electron's)
+ // and Settings' preview.
+ "brand/gitstudio-dock-graphite-1024.png": "dock-graphite.png",
+ "brand/gitstudio-icon-graphite-512.png": "icon-graphite.png",
"brand/gitstudio-icon.svg": "brand-icon.svg",
"brand/gitstudio-icon-light.svg": "brand-icon-light.svg",
"brand/gitstudio-wordmark-light.svg": "brand-wordmark-light.svg",
diff --git a/apps/desktop/harness/check.mjs b/apps/desktop/harness/check.mjs
index 1d65945c..9f2ec623 100755
--- a/apps/desktop/harness/check.mjs
+++ b/apps/desktop/harness/check.mjs
@@ -207,6 +207,11 @@ const CASES = [
["row-meta-columns-align", "explore~type:git~key:Enter"],
["the-dock-reserve-tracks-the-dock", "changes~click:.dock-chevron"],
["changing-the-theme-keeps-what-you-typed", "settings"],
+ // Settings ▸ Appearance ▸ Dark style: Graphite (default) or Neon, for the
+ // window and the Dock; and a window opened on Neon is Neon throughout.
+ ["the-dark-style-picks-the-window-and-the-dock", "settings"],
+ ["the-neon-style-paints-from-its-tokens", "graph", { extra: "darkstyle=neon" }],
+ ["the-launch-screen-covers-the-start-and-hands-off", "dashboard", { extra: "darkstyle=neon" }],
["a-compare-diff-that-fails-says-so", "compare~text:Changed%20files"],
["placeholders-fit-their-field", "orgs"],
["placeholders-fit-their-field", "branches"],
diff --git a/apps/desktop/harness/checks.js b/apps/desktop/harness/checks.js
index 9e89e1b3..074596b3 100644
--- a/apps/desktop/harness/checks.js
+++ b/apps/desktop/harness/checks.js
@@ -1036,7 +1036,8 @@
if (!b) return;
const light = new URLSearchParams(location.search).get("theme") === "light";
c.ok(b.bundleNotRun, "…and was captured before any app code ran (#boot still in #root)");
- c.eq(b.theme, light ? "vscode-light" : "vscode-dark", "painted in the right theme from the first frame");
+ const neonStyle = new URLSearchParams(location.search).get("darkstyle") === "neon";
+ c.eq(b.theme, (light ? "vscode-light" : "vscode-dark") + (neonStyle ? " gs-neon" : ""), "painted in the right theme and dark style from the first frame");
c.eq(b.display, "grid", "it is displayed");
c.eq(b.opacity, "1", "the ground is opaque (only the mark animates in)");
c.ok(b.covers, "it covers the whole window");
@@ -1045,7 +1046,7 @@
c.eq(b.pointerEvents, "auto", "while it is up, clicks do not fall through to an invisible app");
c.eq(b.appRegion, "drag", "while it is up, it drags the window like a title bar");
c.eq(b.bg, getComputedStyle(document.body).backgroundColor, "its ground is the app's own canvas (--app-bg)");
- c.eq(b.bg, light ? "rgb(238, 241, 245)" : "rgb(13, 16, 22)", "…which is the theme's canvas");
+ c.eq(b.bg, light ? "rgb(238, 241, 245)" : neonStyle ? "rgb(9, 9, 14)" : "rgb(13, 16, 22)", "…which is the theme's canvas, in its dark style");
c.eq(b.ink, light ? "rgb(27, 31, 42)" : "rgb(245, 243, 255)", "the wordmark's 'Git' is the theme's ink");
c.eq(b.cubeTop, "rgb(123, 121, 166)", "the cube wears the Dock icon's slate, in both themes");
c.eq(b.node, "rgb(180, 155, 255)", "the commit graph is the icon's violet");
@@ -2423,6 +2424,93 @@
);
},
+ // Settings ▸ Appearance ▸ Dark style. The owner: "both dark themes are
+ // cool (iconwise) … an option to choose between the neon dark theme and
+ // the grayish dark theme, both for the app and for the icon". One control,
+ // two looks, each previewed as its Dock tile and a swatch of its window,
+ // and a pick moves the window AND the Dock. Graphite is the default — it
+ // is the icon macOS shows for the closed app, so opening it changes nothing.
+ "the-dark-style-picks-the-window-and-the-dock": async (f) => {
+ const c = check(f);
+ const opts = $$(".settings-style-opt");
+ c.eq(opts.map((o) => o.dataset.style).join(","), "graphite,neon", "two looks, Graphite first");
+ c.eq(opts.map((o) => o.querySelector(".settings-style-name")?.textContent).join(","), "Graphite,Neon", "named");
+ if (opts.length !== 2) return;
+ const [graphite, neon] = opts;
+ const group = graphite.parentElement;
+ c.eq(group?.getAttribute("role"), "group", "a labelled group");
+ c.eq(document.getElementById(group?.getAttribute("aria-labelledby") ?? "")?.textContent, "Dark style", "…labelled Dark style");
+ for (const o of opts) {
+ const img = o.querySelector("img.settings-style-icon");
+ c.ok(!!img && img.complete && img.naturalWidth > 0, `${o.dataset.style}: its Dock tile preview loads (${img?.getAttribute("src")})`);
+ c.ok(!!o.querySelector(".settings-style-swatch .sw-sel"), `${o.dataset.style}: a swatch of its window`);
+ }
+ c.eq(graphite.querySelector("img")?.getAttribute("src"), "./icon-graphite.png", "Graphite previews the graphite tile");
+ c.eq(neon.querySelector("img")?.getAttribute("src"), "./icon.png", "Neon previews the neon tile");
+ // Each swatch in its OWN colours, whichever style is on.
+ const ground = (o) => getComputedStyle(o.querySelector(".settings-style-swatch")).backgroundColor;
+ c.eq(ground(graphite), "rgb(13, 16, 22)", "the Graphite swatch is Graphite's ground");
+ c.eq(ground(neon), "rgb(9, 9, 14)", "the Neon swatch is Neon's ground");
+
+ const appBg = () => getComputedStyle(document.body).backgroundColor;
+ const dockCalls = () => (window.__GS_INVOKED ?? []).filter((r) => r.channel === "appearance:dockIcon");
+ const prefs = () => JSON.parse(localStorage.getItem("gitstudio.ui.prefs") || "{}");
+ // Nothing picked: Graphite.
+ c.eq(graphite.getAttribute("aria-pressed"), "true", "Graphite is the default");
+ c.eq(neon.getAttribute("aria-pressed"), "false", "…and Neon is not");
+ c.ok(!document.body.classList.contains("gs-neon"), "the window is Graphite");
+ c.eq(appBg(), "rgb(13, 16, 22)", "…on Graphite's ground");
+ const boot = dockCalls().at(-1)?.payload;
+ c.eq(JSON.stringify(boot), JSON.stringify({ variant: "dark", style: "graphite" }), "and the Dock was told Graphite");
+ // Picked like every selected thing here: lit, never lined.
+ const lit = (o) => getComputedStyle(o);
+ c.ok(/rgba?\(/.test(lit(graphite).boxShadow) && lit(graphite).boxShadow.split("rgb").length > 2, `the picked one glows (${lit(graphite).boxShadow})`);
+ c.eq(lit(graphite).borderStyle, "none", "…with no border");
+ c.ok(lit(graphite).backgroundColor !== lit(neon).backgroundColor, "…and a tinted fill the other has not");
+
+ neon.click();
+ await settle(300);
+ c.eq(neon.getAttribute("aria-pressed"), "true", "Neon is picked");
+ c.eq(graphite.getAttribute("aria-pressed"), "false", "…and Graphite is not");
+ c.ok(document.body.classList.contains("gs-neon"), "the window wears Neon's class");
+ c.eq(appBg(), "rgb(9, 9, 14)", "…and Neon's ground, from the tokens");
+ c.eq(getComputedStyle(document.body).getPropertyValue("--app-panel").trim(), "#0e0e15", "…every token re-pointed (--app-panel)");
+ c.eq(prefs().darkStyle, "neon", "persisted for the next launch's first frame");
+ c.eq(JSON.stringify(dockCalls().at(-1)?.payload), JSON.stringify({ variant: "dark", style: "neon" }), "the Dock is told Neon");
+ c.eq($(".settings-logo-preview")?.getAttribute("src"), "./icon.png", "the App icon preview shows the neon tile");
+
+ graphite.click();
+ await settle(300);
+ c.ok(!document.body.classList.contains("gs-neon"), "back to Graphite");
+ c.eq(appBg(), "rgb(13, 16, 22)", "…on Graphite's ground");
+ c.eq(prefs().darkStyle, "graphite", "persisted");
+ c.eq(JSON.stringify(dockCalls().at(-1)?.payload), JSON.stringify({ variant: "dark", style: "graphite" }), "the Dock is told Graphite");
+ c.eq($(".settings-logo-preview")?.getAttribute("src"), "./icon-graphite.png", "the App icon preview shows the graphite tile");
+ },
+
+ // A window opened on Neon is Neon from its first frame to the app: the
+ // launch screen's ground, the body class the renderer keeps, and the
+ // tokens every surface paints from.
+ "the-neon-style-paints-from-its-tokens": async (f) => {
+ const c = check(f);
+ c.eq(launchAtBoot?.theme, "vscode-dark gs-neon", "the first frame is already Neon");
+ c.eq(launchAtBoot?.bg, "rgb(9, 9, 14)", "…on Neon's ground");
+ await settle(300);
+ c.ok(document.body.classList.contains("gs-neon") && document.body.classList.contains("vscode-dark"), "the renderer kept it beside the theme");
+ c.eq(document.body.dataset.darkStyle, "neon", "and says so on
");
+ const cs = getComputedStyle(document.body);
+ c.eq(cs.backgroundColor, "rgb(9, 9, 14)", "the canvas is Neon's");
+ for (const [name, want] of [["--app-panel", "#0e0e15"], ["--app-border", "#221f31"], ["--vscode-editor-background", "#0e0e15"], ["--gs-accent-ink", "#b39bff"]]) {
+ c.eq(cs.getPropertyValue(name).trim(), want, `${name} is Neon's`);
+ }
+ // A real surface, not just the token: the top bar paints from --app-panel.
+ const bar = $(".topbar");
+ c.ok(!!bar, "the top bar is up");
+ if (bar) c.eq(getComputedStyle(bar).backgroundColor, "rgb(14, 14, 21)", "the top bar is Neon's panel");
+ const dock = (window.__GS_INVOKED ?? []).filter((r) => r.channel === "appearance:dockIcon").at(-1);
+ c.eq(JSON.stringify(dock?.payload), JSON.stringify({ variant: "dark", style: "neon" }), "the Dock was told Neon at launch");
+ },
+
// The dock is an overlay footer: it does not shrink the scrollers above it,
// so long views add `--dock-reserve` to their bottom padding to clear it.
// Every path that changes the dock's height must republish that value —
diff --git a/apps/desktop/harness/contrast.mjs b/apps/desktop/harness/contrast.mjs
index 2ed98f65..a619c715 100755
--- a/apps/desktop/harness/contrast.mjs
+++ b/apps/desktop/harness/contrast.mjs
@@ -15,6 +15,7 @@
//
// node harness/contrast.mjs [--theme=light] [--width=1600] [--all]
// node harness/contrast.mjs --sweep [--theme=light] every default scene
+// node harness/contrast.mjs --sweep --theme=dark --darkstyle=neon …in a dark style
//
// Exit code is 1 when anything fails, so it can gate a run.
@@ -73,6 +74,8 @@ const flags = Object.fromEntries(
const positional = argv.filter((a) => !a.startsWith("--"));
const theme = flags.theme ?? "light";
const width = Number(flags.width ?? 1600);
+// --darkstyle=neon|graphite: Settings ▸ Appearance ▸ Dark style (dark theme only).
+const darkStyle = flags.darkstyle === "neon" || flags.darkstyle === "graphite" ? flags.darkstyle : "";
/** The scenes worth sweeping: one per surface, plus the detail pages. */
const SCENES = [
@@ -333,7 +336,7 @@ function run(scene) {
// wrapping here produced an expression statement whose value was discarded
// and every scene came back null.
const probe = probeFile(PAGE, AUDIT);
- const url = `file://${PAGE}?scene=${encodeURIComponent(scene)}&theme=${theme}&probe=${probe.param}`;
+ const url = `file://${PAGE}?scene=${encodeURIComponent(scene)}&theme=${theme}&probe=${probe.param}${darkStyle ? `&darkstyle=${darkStyle}` : ""}`;
return new Promise((done) => {
execFile(
CHROME,
diff --git a/apps/desktop/harness/shim.js b/apps/desktop/harness/shim.js
index 57f3745f..12b5752f 100644
--- a/apps/desktop/harness/shim.js
+++ b/apps/desktop/harness/shim.js
@@ -94,6 +94,11 @@
JSON.stringify({
...(firstRun ? {} : { currentView: view === "inbox" ? "notifications" : view }),
themeMode: theme,
+ // ?darkstyle=neon: Settings ▸ Appearance ▸ Dark style ▸ Neon. theme-boot.js
+ // reads the same switch for the first frame, as it does ?theme=.
+ ...(params.get("darkstyle") === "neon" || params.get("darkstyle") === "graphite"
+ ? { darkStyle: params.get("darkstyle") }
+ : {}),
railWidth: 216,
railCollapsed: false,
terminalOpen: false,
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index ff166868..1611519f 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -1,6 +1,6 @@
{
"name": "gitstudio-desktop",
- "version": "2.3.0",
+ "version": "2.4.0",
"private": true,
"license": "Apache-2.0",
"description": "GitStudio \u2014 the native cross-platform desktop app (Electron). Reuses @gitstudio/engine, git-service, webview-ui, and host-bridge behind host-agnostic seams.",
diff --git a/apps/desktop/src/main/dockAppearance.ts b/apps/desktop/src/main/dockAppearance.ts
new file mode 100644
index 00000000..85f3d1a3
--- /dev/null
+++ b/apps/desktop/src/main/dockAppearance.ts
@@ -0,0 +1,97 @@
+/**
+ * The Dock tile and the remembered appearance, for main.
+ *
+ * The renderer owns the choice (Settings ▸ Appearance: theme, App icon, Dark
+ * style) and reports what it resolves to over `appearance:dockIcon`. This
+ * puts the matching tile on the Dock, and remembers the report in userData so
+ * the NEXT start can paint the right Dock tile and window ground before the
+ * page has said anything. The owner watched the icon change the moment the
+ * app opened; a start that guessed from the OS scheme would change it again
+ * for anyone on Neon.
+ *
+ * Electron-free (the Dock setter is passed in) so it runs under node:test.
+ */
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { DEFAULT_DARK_STYLE, dockIconFor, parseDarkStyle, type DarkStyle } from "../shared/darkStyle";
+
+export interface DockDeps {
+ /** `app.dock.setIcon` — a file path, or null for "the bundle's own icon". */
+ setIcon(image: string | null): void;
+ /** Where the dock*.png tiles are (dist/renderer). */
+ rendererDir: string;
+ /** A packaged macOS build: its bundle icon IS the Graphite tile. */
+ bundleIsGraphite: boolean;
+}
+
+export class DockAppearance {
+ /** The dark style the page last reported (Graphite until it says). */
+ style: DarkStyle = DEFAULT_DARK_STYLE;
+ /** The Dock variant the page last resolved, if it ever has. */
+ variant?: "dark" | "light";
+ /** Whether this process has drawn its own image on the tile. Until it has,
+ * macOS is drawing the bundle's icon, and "the bundle's icon" means: do
+ * nothing at all. */
+ private imageSet = false;
+
+ constructor(
+ private readonly file: string,
+ private readonly deps: DockDeps,
+ ) {}
+
+ /** Read what the last run's page reported. Missing or unreadable: defaults. */
+ load(): void {
+ try {
+ const saved = JSON.parse(readFileSync(this.file, "utf8")) as { style?: unknown; variant?: unknown };
+ this.style = parseDarkStyle(saved.style);
+ this.variant = saved.variant === "dark" || saved.variant === "light" ? saved.variant : undefined;
+ } catch {
+ /* first start */
+ }
+ }
+
+ private save(): void {
+ try {
+ mkdirSync(dirname(this.file), { recursive: true });
+ writeFileSync(this.file, JSON.stringify({ style: this.style, variant: this.variant }));
+ } catch {
+ /* best-effort: the page reports again on the next start */
+ }
+ }
+
+ /**
+ * Put the tile for a variant and style on the Dock (best-effort).
+ *
+ * Graphite in a packaged macOS build is NOT a PNG: it is the icon macOS
+ * already draws from the bundle (build/AppIcon.icon on 26+, the graphite
+ * icns before it) — the icon the Dock showed while the app was closed. Left
+ * alone, nothing is drawn over it and opening GitStudio changes nothing.
+ * Coming back to it from Neon or Light hands the tile back to the system
+ * (`setIcon(null)` → AppKit's `setApplicationIconImage:nil`).
+ */
+ apply(variant: "dark" | "light", style: DarkStyle = this.style): void {
+ const icon = dockIconFor(variant, style, this.deps.bundleIsGraphite);
+ try {
+ if (icon === "bundle") {
+ if (!this.imageSet) return;
+ this.deps.setIcon(null);
+ this.imageSet = false;
+ return;
+ }
+ this.deps.setIcon(join(this.deps.rendererDir, icon));
+ this.imageSet = true;
+ } catch {
+ /* no Dock (Windows/Linux) or a missing file — harmless */
+ }
+ }
+
+ /** The page's report: apply it, and remember it when it changed. */
+ report(variant: "dark" | "light", style: unknown): void {
+ const parsed = parseDarkStyle(style);
+ this.apply(variant, parsed);
+ if (parsed === this.style && variant === this.variant) return;
+ this.style = parsed;
+ this.variant = variant;
+ this.save();
+ }
+}
diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts
index c969bc5f..270b1301 100644
--- a/apps/desktop/src/main/main.ts
+++ b/apps/desktop/src/main/main.ts
@@ -22,6 +22,8 @@ import { AsyncLocalStorage } from "node:async_hooks";
import { join, basename, extname, dirname, resolve as resolvePath } from "node:path";
import { readFile, writeFile, mkdir, stat, readdir, rename, rmdir, rm } from "node:fs/promises";
import { mkdirSync, writeFileSync } from "node:fs";
+import { DEFAULT_DARK_STYLE, parseDarkStyle, windowBackgroundFor, type DarkStyle } from "../shared/darkStyle";
+import { DockAppearance } from "./dockAppearance";
import { redactCredentials } from "@gitstudio/host-bridge/scrub";
import { RepoStore, repoScope } from "./repoStore";
import { cannotOpenNotice, droppedTabsNotice, gitMissingNotice, tabsFullNotice, missingFolderError, missingFolderResult } from "./repoNotice";
@@ -336,16 +338,17 @@ async function createWindow(): Promise {
}
/** The window's own background for a theme: the renderer's --app-bg, which
- * is also the launch screen's ground (test/launchScreen.test.ts). */
-function windowBackground(theme: "dark" | "light"): string {
- return theme === "light" ? "#eef1f5" : "#0d1016";
+ * is also the launch screen's ground (test/launchScreen.test.ts) — in the
+ * dark style picked in Settings (shared/darkStyle.ts). */
+function windowBackground(theme: "dark" | "light", style: DarkStyle = darkStyleNow()): string {
+ return windowBackgroundFor(theme, style);
}
/** Show a window that is still hidden, once — in `theme`'s colour when the
* renderer said which theme its first frame was painted in. */
-function revealWindow(win: BrowserWindow | undefined, theme?: "dark" | "light"): void {
+function revealWindow(win: BrowserWindow | undefined, theme?: "dark" | "light", style?: DarkStyle): void {
if (!win || win.isDestroyed() || win.isVisible()) return;
- if (theme) win.setBackgroundColor(windowBackground(theme));
+ if (theme) win.setBackgroundColor(windowBackground(theme, style ?? darkStyleNow()));
win.show();
}
@@ -355,15 +358,6 @@ function iconPath(variant: "dark" | "light"): string {
return join(__dirname, variant === "light" ? "../renderer/icon-light.png" : "../renderer/icon.png");
}
-/** The DOCK tile for a theme variant: the same artwork on Apple's icon grid
- * (the tile is 824/1024 of the canvas, transparent margins), so it sits at
- * the size of every other icon in the Dock. The window icon above stays
- * full-bleed — Windows and Linux taskbars expect that. */
-function dockIconPath(variant: "dark" | "light"): string {
- return join(__dirname, variant === "light" ? "../renderer/dock-light.png" : "../renderer/dock.png");
-}
-
-
/** Brand icon for the window `icon:`; electron-builder embeds the platform icon,
* this is the dev/window one. Tracks the OS scheme so it isn't visibly wrong. */
function appIcon(): string {
@@ -371,26 +365,28 @@ function appIcon(): string {
}
/**
- * Swap the macOS dock icon to the given brand variant (best-effort).
- *
- * This used to return early on macOS 26 — the system renders the bundle's Icon
- * Composer icon itself, and the worry was that handing `dock.setIcon` a PNG
- * would get it framed as a smaller "legacy" icon on its own backing. The result
- * was a Settings control that did nothing: the dock never matched what the
- * selector said was picked.
- *
- * The framing concern is already answered by the artwork. `brand/margined.py`
- * puts the tile on Apple's grid (824px of a 1024 canvas, transparent margins),
- * which is exactly the geometry the legacy path expects — that script exists
- * because a full-bleed PNG rendered visibly larger than its neighbours. So the
- * swap runs everywhere now, and a picked icon is the icon you get.
+ * The Dock tile, and the appearance the page last reported (dockAppearance.ts):
+ * remembered in userData so a start paints the right tile and window ground
+ * before the page has said anything. Graphite, the default, in a packaged
+ * macOS build is the bundle's own icon — nothing is drawn over it, so opening
+ * GitStudio does not change what the Dock showed while it was closed.
*/
-function setDockIcon(variant: "dark" | "light"): void {
- try {
- app.dock?.setIcon(dockIconPath(variant));
- } catch {
- /* non-macOS or missing — harmless */
- }
+let dockAppearance: DockAppearance | undefined;
+/** The dark style the page last reported (this run, or the last). */
+function darkStyleNow(): DarkStyle {
+ return dockAppearance?.style ?? DEFAULT_DARK_STYLE;
+}
+
+function initDockAppearance(): DockAppearance {
+ const dock = new DockAppearance(join(app.getPath("userData"), "gitstudio-appearance.json"), {
+ // Typed NativeImage | string, but Electron's DockSetIcon takes null as "no
+ // image" and AppKit restores the bundle's icon for a nil one.
+ setIcon: (image) => app.dock?.setIcon(image as string),
+ rendererDir: join(__dirname, "../renderer"),
+ bundleIsGraphite: app.isPackaged && process.platform === "darwin",
+ });
+ dock.load();
+ return dock;
}
// ── Menu ─────────────────────────────────────────────────────────────────────
@@ -1478,14 +1474,15 @@ function registerIpc(): void {
// Appearance: the renderer owns the in-app theme override, so it tells us
// which brand variant the dock should wear.
handle("appearance:dockIcon", async (payload) => {
- setDockIcon(payload.variant);
+ dockAppearance?.report(payload.variant, payload.style);
});
// The launch screen is painted in the page and the bundle is running: show
// its window (src/renderer/launch-reveal.js says why not earlier).
handle("window:launchPainted", async (payload, event) => {
const theme = payload?.theme === "light" || payload?.theme === "dark" ? payload.theme : undefined;
- revealWindow(BrowserWindow.fromWebContents(event.sender) ?? undefined, theme);
+ const style = payload?.style === undefined ? undefined : parseDarkStyle(payload.style);
+ revealWindow(BrowserWindow.fromWebContents(event.sender) ?? undefined, theme, style);
});
}
@@ -1521,6 +1518,9 @@ async function boot(): Promise {
app.on("web-contents-created", (_e, contents) => hardenWebContents(contents));
const state = await loadState();
+ // Before the window exists: its background and the Dock tile are both the
+ // dark style the last run's page chose.
+ dockAppearance = initDockAppearance();
repos = new RepoStore(state.recent);
appSettings = await AppSettings.load(app.getPath("userData"), {
defaultCloneDir: managedReposDir(),
@@ -1618,10 +1618,12 @@ async function boot(): Promise {
registerIpc();
buildMenu();
- // Dev builds show Electron's dock icon; force the GitStudio brand mark. Pick a
- // sensible initial variant from the OS scheme so it doesn't flash the wrong
- // tile before the renderer reports its (possibly overridden) theme.
- setDockIcon(nativeTheme.shouldUseDarkColors ? "dark" : "light");
+ // The Dock tile before the page has reported anything: what the LAST run's
+ // page resolved (gitstudio-appearance.json), else the OS scheme in the
+ // default style. On Graphite in a packaged build that is the bundle's own
+ // icon — nothing is set, so the Dock shows exactly what it showed while the
+ // app was closed. Dev builds need a tile: their bundle icon is Electron's.
+ dockAppearance.apply(dockAppearance.variant ?? (nativeTheme.shouldUseDarkColors ? "dark" : "light"));
// Bring back the tabs the last session had open, with the one that was in
// front (issue #32). A tab whose folder is gone — deleted, moved, an
// unmounted drive — is left out, and ONE quiet notice names what was.
diff --git a/apps/desktop/src/renderer/desktopTheme.ts b/apps/desktop/src/renderer/desktopTheme.ts
index 12f29db0..e0abf244 100644
--- a/apps/desktop/src/renderer/desktopTheme.ts
+++ b/apps/desktop/src/renderer/desktopTheme.ts
@@ -9,6 +9,8 @@
//
// We honor `prefers-color-scheme` and react to OS theme changes live.
+import { NEON_CLASS, type DarkStyle } from "../shared/darkStyle";
+
export type AppTheme = "dark" | "light";
/** The user's choice: follow the OS, or pin light/dark. */
export type ThemeMode = "system" | "light" | "dark";
@@ -23,6 +25,19 @@ export function applyTheme(theme: AppTheme): void {
body.dataset.theme = theme;
}
+/**
+ * Apply the dark style (Settings ▸ Appearance ▸ Dark style). A class beside
+ * the theme's, not instead of it: every `body.vscode-dark` rule still applies,
+ * and app.css's `body.vscode-dark.gs-neon` token block re-points the tokens.
+ * Harmless in Light — no light rule reads it — so switching theme never has
+ * to remember it.
+ */
+export function applyDarkStyle(style: DarkStyle): void {
+ const body = document.body;
+ body.classList.toggle(NEON_CLASS, style === "neon");
+ body.dataset.darkStyle = style;
+}
+
/** Resolve the current OS color-scheme preference. */
export function preferredTheme(): AppTheme {
return window.matchMedia?.("(prefers-color-scheme: light)").matches
diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html
index 973ce393..08168482 100644
--- a/apps/desktop/src/renderer/index.html
+++ b/apps/desktop/src/renderer/index.html
@@ -75,6 +75,11 @@
/* Ease-in: the ground holds while the mark leaves (below), then goes. */
transition: opacity 220ms cubic-bezier(0.4, 0, 1, 1);
}
+ /* Dark style ▸ Neon: the Neon block's --app-bg, and a stronger glow. */
+ body.vscode-dark.gs-neon #launch {
+ --lc-bg: #09090e;
+ --lc-glow: rgba(124, 92, 240, 0.28);
+ }
body.vscode-light #launch {
--lc-bg: #eef1f5;
--lc-edge: rgba(27, 31, 42, 0.16);
diff --git a/apps/desktop/src/renderer/launch-reveal.js b/apps/desktop/src/renderer/launch-reveal.js
index e8f0b6a4..e890848c 100644
--- a/apps/desktop/src/renderer/launch-reveal.js
+++ b/apps/desktop/src/renderer/launch-reveal.js
@@ -28,9 +28,12 @@
var bridge = window.gitstudio;
if (!bridge || typeof bridge.invoke !== "function") return;
// The launch screen's theme: theme-boot.js put it on before it painted.
- var light = /(^|\s)vscode-light(\s|$)/.test((document.body && document.body.className) || "");
+ var cls = (document.body && document.body.className) || "";
+ var light = /(^|\s)vscode-light(\s|$)/.test(cls);
+ // …and its dark style (the Neon class, or Graphite), for the window's ground.
+ var style = /(^|\s)gs-neon(\s|$)/.test(cls) ? "neon" : "graphite";
try {
- var p = bridge.invoke("window:launchPainted", { theme: light ? "light" : "dark" });
+ var p = bridge.invoke("window:launchPainted", { theme: light ? "light" : "dark", style: style });
if (p && typeof p.catch === "function") p.catch(function () {});
} catch (e) {
/* main shows the window on ready-to-show regardless */
diff --git a/apps/desktop/src/renderer/renderer.ts b/apps/desktop/src/renderer/renderer.ts
index 0c5a003c..91e93a56 100644
--- a/apps/desktop/src/renderer/renderer.ts
+++ b/apps/desktop/src/renderer/renderer.ts
@@ -49,7 +49,8 @@ import "./styles/app.css";
// imported last so its correct codepoints override any legacy hand-typed one.
import "./styles/codicons-full.css";
import { host } from "./bridge";
-import { applyTheme, followSystemTheme, resolveTheme } from "./desktopTheme";
+import { applyDarkStyle, applyTheme, followSystemTheme, resolveTheme } from "./desktopTheme";
+import { DARK_STYLES, DEFAULT_DARK_STYLE, parseDarkStyle, previewIconFor, type DarkStyle } from "../shared/darkStyle";
import type { AppTheme, ThemeMode, LogoMode } from "./desktopTheme";
import { dismissLaunchScreen } from "./launchScreen";
import { GraphMount } from "./graphMount";
@@ -585,6 +586,9 @@ class App {
private themeMode: ThemeMode = "system";
/** Dock icon preference: "auto" follows the resolved theme, or pin light/dark. */
private logoMode: LogoMode = "auto";
+ /** Settings ▸ Appearance ▸ Dark style: which dark look, in the window and
+ * in the Dock (shared/darkStyle.ts). Graphite unless picked. */
+ private darkStyle: DarkStyle = DEFAULT_DARK_STYLE;
/** Sidebar rail: persisted width (px) + collapsed-to-icons state. */
private railWidth = 188;
private railCollapsed = false;
@@ -781,6 +785,8 @@ class App {
if (prefs.logoMode === "auto" || prefs.logoMode === "light" || prefs.logoMode === "dark") {
this.logoMode = prefs.logoMode;
}
+ // Shared like the theme: a style picked in one tab is every tab's.
+ this.darkStyle = parseDarkStyle(prefs.darkStyle);
if (typeof prefs.railWidth === "number" && prefs.railWidth >= 168 && prefs.railWidth <= 360) {
this.railWidth = prefs.railWidth;
}
@@ -1403,6 +1409,7 @@ class App {
branchTab: this.branchTab,
themeMode: this.themeMode,
logoMode: this.logoMode,
+ darkStyle: this.darkStyle,
railWidth: this.railWidth,
railCollapsed: this.railCollapsed,
changesListW: this.changesListW,
@@ -1425,6 +1432,27 @@ class App {
this.persist();
}
+ /**
+ * Change the dark style: re-point the tokens, repaint what reads them once
+ * (Monaco's theme, the terminals), move the Dock to the style's tile, and
+ * persist — theme-boot.js reads it back to paint the next launch's first
+ * frame in it.
+ */
+ private setDarkStyle(style: DarkStyle): void {
+ this.darkStyle = style;
+ applyDarkStyle(style);
+ refreshHighlightTheme();
+ this.terminalDock?.applyTheme();
+ this.syncDockIcon();
+ this.invalidateAppearanceCard();
+ this.persist();
+ }
+
+ /** The dark style a tab was built with — the shell applies it at launch. */
+ get darkStylePref(): DarkStyle {
+ return this.darkStyle;
+ }
+
/** Change the dock icon mode: push to the dock + persist. */
private setLogoMode(mode: LogoMode): void {
this.logoMode = mode;
@@ -1553,7 +1581,7 @@ class App {
/** Push the resolved dock icon variant to the main process (best-effort). */
private syncDockIcon(): void {
- void host.invoke("appearance:dockIcon", { variant: this.dockVariant() }).catch(() => {});
+ void host.invoke("appearance:dockIcon", { variant: this.dockVariant(), style: this.darkStyle }).catch(() => {});
}
/** Step back in the in-app navigation history (⌘[ / topbar chevron). */
@@ -5526,6 +5554,51 @@ class App {
}
markSegment(seg, "Theme");
+ // Dark style: the two dark looks, each shown as what it IS — its Dock
+ // tile beside a swatch of its window — and applying to both at once.
+ const styleLabel = el("div", "settings-field-label");
+ styleLabel.textContent = "Dark style";
+ const styleSub = el("div", "settings-sub");
+ styleSub.textContent =
+ "The dark window and the Dock icon, together. Graphite matches the icon macOS shows while " +
+ "GitStudio is closed. With Neon the Dock icon turns neon while GitStudio runs; macOS still " +
+ "shows the Graphite icon while it is closed.";
+ const styleRow = el("div", "settings-style-row");
+ const styleInfo: Record = {
+ graphite: { name: "Graphite", blurb: "Soft grey, the icon macOS shows" },
+ neon: { name: "Neon", blurb: "Near-black, more violet" },
+ };
+ const styleBtns: HTMLElement[] = [];
+ for (const id of DARK_STYLES) {
+ const b = el("button", "settings-style-opt" + (this.darkStyle === id ? " active" : ""));
+ b.dataset.style = id;
+ const icon = el("img", "settings-style-icon") as HTMLImageElement;
+ icon.src = previewIconFor(id);
+ icon.alt = "";
+ // A few painted boxes in the style's own colours (app.css
+ // .settings-style-swatch[data-style]): the ground, the sidebar, a row,
+ // and a lit selected row — what the window will look like.
+ const swatch = el("div", "settings-style-swatch");
+ swatch.dataset.style = id;
+ swatch.setAttribute("aria-hidden", "true");
+ swatch.append(el("div", "sw-rail"), el("div", "sw-row sw-sel"), el("div", "sw-row"), el("div", "sw-row sw-short"));
+ const text = el("div", "settings-style-text");
+ const name = el("div", "settings-style-name");
+ name.textContent = styleInfo[id].name;
+ const blurb = el("div", "settings-style-blurb");
+ blurb.textContent = styleInfo[id].blurb;
+ text.append(name, blurb);
+ b.append(icon, swatch, text);
+ b.addEventListener("click", () => {
+ this.setDarkStyle(id);
+ styleBtns.forEach((x) => x.classList.toggle("active", x === b));
+ syncLogoPreview();
+ });
+ styleBtns.push(b);
+ styleRow.appendChild(b);
+ }
+ markSegment(styleRow, styleLabel);
+
// App icon: sits right next to the theme control, same card. "Auto" matches
// the theme; the others pin the dock mark regardless of the in-app theme.
const logoLabel = el("div", "settings-field-label");
@@ -5538,7 +5611,7 @@ class App {
const preview = el("img", "settings-logo-preview") as HTMLImageElement;
const syncLogoPreview = (): void => {
const light = this.dockVariant() === "light";
- preview.src = light ? "./icon-light.png" : "./icon.png";
+ preview.src = light ? "./icon-light.png" : previewIconFor(this.darkStyle);
preview.alt = `Dock icon preview — the ${light ? "light" : "dark"} mark`;
preview.title = preview.alt;
};
@@ -5598,11 +5671,12 @@ class App {
// keep-alive view fires on precisely the path that matters.
btns.forEach((b, i) => b.classList.toggle("active", modes[i].id === this.themeMode));
logoBtns.forEach((b, i) => b.classList.toggle("active", logoModes[i].id === this.logoMode));
+ styleBtns.forEach((b) => b.classList.toggle("active", b.dataset.style === this.darkStyle));
// `aria-pressed` too, not just the class. `markSegment` keeps it in step
// from a delegated CLICK listener, so a theme changed from anywhere else
// — ⌘K, the menu, an OS flip — moved the highlight while leaving the
// announced state on the button that is no longer chosen.
- for (const b of [...btns, ...logoBtns]) {
+ for (const b of [...btns, ...logoBtns, ...styleBtns]) {
b.setAttribute("aria-pressed", String(b.classList.contains("active")));
}
syncLogoPreview();
@@ -5614,7 +5688,7 @@ class App {
// so it lost the border, gained a plinth, and stands off by --sp-4.
logoRow.append(logoSeg, preview);
- body.append(sub, seg, logoLabel, logoSub, logoRow, picRow);
+ body.append(sub, seg, styleLabel, styleSub, styleRow, logoLabel, logoSub, logoRow, picRow);
return card;
}
@@ -11112,6 +11186,9 @@ class TabShell {
? prefs.themeMode
: "system";
applyTheme(resolveTheme(mode));
+ // theme-boot.js already put the style's class on for the first
+ // frame; this is the renderer agreeing with it.
+ applyDarkStyle(parseDarkStyle(prefs.darkStyle));
followSystemTheme((osTheme) => {
if ((this.active?.themeModePref ?? "system") !== "system") return;
applyTheme(osTheme);
diff --git a/apps/desktop/src/renderer/styles/app.css b/apps/desktop/src/renderer/styles/app.css
index 9af40f7e..4e9d2b99 100644
--- a/apps/desktop/src/renderer/styles/app.css
+++ b/apps/desktop/src/renderer/styles/app.css
@@ -177,6 +177,67 @@ body.vscode-dark {
var(--app-bg);
}
+/* ── Neon dark tokens ──────────────────────────────────────────────────────
+ Settings ▸ Appearance ▸ Dark style ▸ Neon (shared/darkStyle.ts): the dark
+ theme on the neon icon's own ground — the near-black tile of
+ brand/gitstudio-icon.svg (#08080C–#1B1B27) — with the brand violet carried
+ further into selection, focus and the canvas glow. Graphite, the default, is
+ the block above, unchanged.
+
+ Only tokens move. Every surface already paints from these names, so the
+ whole app follows without one rule of its own; theme-boot.js puts the class
+ on before the first frame and index.html's #launch reads the same
+ ground (test/launchScreen.test.ts), as does main's window background
+ (shared/darkStyle.ts WINDOW_BACKGROUND.neon).
+
+ Contrast is held to Graphite's (harness/contrast.mjs over both): the ground
+ is darker, so text and muted ink keep or gain ratio; the lifted surfaces
+ carry a violet cast instead of slate blue. */
+body.vscode-dark.gs-neon {
+ --vscode-foreground: #dddbe8;
+ --vscode-descriptionForeground: #938fab;
+ --vscode-editor-background: #0e0e15;
+ --vscode-editor-foreground: #dddbe8;
+ --vscode-editorLineNumber-foreground: #7f7b99;
+ --vscode-editorLineNumber-activeForeground: #938fab;
+ --vscode-editorCursor-foreground: #dddbe8;
+ --vscode-editor-selectionBackground: #33295e;
+ --vscode-editor-inactiveSelectionBackground: #272042;
+ --vscode-editorWidget-background: #16141f;
+ --vscode-editorWidget-border: #292539;
+ --vscode-scrollbarSlider-background: #4d476955;
+ --vscode-scrollbarSlider-hoverBackground: #5d577c88;
+ --vscode-scrollbarSlider-activeBackground: #6c6690aa;
+ --vscode-editorIndentGuide-background: #201d2e;
+
+ --app-bg: #09090e;
+ --app-panel: #0e0e15;
+ --app-elevated: #14131d;
+ --app-border: #221f31;
+ --app-hover: #1b1929;
+ --app-active: #28213f;
+ --app-muted: #938fab;
+
+ --gs-accent-ink: #b39bff;
+
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.40), 0 1px 1px rgba(0, 0, 0, 0.28);
+ --shadow-md: 0 6px 18px rgba(0, 0, 0, 0.50), 0 1px 3px rgba(0, 0, 0, 0.36);
+ --shadow-lg: 0 18px 44px rgba(0, 0, 0, 0.60), 0 4px 12px rgba(0, 0, 0, 0.38);
+ --shadow-pop: 0 20px 56px rgba(0, 0, 0, 0.68), 0 2px 10px rgba(0, 0, 0, 0.46);
+ /* The violet, more present: a lit selection is lit a little brighter and
+ glows a little wider, and the focus ring and washes carry more of it. */
+ --ring: 0 0 0 3px color-mix(in srgb, var(--gs-accent) 34%, transparent);
+ --accent-soft: color-mix(in srgb, var(--gs-accent) 19%, transparent);
+ --accent-line: color-mix(in srgb, var(--gs-accent) 48%, var(--app-border));
+ --sel-fill: color-mix(in srgb, var(--gs-accent) 22%, transparent);
+ --sel-fill-strong: color-mix(in srgb, var(--gs-accent) 32%, transparent);
+ --sel-glow: 0 0 20px -4px color-mix(in srgb, var(--gs-accent) 85%, transparent);
+ --sel-glow-soft: 0 0 14px -5px color-mix(in srgb, var(--gs-accent) 75%, transparent);
+ --canvas: radial-gradient(1200px 560px at 50% -340px,
+ color-mix(in srgb, var(--gs-accent) 13%, transparent), transparent 70%),
+ var(--app-bg);
+}
+
/* ── Light theme tokens ────────────────────────────────────────────────────── */
/* ── The VS Code vocabulary the SHARED packages speak ─────────────────────
On `body`, unqualified, so BOTH themes match it. It lived in the
@@ -4350,6 +4411,81 @@ body.resizing-h .cmp-diffpane .monaco-editor { pointer-events: none; }
pointer-events: none;
}
+/* Dark style: two option cards, each its Dock tile and a swatch of its
+ window. Picked = lit (tinted fill + soft glow), never outlined — the owner's
+ rule for every selected thing. The swatch paints in ITS style's colours,
+ not the live tokens, so both read correctly whichever one is on; the values
+ are the two dark token blocks' (--app-bg, --app-panel, --app-hover, and the
+ selection's accent tint). */
+.settings-style-row { display: flex; flex-wrap: wrap; gap: var(--sp-3); }
+.settings-card-body > * + .settings-style-row { margin-top: 10px; }
+.settings-card-body > .settings-field-label + .settings-style-row { margin-top: 0; }
+.settings-style-opt {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-3);
+ min-width: 0;
+ padding: 10px 14px 10px 10px;
+ border: none;
+ border-radius: var(--r-lg);
+ background: var(--app-elevated);
+ box-shadow: var(--sheen);
+ color: var(--vscode-foreground);
+ font-family: inherit;
+ text-align: left;
+ cursor: pointer;
+ transition: background var(--dur-1) var(--ease), box-shadow var(--dur-2) var(--ease), transform var(--dur-1) var(--ease);
+}
+.settings-style-opt:hover { background: var(--app-hover); }
+.settings-style-opt:active { transform: translateY(0.5px); }
+.settings-style-opt.active { background: var(--sel-fill); box-shadow: var(--sheen), var(--sel-glow); }
+.settings-style-opt:focus-visible { outline: none; box-shadow: var(--sheen), var(--ring); }
+.settings-style-icon { width: 44px; height: 44px; flex: none; pointer-events: none; }
+.settings-style-swatch {
+ --sw-bg: #0d1016;
+ --sw-panel: #12151c;
+ --sw-row: #232c40;
+ --sw-sel: color-mix(in srgb, #7c5cf0 18%, #0d1016);
+ --sw-glow: 0 0 10px -3px color-mix(in srgb, #7c5cf0 70%, transparent);
+ position: relative;
+ width: 76px;
+ height: 46px;
+ flex: none;
+ border-radius: var(--r-sm);
+ background: var(--sw-bg);
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12), 0 1px 3px rgba(0, 0, 0, 0.35);
+ overflow: hidden;
+}
+.settings-style-swatch[data-style="neon"] {
+ --sw-bg: #09090e;
+ --sw-panel: #0e0e15;
+ --sw-row: #28213f;
+ --sw-sel: color-mix(in srgb, #7c5cf0 22%, #09090e);
+ --sw-glow: 0 0 12px -3px color-mix(in srgb, #7c5cf0 85%, transparent);
+ background:
+ radial-gradient(90px 40px at 50% -22px, color-mix(in srgb, #7c5cf0 22%, transparent), transparent 70%),
+ var(--sw-bg);
+}
+.settings-style-swatch .sw-rail { position: absolute; inset: 0 auto 0 0; width: 18px; background: var(--sw-panel); }
+.settings-style-swatch .sw-row {
+ position: absolute;
+ left: 24px;
+ right: 8px;
+ height: 7px;
+ border-radius: 3px;
+ background: var(--sw-row);
+}
+.settings-style-swatch .sw-row:nth-child(2) { top: 8px; }
+.settings-style-swatch .sw-row:nth-child(3) { top: 20px; }
+.settings-style-swatch .sw-row:nth-child(4) { top: 32px; }
+.settings-style-swatch .sw-sel { background: var(--sw-sel); box-shadow: var(--sw-glow); }
+.settings-style-swatch .sw-short { right: 26px; }
+.settings-style-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
+.settings-style-name { font-size: 13px; font-weight: 600; }
+.settings-style-blurb { font-size: 12px; color: var(--app-muted); }
+.settings-style-opt.active .settings-style-name { color: var(--sel-ink); }
+.settings-style-opt.active .settings-style-blurb { color: var(--sel-muted); }
+
/* Account */
.settings-account-who { display: flex; align-items: center; gap: 9px; }
.settings-account-who .codicon { font-size: 18px; }
diff --git a/apps/desktop/src/renderer/theme-boot.js b/apps/desktop/src/renderer/theme-boot.js
index 9a2713bb..d5b9f445 100644
--- a/apps/desktop/src/renderer/theme-boot.js
+++ b/apps/desktop/src/renderer/theme-boot.js
@@ -20,22 +20,35 @@
// what stops a window pinned to Light from opening on a dark frame when the
// OS is dark — the launch screen below is the first thing anyone sees.
var saved = null;
+ // Settings ▸ Appearance ▸ Dark style (shared/darkStyle.ts): "neon" puts the
+ // Neon token block's class on beside the theme's, so the first frame
+ // is painted on that ground; anything else is Graphite, the default. The
+ // harness passes ?darkstyle= the way it passes ?theme=.
+ var style = "graphite";
+ var prefs = null;
+ try {
+ prefs = JSON.parse(localStorage.getItem("gitstudio.ui.prefs") || "null");
+ } catch (e) {
+ /* no storage, or a blob that does not parse — follow the OS */
+ }
if (forced !== "light" && forced !== "dark") {
- try {
- var prefs = JSON.parse(localStorage.getItem("gitstudio.ui.prefs") || "null");
- var mode = prefs && prefs.themeMode;
- if (mode === "light" || mode === "dark") saved = mode;
- } catch (e) {
- /* no storage, or a blob that does not parse — follow the OS */
- }
+ var mode = prefs && prefs.themeMode;
+ if (mode === "light" || mode === "dark") saved = mode;
+ }
+ var forcedStyle = null;
+ try {
+ forcedStyle = new URLSearchParams(location.search).get("darkstyle");
+ } catch (e) {
+ /* as above */
}
+ if (forcedStyle === "neon" || (forcedStyle !== "graphite" && prefs && prefs.darkStyle === "neon")) style = "neon";
var light =
forced === "light" ||
(forced !== "dark" &&
(saved
? saved === "light"
: !!(window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches)));
- document.body.className = light ? "vscode-light" : "vscode-dark";
+ document.body.className = (light ? "vscode-light" : "vscode-dark") + (style === "neon" ? " gs-neon" : "");
// Tag the OS on so the topbar can reserve room for the macOS traffic
// lights ONLY on macOS (Windows/Linux draw their window controls elsewhere).
@@ -73,7 +86,7 @@
var bridge = window.gitstudio;
if (bridge && typeof bridge.invoke === "function") {
try {
- var p = bridge.invoke("window:launchPainted", { theme: light ? "light" : "dark" });
+ var p = bridge.invoke("window:launchPainted", { theme: light ? "light" : "dark", style: style });
if (p && typeof p.catch === "function") p.catch(function () {});
} catch (e) {
/* main shows the window on ready-to-show regardless */
diff --git a/apps/desktop/src/shared/darkStyle.ts b/apps/desktop/src/shared/darkStyle.ts
new file mode 100644
index 00000000..83f99e6a
--- /dev/null
+++ b/apps/desktop/src/shared/darkStyle.ts
@@ -0,0 +1,79 @@
+/**
+ * Settings ▸ Appearance ▸ Dark style — which of the two dark looks GitStudio
+ * wears, in the window AND in the Dock.
+ *
+ * graphite the grey tile macOS draws for the app while it is CLOSED (the
+ * Icon Composer icon, build/AppIcon.icon, on its #2B2B30 ground)
+ * and the app's slate dark palette. The default, because it is the
+ * only dark look the Dock can show both before and after the app
+ * opens: the owner saw the icon change the moment GitStudio started.
+ * neon the near-black tile with the violet cube (brand/gitstudio-icon.svg)
+ * and a deeper, more violet dark palette.
+ *
+ * Pure data, shared by main (window background, Dock tile), the renderer
+ * (body class, Settings) and the tests that hold the three in step with
+ * app.css and index.html. theme-boot.js cannot import it (it runs before the
+ * bundle) and repeats the pref key and class; test/darkStyle.test.ts keeps
+ * the copies equal.
+ */
+
+export type DarkStyle = "graphite" | "neon";
+
+export const DARK_STYLES: readonly DarkStyle[] = ["graphite", "neon"];
+
+/** What a person who never chose gets: the look the closed app already has. */
+export const DEFAULT_DARK_STYLE: DarkStyle = "graphite";
+
+/** The body class that switches app.css to the Neon token block. Graphite is
+ * the plain `body.vscode-dark` block and needs no class. */
+export const NEON_CLASS = "gs-neon";
+
+/** The field in the renderer's prefs blob (localStorage gitstudio.ui.prefs). */
+export const DARK_STYLE_PREF = "darkStyle";
+
+/** Anything read from storage or IPC → a style; unknown values are the default. */
+export function parseDarkStyle(v: unknown): DarkStyle {
+ return v === "neon" || v === "graphite" ? v : DEFAULT_DARK_STYLE;
+}
+
+/**
+ * The window's own background for a theme: the renderer's `--app-bg`, which
+ * is also the launch screen's ground (test/launchScreen.test.ts). Shown for
+ * the frames before the page paints, so it must be the ground that page
+ * paints — per dark style.
+ */
+export const WINDOW_BACKGROUND = {
+ light: "#eef1f5",
+ graphite: "#0d1016",
+ neon: "#09090e",
+} as const;
+
+export function windowBackgroundFor(theme: "dark" | "light", style: DarkStyle): string {
+ return theme === "light" ? WINDOW_BACKGROUND.light : WINDOW_BACKGROUND[style];
+}
+
+/**
+ * What the Dock should show while the app runs.
+ *
+ * "bundle" nothing — the icon macOS already draws from the app bundle.
+ * That IS the closed icon (Graphite), so there is nothing to
+ * change and nothing to get subtly wrong: not re-drawing it is the
+ * only way the running icon is guaranteed identical.
+ * a file one of the margined PNGs esbuild copies into dist/renderer.
+ *
+ * `bundleIsGraphite` is true in a packaged macOS build (the bundle carries
+ * AppIcon.icon / the graphite icns). A dev build's bundle is Electron's own,
+ * so it paints the graphite tile from its PNG instead.
+ */
+export type DockIcon = "bundle" | "dock.png" | "dock-light.png" | "dock-graphite.png";
+
+export function dockIconFor(variant: "dark" | "light", style: DarkStyle, bundleIsGraphite: boolean): DockIcon {
+ if (variant === "light") return "dock-light.png";
+ if (style === "neon") return "dock.png";
+ return bundleIsGraphite ? "bundle" : "dock-graphite.png";
+}
+
+/** The full-bleed tile Settings previews each style with (dist/renderer). */
+export function previewIconFor(style: DarkStyle): string {
+ return style === "neon" ? "./icon.png" : "./icon-graphite.png";
+}
diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts
index a915c608..c3c2b163 100644
--- a/apps/desktop/src/shared/ipc.ts
+++ b/apps/desktop/src/shared/ipc.ts
@@ -2895,14 +2895,16 @@ export interface IpcChannels {
"actions:setVariable": [{ name: string; value: string }, CommitActionResult];
"actions:deleteVariable": [string, CommitActionResult];
// ── Appearance ──
- /** Set the macOS dock icon to the light/dark brand mark. Renderer resolves
- * the effective variant (it alone knows the in-app theme override). */
- "appearance:dockIcon": [{ variant: "dark" | "light" }, void];
+ /** Set the macOS dock icon to the light/dark brand mark, in the dark style
+ * picked in Settings (shared/darkStyle.ts). Renderer resolves the effective
+ * variant (it alone knows the in-app theme override). `style` is optional
+ * so an older page's call still reads as the default, Graphite. */
+ "appearance:dockIcon": [{ variant: "dark" | "light"; style?: "graphite" | "neon" }, void];
// ── Window ──
/** The launch screen is painted and the bundle has started (launch-reveal.js;
* theme-boot.js as a fallback): show the still-hidden window now, in that
* screen's theme, instead of after the whole bundle. */
- "window:launchPainted": [{ theme: "dark" | "light" }, void];
+ "window:launchPainted": [{ theme: "dark" | "light"; style?: "graphite" | "neon" }, void];
}
export type IpcChannel = keyof IpcChannels;
diff --git a/apps/desktop/test/darkStyle.test.ts b/apps/desktop/test/darkStyle.test.ts
new file mode 100644
index 00000000..13a38e60
--- /dev/null
+++ b/apps/desktop/test/darkStyle.test.ts
@@ -0,0 +1,177 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import {
+ DARK_STYLE_PREF,
+ DARK_STYLES,
+ DEFAULT_DARK_STYLE,
+ NEON_CLASS,
+ dockIconFor,
+ parseDarkStyle,
+ previewIconFor,
+} from "../src/shared/darkStyle";
+import { DockAppearance } from "../src/main/dockAppearance";
+
+// Settings ▸ Appearance ▸ Dark style: Graphite or Neon, for the window AND the
+// Dock. The owner's complaint was the Dock icon changing the moment the app
+// opened; these pin the rules that stop it.
+
+const ROOT = join(__dirname, "../../..");
+const SRC = join(__dirname, "../src");
+const appCss = readFileSync(join(SRC, "renderer/styles/app.css"), "utf8");
+const themeBoot = readFileSync(join(SRC, "renderer/theme-boot.js"), "utf8");
+const launchReveal = readFileSync(join(SRC, "renderer/launch-reveal.js"), "utf8");
+const esbuild = readFileSync(join(__dirname, "../esbuild.js"), "utf8");
+const rendererTs = readFileSync(join(SRC, "renderer/renderer.ts"), "utf8");
+
+test("the default is Graphite — the look macOS already shows for the closed app", () => {
+ assert.equal(DEFAULT_DARK_STYLE, "graphite");
+ assert.deepEqual([...DARK_STYLES], ["graphite", "neon"]);
+ assert.equal(parseDarkStyle(undefined), "graphite");
+ assert.equal(parseDarkStyle("neon"), "neon");
+ assert.equal(parseDarkStyle("graphite"), "graphite");
+ for (const junk of ["NEON", "", null, 1, {}, "system"]) assert.equal(parseDarkStyle(junk), "graphite", String(junk));
+});
+
+test("each style maps to its Dock tile; Graphite in a packaged Mac build is the bundle's own icon", () => {
+ // Packaged macOS: the bundle icon IS Graphite (AppIcon.icon on 26, the
+ // graphite icns before it) — so the app draws nothing over it.
+ assert.equal(dockIconFor("dark", "graphite", true), "bundle");
+ assert.equal(dockIconFor("dark", "neon", true), "dock.png");
+ assert.equal(dockIconFor("light", "graphite", true), "dock-light.png", "Light is unchanged");
+ assert.equal(dockIconFor("light", "neon", true), "dock-light.png", "Light is unchanged");
+ // A dev build's bundle icon is Electron's, so Graphite needs its tile.
+ assert.equal(dockIconFor("dark", "graphite", false), "dock-graphite.png");
+ assert.equal(dockIconFor("dark", "neon", false), "dock.png");
+});
+
+test("every tile the mapping names ships in dist/renderer, from a brand file that exists", () => {
+ const brand = new Map();
+ for (const m of esbuild.matchAll(/"(brand\/[\w.-]+)":\s*"([\w.-]+)"/g)) brand.set(m[2], m[1]);
+ const named = new Set();
+ for (const v of ["dark", "light"] as const)
+ for (const s of DARK_STYLES) for (const b of [true, false]) named.add(dockIconFor(v, s, b));
+ named.delete("bundle");
+ for (const s of DARK_STYLES) named.add(previewIconFor(s).replace(/^\.\//, ""));
+ for (const file of named) {
+ const src = brand.get(file);
+ assert.ok(src, `esbuild.js copies something to ${file}`);
+ assert.ok(existsSync(join(ROOT, src)), `${src} exists`);
+ }
+});
+
+test("the closed-app icns for macOS 11–15 is the Graphite tile, so closed = open everywhere", () => {
+ const compile = readFileSync(join(__dirname, "../build/compile-icon.sh"), "utf8");
+ assert.match(compile, /Image\.open\("\.\.\/\.\.\/\.\.\/brand\/gitstudio-dock-graphite-1024\.png"\)/);
+});
+
+test("theme-boot.js, launch-reveal.js and app.css speak the shared names", () => {
+ // theme-boot.js runs before the bundle and cannot import darkStyle.ts.
+ assert.equal(NEON_CLASS, "gs-neon");
+ assert.equal(DARK_STYLE_PREF, "darkStyle");
+ assert.match(themeBoot, /prefs\.darkStyle === "neon"/);
+ assert.match(themeBoot, /" gs-neon"/);
+ assert.match(launchReveal, /gs-neon/);
+ assert.match(appCss, /^body\.vscode-dark\.gs-neon \{/m, "one Neon token block");
+ // The renderer persists it under the same key.
+ assert.match(rendererTs, /darkStyle: this\.darkStyle,/);
+ assert.match(rendererTs, /this\.darkStyle = parseDarkStyle\(prefs\.darkStyle\);/);
+});
+
+test("the Neon block only re-points tokens Graphite declares", () => {
+ const block = (sel: string): Map => {
+ const at = appCss.search(new RegExp(`^${sel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} \\{`, "m"));
+ assert.ok(at >= 0, sel);
+ const body = appCss.slice(at, appCss.indexOf("\n}", at)).replace(/\/\*[\s\S]*?\*\//g, "");
+ return new Map([...body.matchAll(/(--[\w-]+):\s*([^;]+);/g)].map((m) => [m[1], m[2].trim()]));
+ };
+ const graphite = block("body.vscode-dark");
+ const neon = block("body.vscode-dark.gs-neon");
+ assert.ok(neon.size >= 20, `the Neon block is found (${neon.size} tokens)`);
+ for (const name of neon.keys()) assert.ok(graphite.has(name), `${name} is a Graphite token`);
+ // The swatches in Settings paint each style's own ground and sidebar.
+ const sw = (sel: string, name: string) => new RegExp(`${sel}[^{]*\\{[^}]*${name}:\\s*([^;]+);`).exec(appCss)?.[1].trim();
+ assert.equal(sw("\\.settings-style-swatch ", "--sw-bg"), graphite.get("--app-bg"));
+ assert.equal(sw("\\.settings-style-swatch ", "--sw-panel"), graphite.get("--app-panel"));
+ assert.equal(sw('\\.settings-style-swatch\\[data-style="neon"\\]', "--sw-bg"), neon.get("--app-bg"));
+ assert.equal(sw('\\.settings-style-swatch\\[data-style="neon"\\]', "--sw-panel"), neon.get("--app-panel"));
+});
+
+/** A DockAppearance over a temp userData, recording every Dock call. */
+function dock(bundleIsGraphite = true) {
+ const dir = mkdtempSync(join(tmpdir(), "gs-dock-"));
+ const file = join(dir, "sub", "gitstudio-appearance.json");
+ const calls: Array = [];
+ const make = () =>
+ new DockAppearance(file, { setIcon: (i) => calls.push(i === null ? null : i.replace(/^.*[\\/]/, "")), rendererDir: "/r", bundleIsGraphite });
+ return { dir, file, calls, make, done: () => rmSync(dir, { recursive: true, force: true }) };
+}
+
+test("Graphite never touches the Dock: opening the app shows what the closed app showed", () => {
+ const d = dock();
+ const a = d.make();
+ a.load(); // nothing saved yet
+ a.apply("dark");
+ a.report("dark", "graphite");
+ a.report("dark", undefined); // an older page: no style → Graphite
+ assert.deepEqual(d.calls, [], "no setIcon at all");
+ d.done();
+});
+
+test("Neon draws its tile, and going back to Graphite hands the tile back to macOS", () => {
+ const d = dock();
+ const a = d.make();
+ a.report("dark", "neon");
+ a.report("dark", "graphite");
+ a.report("light", "graphite");
+ a.report("dark", "graphite");
+ assert.deepEqual(d.calls, ["dock.png", null, "dock-light.png", null]);
+ d.done();
+});
+
+test("a dev build paints Graphite from its PNG", () => {
+ const d = dock(false);
+ d.make().report("dark", "graphite");
+ assert.deepEqual(d.calls, ["dock-graphite.png"]);
+ d.done();
+});
+
+test("the report is remembered, so the next start paints the right tile before the page speaks", () => {
+ const d = dock();
+ const first = d.make();
+ first.report("dark", "neon");
+ assert.deepEqual(JSON.parse(readFileSync(d.file, "utf8")), { style: "neon", variant: "dark" });
+ const next = d.make();
+ next.load();
+ assert.equal(next.style, "neon");
+ assert.equal(next.variant, "dark");
+ next.apply(next.variant ?? "dark");
+ assert.deepEqual(d.calls, ["dock.png", "dock.png"], "the second start's first act is the Neon tile");
+ d.done();
+});
+
+test("an unreadable appearance file is the defaults, never a throw", () => {
+ const d = dock();
+ const a = d.make();
+ a.report("dark", "graphite"); // creates the folder
+ writeFileSync(d.file, "{not json");
+ const b = d.make();
+ b.load();
+ assert.equal(b.style, "graphite");
+ assert.equal(b.variant, undefined, "no remembered variant: main falls back to the OS scheme");
+ d.done();
+});
+
+test("a failing Dock is harmless", () => {
+ const a = new DockAppearance(join(tmpdir(), "gs-no-such", "x.json"), {
+ setIcon: () => {
+ throw new Error("no dock");
+ },
+ rendererDir: "/r",
+ bundleIsGraphite: true,
+ });
+ assert.doesNotThrow(() => a.report("dark", "neon"));
+ rmSync(join(tmpdir(), "gs-no-such"), { recursive: true, force: true });
+});
diff --git a/apps/desktop/test/expectedConditions.test.ts b/apps/desktop/test/expectedConditions.test.ts
index cfc18bc3..e75a5748 100644
--- a/apps/desktop/test/expectedConditions.test.ts
+++ b/apps/desktop/test/expectedConditions.test.ts
@@ -25,7 +25,7 @@
//
// The extension has no twin of this: its reporter fires only from
// showGitError(), which is reached only after a git command exits non-zero, and
-// its handful of `ok:false` values (gitBrain's Test-connection answer,
+// its handful of `ok:false` values (the AI layer's Test-connection answer,
// commitView's `commitDone` messages) never
// reach ErrorReporter at all. There is nothing there to mark.
diff --git a/apps/desktop/test/installScript.test.ts b/apps/desktop/test/installScript.test.ts
index f23ccb50..81b67107 100644
--- a/apps/desktop/test/installScript.test.ts
+++ b/apps/desktop/test/installScript.test.ts
@@ -13,6 +13,9 @@ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod
import { tmpdir } from "node:os";
import { join } from "node:path";
+/** Every regex metacharacter, not only the dots in a version. */
+const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+
const SCRIPT = join(__dirname, "..", "..", "..", "scripts", "install.sh");
const skip = process.platform === "win32" && "the installer is for macOS and Linux";
@@ -58,7 +61,8 @@ test("macOS 11 with the last release that runs there pinned: let through, and it
const r = install("Darwin", "11.7.10", { GITSTUDIO_VERSION: pin });
assert.doesNotMatch(r.stderr, /need macOS 12/, pin);
const version = pin.replace(/^(app-)?v/, "");
- assert.match(r.curled, new RegExp(`releases/download/app-v${version.replace(/\./g, "\\.")}/GitStudio-${version.replace(/\./g, "\\.")}-arm64\\.dmg`), pin);
+ const v = escapeRegExp(version);
+ assert.match(r.curled, new RegExp(`releases/download/app-v${v}/GitStudio-${v}-arm64\\.dmg`), pin);
}
// …but not a release that needs 12.
const r = install("Darwin", "11.7.10", { GITSTUDIO_VERSION: "2.3.0" });
diff --git a/apps/desktop/test/launchScreen.test.ts b/apps/desktop/test/launchScreen.test.ts
index dc7dc63e..40e6fab9 100644
--- a/apps/desktop/test/launchScreen.test.ts
+++ b/apps/desktop/test/launchScreen.test.ts
@@ -4,6 +4,7 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import { runInNewContext } from "node:vm";
import { LAUNCH_FADE_MS } from "../src/renderer/launchScreen";
+import { WINDOW_BACKGROUND, windowBackgroundFor } from "../src/shared/darkStyle";
// The launch screen is the window's first frame: inline CSS + inline SVG in
// index.html, painted before the bundle runs, faded out by launchScreen.ts.
@@ -65,15 +66,20 @@ test("the bundle is deferred, so the launch screen paints while it compiles", ()
assert.match(html, /