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, /