diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3aa4c8..4dc6d3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: run: npm audit --omit=dev --audit-level=high rust-check: - runs-on: macos-latest + runs-on: macos-26 steps: - name: Check out repository uses: actions/checkout@v6 diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml new file mode 100644 index 0000000..cb95930 --- /dev/null +++ b/.github/workflows/deploy-pages.yml @@ -0,0 +1,36 @@ +name: deploy-pages + +# Deploys the static marketing site in website/ to GitHub Pages whenever it +# changes on main. Requires Pages to be enabled with "Source: GitHub Actions" +# in repo Settings -> Pages (one-time). Runs on demand via workflow_dispatch too. +on: + push: + branches: [main] + paths: + - 'website/**' + - '.github/workflows/deploy-pages.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: website + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0c546fd..4456e99 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,7 +73,7 @@ jobs: build-macos: permissions: contents: read - runs-on: macos-latest + runs-on: macos-26 needs: decide if: ${{ needs.decide.outputs.skip != 'true' }} strategy: @@ -167,7 +167,7 @@ jobs: - name: Normalize macOS artifact filenames shell: bash - working-directory: src-tauri/target/${{ matrix.target }}/release/bundle + working-directory: target/${{ matrix.target }}/release/bundle env: ARCH: ${{ matrix.arch }} run: | @@ -186,9 +186,9 @@ jobs: with: name: release-macos-${{ matrix.arch }} path: | - src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg - src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz - src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig + target/${{ matrix.target }}/release/bundle/dmg/*.dmg + target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz + target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig if-no-files-found: error release: @@ -219,6 +219,7 @@ jobs: path: release-assets/macos-x86_64 - name: Group the changelog + id: group env: PREV_TAG: ${{ needs.decide.outputs.previous_tag }} VERSION: ${{ needs.decide.outputs.version }} @@ -227,13 +228,18 @@ jobs: [ -n "$PREV_TAG" ] && range="${PREV_TAG}..HEAD" git log "$range" --no-merges --pretty='%s' \ | node scripts/ci/gen-changelog.mjs > grouped-changelog.md + echo "chars=$(wc -c < grouped-changelog.md | tr -d ' ')" >> "$GITHUB_OUTPUT" echo "----- grouped-changelog.md -----" cat grouped-changelog.md - name: Rewrite the changelog (GitHub Models) id: changelog_ai + # gpt-5 via GitHub Models caps the request body near 4000 tokens; skip the + # AI rewrite for an oversized changelog (e.g. the first release, which spans + # all history) and fall back to the grouped changelog in the next step. + if: ${{ fromJSON(steps.group.outputs.chars) <= 10000 }} continue-on-error: true - uses: actions/ai-inference@v1 + uses: actions/ai-inference@v2 with: model: openai/gpt-5 system-prompt: | diff --git a/CLAUDE.md b/CLAUDE.md index b30d970..0b1f5d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,8 +5,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **Git It** — a native macOS git client (commit graph, branches, merges, rebases, working-copy diffs, remotes) with first-class commit-time editing. Also hosts the headless **agent** that lets the sibling iOS app drive local git remotely. Tauri 2 (Rust) + SvelteKit 5 (runes). -macOS only; window uses native `NSVisualEffect` vibrancy (glass panels). Private; `UNLICENSED` -— licensing is intentionally undecided, do not add a LICENSE. +macOS only; window uses native `NSVisualEffect` vibrancy (glass panels). Licensed +**CC BY-NC-SA 4.0** (source-available, noncommercial — see `LICENSE`); **not** OSI +open source, so never describe it as "open source". ## Commands @@ -28,7 +29,7 @@ Frontend-only changes hot-reload under `npm run tauri dev`. **Vitest and the bro cannot exercise Tauri-only paths** (anything behind `invoke`: native dialogs, working-copy staging, reword, real git). Those require a running `tauri dev`/`build`. -Requires system `git` on `PATH`. Commit-time editing additionally shells out to `git-filter-repo`. +Requires system `git` and `python3` on `PATH` (both ship with the Xcode Command Line Tools). Commit-time editing runs the **bundled** `git-filter-repo` script (`src-tauri/resources/git-filter-repo/`) via the host `python3` — it is not a `PATH` binary. ## Architecture @@ -47,9 +48,12 @@ Three-layer split; a **cargo workspace** (root `Cargo.toml`, members below) with `relay.rs` (Convex client + message loop), `auth.rs`, `repos.rs`, `crypto/` (HPKE/Ed25519 E2E). - **`src/`** — SvelteKit 5 SPA (static adapter, single `+page.svelte` route). `lib/store.svelte.ts` is central runes state; `lib/gitActions.ts` calls Tauri commands; `lib/graph/` renders the lane - graph; `lib/diff/` is Shiki-highlighted diffs; `lib/github/` is the GitHub dashboard screen. + graph; `lib/diff/` is Shiki-highlighted diffs; `lib/github/` is the GitHub dashboard screen; + `lib/theme/` (`nerv.css`, `nerv-motion.css`, `themeMode.ts`) is the Classic↔NERV theme system. **Pure logic modules are unit-tested with vitest** and must stay Tauri-free: `dates.ts`, `fileTree.ts`, `refTree.ts`, `commitBody.ts`, graph windowing (`.test.ts` alongside each). +- **`website/`** — the static NERV-themed marketing site (git-it.app), deployed by + `.github/workflows/deploy-pages.yml`; plain HTML/CSS/JS, independent of the SvelteKit app build. **Data flow:** Svelte `invoke` (`@tauri-apps/api`) → `src-tauri/commands.rs` → `git-core` → shells out to system `git` (and `gh` for the GitHub screen). @@ -64,6 +68,8 @@ shells out to system `git` (and `gh` for the GitHub screen). whole app during the call (this exact bug froze the GitHub screen). - Business logic that can be pure belongs in a testable `src/lib/*.ts` module (vitest), not inside a `.svelte` component — the graph/date/tree logic is covered this way. +- **NERV theme CSS:** never use `:global()` in plain `src/lib/theme/nerv.css` — it isn't a Svelte + ` + +
+ + +
+
+
+ macOS git client · Apple Silicon + Intel + +

+ Bend the + timeline.REV 0.2.0 +

+ +

Commit-time editing — shift, set, or compress any range. Preview, then undo.

+ +

Git It is a fast, native git client for macOS — commit graph, branches, merges, per-hunk diffs, remotes, and a built-in GitHub dashboard. Everything runs locally. No account, no telemetry.

+ + + +
+ 0 Telemetry + Timelines + 100% Local + Auto-updates +
+
+ + +
+
+ +
+ +
+
+
Platform
macOS 12.3+
+
Architecture
Apple Silicon · Intel
+
Engine
Rust · Tauri 2 · Svelte 5
+
Price
Free · Open Source
+
+
+
diff --git a/docs/superpowers/plans/2026-07-06-bundle-filter-repo.md b/docs/superpowers/plans/2026-07-06-bundle-filter-repo.md new file mode 100644 index 0000000..6d8ad14 --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-bundle-filter-repo.md @@ -0,0 +1,79 @@ +# Bundle git-filter-repo + Actionable Prerequisites — Implementation Plan + +> Spec: `docs/superpowers/specs/2026-07-06-bundle-filter-repo-design.md`. Branch: `bundle-filter-repo`. +> The vendored script already exists at `src-tauri/resources/git-filter-repo/{git-filter-repo, COPYING.mit, README.md}`. + +**Gates (run the relevant ones after each task; all green before "done"):** +`cargo test -p git-core -p git-it` · `cargo clippy -p git-core -p git-it -- -D warnings` · `npm run check` · `npm test` · `npm run build` + +**Ordering is sequential and load-bearing** — this is a coupled change: Task 1 (git-core signatures) must compile before Task 2 (src-tauri uses them); Task 4 (`types.ts`) must match Task 1 (`types.rs`). Do not reorder. + +--- + +### Task 1 — git-core: parameterize filter-repo invocation + prerequisites (Rust) + +**Files:** `crates/git-core/src/types.rs`, `crates/git-core/src/rewrite.rs`, `crates/git-core/src/git_ops.rs` + +- `types.rs`: add to `PrerequisiteCheck`: `pub python3: bool`, `pub python3_version: Option`. Keep `filter_repo` / `filter_repo_version` (now = the *bundled* script). +- `rewrite.rs::rewrite_history`: add a parameter `filter_repo_argv: &[String]` (the invocation prefix, e.g. `["python3", "/…/git-filter-repo"]`). Replace `Command::new("git-filter-repo")` with `Command::new(&filter_repo_argv[0])` then `.args(&filter_repo_argv[1..])`, then the existing `--commit-callback …`, `--force --partial --replace-refs delete-no-add`, and optional `--refs` args. Update the spawn-error message to drop `brew install git-filter-repo` (say the bundled filter-repo failed to launch and reference Python). +- Extract the argv assembly into a pure, testable helper, e.g. `fn build_filter_repo_argv(prefix: &[String], callback: &str, refs: Option<&str>) -> Vec` and have `rewrite_history` use it, so it can be unit-tested without spawning. +- `git_ops.rs::check_prerequisites`: change signature to `check_prerequisites(filter_repo_argv: &[String]) -> PrerequisiteCheck`. Probe: `git --version` (existing), `python3 --version` → `python3`/`python3_version`, and ` --version` → `filter_repo`/`filter_repo_version`. Preserve the existing shell-out safety conventions. + +**Tests:** unit-test `build_filter_repo_argv` for a representative prefix + options (with and without `--refs`). Update any existing `check_prerequisites` callers/tests to pass a prefix. + +**Gate:** `cargo test -p git-core` + `cargo clippy -p git-core -- -D warnings`. + +--- + +### Task 2 — src-tauri: resource resolver, command wiring, install command (Rust) + +**Files:** `src-tauri/src/filter_repo.rs` (new), `src-tauri/src/commands.rs`, `src-tauri/src/lib.rs`, `src-tauri/tauri.conf.json` + +- `filter_repo.rs`: `pub fn filter_repo_argv(app: &tauri::AppHandle) -> Vec`. Resolve the bundled script: production via `app.path().resolve("resources/git-filter-repo/git-filter-repo", BaseDirectory::Resource)`; if that path doesn't exist (i.e. `tauri dev`), fall back to the source tree relative to `CARGO_MANIFEST_DIR` (`.../src-tauri/resources/git-filter-repo/git-filter-repo`). Return `vec!["python3".into(), ]`. +- `commands.rs`: `check_prerequisites` becomes an `AppHandle`-taking command that resolves the argv and calls `git_ops::check_prerequisites(&argv)`. `apply_rewrite` resolves the argv and passes it to `rewrite_history`. Add `#[tauri::command] fn install_command_line_tools() -> Result` that runs `xcode-select --install` and returns a friendly status (treat the "already installed" nonzero exit as an informative Ok/Err with a clear message). +- `lib.rs`: register `install_command_line_tools` in the invoke handler. +- `tauri.conf.json`: add the vendored dir under `bundle.resources` so `resources/git-filter-repo/**` ships in the `.app`. + +**Gate:** `cargo test -p git-it` + `cargo clippy -p git-it -- -D warnings`. + +--- + +### Task 3 — src-tauri integration test: real bundled-script run + +**Files:** `src-tauri/tests/filter_repo_bundled.rs` (new; or a gated `#[test]` module) + +- Resolve the dev resource path (source tree, `CARGO_MANIFEST_DIR`). Skip gracefully if `python3` is absent so the test never flakes on a bare box. +- Create a temp git repo with ≥2 commits (set `GIT_AUTHOR_*`/`GIT_COMMITTER_*` for determinism). Call `git_core::rewrite::rewrite_history(repo, &[DateMapping…], &RewriteOptions{…}, |_|{})` with prefix `["python3", ]`. Assert the targeted commit's committer/author date changed to the mapped value. + +**Gate:** `cargo test -p git-it` (runs on macOS + CI). + +--- + +### Task 4 — frontend: types, api, actionable banner (Svelte/TS) + +**Files:** `src/lib/types.ts`, `src/lib/api.ts`, `src/lib/components/PrereqBanner.svelte` + +- `types.ts`: add `python3: boolean` and `python3Version: string | null` to `PrerequisiteCheck` (match the Rust serde camelCase). +- `api.ts`: add `installCommandLineTools: () => invoke("install_command_line_tools")`. +- `PrereqBanner.svelte`: `ok = check.git && check.python3`. When `!git || !python3`, show one concise line + an **"Install Command Line Tools"** button that calls `api.installCommandLineTools()` and surfaces the returned status inline (and disables while running). Remove the `brew install git-filter-repo` block. If `check.python3 && !check.filterRepo` (packaging bug), show a short diagnostic line, no install button. + +**Gate:** `npm run check` + `npm test` + `npm run build`. + +--- + +### Task 5 — docs: README + CLAUDE.md + +**Files:** `README.md`, `CLAUDE.md` + +- README **Prerequisites**: replace the two `brew install` blocks with: Git It needs `git` and `python3`, both provided by the **Xcode Command Line Tools** (`xcode-select --install`); `git-filter-repo` is **bundled**. Keep the startup-banner mention (now offers a one-click install). Update the "No Python interpreter is bundled" sentence to: the interpreter is host-provided (CLT), and the `git-filter-repo` script is bundled. +- CLAUDE.md: update the note that currently says `git-filter-repo` must be on PATH — it's bundled; the app invokes it with host `python3`. + +**Gate:** none (docs). + +--- + +### Task 6 — full gates + adversarial review + manual preview + +- Run the entire gate suite green. +- Adversarial multi-lens review (packaging/path-resolution/notarization, argv+prereq correctness & shell-out safety, banner UX & Rust/TS type-sync). +- Manual: a real commit-time edit through the running app to confirm the bundled script drives a rewrite end-to-end. diff --git a/docs/superpowers/plans/2026-07-06-git-it-landing-page.md b/docs/superpowers/plans/2026-07-06-git-it-landing-page.md new file mode 100644 index 0000000..28e52a2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-git-it-landing-page.md @@ -0,0 +1,299 @@ +# Git It Landing Page — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the static "NERV console" marketing landing page for the Git It macOS git client, deployed to GitHub Pages at `git-it.app`. + +**Architecture:** A hand-authored static site — `index.html` (long-scroll), `guides.html`, `404.html`, one `styles.css`, one small `app.js`, self-hosted fonts, inline SVG illustrations. No framework, no build step, no runtime third-party requests. Verification is browser-based via the preview tools (serve → screenshot/inspect/snapshot/console), not unit tests. + +**Tech Stack:** HTML5, CSS3 (custom properties, grid/flex, `clamp()`), vanilla JS (`IntersectionObserver`), self-hosted woff2 (Anton, IBM Plex Sans, IBM Plex Mono), GitHub Pages. + +**Canonical design reference:** `design/hero-mockup-v1.html` in this repo is the approved implementation of the visual system, nav, hero, spec band, and hazard divider. Reuse its CSS/markup as the starting point; apply the deltas each task specifies (font swap + copy corrections). Full spec: `docs/superpowers/specs/2026-07-06-git-it-landing-page-design.md`. + +## Global Constraints + +*Every task's requirements implicitly include this section. Values are verbatim from the spec.* + +- **Platform copy:** target is **macOS 12.3+**, **Apple Silicon + Intel** native builds, **no Electron**. +- **Requirements copy:** users need **`git` + `python3` on `PATH`** (`xcode-select --install`). `git-filter-repo` is **bundled** — never tell users to install it. +- **Download trust line:** the release is **"Signed & notarized by Apple — opens with a normal double-click."** NEVER tell download users to right-click→Open or bypass Gatekeeper (that applies only to a self-built ad-hoc copy, mentioned only in a build-from-source aside if at all). +- **Privacy copy (precise):** "Local-first. No account, no analytics, no tracking. Repos and credentials stay on your Mac; the only network calls are the git/GitHub operations you trigger, plus a version check for updates." Footer site-scoped line: **"This site collects nothing — no analytics, no cookies, no third-party requests."** Never claim "100% local / everything runs locally." +- **License copy:** **"Free · source-available · noncommercial (CC BY-NC-SA 4.0)."** NEVER use the words **"open source."** Pair "built in the open" with the license line. Footnote: *"Source-available under CC BY-NC-SA 4.0 — not an OSI open-source license."* +- **No hard-coded version** anywhere (repo is at 0.1.0; don't print a `REV x.y.z`). +- **Download links** resolve to `https://github.com/ashproto/git-it/releases/latest` unless arch-stable asset names exist (see Task 9). +- **No runtime third-party requests:** fonts self-hosted; no CDNs, analytics, cookies, or web-font URLs. +- **Domain:** `git-it.app`. **GitHub repo:** `https://github.com/ashproto/git-it`. +- **Accent discipline:** orange (`--orange #F2542D`) is the only brand accent; green (`--phosphor #46E88B`) = status/data only; red (`--alert #FF4438`) = single hazard only. +- **Fonts:** display **Anton**; body **IBM Plex Sans**; mono **IBM Plex Mono** (self-hosted woff2). +- **Motion:** every animation gated behind `@media (prefers-reduced-motion: no-preference)`; reduced-motion shows final state. +- **A11y:** semantic landmarks, visible focus, descriptive `alt`/`aria` on illustrations, AA contrast. + +--- + +## File Structure + +``` +git-it-site/ +├── index.html # long-scroll landing (nav, hero, what's-different, spec, features, download, guides, faq, community, footer) +├── guides.html # two light guides (getting-started, commit-time-editing) +├── 404.html # "// SIGNAL LOST" +├── CNAME # git-it.app +├── robots.txt +├── sitemap.xml +├── site.webmanifest +├── assets/ +│ ├── css/styles.css # reset + tokens + all component CSS +│ ├── js/app.js # boot reveal + IntersectionObserver scroll-reveal + nav +│ ├── fonts/ # anton, ibm-plex-sans, ibm-plex-mono woff2 + fonts.css (@font-face) +│ └── img/ # favicon.svg, favicon.ico, apple-touch-icon.png, og.png (1200×630) +└── .claude/launch.json # static server for preview tools +``` + +Responsibilities: `styles.css` holds the token system + every component's styles (organized by clearly-commented section). `app.js` holds only progressive-enhancement JS (page works without it). Illustrations are **inline SVG** in the HTML so they theme via CSS variables and animate via CSS. `guides.html` and `404.html` reuse `styles.css`. + +--- + +## Task 1: Scaffold, tokens, fonts, meta, preview server + +**Files:** +- Create: `assets/css/styles.css`, `assets/fonts/fonts.css`, `assets/js/app.js`, `index.html`, `CNAME`, `robots.txt`, `sitemap.xml`, `site.webmanifest`, `.claude/launch.json` +- Create: `assets/fonts/*.woff2` (Anton, IBM Plex Sans 400/600, IBM Plex Mono 400/500) +- Create: `assets/img/favicon.svg` (hexagon+commit-graph glyph) + +**Interfaces:** +- Produces (consumed by all later tasks): CSS custom properties from spec §5.1 (`--void`, `--void2`, `--panel`, `--line`, `--line2`, `--bone`, `--haze`, `--haze-dim`, `--orange`, `--orange-deep`, `--phosphor`, `--alert`), font vars `--font-display` (Anton), `--font-body` (IBM Plex Sans), `--font-mono` (IBM Plex Mono), the `.wrap` container (max-width 1240px, responsive padding), and the `.page` ground/scanline/grid background. + +- [ ] **Step 1: Create the design-token + reset CSS.** In `assets/css/styles.css`, port the `:root` tokens, the `*{box-sizing}` reset, `.page` background (radial glows + scanlines + grid overlay), and `.wrap` container from `design/hero-mockup-v1.html`'s ` %sveltekit.head% diff --git a/src/lib/api.ts b/src/lib/api.ts index dd78798..3889975 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -54,6 +54,7 @@ export async function pickRepoFolder(initial?: string): Promise { export const api = { checkPrerequisites: () => invoke("check_prerequisites"), + installCommandLineTools: () => invoke("install_command_line_tools"), isGitRepo: (repo: string) => invoke("is_git_repo", { repo }), // ── auto-updater (desktop only) ────────────────────────────────────────── checkUpdateOnChannel: (channel: "stable" | "beta") => diff --git a/src/lib/components/AmendDialog.svelte b/src/lib/components/AmendDialog.svelte index 730c646..871812b 100644 --- a/src/lib/components/AmendDialog.svelte +++ b/src/lib/components/AmendDialog.svelte @@ -93,7 +93,7 @@ max-width: calc(100vw - 32px); background: var(--popover-bg, var(--panel-bg)); border: 1px solid var(--border); - border-radius: 10px; + border-radius: var(--radius-lg); padding: 16px 18px; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28); backdrop-filter: blur(20px) saturate(140%); @@ -113,7 +113,7 @@ color: var(--text-muted); } .mono { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); } .lbl { display: block; @@ -125,7 +125,7 @@ width: 100%; box-sizing: border-box; padding: 7px 10px; - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--input-bg); color: var(--text); @@ -167,7 +167,7 @@ } button { padding: 6px 14px; - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--btn-bg); color: var(--text); @@ -180,7 +180,7 @@ button.primary { background: var(--accent); border-color: var(--accent); - color: #fff; + color: var(--on-accent); } button.primary.danger { background: var(--danger); diff --git a/src/lib/components/ApplyPanel.svelte b/src/lib/components/ApplyPanel.svelte index 15d4850..a39786a 100644 --- a/src/lib/components/ApplyPanel.svelte +++ b/src/lib/components/ApplyPanel.svelte @@ -107,7 +107,7 @@ } .apply-row.panel { background: var(--panel-bg); - border-radius: 10px; + border-radius: var(--radius-lg); padding: 10px 14px; border: 1px solid var(--border); } @@ -145,7 +145,7 @@ button.primary { background: var(--accent); border-color: var(--accent); - color: white; + color: var(--on-accent); } button.danger.primary { background: var(--danger); diff --git a/src/lib/components/BackupsPanel.svelte b/src/lib/components/BackupsPanel.svelte index 7f2a3f2..51a55f1 100644 --- a/src/lib/components/BackupsPanel.svelte +++ b/src/lib/components/BackupsPanel.svelte @@ -193,14 +193,14 @@ display: flex; gap: 2px; background: var(--input-bg); - border-radius: 6px; + border-radius: var(--radius-md); padding: 2px; width: fit-content; margin-bottom: 8px; } .mode-tabs button { padding: 4px 10px; - border-radius: 4px; + border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); @@ -225,7 +225,7 @@ } button { padding: 5px 10px; - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--btn-bg); color: var(--text); @@ -238,7 +238,7 @@ button.primary { background: var(--accent); border-color: var(--accent); - color: white; + color: var(--on-accent); } button:disabled { opacity: 0.5; @@ -250,7 +250,7 @@ padding: 0; max-height: 200px; overflow: auto; - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid var(--border); } .list li { @@ -293,6 +293,6 @@ min-width: 70px; } .mono { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); } diff --git a/src/lib/components/BranchColorDialog.svelte b/src/lib/components/BranchColorDialog.svelte index 37348d6..1316d22 100644 --- a/src/lib/components/BranchColorDialog.svelte +++ b/src/lib/components/BranchColorDialog.svelte @@ -127,7 +127,7 @@ max-width: calc(100vw - 32px); background: var(--popover-bg, var(--panel-bg)); border: 1px solid var(--border); - border-radius: 10px; + border-radius: var(--radius-lg); padding: 16px 18px; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28); backdrop-filter: blur(20px) saturate(140%); @@ -195,7 +195,7 @@ .swatch { width: 100%; aspect-ratio: 1; - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid var(--border); cursor: pointer; padding: 0; @@ -217,7 +217,7 @@ .hex-preview { width: 22px; height: 22px; - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid var(--border); flex-shrink: 0; } @@ -225,7 +225,7 @@ flex: 1; min-width: 0; padding: 5px 8px; - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--input-bg, var(--btn-bg)); color: var(--text); @@ -237,10 +237,10 @@ } .apply { padding: 5px 12px; - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid var(--accent); background: var(--accent); - color: #fff; + color: var(--on-accent); font-size: 12px; cursor: pointer; flex-shrink: 0; @@ -255,7 +255,7 @@ } .reset { padding: 5px 10px; - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--btn-bg); color: var(--text); @@ -266,6 +266,6 @@ background: var(--btn-hover); } .mono { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); } diff --git a/src/lib/components/CollapsiblePanel.svelte b/src/lib/components/CollapsiblePanel.svelte index 23b1eb7..3bbc1eb 100644 --- a/src/lib/components/CollapsiblePanel.svelte +++ b/src/lib/components/CollapsiblePanel.svelte @@ -199,7 +199,7 @@ diff --git a/src/lib/components/EditTabs.svelte b/src/lib/components/EditTabs.svelte index c9c34a8..92d3388 100644 --- a/src/lib/components/EditTabs.svelte +++ b/src/lib/components/EditTabs.svelte @@ -315,12 +315,12 @@ display: flex; gap: 2px; background: var(--input-bg); - border-radius: 6px; + border-radius: var(--radius-md); padding: 2px; } .mode-tabs button { padding: 4px 10px; - border-radius: 4px; + border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); @@ -367,12 +367,12 @@ .num { width: 4rem; padding: 4px 6px; - border-radius: 4px; + border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--input-bg); color: var(--text); font-size: 12px; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); } .spacer { width: 6px; @@ -384,12 +384,12 @@ overflow-wrap: anywhere; } .preview-line .mono { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); color: var(--text); } button { padding: 5px 10px; - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--btn-bg); color: var(--text); @@ -402,7 +402,7 @@ button.primary { background: var(--accent); border-color: var(--accent); - color: white; + color: var(--on-accent); } button.primary:hover { background: var(--accent-hover); diff --git a/src/lib/components/GraphGutter.svelte b/src/lib/components/GraphGutter.svelte index 468e58d..bf73a5f 100644 --- a/src/lib/components/GraphGutter.svelte +++ b/src/lib/components/GraphGutter.svelte @@ -22,6 +22,10 @@ renderStart = 0, renderEnd = undefined, colorOf, + reveal = false, + revealBottomIndex = 0, + revealStep = 20, + revealBase = 0, }: { rows: RowLayout[]; heads: boolean[]; @@ -35,8 +39,21 @@ renderEnd?: number; /** Resolve a lane colour from its colorIndex (override-aware); defaults to the palette. */ colorOf?: (colorIndex: number) => string; + /** NERV one-shot reveal: stroke-draw each edge + pop each dot, staggered bottom→top. */ + reveal?: boolean; + /** Index whose reveal delay is 0 (bottom of the first viewport); rows above it draw later. */ + revealBottomIndex?: number; + /** Per-row reveal stagger in ms. */ + revealStep?: number; + /** Base delay added to every reveal animation (draw plays after the materialize frame). */ + revealBase?: number; } = $props(); + // Reveal delay for the element on absolute commit row `i`: bottom rows first + // (delay 0), climbing upward. Clamped so off-screen-below rows don't get negatives. + // revealBase pushes the whole draw past the boot frame on a repo switch (0 otherwise). + const revealDelay = (i: number) => revealBase + Math.max(0, revealBottomIndex - i) * revealStep; + // Lane-colour resolver: a manual override wins where present, else the palette. const resolveColor = (idx: number) => (colorOf ? colorOf(idx) : laneColor(idx, null, {})); @@ -79,6 +96,9 @@ stroke={resolveColor(edge.colorIndex)} stroke-width="2" fill="none" + pathLength="1" + class:nerv-edge-draw={reveal} + style={reveal ? `animation-delay:${revealDelay(i)}ms` : ""} /> {/each} {/each} @@ -92,6 +112,8 @@ fill="none" stroke="var(--accent)" stroke-width="1.5" + class:nerv-node-in={reveal} + style={reveal ? `animation-delay:${revealDelay(i)}ms` : ""} /> {/if} +
+
+
+
{/if} diff --git a/src/lib/components/Modal.svelte b/src/lib/components/Modal.svelte index d115248..36463e0 100644 --- a/src/lib/components/Modal.svelte +++ b/src/lib/components/Modal.svelte @@ -402,7 +402,7 @@ max-width: calc(100vw - 32px); background: var(--popover-bg, var(--panel-bg)); border: 1px solid var(--border); - border-radius: 10px; + border-radius: var(--radius-lg); padding: 16px 18px; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28); backdrop-filter: blur(20px) saturate(140%); @@ -434,7 +434,7 @@ width: 100%; box-sizing: border-box; padding: 7px 10px; - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--input-bg); color: var(--text); @@ -449,7 +449,7 @@ } button { padding: 6px 14px; - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--btn-bg); color: var(--text); @@ -462,7 +462,7 @@ button.primary { background: var(--accent); border-color: var(--accent); - color: #fff; + color: var(--on-accent); } button.primary.danger { background: var(--danger); @@ -523,7 +523,7 @@ width: 100%; box-sizing: border-box; padding: 7px 10px; - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--input-bg); color: var(--text); @@ -553,7 +553,7 @@ flex: 1; min-width: 0; padding: 6px 8px; - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--input-bg); color: var(--text); diff --git a/src/lib/components/PrereqBanner.svelte b/src/lib/components/PrereqBanner.svelte index 386c33b..2692a5c 100644 --- a/src/lib/components/PrereqBanner.svelte +++ b/src/lib/components/PrereqBanner.svelte @@ -4,6 +4,8 @@ import type { PrerequisiteCheck } from "../types"; let check = $state(null); + let installing = $state(false); + let installStatus = $state(null); onMount(async () => { try { @@ -13,24 +15,44 @@ } }); - let ok = $derived(check ? check.git && check.filterRepo : true); + let ok = $derived(check ? check.git && check.python3 : true); + + async function installTools() { + installing = true; + installStatus = null; + try { + installStatus = await api.installCommandLineTools(); + } catch (e) { + installStatus = String(e); + } finally { + installing = false; + } + } {#if check && !ok} +{:else if check && check.python3 && !check.filterRepo} + {/if} @@ -39,7 +61,7 @@ background: #fde68a22; border: 1px solid #fde68a; color: var(--text); - border-radius: 8px; + border-radius: var(--radius-dialog); padding: 10px 14px; margin-bottom: 8px; font-size: 13px; @@ -50,10 +72,33 @@ .banner p { margin: 6px 0 0 0; } + .actions { + display: flex; + align-items: center; + gap: 10px; + margin-top: 8px; + } + .actions button { + font: inherit; + color: #fff; + background: #b45309; + border: none; + border-radius: var(--radius-md); + padding: 5px 12px; + cursor: pointer; + } + .actions button:disabled { + opacity: 0.6; + cursor: default; + } + .status { + color: var(--text); + opacity: 0.85; + } code { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); background: #00000010; padding: 1px 5px; - border-radius: 4px; + border-radius: var(--radius-sm); } diff --git a/src/lib/components/RebaseTodo.svelte b/src/lib/components/RebaseTodo.svelte index 2e760d5..53efa57 100644 --- a/src/lib/components/RebaseTodo.svelte +++ b/src/lib/components/RebaseTodo.svelte @@ -207,7 +207,7 @@ max-height: calc(100vh - 64px); background: var(--popover-bg, var(--panel-bg)); border: 1px solid var(--border); - border-radius: 10px; + border-radius: var(--radius-lg); padding: 16px 18px; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28); backdrop-filter: blur(20px) saturate(140%); @@ -228,7 +228,7 @@ color: var(--text-muted); } .mono { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); } .note { margin: 0; @@ -255,7 +255,7 @@ align-items: center; gap: 6px; padding: 4px 6px; - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid var(--border-subtle, var(--border)); background: var(--panel-bg); min-height: 34px; @@ -295,7 +295,7 @@ background: var(--input-bg); color: var(--text); font-size: 12px; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); cursor: pointer; } .sha { @@ -339,7 +339,7 @@ } button { padding: 6px 14px; - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--btn-bg); color: var(--text); @@ -356,7 +356,7 @@ button.primary { background: var(--accent); border-color: var(--accent); - color: #fff; + color: var(--on-accent); } button.primary.danger { background: var(--danger, #dc2626); diff --git a/src/lib/components/RefTree.svelte b/src/lib/components/RefTree.svelte index 2e167b6..45a8d99 100644 --- a/src/lib/components/RefTree.svelte +++ b/src/lib/components/RefTree.svelte @@ -107,7 +107,7 @@ padding-right: 8px; background: none; border: none; - border-radius: 6px; + border-radius: var(--radius-md); color: var(--text); font-size: 12.5px; cursor: pointer; diff --git a/src/lib/components/ReflogPanel.svelte b/src/lib/components/ReflogPanel.svelte index 1f66fe2..29631d4 100644 --- a/src/lib/components/ReflogPanel.svelte +++ b/src/lib/components/ReflogPanel.svelte @@ -145,7 +145,7 @@ white-space: nowrap; } .mono { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); } .entry-actions { display: flex; @@ -172,6 +172,6 @@ } button.danger:hover { background: var(--err, #c0392b); - color: white; + color: var(--on-accent); } diff --git a/src/lib/components/RemotePanel.svelte b/src/lib/components/RemotePanel.svelte index 1ad9255..5e88654 100644 --- a/src/lib/components/RemotePanel.svelte +++ b/src/lib/components/RemotePanel.svelte @@ -142,7 +142,7 @@ .rurl { color: var(--text-muted); font-size: 11px; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -169,7 +169,7 @@ button.primary { background: var(--accent); border-color: var(--accent); - color: white; + color: var(--on-accent); padding: 4px 12px; font-size: 12px; } @@ -182,6 +182,6 @@ } button.danger:hover { background: var(--danger); - color: white; + color: var(--on-accent); } diff --git a/src/lib/components/RemoteProgress.svelte b/src/lib/components/RemoteProgress.svelte index 17e6f53..09c6e6c 100644 --- a/src/lib/components/RemoteProgress.svelte +++ b/src/lib/components/RemoteProgress.svelte @@ -36,7 +36,7 @@ padding: 6px 14px; background: var(--panel-bg); border: 1px solid var(--border); - border-radius: 8px; + border-radius: var(--radius-dialog); font-size: 12px; } @@ -67,7 +67,7 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); font-size: 11px; } diff --git a/src/lib/components/RepoList.svelte b/src/lib/components/RepoList.svelte index e6bd5cd..e4d7d4e 100644 --- a/src/lib/components/RepoList.svelte +++ b/src/lib/components/RepoList.svelte @@ -139,7 +139,7 @@ .repo-row { display: flex; align-items: center; - border-radius: 6px; + border-radius: var(--radius-md); } .repo-row:hover .remove-btn { opacity: 1; @@ -157,7 +157,7 @@ padding: 5px 8px 5px 18px; background: none; border: none; - border-radius: 6px; + border-radius: var(--radius-md); color: var(--text); font-size: 12.5px; cursor: pointer; @@ -215,7 +215,7 @@ flex-shrink: 0; margin-right: 4px; border: none; - border-radius: 4px; + border-radius: var(--radius-sm); background: none; color: var(--text-muted); font-size: 14px; @@ -239,7 +239,7 @@ padding: 5px 8px 5px 16px; background: none; border: none; - border-radius: 6px; + border-radius: var(--radius-md); color: var(--text-muted); font-size: 12.5px; cursor: pointer; diff --git a/src/lib/components/RepoTabs.svelte b/src/lib/components/RepoTabs.svelte index 6e03860..13e7568 100644 --- a/src/lib/components/RepoTabs.svelte +++ b/src/lib/components/RepoTabs.svelte @@ -358,7 +358,7 @@ box-sizing: border-box; background: var(--header-bg); color: var(--text); - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid var(--border); box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28); font-size: 12.5px; @@ -407,7 +407,7 @@ flex-shrink: 0; margin-left: 0; border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-md); background: var(--btn-bg); color: var(--text-muted); font-size: 18px; @@ -440,7 +440,7 @@ padding: 4px; background: var(--popover-bg, var(--panel-bg)); border: 1px solid var(--border); - border-radius: 8px; + border-radius: var(--radius-dialog); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); backdrop-filter: blur(20px) saturate(140%); -webkit-backdrop-filter: blur(20px) saturate(140%); diff --git a/src/lib/components/SettingsPanel.svelte b/src/lib/components/SettingsPanel.svelte index f0c2cce..1721382 100644 --- a/src/lib/components/SettingsPanel.svelte +++ b/src/lib/components/SettingsPanel.svelte @@ -3,6 +3,9 @@ import { settingsPanel } from "../settingsPanel.svelte"; import { manualCheckForUpdates } from "../updater.svelte"; import { onMount } from "svelte"; + import { NERV_SCHEME_PALETTES, type Scheme } from "../graph"; + + const SCHEMES: readonly Scheme[] = ["orange", "phosphor", "steel", "amber", "violet", "crimson"]; let closeBtn = $state(); @@ -32,6 +35,36 @@ settingsPanel.close(); } } + + // Tabbed sections — the active tab persists in localStorage (browser-safe). + type SettingsTab = "appearance" | "graph" | "commits" | "behavior" | "updates"; + const TAB_KEY = "gitit.settingsTab.v1"; + function loadTab(): SettingsTab { + try { + const v = localStorage.getItem(TAB_KEY); + if ( + v === "appearance" || + v === "graph" || + v === "commits" || + v === "behavior" || + v === "updates" + ) { + return v; + } + } catch { + /* ignore */ + } + return "appearance"; + } + let activeTab = $state(loadTab()); + function setTab(t: SettingsTab) { + activeTab = t; + try { + localStorage.setItem(TAB_KEY, t); + } catch { + /* ignore */ + } + } {#if settingsPanel.open} @@ -55,246 +88,336 @@ >✕ - -

Appearance

- -
- Graph lines -
- -
-
- -
- Merge-in curve -
- -
-
- -
- Curviness -
- -
-
- -
- Repository switcher -
- -
-
- -
- Diff view -
- -
-
- -
- PR activity order -
- -
-
- -
- - -

Commit dates

- - - - - - - -
- - -

Behavior

- - - - - - -
- - -

Updates

- - - -
- Update channel -
- -
+ +
+ + + + +
-
- {appVersion ? `Version ${appVersion}` : "Version"} - +
+ {#if activeTab === "appearance"} + +
+ Theme +
+ +
+
+ + {#if appState.theme === "nerv"} +
+ Color scheme +
+ {#each SCHEMES as s (s)} + + {/each} +
+
+ + + {/if} + +
+ Diff view +
+ +
+
+ {/if} + + {#if activeTab === "graph"} + +
+ Graph lines +
+ +
+
+ +
+ Merge-in curve +
+ +
+
+ +
+ Curviness +
+ +
+
+ +
+ Repository switcher +
+ +
+
+ {/if} + + {#if activeTab === "commits"} + + + + + + + +
+ PR activity order +
+ +
+
+ {/if} + + {#if activeTab === "behavior"} + + + + + + {/if} + + {#if activeTab === "updates"} + + + +
+ Update channel +
+ +
+
+ +
+ {appVersion ? `Version ${appVersion}` : "Version"} + +
+ {/if}
@@ -315,17 +438,16 @@ width: 440px; max-width: calc(100vw - 32px); max-height: calc(100vh - 80px); - overflow-y: auto; + overflow: hidden; background: var(--popover-bg, var(--panel-bg)); border: 1px solid var(--border); - border-radius: 10px; + border-radius: var(--radius-lg); padding: 16px 18px; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28); backdrop-filter: blur(20px) saturate(140%); -webkit-backdrop-filter: blur(20px) saturate(140%); display: flex; flex-direction: column; - gap: 2px; } .header { @@ -333,6 +455,7 @@ align-items: center; justify-content: space-between; margin-bottom: 10px; + flex-shrink: 0; } h3 { @@ -368,13 +491,54 @@ outline-offset: 2px; } - .group-label { - margin: 6px 4px 6px; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; + /* Tab strip */ + .tab-strip { + display: flex; + gap: 2px; + padding: 3px; + margin-bottom: 8px; + background: var(--btn-bg); + border: 1px solid var(--border); + border-radius: var(--radius-md); + flex-shrink: 0; + } + + .tab-strip button { + flex: 1; + padding: 5px 8px; + border: none; + background: transparent; color: var(--text-muted); + font-size: 12px; + font-weight: 500; + cursor: pointer; + border-radius: calc(var(--radius-md) - 3px); + transition: background 0.1s, color 0.1s; + white-space: nowrap; + } + + .tab-strip button:hover { + color: var(--text); + } + + .tab-strip button.active { + background: var(--accent); + color: var(--on-accent); + } + + .tab-strip button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; + } + + /* Active tab body — scrolls when tall */ + .tab-content { + display: flex; + flex-direction: column; + gap: 2px; + overflow-y: auto; + flex: 1 1 auto; + min-height: 0; } /* Segmented control rows */ @@ -395,7 +559,7 @@ display: flex; align-items: center; border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-md); overflow: hidden; flex-shrink: 0; } @@ -422,7 +586,7 @@ .seg button.active { background: var(--accent); - color: #fff; + color: var(--on-accent); } .seg button:focus-visible { @@ -430,6 +594,38 @@ outline-offset: -2px; } + /* NERV color-scheme swatches */ + .swatch-row { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + } + + .swatch { + width: 18px; + height: 18px; + padding: 0; + border-radius: 50%; + border: 2px solid var(--border); + cursor: pointer; + box-sizing: border-box; + transition: border-color 0.1s, transform 0.1s; + } + + .swatch:hover { + transform: scale(1.1); + } + + .swatch.active { + border-color: var(--text); + } + + .swatch:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + /* Checkbox rows — mirrors DateFormatMenu .opt */ .opt { display: flex; @@ -457,16 +653,10 @@ outline-offset: 1px; } - .divider { - border: none; - border-top: 1px solid var(--border); - margin: 8px 0 4px; - } - .check-updates-btn { padding: 4px 12px; border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-md); background: var(--btn-bg); color: var(--text); font-size: 12px; diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 6b36849..2ec891d 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -323,7 +323,7 @@ margin-bottom: 2px; background: none; border: none; - border-radius: 6px; + border-radius: var(--radius-md); color: var(--text); font-size: 12.5px; font-weight: 500; @@ -392,7 +392,7 @@ padding: 5px 8px 5px 18px; background: none; border: none; - border-radius: 6px; + border-radius: var(--radius-md); color: var(--text); font-size: 12.5px; cursor: pointer; @@ -410,7 +410,7 @@ and commits made here are easy to lose). */ .ref.detached { background: color-mix(in srgb, #d97706 10%, transparent); - border-radius: 6px; + border-radius: var(--radius-md); } .ref.detached .rn { color: color-mix(in srgb, #d97706 75%, var(--text)); diff --git a/src/lib/components/StashPanel.svelte b/src/lib/components/StashPanel.svelte index 6e8746c..451d2d0 100644 --- a/src/lib/components/StashPanel.svelte +++ b/src/lib/components/StashPanel.svelte @@ -146,7 +146,7 @@ white-space: nowrap; } .mono { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); } .entry-actions { display: flex; @@ -170,7 +170,7 @@ button.primary { background: var(--accent); border-color: var(--accent); - color: white; + color: var(--on-accent); padding: 4px 12px; font-size: 12px; } @@ -183,6 +183,6 @@ } button.danger:hover { background: var(--err, #c0392b); - color: white; + color: var(--on-accent); } diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index ea56db9..b80a6c0 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -73,6 +73,7 @@ {@const opLabel = appState.busyOp ? `${appState.busyOp} in progress` : "Operation in progress"} {/if} + {appState.status} @@ -161,6 +162,12 @@ min-width: 0; } + /* Persistent "system alive" indicator — NERV-only (styled in nerv.css); + Classic never sets data-theme, so this stays hidden there. */ + .live-dot { + display: none; + } + /* Pulsing dot — signals an in-flight operation (remote or rewrite) */ @keyframes pulse { 0%, 100% { opacity: 1; } diff --git a/src/lib/components/UndoBar.svelte b/src/lib/components/UndoBar.svelte index 0ee50a5..cfd8a2c 100644 --- a/src/lib/components/UndoBar.svelte +++ b/src/lib/components/UndoBar.svelte @@ -24,7 +24,7 @@ align-items: center; gap: 10px; padding: 6px 12px; - border-radius: 8px; + border-radius: var(--radius-dialog); border: 1px solid var(--accent); background: var(--panel-bg); font-size: 12.5px; @@ -40,10 +40,10 @@ } .undo-btn { padding: 4px 12px; - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid var(--accent); background: var(--accent); - color: #fff; + color: var(--on-accent); font-size: 12px; cursor: pointer; white-space: nowrap; @@ -54,7 +54,7 @@ } .dismiss-btn { padding: 2px 7px; - border-radius: 4px; + border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--btn-bg); color: var(--text-muted); diff --git a/src/lib/components/WorkingCopyView.svelte b/src/lib/components/WorkingCopyView.svelte index 5e72aa2..2596ba4 100644 --- a/src/lib/components/WorkingCopyView.svelte +++ b/src/lib/components/WorkingCopyView.svelte @@ -576,7 +576,7 @@ display: flex; flex-direction: column; border: 1px solid var(--border); - border-radius: 8px; + border-radius: var(--radius-dialog); background: var(--panel-bg); overflow: hidden; /* Fill the available vertical space in the main column (the parent flex chain @@ -658,7 +658,7 @@ .ft-toggle.active { background: var(--accent); border-color: var(--accent); - color: #fff; + color: var(--on-accent); } .ft-toggle:hover { background: var(--btn-hover); @@ -773,7 +773,7 @@ text-align: center; font-size: 11px; font-weight: 700; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); border-radius: 3px; padding: 1px 3px; } @@ -821,7 +821,7 @@ } .mono { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); } /* Stacked/narrow layout: the shell is content-sized (no full-height chain), so diff --git a/src/lib/components/github/GithubActionModal.svelte b/src/lib/components/github/GithubActionModal.svelte index 4800cf8..67dcafb 100644 --- a/src/lib/components/github/GithubActionModal.svelte +++ b/src/lib/components/github/GithubActionModal.svelte @@ -117,7 +117,7 @@ background: var(--input-bg, var(--btn-bg)); color: var(--text); border: 1px solid var(--border); - border-radius: 8px; + border-radius: var(--radius-dialog); padding: 8px 10px; font: inherit; font-size: 13px; @@ -157,7 +157,7 @@ .actions .primary { background: var(--accent); border-color: var(--accent); - color: #fff; + color: var(--on-accent); } .err { margin: 0; diff --git a/src/lib/components/github/GithubActions.svelte b/src/lib/components/github/GithubActions.svelte index eaed751..872f0b1 100644 --- a/src/lib/components/github/GithubActions.svelte +++ b/src/lib/components/github/GithubActions.svelte @@ -102,7 +102,7 @@ color: var(--text-muted); } .mono { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); font-size: 11px; } .note { diff --git a/src/lib/components/github/GithubCommentBox.svelte b/src/lib/components/github/GithubCommentBox.svelte index ac6b8bb..3315b74 100644 --- a/src/lib/components/github/GithubCommentBox.svelte +++ b/src/lib/components/github/GithubCommentBox.svelte @@ -64,7 +64,7 @@ line-height: 1.5; padding: 8px 10px; border: 1px solid var(--border); - border-radius: 8px; + border-radius: var(--radius-dialog); background: var(--input-bg, var(--panel-bg)); color: var(--text); } @@ -92,7 +92,7 @@ } .post { background: var(--accent); - color: #fff; + color: var(--on-accent); border: none; border-radius: 7px; padding: 5px 16px; diff --git a/src/lib/components/github/GithubDetail.svelte b/src/lib/components/github/GithubDetail.svelte index 2b12a90..a98170a 100644 --- a/src/lib/components/github/GithubDetail.svelte +++ b/src/lib/components/github/GithubDetail.svelte @@ -379,7 +379,7 @@ .meta { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; } .label { font-size: 10.5px; padding: 0 7px; border-radius: 999px; border: 1px solid var(--lc); color: var(--lc); } .m { font-size: 12px; color: var(--text-muted); } - .body { position: relative; border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; background: var(--panel-bg); } + .body { position: relative; border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 12px 14px; background: var(--panel-bg); } .body-acts { position: absolute; top: 8px; right: 12px; } /* Reveal own-comment/description actions on hover (GitHub-style). */ .body:hover :global(.cacts), @@ -421,7 +421,7 @@ .diffstat { color: var(--text-muted); } .add { color: var(--status-add, #2ea043); } .del { color: var(--err, #c0392b); } - .block { border: 1px solid var(--border); border-radius: 8px; padding: 6px 10px; } + .block { border: 1px solid var(--border); border-radius: var(--radius-dialog); padding: 6px 10px; } .block summary { cursor: pointer; font-size: 12.5px; } .checks { list-style: none; margin: 8px 0 0; padding: 0; } .checks li { display: flex; align-items: center; gap: 8px; padding: 2px 0; font-size: 12px; } @@ -432,7 +432,7 @@ display: flex; align-items: center; border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-md); overflow: hidden; flex-shrink: 0; } @@ -448,7 +448,7 @@ } .seg button + button { border-left: 1px solid var(--border); } .seg button:hover { background: var(--btn-hover); color: var(--text); } - .seg button.active { background: var(--accent); color: #fff; } + .seg button.active { background: var(--accent); color: var(--on-accent); } .seg button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } .g { width: 14px; text-align: center; font-weight: 700; } .g.pass { color: var(--status-add, #2ea043); } @@ -465,10 +465,10 @@ .ck-sum .sep { color: var(--text-muted); margin: 0 2px; } .ck-label { color: var(--text-muted); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.03em; } .comments h3, .activity h3 { font-size: 13px; margin: 6px 0; } - .comment { border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; margin-bottom: 8px; background: var(--panel-bg); } + .comment { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 10px 12px; margin-bottom: 8px; background: var(--panel-bg); } .chead { font-size: 12.5px; margin-bottom: 4px; display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; } .when { color: var(--text-muted); } - .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; } + .mono { font-family: var(--font-mono); font-size: 11.5px; } .note { margin: 8px 2px; color: var(--text-muted); font-size: 12.5px; } .note.err { color: var(--err, #c0392b); } diff --git a/src/lib/components/github/GithubHeader.svelte b/src/lib/components/github/GithubHeader.svelte index 6f016ae..3f7f103 100644 --- a/src/lib/components/github/GithubHeader.svelte +++ b/src/lib/components/github/GithubHeader.svelte @@ -74,7 +74,7 @@ width: 30px; height: 30px; border: 1px solid var(--border); - border-radius: 8px; + border-radius: var(--radius-dialog); background: var(--panel-bg); color: var(--text); cursor: pointer; diff --git a/src/lib/components/github/GithubInsights.svelte b/src/lib/components/github/GithubInsights.svelte index e5ab604..0d412fd 100644 --- a/src/lib/components/github/GithubInsights.svelte +++ b/src/lib/components/github/GithubInsights.svelte @@ -156,7 +156,7 @@ } .card { border: 1px solid var(--border); - border-radius: 10px; + border-radius: var(--radius-lg); padding: 14px; background: var(--panel-bg); } @@ -258,7 +258,7 @@ font-size: 9px; padding: 0 5px; border: 1px solid var(--border); - border-radius: 4px; + border-radius: var(--radius-sm); color: var(--text-muted); text-transform: uppercase; } diff --git a/src/lib/components/github/GithubIssues.svelte b/src/lib/components/github/GithubIssues.svelte index 2271e2d..f1c7658 100644 --- a/src/lib/components/github/GithubIssues.svelte +++ b/src/lib/components/github/GithubIssues.svelte @@ -105,7 +105,7 @@ border: 1px solid var(--accent); border-radius: 999px; background: var(--accent); - color: #fff; + color: var(--on-accent); font-size: 12px; cursor: pointer; } @@ -118,7 +118,7 @@ padding: 2px 10px; font-size: 11.5px; border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-md); background: var(--btn-bg); color: var(--text); cursor: pointer; @@ -143,7 +143,7 @@ cursor: pointer; } .chip.active { - color: #fff; + color: var(--on-accent); background: var(--accent); border-color: var(--accent); } diff --git a/src/lib/components/github/GithubOverview.svelte b/src/lib/components/github/GithubOverview.svelte index 244717a..51bb151 100644 --- a/src/lib/components/github/GithubOverview.svelte +++ b/src/lib/components/github/GithubOverview.svelte @@ -150,10 +150,10 @@ .readme :global(img) { max-width: 100%; height: auto; - border-radius: 4px; + border-radius: var(--radius-sm); } .readme :global(code) { - font-family: ui-monospace, monospace; + font-family: var(--font-mono); font-size: 0.85em; background: var(--code-bg, rgba(128,128,128,0.12)); padding: 0.1em 0.35em; @@ -161,7 +161,7 @@ } .readme :global(pre) { background: var(--code-bg, rgba(128,128,128,0.12)); - border-radius: 6px; + border-radius: var(--radius-md); padding: 10px 14px; overflow-x: auto; font-size: 12px; diff --git a/src/lib/components/github/GithubPulls.svelte b/src/lib/components/github/GithubPulls.svelte index 1360451..f1f9032 100644 --- a/src/lib/components/github/GithubPulls.svelte +++ b/src/lib/components/github/GithubPulls.svelte @@ -114,7 +114,7 @@ padding: 2px 10px; font-size: 11.5px; border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-md); background: var(--btn-bg); color: var(--text); cursor: pointer; @@ -142,7 +142,7 @@ cursor: pointer; } .chip.active { - color: #fff; + color: var(--on-accent); background: var(--accent); border-color: var(--accent); } @@ -208,7 +208,7 @@ color: var(--text-muted); } .mono { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); font-size: 11px; } .rev { diff --git a/src/lib/components/github/GithubReleases.svelte b/src/lib/components/github/GithubReleases.svelte index 5d74609..6c6d2d0 100644 --- a/src/lib/components/github/GithubReleases.svelte +++ b/src/lib/components/github/GithubReleases.svelte @@ -175,7 +175,7 @@ } .chart { border: 1px solid var(--border); - border-radius: 10px; + border-radius: var(--radius-lg); padding: 12px 14px; background: var(--panel-bg); } @@ -288,7 +288,7 @@ font-size: 10px; padding: 0 6px; border: 1px solid var(--border); - border-radius: 4px; + border-radius: var(--radius-sm); text-transform: uppercase; } .when { @@ -335,7 +335,7 @@ font-variant-numeric: tabular-nums; } .mono { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); } .note { margin: 14px 2px; diff --git a/src/lib/components/github/GithubSetupCard.svelte b/src/lib/components/github/GithubSetupCard.svelte index b5ba4cf..13fe519 100644 --- a/src/lib/components/github/GithubSetupCard.svelte +++ b/src/lib/components/github/GithubSetupCard.svelte @@ -44,7 +44,7 @@ margin: 4px 0; padding: 8px 14px; background: var(--btn-bg); - border-radius: 6px; + border-radius: var(--radius-md); font-size: 12.5px; } a { @@ -55,7 +55,7 @@ margin-top: 8px; padding: 6px 16px; border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-md); background: var(--btn-bg); color: var(--text); cursor: pointer; diff --git a/src/lib/components/github/GithubSkeleton.svelte b/src/lib/components/github/GithubSkeleton.svelte index 530098f..b561127 100644 --- a/src/lib/components/github/GithubSkeleton.svelte +++ b/src/lib/components/github/GithubSkeleton.svelte @@ -197,7 +197,7 @@ flex-direction: column; gap: 10px; border: 1px solid var(--border); - border-radius: 10px; + border-radius: var(--radius-lg); padding: 12px 14px; background: var(--panel-bg); } diff --git a/src/lib/components/github/Markdown.svelte b/src/lib/components/github/Markdown.svelte index 18a0a65..fd51cc0 100644 --- a/src/lib/components/github/Markdown.svelte +++ b/src/lib/components/github/Markdown.svelte @@ -32,16 +32,16 @@ margin: 6px 0; } .md :global(code) { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); font-size: 11.5px; background: var(--btn-bg); padding: 1px 4px; - border-radius: 4px; + border-radius: var(--radius-sm); } .md :global(pre) { background: var(--btn-bg); padding: 10px 12px; - border-radius: 8px; + border-radius: var(--radius-dialog); overflow-x: auto; } .md :global(pre code) { diff --git a/src/lib/components/github/PrCommitsTab.svelte b/src/lib/components/github/PrCommitsTab.svelte index fcef526..a968660 100644 --- a/src/lib/components/github/PrCommitsTab.svelte +++ b/src/lib/components/github/PrCommitsTab.svelte @@ -81,7 +81,7 @@ } .sha { flex: 0 0 auto; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); font-size: 11px; color: var(--accent); background: none; diff --git a/src/lib/components/github/PrFilesTab.svelte b/src/lib/components/github/PrFilesTab.svelte index ca784d9..a4cf57b 100644 --- a/src/lib/components/github/PrFilesTab.svelte +++ b/src/lib/components/github/PrFilesTab.svelte @@ -243,7 +243,7 @@ .ft-toggle.active { background: var(--accent); border-color: var(--accent); - color: #fff; + color: var(--on-accent); } .ft-toggle:hover { background: var(--btn-hover); @@ -254,7 +254,7 @@ height: clamp(320px, 58vh, 820px); margin-top: 4px; border: 1px solid var(--border); - border-radius: 10px; + border-radius: var(--radius-lg); background: var(--panel-bg); padding: 6px; } @@ -280,7 +280,7 @@ padding: 4px 8px; background: none; border: none; - border-radius: 6px; + border-radius: var(--radius-md); color: var(--text); font-size: 12.5px; cursor: pointer; @@ -312,7 +312,7 @@ margin-left: auto; flex-shrink: 0; font-size: 11.5px; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); } .add { color: var(--status-add, #2ea043); @@ -339,7 +339,7 @@ .composer { margin-top: 8px; border: 1px solid var(--accent); - border-radius: 10px; + border-radius: var(--radius-lg); background: var(--panel-bg); padding: 8px 10px; display: flex; @@ -359,7 +359,7 @@ white-space: nowrap; } .mono { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); font-size: 11.5px; } .c-editing { @@ -377,7 +377,7 @@ line-height: 1.5; padding: 7px 9px; border: 1px solid var(--border); - border-radius: 8px; + border-radius: var(--radius-dialog); background: var(--input-bg, var(--panel-bg)); color: var(--text); } @@ -415,7 +415,7 @@ } .c-save { background: var(--accent); - color: #fff; + color: var(--on-accent); border: none; border-radius: 7px; padding: 4px 14px; diff --git a/src/lib/components/github/PrTimeline.svelte b/src/lib/components/github/PrTimeline.svelte index 81bdbcd..4f6ff3a 100644 --- a/src/lib/components/github/PrTimeline.svelte +++ b/src/lib/components/github/PrTimeline.svelte @@ -478,7 +478,7 @@ .row.comment .card, .row.review .card { border: 1px solid var(--border); - border-radius: 10px; + border-radius: var(--radius-lg); padding: 10px 12px; margin-bottom: 12px; background: var(--panel-bg); @@ -510,7 +510,7 @@ .threads { list-style: none; margin: 8px 0 0; padding: 0; display: flex; flex-direction: column; gap: 8px; } .thread { border: 1px solid var(--border); - border-radius: 8px; + border-radius: var(--radius-dialog); padding: 8px 10px; background: var(--btn-bg); } @@ -606,5 +606,5 @@ .g.fail { color: var(--err, #c0392b); } .g.pending { color: var(--status-mod, #d29922); } .g.neutral { color: var(--text-muted); } - .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; } + .mono { font-family: var(--font-mono); font-size: 11.5px; } diff --git a/src/lib/components/github/ReactionBar.svelte b/src/lib/components/github/ReactionBar.svelte index 32905a7..d68ee64 100644 --- a/src/lib/components/github/ReactionBar.svelte +++ b/src/lib/components/github/ReactionBar.svelte @@ -157,7 +157,7 @@ .pick { background: none; border: none; - border-radius: 6px; + border-radius: var(--radius-md); font-size: 14px; line-height: 1; padding: 3px 4px; diff --git a/src/lib/components/github/ReviewBar.svelte b/src/lib/components/github/ReviewBar.svelte index fc6f38f..fbe0453 100644 --- a/src/lib/components/github/ReviewBar.svelte +++ b/src/lib/components/github/ReviewBar.svelte @@ -167,7 +167,7 @@ .finish { background: var(--accent); border: 1px solid var(--accent); - color: #fff; + color: var(--on-accent); font-weight: 500; } .finish:hover { @@ -175,7 +175,7 @@ } .panel { border: 1px solid var(--accent); - border-radius: 10px; + border-radius: var(--radius-lg); background: var(--panel-bg); padding: 10px 12px; display: flex; @@ -202,7 +202,7 @@ line-height: 1.5; padding: 7px 9px; border: 1px solid var(--border); - border-radius: 8px; + border-radius: var(--radius-dialog); background: var(--input-bg, var(--panel-bg)); color: var(--text); } @@ -271,7 +271,7 @@ .submit { background: var(--accent); border: none; - color: #fff; + color: var(--on-accent); font-weight: 500; } .submit:hover:not(:disabled) { diff --git a/src/lib/components/github/StatTile.svelte b/src/lib/components/github/StatTile.svelte index 3faad86..35aa899 100644 --- a/src/lib/components/github/StatTile.svelte +++ b/src/lib/components/github/StatTile.svelte @@ -20,7 +20,7 @@ gap: 2px; padding: 8px 14px; border: 1px solid var(--border); - border-radius: 8px; + border-radius: var(--radius-dialog); background: var(--panel-bg); color: var(--text); text-align: left; diff --git a/src/lib/diff/highlight.ts b/src/lib/diff/highlight.ts index 56e219e..f4e388f 100644 --- a/src/lib/diff/highlight.ts +++ b/src/lib/diff/highlight.ts @@ -1,4 +1,5 @@ import { createHighlighter, type Highlighter } from "shiki"; +import { nervShikiTheme } from "./nervShikiTheme"; // Curated language list — bounds the bundle size by not loading every grammar. // Must match the `language` values produced by parse.ts's EXT_LANG map + "text". @@ -30,6 +31,19 @@ export const LANGS = [ export type SupportedLang = (typeof LANGS)[number]; +// One Shiki theme per NERV scheme — DiffView picks "nerv-" + appState.scheme, so every +// scheme needs a registered variant or the highlighter throws on an unknown theme name. +const NERV_SHIKI = ( + [ + ["nerv-orange", "#F2542D"], + ["nerv-phosphor", "#46E88B"], + ["nerv-steel", "#5AA9E6"], + ["nerv-amber", "#E8A33D"], + ["nerv-violet", "#9B7FE0"], + ["nerv-crimson", "#E0445A"], + ] as const +).map(([name, accent]) => nervShikiTheme(name, accent)); + // Memoised promise — created once, shared across all callers. // Shiki's grammars and themes are bundled by Vite as dynamic-import chunks: // offline, no CDN, no network required at runtime. @@ -38,7 +52,7 @@ let hp: Promise | null = null; export function getHighlighter(): Promise { if (!hp) { hp = createHighlighter({ - themes: ["github-light", "github-dark"], + themes: ["github-light", "github-dark", ...NERV_SHIKI], langs: [...LANGS], }).catch((e) => { // If Shiki fails to initialise (e.g. in SSR / test env), reset so a diff --git a/src/lib/diff/nervShikiTheme.ts b/src/lib/diff/nervShikiTheme.ts new file mode 100644 index 0000000..f3b5695 --- /dev/null +++ b/src/lib/diff/nervShikiTheme.ts @@ -0,0 +1,23 @@ +import type { ThemeRegistrationRaw } from "shiki"; + +// Minimal NERV-tuned dark theme: bone text on the void, phosphor strings, restrained +// accents. A raw object → no extra bundled-theme chunk, offline. `accent` recolors the +// keyword/tag tokens to match the scheme's --accent; the rest stay fixed across schemes. +export function nervShikiTheme(name: string, accent: string): ThemeRegistrationRaw { + return { + name, + type: "dark", + colors: { "editor.background": "#0A0C0F", "editor.foreground": "#EAE6DA" }, + settings: [ + { scope: ["comment", "punctuation.definition.comment"], settings: { foreground: "#7C8794", fontStyle: "italic" } }, + { scope: ["string", "string.quoted", "constant.other.symbol"], settings: { foreground: "#46E88B" } }, + { scope: ["keyword", "storage", "storage.type", "keyword.control"], settings: { foreground: accent } }, + { scope: ["entity.name.function", "support.function", "meta.function-call"], settings: { foreground: "#E8A33D" } }, + { scope: ["entity.name.type", "support.type", "support.class", "entity.name.class"], settings: { foreground: "#5AA9E6" } }, + { scope: ["constant.numeric", "constant.language"], settings: { foreground: "#9B7FE0" } }, + { scope: ["variable", "meta.definition.variable"], settings: { foreground: "#EAE6DA" } }, + { scope: ["entity.name.tag"], settings: { foreground: accent } }, + { scope: ["entity.other.attribute-name"], settings: { foreground: "#E0608A" } }, + ], + }; +} diff --git a/src/lib/graph/colors.test.ts b/src/lib/graph/colors.test.ts index d470bc0..acfc929 100644 --- a/src/lib/graph/colors.test.ts +++ b/src/lib/graph/colors.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { LANE_PALETTE, laneColor } from "./colors"; +import { LANE_PALETTE, NERV_LANE_PALETTE, laneColor, schemeLanePalette } from "./colors"; describe("laneColor", () => { it("returns the palette color for a color index", () => { @@ -28,4 +28,22 @@ describe("laneColor", () => { expect(laneColor(-1, null, {})).toBe(LANE_PALETTE[LANE_PALETTE.length - 1]); expect(laneColor(-LANE_PALETTE.length, null, {})).toBe(LANE_PALETTE[0]); }); + + it("uses a provided palette when passed", () => { + expect(laneColor(0, null, {}, NERV_LANE_PALETTE)).toBe(NERV_LANE_PALETTE[0]); + expect(laneColor(NERV_LANE_PALETTE.length, null, {}, NERV_LANE_PALETTE)).toBe(NERV_LANE_PALETTE[0]); + }); + it("defaults to the classic palette when no palette is passed", () => { + expect(laneColor(3, null, {})).toBe(LANE_PALETTE[3]); + }); +}); + +describe("schemeLanePalette", () => { + it("returns the scheme's lead-accent palette", () => { + expect(schemeLanePalette("phosphor")[0]).toBe("#46E88B"); + }); + + it("returns the same NERV_LANE_PALETTE reference for orange (no allocation)", () => { + expect(schemeLanePalette("orange")).toBe(NERV_LANE_PALETTE); + }); }); diff --git a/src/lib/graph/colors.ts b/src/lib/graph/colors.ts index 2eca424..c2d9acc 100644 --- a/src/lib/graph/colors.ts +++ b/src/lib/graph/colors.ts @@ -9,13 +9,40 @@ export const LANE_PALETTE: string[] = [ "#5F5E5A", ]; +export const NERV_LANE_PALETTE: readonly string[] = Object.freeze([ + "#F2542D", // orange (lead) + "#46E88B", // phosphor + "#5AA9E6", // steel-cyan + "#9B7FE0", // violet + "#E8A33D", // amber + "#E0608A", // pink + "#EAE6DA", // bone + "#8A94A0", // haze +]); + +export type Scheme = "orange" | "phosphor" | "steel" | "amber" | "violet" | "crimson"; +// Each leads with the scheme accent; the rest are shared NERV secondaries (tuned in QA). +export const NERV_SCHEME_PALETTES: Readonly> = Object.freeze({ + orange: NERV_LANE_PALETTE, + phosphor: Object.freeze(["#46E88B", "#F2542D", "#5AA9E6", "#9B7FE0", "#E8A33D", "#E0608A", "#EAE6DA", "#8A94A0"]), + steel: Object.freeze(["#5AA9E6", "#46E88B", "#F2542D", "#9B7FE0", "#E8A33D", "#E0608A", "#EAE6DA", "#8A94A0"]), + amber: Object.freeze(["#E8A33D", "#46E88B", "#5AA9E6", "#9B7FE0", "#F2542D", "#E0608A", "#EAE6DA", "#8A94A0"]), + violet: Object.freeze(["#9B7FE0", "#46E88B", "#5AA9E6", "#E8A33D", "#F2542D", "#E0608A", "#EAE6DA", "#8A94A0"]), + crimson: Object.freeze(["#E0445A", "#46E88B", "#5AA9E6", "#9B7FE0", "#E8A33D", "#EAE6DA", "#8A94A0", "#F2542D"]), +}); + +export function schemeLanePalette(scheme: Scheme): readonly string[] { + return NERV_SCHEME_PALETTES[scheme] ?? NERV_LANE_PALETTE; +} + export function laneColor( colorIndex: number, branchName: string | null, overrides: Record, + palette: readonly string[] = LANE_PALETTE, ): string { if (branchName && overrides[branchName]) return overrides[branchName]; // Wrap into range, tolerating negative indices (JS % keeps the operand's sign). - const i = ((colorIndex % LANE_PALETTE.length) + LANE_PALETTE.length) % LANE_PALETTE.length; - return LANE_PALETTE[i]; + const i = ((colorIndex % palette.length) + palette.length) % palette.length; + return palette[i]; } diff --git a/src/lib/graph/index.ts b/src/lib/graph/index.ts index 42edf96..cd0f066 100644 --- a/src/lib/graph/index.ts +++ b/src/lib/graph/index.ts @@ -1,6 +1,7 @@ export type { LaneCommit, Edge, EdgeKind, RowLayout } from "./types"; export { computeLanes } from "./lanes"; -export { LANE_PALETTE, laneColor } from "./colors"; +export { LANE_PALETTE, NERV_LANE_PALETTE, laneColor, NERV_SCHEME_PALETTES, schemeLanePalette } from "./colors"; +export type { Scheme } from "./colors"; export { laneX, curvedEdgePath, angularEdgePath, LANE_WIDTH, OFFSET_X } from "./paths"; export type { GeomConfig, MergeInStyle } from "./paths"; export { commitWindow } from "./window"; diff --git a/src/lib/graph/paths.test.ts b/src/lib/graph/paths.test.ts index 956d4d3..c8f1e75 100644 --- a/src/lib/graph/paths.test.ts +++ b/src/lib/graph/paths.test.ts @@ -57,7 +57,10 @@ describe("angularEdgePath", () => { it("forks late for a branch edge (down then across)", () => { expect(angularEdgePath(edge(0, 1, "branch"), 0, g)).toBe("M12 0 L12 15 L28 30"); }); - it("converges early for a merge edge (across then down)", () => { - expect(angularEdgePath(edge(1, 0, "merge"), 0, g)).toBe("M28 0 L12 15 L12 30"); + it("stubs out of the node before angling for a fork/lane-shift edge", () => { + expect(angularEdgePath(edge(0, 1, "merge"), 0, g)).toBe("M12 0 L12 12.6 L28 17.4 L28 30"); + }); + it("stubs out of the node before angling for a merge edge (other direction)", () => { + expect(angularEdgePath(edge(1, 0, "merge"), 0, g)).toBe("M28 0 L28 12.6 L12 17.4 L12 30"); }); }); diff --git a/src/lib/graph/paths.ts b/src/lib/graph/paths.ts index e6b512a..31047b3 100644 --- a/src/lib/graph/paths.ts +++ b/src/lib/graph/paths.ts @@ -73,5 +73,6 @@ export function angularEdgePath(edge: Edge, topY: number, g: GeomConfig): string if (x1 === x2) return `M${x1} ${y1} L${x2} ${y2}`; const my = y1 + g.rowHeight / 2; if (edge.kind === "branch") return `M${x1} ${y1} L${x1} ${my} L${x2} ${y2}`; - return `M${x1} ${y1} L${x2} ${my} L${x2} ${y2}`; + const s = Math.min(g.rowHeight * 0.42, g.rowHeight / 2 - 1); // clearly-visible straight stub + return `M${x1} ${y1} L${x1} ${y1 + s} L${x2} ${y2 - s} L${x2} ${y2}`; } diff --git a/src/lib/store.reveal.test.ts b/src/lib/store.reveal.test.ts new file mode 100644 index 0000000..f8abbb6 --- /dev/null +++ b/src/lib/store.reveal.test.ts @@ -0,0 +1,116 @@ +// Arming tests for the Task 9 one-shot timeline "graph builds itself" reveal. +// +// `appState.revealing` gates the row-cascade + edge-draw animations. The +// load-bearing invariant is that the reveal is armed EXACTLY ONCE per repo-identity +// change (or a same-repo view switch, exercised in +page.svelte) and must NOT replay +// on an fswatch/window-focus refresh, on infinite-scroll paging, or on a same-repo +// git-op reload. The arming logic lives in the runes store (setGraphCommits + +// armReveal), which imports and runs as plain logic under Vitest — the reveal uses a +// plain seq-guarded setTimeout (NO $effect), so fake timers drive it deterministically. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { appState } from "./store.svelte"; +import { SAMPLE_GRAPH } from "./graph/sample"; +import type { GraphCommit } from "./types"; + +// A synthetic older page (fresh SHAs) so appendGraphCommits takes its real append +// path rather than the dedupe early-return. +const EXTRA: GraphCommit[] = [ + { + sha: "ffff0000aaaa", + parents: [], + author_name: "A. Shah", + author_email: "a@x.dev", + author_date: "2026-06-12T09:00:00Z", + committer_name: "A. Shah", + committer_date: "2026-06-12T09:00:00Z", + refs: [], + subject: "older page commit", + body: "", + }, +]; + +// The store is a process-wide singleton; reset the view-model baseline. NOTE: the +// repo setter never touches graphCommitsRepo, so each test primes it explicitly via +// setGraphCommits with distinct repo paths — arming is independent of test order. +beforeEach(() => { + appState.repo = ""; + appState.setActiveView("timeline"); + appState.setCurrent(null); + appState.selected = new Set(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("Timeline reveal arming (Task 9 one-shot)", () => { + it("arms on a repo-identity change (first load into a repo)", () => { + vi.useFakeTimers(); + // Prime a baseline repo so graphCommitsRepo is known, then load a DIFFERENT repo. + appState.repo = "/tmp/reveal-arm-base"; + appState.setGraphCommits(SAMPLE_GRAPH); + vi.runOnlyPendingTimers(); + expect(appState.revealing).toBe(false); + + appState.repo = "/tmp/reveal-arm-next"; + appState.setGraphCommits(SAMPLE_GRAPH); + expect(appState.revealing).toBe(true); // armed on the identity transition + + vi.advanceTimersByTime(1100); + expect(appState.revealing).toBe(false); // one-shot: self-clears + }); + + it("does NOT replay on refresh / paging / same-repo reload (load-bearing invariant)", () => { + vi.useFakeTimers(); + appState.repo = "/tmp/reveal-noreplay"; + appState.setGraphCommits(SAMPLE_GRAPH); // arm once + vi.advanceTimersByTime(1100); + expect(appState.revealing).toBe(false); + + // fswatch / window-focus live refresh must NOT re-arm. + appState.applyGraphRefresh(SAMPLE_GRAPH); + expect(appState.revealing).toBe(false); + + // infinite-scroll paging must NOT re-arm (fresh SHAs → real append path). + appState.appendGraphCommits(EXTRA); + expect(appState.revealing).toBe(false); + + // same-repo git-op reload (setGraphCommits with an unchanged repo identity). + appState.setGraphCommits(SAMPLE_GRAPH); + expect(appState.revealing).toBe(false); + }); + + it("re-arms on a genuine repo switch", () => { + vi.useFakeTimers(); + appState.repo = "/tmp/reveal-switch-a"; + appState.setGraphCommits(SAMPLE_GRAPH); + vi.advanceTimersByTime(1100); + expect(appState.revealing).toBe(false); + + appState.repo = "/tmp/reveal-switch-b"; + appState.setGraphCommits(SAMPLE_GRAPH); + expect(appState.revealing).toBe(true); + vi.advanceTimersByTime(1100); + }); + + it("a re-arm mid-reveal supersedes the older timeout (seq guard, no early clear)", () => { + vi.useFakeTimers(); + appState.repo = "/tmp/reveal-seq-a"; + appState.setGraphCommits(SAMPLE_GRAPH); // arm #1: clear scheduled at t=1100 + vi.advanceTimersByTime(600); + appState.repo = "/tmp/reveal-seq-b"; + appState.setGraphCommits(SAMPLE_GRAPH); // arm #2: bumps seq, clear at t=1700 + vi.advanceTimersByTime(600); // t=1200: arm#1's stale timeout fires but seq mismatches → NO clear + expect(appState.revealing).toBe(true); + vi.advanceTimersByTime(600); // t=1800: arm#2's timeout fires → clears + expect(appState.revealing).toBe(false); + }); + + it("armReveal() (the view-switch entry point) is exposed and self-clears", () => { + vi.useFakeTimers(); + appState.armReveal(); + expect(appState.revealing).toBe(true); + vi.advanceTimersByTime(1100); + expect(appState.revealing).toBe(false); + }); +}); diff --git a/src/lib/store.svelte.ts b/src/lib/store.svelte.ts index 9d7f5c7..bf8b542 100644 --- a/src/lib/store.svelte.ts +++ b/src/lib/store.svelte.ts @@ -3,8 +3,18 @@ import type { Commit, GraphCommit, Ref, RefEntry, RemoteInfo, RepoStatus, UndoSnapshot, WorkingFile } from "./types"; import type { DateFormatPrefs } from "./dates"; import type { Store } from "@tauri-apps/plugin-store"; -import { computeLanes, laneColor, type MergeInStyle } from "./graph"; +import { + computeLanes, + laneColor, + LANE_PALETTE, + NERV_LANE_PALETTE, + NERV_SCHEME_PALETTES, + schemeLanePalette, + type MergeInStyle, + type Scheme, +} from "./graph"; import { reorder } from "./reorder"; +import { tick } from "svelte"; // Preferences persist via the Tauri Store plugin (a JSON file written by Rust) so // they survive a force-quit/crash — macOS WKWebView flushes localStorage only @@ -61,6 +71,15 @@ function graphToCommit(g: GraphCommit): Commit { const LINESTYLE_KEY = "gitit.graphLineStyle.v1"; const LINESTYLE_STORE_KEY = "graphLineStyle"; +const THEME_KEY = "gitit.theme.v1"; // localStorage (also read synchronously by themeMode.ts) +const THEME_STORE_KEY = "theme"; // Tauri store (durable) + +const SCHEME_KEY = "gitit.scheme.v1"; // localStorage (also read synchronously by themeMode.ts) +const SCHEME_STORE_KEY = "scheme"; // Tauri store (durable) + +const MOTION_KEY = "gitit.motion.v1"; // localStorage (also read synchronously by themeMode.ts) +const MOTION_STORE_KEY = "motion"; // Tauri store (durable) + const MERGEINSTYLE_KEY = "gitit.graphMergeInStyle.v1"; const MERGEINSTYLE_STORE_KEY = "graphMergeInStyle"; @@ -167,17 +186,126 @@ function loadSyncRepoMode(): "tabs" | "sidebar" { } } -function loadSyncLineStyle(): "curved" | "angular" { - if (isTauri()) return "curved"; +function loadSyncLineStyle(): "auto" | "curved" | "angular" { + if (isTauri()) return "auto"; try { - if (typeof localStorage === "undefined") return "curved"; + if (typeof localStorage === "undefined") return "auto"; const raw = localStorage.getItem(LINESTYLE_KEY); - return raw === "angular" ? "angular" : "curved"; + return raw === "auto" || raw === "angular" || raw === "curved" ? raw : "auto"; + } catch { + return "auto"; + } +} + +// DEVIATION vs loadSyncLineStyle: reads localStorage UNCONDITIONALLY (incl. under +// Tauri) so the store seed matches what themeMode.ts already painted pre-paint. +function loadSyncTheme(): "classic" | "nerv" { + try { + if (typeof localStorage === "undefined") return "classic"; + return localStorage.getItem(THEME_KEY) === "nerv" ? "nerv" : "classic"; } catch { - return "curved"; + return "classic"; } } +// Reflect the theme onto imperatively — NO $effect (module-scope factory). +function applyThemeAttr(t: "classic" | "nerv"): void { + if (typeof document === "undefined") return; + if (t === "nerv") document.documentElement.setAttribute("data-theme", "nerv"); + else document.documentElement.removeAttribute("data-theme"); +} + +// One-shot NERV "boot reveal" pulse (Task 8) — mirrors themeMode.ts's launch-time +// pulse for the Classic→NERV toggle path. Caller is responsible for only invoking +// this when motion is on (see setTheme). CSS keyframes gated on [data-boot] + +// reduced-motion live in nerv-motion.css. +// Task 7: panels get an ascending --boot-i so nerv-motion.css can stagger the +// per-panel cascade + bracket draw-on. +// How long [data-boot] stays on . Must outlast the full staggered materialize: +// last panel starts at (panelCount-1)*BOOT_STAGGER_MS and runs ~MAT_MS, plus headroom. +const BOOT_MS = 1600; +// A single owned teardown timer. Two pulses can overlap (a cold Tauri launch fires +// onMount + the async repo-load, and rapid repo switches stack) — without this guard +// the FIRST pulse's setTimeout would strip data-boot / --boot-* mid-animation of the +// LATEST pulse. Clearing the prior timer means only the latest pulse owns cleanup. +let bootTimer: ReturnType | null = null; +function pulseBootReveal(): void { + if (typeof document === "undefined") return; + if (bootTimer !== null) clearTimeout(bootTimer); + document.documentElement.dataset.boot = ""; + const panels = document.querySelectorAll(".panel"); + const SQUARE_PX = 24; // start size of the materialize square + panels.forEach((el, i) => { + const p = el as HTMLElement; + p.style.setProperty("--boot-i", String(i)); + // --boot-sx/--boot-sy = the scale that shrinks the inset:0 overlay to a ~24px + // SQUARE (not a panel-shaped sliver); nerv-motion.css's materialize keyframe + // starts there and grows to scale(1,1) = the full panel. Computed here (not via + // CSS calc on a custom prop) so it's a plain, well-supported number. + p.style.setProperty("--boot-sx", (SQUARE_PX / Math.max(1, p.offsetWidth)).toFixed(4)); + p.style.setProperty("--boot-sy", (SQUARE_PX / Math.max(1, p.offsetHeight)).toFixed(4)); + }); + bootTimer = setTimeout(() => { + bootTimer = null; + delete document.documentElement.dataset.boot; + panels.forEach((el) => { + const p = el as HTMLElement; + p.style.removeProperty("--boot-i"); + p.style.removeProperty("--boot-sx"); + p.style.removeProperty("--boot-sy"); + }); + }, BOOT_MS); +} + +// Own-property check (not `in`) so a malformed localStorage/store value like +// "__proto__" can't resolve through the prototype chain instead of failing validation. +function isScheme(v: unknown): v is Scheme { + return typeof v === "string" && Object.prototype.hasOwnProperty.call(NERV_SCHEME_PALETTES, v); +} + +// DEVIATION vs loadSyncLineStyle: reads localStorage UNCONDITIONALLY (incl. under +// Tauri) so the store seed matches what themeMode.ts already painted pre-paint. +function loadSyncScheme(): Scheme { + try { + if (typeof localStorage === "undefined") return "orange"; + const raw = localStorage.getItem(SCHEME_KEY); + return isScheme(raw) ? raw : "orange"; + } catch { + return "orange"; + } +} + +// Reflect the scheme onto imperatively — NO $effect (module-scope factory). +// Always set (even "orange") — harmless, and keeps the attribute present for CSS +// that may later key off it regardless of value. +function applySchemeAttr(s: Scheme): void { + if (typeof document === "undefined") return; + document.documentElement.dataset.scheme = s; +} + +// INVERTED default vs loadSyncTheme/loadSyncScheme: motion defaults to ON +// (attribute PRESENT by default) — only an explicit "off" disables it, so an +// absent/malformed key must not silently turn motion off. Still reads +// localStorage UNCONDITIONALLY (incl. under Tauri) so the store seed matches +// what themeMode.ts already painted pre-paint. +function loadSyncMotion(): boolean { + try { + if (typeof localStorage === "undefined") return true; + return localStorage.getItem(MOTION_KEY) !== "off"; + } catch { + return true; + } +} + +// Reflect motion onto imperatively — NO $effect (module-scope factory). +// INVERTED vs applyThemeAttr: "on" SETS the attribute (default-present); "off" +// removes it. +function applyMotionAttr(on: boolean): void { + if (typeof document === "undefined") return; + if (on) document.documentElement.setAttribute("data-motion", "on"); + else document.documentElement.removeAttribute("data-motion"); +} + function loadSyncMergeInStyle(): MergeInStyle { if (isTauri()) return "hooked"; try { @@ -542,6 +670,44 @@ function makeState() { let graphCommitsRepo = $state(""); let graphHasMore = $state(false); let graphLoadingMore = $state(false); + // ── Timeline "graph builds itself" reveal (Task 9) ───────────────────────── + // Plays ONCE when a repo's graph first appears — armed on a repo-identity change + // in setGraphCommits, or a genuine Local Changes→Timeline view switch (+page.svelte). + // Must NOT replay on scroll, on an fswatch/window-focus refresh (applyGraphRefresh), + // on paging (appendGraphCommits), or on same-repo git ops. NO $effect here: this is + // a module-scope factory, so an $effect throws effect_orphan (same reason the theme + // attrs are reflected imperatively). armReveal() bumps revealSeq and clears + // `revealing` via a seq-guarded setTimeout so a re-arm mid-reveal supersedes the + // older timer instead of clearing early. + let revealing = $state(false); + let revealSeq = 0; + // Base delay (ms) ADDED to every per-row/edge reveal animation. On a repo-switch + // materialize this pushes the DRAW to after the commits frame + content have faded + // in — WITHOUT delaying `revealing` itself. That distinction fixes the flash: if we + // instead delayed `revealing`, the content-fade would paint the whole timeline at + // rest first, then nerv-row-in would snap it hidden and redraw it. Arming `revealing` + // immediately means the rows carry the hidden nerv-row-in from-state from their first + // paint (held through this base delay via `both`), so the timeline is never shown at + // rest — only the offset draw plays. A view switch (no materialize) uses base 0. + let revealBaseMs = $state(0); + // Wipe duration (nerv-motion.css .nerv-graph-reveal, ~700ms) + a small buffer so + // `revealing` clears just after the staggered bottom→top draw finishes (max row + // delay ~≤650ms + the ~320ms edge-draw duration, plus headroom). If this fires + // before the last row's animation ends, that row would snap to its resting state. + const REVEAL_MS = 1100; + // The base offset for a repo-switch: commits is the last-staggered panel, its content + // fades in ~930ms into the boot, so the draw starts ~here (relative to the rows' mount). + const REVEAL_BASE_MAT_MS = 900; + function armReveal(baseMs = 0) { + revealSeq++; + const seq = revealSeq; + revealBaseMs = baseMs; + revealing = true; // ALWAYS synchronous — rows must carry nerv-row-in from first paint. + // The window must outlast the base offset + the staggered draw, else the last row snaps. + setTimeout(() => { + if (revealSeq === seq) revealing = false; + }, REVEAL_MS + baseMs); + } const rows = $derived( computeLanes(graphCommits.map((c) => ({ sha: c.sha, parents: c.parents }))), ); @@ -651,7 +817,7 @@ function makeState() { return m; }); - let graphLineStyle = $state<"curved" | "angular">(loadSyncLineStyle()); + let graphLineStyle = $state<"auto" | "curved" | "angular">(loadSyncLineStyle()); let graphLineStyleTouched = false; // Working-copy/op status from repo_status; null in browser/sample mode (no op). @@ -662,7 +828,10 @@ function makeState() { lsHydrate .then((store) => store.get(LINESTYLE_STORE_KEY)) .then((saved) => { - if ((saved === "curved" || saved === "angular") && !graphLineStyleTouched) { + if ( + (saved === "auto" || saved === "curved" || saved === "angular") && + !graphLineStyleTouched + ) { graphLineStyle = saved; } }) @@ -686,6 +855,129 @@ function makeState() { } } + let theme = $state<"classic" | "nerv">(loadSyncTheme()); + let themeTouched = false; + + const themeHydrate = getStore(); + if (themeHydrate) { + themeHydrate + .then((store) => store.get(THEME_STORE_KEY)) + .then((saved) => { + if ((saved === "classic" || saved === "nerv") && !themeTouched) { + theme = saved; + applyThemeAttr(saved); + // Refresh the pre-paint mirror themeMode.ts reads. The durable copy is the Tauri + // store; if localStorage was dropped/stale (the case this module exists to survive) + // the next launch would keep painting the stale theme until this async hydrate runs + // again — so write the accepted value back to the mirror now. + try { + if (typeof localStorage !== "undefined") localStorage.setItem(THEME_KEY, saved); + } catch (e) { + console.warn("[gte] could not refresh theme mirror", e); + } + } + }) + .catch((e) => console.warn("[gte] could not load theme", e)); + } + + function persistTheme() { + const snapshot = theme; + // DEVIATION vs persistLineStyle: write localStorage in BOTH branches so + // themeMode.ts's synchronous pre-paint read is always current. + try { + if (typeof localStorage !== "undefined") localStorage.setItem(THEME_KEY, snapshot); + } catch (e) { + console.warn("[gte] could not persist theme (localStorage)", e); + } + const sp = getStore(); + if (sp) { + sp.then(async (store) => { + await store.set(THEME_STORE_KEY, snapshot); + await store.save(); + }).catch((e) => console.warn("[gte] could not persist theme (store)", e)); + } + } + + let scheme = $state(loadSyncScheme()); + let schemeTouched = false; + + const schemeHydrate = getStore(); + if (schemeHydrate) { + schemeHydrate + .then((store) => store.get(SCHEME_STORE_KEY)) + .then((saved) => { + if (isScheme(saved) && !schemeTouched) { + scheme = saved; + applySchemeAttr(saved); + // Refresh the pre-paint mirror themeMode.ts reads (see theme hydrate above). + try { + if (typeof localStorage !== "undefined") localStorage.setItem(SCHEME_KEY, saved); + } catch (e) { + console.warn("[gte] could not refresh scheme mirror", e); + } + } + }) + .catch((e) => console.warn("[gte] could not load scheme", e)); + } + + function persistScheme() { + const snapshot = scheme; + // DEVIATION vs persistLineStyle: write localStorage in BOTH branches so + // themeMode.ts's synchronous pre-paint read is always current. + try { + if (typeof localStorage !== "undefined") localStorage.setItem(SCHEME_KEY, snapshot); + } catch (e) { + console.warn("[gte] could not persist scheme (localStorage)", e); + } + const sp = getStore(); + if (sp) { + sp.then(async (store) => { + await store.set(SCHEME_STORE_KEY, snapshot); + await store.save(); + }).catch((e) => console.warn("[gte] could not persist scheme (store)", e)); + } + } + + let motion = $state(loadSyncMotion()); + let motionTouched = false; + + const motionHydrate = getStore(); + if (motionHydrate) { + motionHydrate + .then((store) => store.get(MOTION_STORE_KEY)) + .then((saved) => { + if ((saved === "on" || saved === "off") && !motionTouched) { + motion = saved === "on"; + applyMotionAttr(motion); + // Refresh the pre-paint mirror themeMode.ts reads (see theme hydrate above). + try { + if (typeof localStorage !== "undefined") localStorage.setItem(MOTION_KEY, saved); + } catch (e) { + console.warn("[gte] could not refresh motion mirror", e); + } + } + }) + .catch((e) => console.warn("[gte] could not load motion", e)); + } + + function persistMotion() { + const snapshot = motion ? "on" : "off"; + // DEVIATION vs persistLineStyle: write localStorage in BOTH branches so + // themeMode.ts's synchronous pre-paint read is always current. + try { + if (typeof localStorage !== "undefined") localStorage.setItem(MOTION_KEY, snapshot); + } catch (e) { + console.warn("[gte] could not persist motion (localStorage)", e); + } + const sp = getStore(); + if (sp) { + sp.then(async (store) => { + await store.set(MOTION_STORE_KEY, snapshot); + await store.save(); + }).catch((e) => console.warn("[gte] could not persist motion (store)", e)); + } + } + let graphMergeInStyle = $state(loadSyncMergeInStyle()); let graphMergeInStyleTouched = false; let graphCurviness = $state(loadSyncCurviness()); @@ -1462,6 +1754,23 @@ function makeState() { get graphCommitsRepo() { return graphCommitsRepo; }, + // Task 9 one-shot timeline reveal. `revealing` gates the row-cascade / edge-draw + // animations (GraphHistory/GraphGutter); armReveal() is also called from the + // view-switch effect in +page.svelte on a same-repo Local Changes→Timeline switch. + get revealing() { + return revealing; + }, + // Base ms added to every reveal animation-delay (GraphHistory/GraphGutter) so the + // draw plays after the materialize frame on a repo switch; 0 on a view switch. + get revealBaseMs() { + return revealBaseMs; + }, + armReveal, + // One-shot NERV boot reveal. Fired from +page.svelte's onMount (NOT at module + // load) so the .panel elements exist to receive --boot-i; onMount runs before + // the first browser paint, so the staggered cascade applies with no rest-state + // flash. Also fired by setTheme on a Classic→NERV toggle. + bootPulse: pulseBootReveal, get rows() { return rows; }, @@ -1489,15 +1798,17 @@ function makeState() { colorForRef(name: string, sha: string): string { const ov = branchColors[repo]?.[name]; if (ov) return ov; + const palette = theme === "nerv" ? schemeLanePalette(scheme) : LANE_PALETTE; const idx = colorBySha.get(sha); - if (idx === undefined) return laneColor(0, null, {}); - return overrideByIndex.get(idx) ?? laneColor(idx, null, {}); + if (idx === undefined) return laneColor(0, null, {}, palette); + return overrideByIndex.get(idx) ?? laneColor(idx, null, {}, palette); }, // The colour for a lane (by colorIndex): an override wins if a ref tip on that // lane has one, else the palette colour. Used by the gutter so an override // recolours the descending lane line, not just the sidebar dot. colorForIndex(idx: number): string { - return overrideByIndex.get(idx) ?? laneColor(idx, null, {}); + const palette = theme === "nerv" ? schemeLanePalette(scheme) : LANE_PALETTE; + return overrideByIndex.get(idx) ?? laneColor(idx, null, {}, palette); }, setBranchColor(name: string, hex: string) { branchColorsTouched = true; @@ -1527,11 +1838,43 @@ function makeState() { get graphLineStyle() { return graphLineStyle; }, - setGraphLineStyle(v: "curved" | "angular") { + get effectiveGraphLineStyle(): "curved" | "angular" { + if (graphLineStyle === "curved" || graphLineStyle === "angular") return graphLineStyle; + return theme === "nerv" ? "angular" : "curved"; // "auto" + }, + setGraphLineStyle(v: "auto" | "curved" | "angular") { graphLineStyleTouched = true; graphLineStyle = v; persistLineStyle(); }, + get theme() { + return theme; + }, + setTheme(v: "classic" | "nerv") { + themeTouched = true; + theme = v; + applyThemeAttr(v); + persistTheme(); + if (v === "nerv" && motion) pulseBootReveal(); + }, + get scheme() { + return scheme; + }, + setScheme(v: Scheme) { + schemeTouched = true; + scheme = v; + applySchemeAttr(v); + persistScheme(); + }, + get motion() { + return motion; + }, + setMotion(v: boolean) { + motionTouched = true; + motion = v; + applyMotionAttr(v); + persistMotion(); + }, get graphMergeInStyle() { return graphMergeInStyle; }, @@ -1737,6 +2080,11 @@ function makeState() { persistRelativeDates(); }, setGraphCommits(gc: GraphCommit[]) { + // Capture BEFORE reassigning graphCommitsRepo — a genuine repo-identity + // transition (incl. the first load, when graphCommitsRepo === "") arms the + // one-shot reveal below. applyGraphRefresh/appendGraphCommits deliberately do + // NOT arm, so fswatch/focus refreshes and paging never replay it. + const repoChanged = graphCommitsRepo !== repo; graphCommits = gc; graphCommitsRepo = repo; commits = gc.map(graphToCommit); @@ -1745,6 +2093,21 @@ function makeState() { currentSha = null; // NOTE: lastUndo is intentionally NOT cleared here — a destructive op reloads the // graph and we want the UndoBar to remain visible after that refresh. + if (repoChanged) { + // In NERV + motion, a repo switch re-runs the "materialize" boot on every panel + // (square→outline→brackets); the timeline reveal is delayed so it draws AFTER the + // commits frame has settled. In Classic / motion-off there's no materialize, so + // the reveal arms immediately (it's inert there anyway — the CSS is NERV-gated). + const nervMaterialize = theme === "nerv" && motion; + // Defer the DOM-measuring pulse until AFTER Svelte flushes: during a repo + // switch the commits panel is unmounted (GraphSkeleton shows while repoLoading); + // setting graphCommitsRepo=repo above flips +page's skeleton guard, so the next + // flush (awaited by tick) mounts the commits .panel — only then can pulseBootReveal + // measure it and set its --boot-i/--boot-sx/--boot-sy. Firing synchronously would + // miss it → a panel-shaped sliver with no stagger. (Mirrors the onMount tick fix.) + if (nervMaterialize) void tick().then(pulseBootReveal); + armReveal(nervMaterialize ? REVEAL_BASE_MAT_MS : 0); + } }, // Non-destructive graph update for a LIVE refresh (filesystem watcher / window // focus), as opposed to setGraphCommits (the repo-switch reset). Keeps the open diff --git a/src/lib/theme/nerv-motion.css b/src/lib/theme/nerv-motion.css new file mode 100644 index 0000000..5b50065 --- /dev/null +++ b/src/lib/theme/nerv-motion.css @@ -0,0 +1,328 @@ +/* NERV motion — ambient drift + interaction transitions (Task 7). Plain CSS + (not Svelte), so bare `:root[data-theme="nerv"]…` selectors reach every + host globally — NEVER `:global()`, which is Svelte-only and silently + dropped here (see nerv.css's B1 comment for the same gotcha). + + EVERY rule below is double-gated on `:root[data-theme="nerv"][data-motion="on"]` + AND `@media (prefers-reduced-motion: no-preference)`. Motion-off OR + reduced-motion ⇒ no animation/transition — NERV falls back to fully static, + matching nerv.css's base (non-motion) appearance byte-for-byte. */ + +/* ── Ambient scanline drift ─────────────────────────────────────────────── + A SEPARATE thin layer from nerv.css's static root ::after (grid + accent + wash) — the drift never touches that texture, so ::after can stay static + by design. transform-only (translateY) keeps this on the compositor's fast + path: animating background-position/opacity on a full-viewport fixed layer + would repaint every frame under the backdrop-filter panels (the exact + framerate wall nerv.css's B1 comment calls out). Overscan the top by one + drift-cycle (-4px) so translating down never reveals a seam at the edge. + The pattern's period is 4px (1px line + 3px gap), matching the 4px drift + distance, so the loop is seamless. */ +/* Gated on [data-motion="on"] so motion-off NERV renders NO extra scanline layer + and matches nerv.css's base static appearance; the drift animation below adds + the same [prefers-reduced-motion] gate. */ +:root[data-theme="nerv"][data-motion="on"]::before { + content: ""; + position: fixed; + inset: -4px 0 0 0; + pointer-events: none; + z-index: 9999; + background: repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.015) 0 1px, transparent 1px 4px); +} +@media (prefers-reduced-motion: no-preference) { + :root[data-theme="nerv"][data-motion="on"]::before { + animation: nerv-scanline-drift 8s linear infinite; + } +} +@keyframes nerv-scanline-drift { + to { transform: translateY(4px); } /* transform only */ +} + +/* ── Interaction motion ─────────────────────────────────────────────────── + Corner brackets (nerv.css B3) grow + brighten on hover. --b is registered + via @property in nerv.css (syntax ), which is what + makes it interpolatable — a plain custom property can only be swapped + discretely, not transitioned. */ +@media (prefers-reduced-motion: no-preference) { + :root[data-theme="nerv"][data-motion="on"] .panel::after, + :root[data-theme="nerv"][data-motion="on"] .dialog::after { + transition: --b 180ms ease, filter 180ms ease; + } + :root[data-theme="nerv"][data-motion="on"] .panel:hover::after, + :root[data-theme="nerv"][data-motion="on"] .dialog:hover::after { + --b: 22px; filter: brightness(1.5); + } + + /* Active-segment (SettingsPanel .seg buttons) + row-selection (GraphHistory + .row / .wc-row) — short background transitions instead of an instant + snap. Row selection changes rarely (click/keyboard nav, not per-frame), + so this doesn't reintroduce a scroll-time repaint cost. */ + :root[data-theme="nerv"][data-motion="on"] .seg button { + transition: background-color 120ms ease, color 120ms ease; + } + :root[data-theme="nerv"][data-motion="on"] .row, + :root[data-theme="nerv"][data-motion="on"] .wc-row { + transition: background-color 120ms ease; + } +} + +/* ── Toolbar button corner-bracket hover + lift (Task 5) ────────────────── + Fetch/Pull/Push (+ split push-main/push-arrow) and Manage/Settings gear + buttons — the corner-bracket pseudos (nerv.css B5) reveal on hover with a + small lift, mirroring the website's .btn-ghost:hover treatment. `:not(:disabled)` + matches the existing Classic `.fetch-btn:hover:not(:disabled)` gate so a busy/ + no-remote button doesn't lift; .gear-btn has no disabled state so plain :hover + suffices there. */ +@media (prefers-reduced-motion: no-preference) { + :root[data-theme="nerv"][data-motion="on"] .fetch-btn, + :root[data-theme="nerv"][data-motion="on"] .gear-btn { + transition: transform 120ms ease; + } + :root[data-theme="nerv"][data-motion="on"] .fetch-btn::before, + :root[data-theme="nerv"][data-motion="on"] .fetch-btn::after, + :root[data-theme="nerv"][data-motion="on"] .gear-btn::before, + :root[data-theme="nerv"][data-motion="on"] .gear-btn::after { + transition: opacity 120ms ease; + } + :root[data-theme="nerv"][data-motion="on"] .fetch-btn:hover:not(:disabled), + :root[data-theme="nerv"][data-motion="on"] .gear-btn:hover { + transform: translateY(-1px); + } + :root[data-theme="nerv"][data-motion="on"] .fetch-btn:hover:not(:disabled)::before, + :root[data-theme="nerv"][data-motion="on"] .fetch-btn:hover:not(:disabled)::after, + :root[data-theme="nerv"][data-motion="on"] .gear-btn:hover::before, + :root[data-theme="nerv"][data-motion="on"] .gear-btn:hover::after { + opacity: 1; + } +} + +/* ── Op spinner "● LIVE" blink ──────────────────────────────────────────── + Static appearance (border/radius/background) lives in nerv.css; only the + blink animation is gated here (moved from nerv.css, Task 7). */ +@media (prefers-reduced-motion: no-preference) { + :root[data-theme="nerv"][data-motion="on"] .spinner { + animation: nerv-live-blink 1.2s steps(1, end) infinite; + } +} +@keyframes nerv-live-blink { 50% { opacity: 0.2; } } + +/* ── Persistent live-dot pulse ───────────────────────────────────────────── + Idle: gentle slow pulse (~2s). Busy: faster pulse (~0.8s) — the compound + `.live-dot.busy` selector is more specific than the idle rule, so it wins + the cascade whenever both classes are present. Motion-off or + reduced-motion falls back to the static dot from nerv.css. */ +@media (prefers-reduced-motion: no-preference) { + :root[data-theme="nerv"][data-motion="on"] .live-dot { + animation: nerv-live-dot-pulse 2s ease-in-out infinite; + } + :root[data-theme="nerv"][data-motion="on"] .live-dot.busy { + animation: nerv-live-dot-pulse 0.8s ease-in-out infinite; + } +} +@keyframes nerv-live-dot-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(0.8); } +} + +/* ── One-shot NERV "materialize" boot ────────────────────────────────────── + Pulsed via [data-boot] on (store.svelte.ts pulseBootReveal): on app + launch (+page onMount), on a Classic→NERV toggle (setTheme), and on every repo + switch (setGraphCommits). Each .panel "materializes": a tiny accent SQUARE grows + to the panel's footprint (filled), the fill drains to a full rectangular outline, + then that outline retracts into the four resting corner brackets — i.e. the + collapse animation played in reverse (--b 51% → 14px). Staggered per panel by + --boot-i; the square's start scale (--boot-sx/--boot-sy) is set per panel in JS so + it reads as a real square, not a panel-shaped sliver. Same double-gate as the rest + of this file; Classic / motion-off / reduced-motion get the static resting panel. */ + +/* Power-on scan — a single bright accent band sweeps top→bottom over the whole + viewport. Overrides the ambient-drift ::before for the boot window (the [data-boot] + selector is more specific, so it wins; only background+animation are overridden — + position/inset/z-index fall through from the drift rule — and the drift resumes + once [data-boot] lifts). */ +@media (prefers-reduced-motion: no-preference) { + :root[data-theme="nerv"][data-motion="on"][data-boot]::before { + background: linear-gradient( + 180deg, + transparent 0%, + color-mix(in srgb, var(--accent) 6%, transparent) 44%, + color-mix(in srgb, var(--accent) 45%, transparent) 50%, + color-mix(in srgb, var(--accent) 6%, transparent) 56%, + transparent 100% + ); + animation: nerv-boot-scan 640ms ease-in both; + } +} +@keyframes nerv-boot-scan { + from { transform: translateY(-100%); } + to { transform: translateY(100%); } +} + +/* Per-panel materialize overlay. inset:0 = the panel box; --boot-sx/--boot-sy shrink + it to a ~24px centred square at 0%. It fades in and grows FILLED to the full panel, + then a panel-bg rectangle grows from the CENTRE (background-size, animated by + nerv-mat) so the fill's inner edge opens outward and the red thins into a border-only + outline — which then fades as the ::after brackets (below) take over. The 1.5px border + never gets covered (background-origin defaults to padding-box) so it survives as the + resting outline. z-index:3 so the fill briefly covers the panel content. */ +@media (prefers-reduced-motion: no-preference) { + :root[data-theme="nerv"][data-motion="on"][data-boot] .panel::before { + content: ""; + position: absolute; + inset: 0; + z-index: 3; + pointer-events: none; + transform-origin: center; + border: 1.5px solid var(--accent); + /* Red base + a centred panel-bg rectangle whose size nerv-mat grows 0→100%, carving + the fill into a frame from the inside out. */ + background-color: var(--accent); + background-image: linear-gradient(var(--panel-bg), var(--panel-bg)); + background-repeat: no-repeat; + background-position: center; + background-size: 0% 0%; + animation: nerv-mat 720ms cubic-bezier(0.45, 0, 0.15, 1) both; + animation-delay: calc(var(--boot-i, 0) * 105ms); + } +} +@keyframes nerv-mat { + 0% { transform: scale(var(--boot-sx, 0.06), var(--boot-sy, 0.06)); background-size: 0% 0%; border-color: var(--accent); opacity: 0; } + 6% { opacity: 1; } + 30% { transform: scale(1, 1); background-size: 0% 0%; border-color: var(--accent); opacity: 1; } + 48% { transform: scale(1, 1); background-size: 100% 100%; border-color: var(--accent); opacity: 1; } + 100% { transform: scale(1, 1); background-size: 100% 100%; border-color: transparent; opacity: 0; } +} + +/* Corner-bracket draw-on — the full outline (--b:51%, arms met, matching the + materialize overlay's outline stage) retracts into the four resting L-brackets + (--b:14px), so the outline visibly "opens" into the brackets. Timed to fade in as + the overlay reaches its outline stage (~48% of 720ms ≈ 346ms). .panel only — + dialogs keep their own nerv-dialog-in entrance. `:not(.collapsed)` because a + COLLAPSED panel's resting state IS the full outline (--b:51%, nerv.css) — it must + NOT retract to brackets and then snap/ease back to the outline when [data-boot] + lifts. Collapsed panels keep --b:51% throughout; their ::before fill still plays. */ +@media (prefers-reduced-motion: no-preference) { + :root[data-theme="nerv"][data-motion="on"][data-boot] .panel:not(.collapsed)::after { + animation: nerv-boot-draw 400ms cubic-bezier(0.45, 0, 0.15, 1) both; + animation-delay: calc(var(--boot-i, 0) * 105ms + 340ms); + } +} +@keyframes nerv-boot-draw { + 0% { --b: 51%; opacity: 0; } + 18% { --b: 51%; opacity: 1; } + 100% { --b: 14px; opacity: 1; } +} + +/* Collapsed panels keep their full outline (--b:51%) — they must NOT retract to + brackets (hence :not(.collapsed) above) — but still fade that outline IN with the + frame, so a collapsed panel is part of the "nothing → materialize" sequence instead + of showing a static outline from t=0 during the seed phase. */ +@media (prefers-reduced-motion: no-preference) { + :root[data-theme="nerv"][data-motion="on"][data-boot] .panel.collapsed::after { + animation: nerv-boot-draw-collapsed 400ms cubic-bezier(0.45, 0, 0.15, 1) both; + animation-delay: calc(var(--boot-i, 0) * 105ms + 340ms); + } +} +@keyframes nerv-boot-draw-collapsed { + 0% { --b: 51%; opacity: 0; } + 18% { --b: 51%; opacity: 1; } + 100% { --b: 51%; opacity: 1; } +} + +/* Panel BOX reveal — the panel's own background + border start invisible so at t=0 + there is genuinely NOTHING (no dark panel box), only the materialize square on the + app backdrop. The box fades in as the ::before fill drains into the outline (~46-70% + of 720ms), so the frame appears to "deposit" the panel. Animates .panel's own + background/border only — the ::before/::after overlays are unaffected. The keyframe + ends at the resting tokens, so when [data-boot] lifts the base rule takes over seamlessly. */ +@media (prefers-reduced-motion: no-preference) { + :root[data-theme="nerv"][data-motion="on"][data-boot] .panel { + animation: nerv-boot-box 720ms ease-out both; + animation-delay: calc(var(--boot-i, 0) * 105ms); + } +} +@keyframes nerv-boot-box { + 0%, 46% { background-color: transparent; border-color: transparent; } + 70% { background-color: var(--panel-bg); border-color: var(--border); } + 100% { background-color: var(--panel-bg); border-color: var(--border); } +} + +/* Panel CONTENT reveal — the header + body (`.panel > *`, which excludes the ::before/ + ::after materialize pseudos) start hidden and fade+rise in AFTER the frame has + materialized, so the sequence reads: nothing → red square/frame → content. Staggered + per panel by the same --boot-i. `both` holds opacity:0 through the delay (no flash of + content) and opacity:1 after (until [data-boot] lifts → base takes over). */ +@media (prefers-reduced-motion: no-preference) { + :root[data-theme="nerv"][data-motion="on"][data-boot] .panel > * { + animation: nerv-boot-content 820ms ease-out both; + animation-delay: calc(var(--boot-i, 0) * 105ms); + } +} +@keyframes nerv-boot-content { + 0%, 62% { opacity: 0; transform: translateY(4px); } + 82% { opacity: 1; transform: none; } + 100% { opacity: 1; transform: none; } +} + +/* ── One-shot timeline "graph builds itself" reveal ──────────────────────── + Armed exactly once per repo-identity change / same-repo Local Changes→Timeline + switch (store.svelte.ts → appState.revealing). NOT a clip-path mask over the + whole graph (that reads as a cheap wipe) — instead the ACTUAL line is drawn: + every gutter edge stroke-draws itself (.nerv-edge-draw), every dot pops in + (.nerv-node-in), and every commit row fades in under it (.nerv-row-in). Each + element carries an inline animation-delay staggered bottom→top (set by + GraphHistory/GraphGutter), so the timeline reads as one continuous line growing + upward with the entries arriving beneath it. Same double-gate as the rest of + this file, so Classic / motion-off / reduced-motion render the static graph. */ +@media (prefers-reduced-motion: no-preference) { + /* Edges: pathLength="1" (set on the ) normalises every edge to length 1, + so dasharray:1 + dashoffset:-1→0 draws each segment from its bottom (parent) + end up toward its top (child) dot — matching the bottom→top global sweep. */ + :root[data-theme="nerv"][data-motion="on"] .nerv-edge-draw { + stroke-dasharray: 1; + animation: nerv-edge-draw 320ms ease-out both; + } + /* Dots pop from their own centre (fill-box, not the SVG origin). */ + :root[data-theme="nerv"][data-motion="on"] .nerv-node-in { + transform-box: fill-box; + transform-origin: center; + animation: nerv-node-in 240ms ease-out both; + } + /* Commit rows (+ the working-copy row) slide in from the left as the line reaches them. */ + :root[data-theme="nerv"][data-motion="on"] .nerv-row-in { + animation: nerv-row-in 300ms ease-out both; + } +} +@keyframes nerv-edge-draw { + from { stroke-dashoffset: -1; } + to { stroke-dashoffset: 0; } +} +@keyframes nerv-node-in { + from { opacity: 0; transform: scale(0.1); } + to { opacity: 1; transform: scale(1); } +} +@keyframes nerv-row-in { + from { opacity: 0; transform: translateX(-8px); } + to { opacity: 1; transform: none; } +} + +/* ── Context menu + Settings dialog entrance (motion-polish round 2, Task 8) ── + Short fade + scale on mount so the transient popovers don't just snap into + existence. `both` holds the `from` frame before the animation starts (no + flash-of-final-state) and the `to` frame after it ends. */ +@media (prefers-reduced-motion: no-preference) { + :root[data-theme="nerv"][data-motion="on"] .menu { + animation: nerv-menu-in 140ms ease-out both; + } + :root[data-theme="nerv"][data-motion="on"] .dialog { + animation: nerv-dialog-in 180ms ease-out both; + } +} +@keyframes nerv-menu-in { + from { opacity: 0; transform: scale(0.97) translateY(-2px); } + to { opacity: 1; transform: none; } +} +@keyframes nerv-dialog-in { + from { opacity: 0; transform: scale(0.98); } + to { opacity: 1; transform: none; } +} diff --git a/src/lib/theme/nerv.css b/src/lib/theme/nerv.css new file mode 100644 index 0000000..36acbcf --- /dev/null +++ b/src/lib/theme/nerv.css @@ -0,0 +1,302 @@ +/* NERV theme — global, data-theme-scoped. @font-face declarations are unconditional + but cost nothing until NERV actually renders these families. SIL OFL notices ship + in static/fonts/{Anton-OFL,IBMPlex-OFL}.txt. */ + +/* Registers --b (the B3 corner-bracket arm length) as an interpolatable + custom property. Without this, --b is a plain custom + property and the browser can only discretely swap it — no transition is + possible, which is why panel-hover previously had to fake motion with + filter:brightness alone. Must be a top-level @property (not nested) — + registered once here, ahead of the B3 rules that consume --b. */ +@property --b { + syntax: ""; + inherits: false; + initial-value: 14px; +} + +@font-face { font-family: "Anton"; font-style: normal; font-weight: 400; font-display: swap; + src: url("/fonts/anton-latin-400-normal.woff2") format("woff2"); } +@font-face { font-family: "IBM Plex Sans"; font-style: normal; font-weight: 400; font-display: swap; + src: url("/fonts/ibm-plex-sans-latin-400-normal.woff2") format("woff2"); } +@font-face { font-family: "IBM Plex Sans"; font-style: normal; font-weight: 500; font-display: swap; + src: url("/fonts/ibm-plex-sans-latin-500-normal.woff2") format("woff2"); } +@font-face { font-family: "IBM Plex Sans"; font-style: normal; font-weight: 600; font-display: swap; + src: url("/fonts/ibm-plex-sans-latin-600-normal.woff2") format("woff2"); } +@font-face { font-family: "IBM Plex Mono"; font-style: normal; font-weight: 400; font-display: swap; + src: url("/fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2"); } +@font-face { font-family: "IBM Plex Mono"; font-style: normal; font-weight: 500; font-display: swap; + src: url("/fonts/ibm-plex-mono-latin-500-normal.woff2") format("woff2"); } + +/* ── Phase A: stuck-in-Classic color fixes ────────────────────────────────── + Theme-scoped overrides only (all selectors gated by :root[data-theme="nerv"]), + so Classic — which never sets data-theme — renders byte-identically. Each rule + retargets an existing component class whose hardcoded Classic literal would + read wrong on the NERV void. Selectors were checked to be unique to their + component (StatusBar uses .left.detached, not .ref.detached). */ + +/* Sidebar detached-HEAD row — this is a WARNING, not decoration, so it stays + amber (var(--err)) in every scheme rather than following the accent (else + the Phosphor scheme would paint detached-HEAD green). */ +:root[data-theme="nerv"] .ref.detached { + background: color-mix(in srgb, var(--err) 10%, transparent); +} +:root[data-theme="nerv"] .ref.detached .rn { + color: color-mix(in srgb, var(--err) 75%, var(--text)); +} +:root[data-theme="nerv"] .ref.detached .warn-icon { + color: var(--err); +} + +/* WorkingCopyView status-glyph tints — retint the hardcoded Classic green/amber/ + red backgrounds to NERV equivalents. The glyph text colour already themes via + var(--status-*); only the background literals are stuck. .glyph.s-{add,mod,del} + is a compound selector unique to WorkingCopyView. s-mod follows --status-mod + (amber, warning-family), not the accent. */ +:root[data-theme="nerv"] .glyph.s-add { background: rgba(70, 232, 139, 0.1); } +:root[data-theme="nerv"] .glyph.s-mod { background: color-mix(in srgb, var(--status-mod) 10%, transparent); } +:root[data-theme="nerv"] .glyph.s-del { background: rgba(255, 68, 56, 0.1); } + +/* PrereqBanner is a WARNING banner — heading + border + background wash stay + amber (var(--err)) in every scheme, matching .ref.detached above. The CTA + button is a primary action, not the warning itself, so it intentionally + stays on the accent. */ +:root[data-theme="nerv"] .banner { + background: color-mix(in srgb, var(--err) 8%, transparent); + border-color: var(--err); +} +:root[data-theme="nerv"] .banner strong { + color: var(--err); +} +:root[data-theme="nerv"] .banner .actions button { + background: var(--accent); + color: var(--on-accent); +} + +/* ── Phase A: NERV display type (Anton) ───────────────────────────────────── + Anton is applied SPARINGLY — only to chrome headers and section labels, never + to content (e.g. commit-subject .title, of which there are ~46). Classic is + unaffected (data-theme-scoped). These selectors are the panel/section/dialog + headers scouted in the components. font-size/weight stay as the component set + them (density preserved); only the family + a touch of tracking change. */ +:root[data-theme="nerv"] .panel-header, +:root[data-theme="nerv"] .group-label, +:root[data-theme="nerv"] .section-title, +:root[data-theme="nerv"] .dialog .header h3 { + font-family: var(--font-display); + letter-spacing: 0.06em; + font-weight: 400; /* Anton ships a single 400 weight; avoid faux-bold synthesis */ +} + +/* ══ Phase B — HUD decoration (all rules bare :root[data-theme="nerv"], NEVER :global() + which is Svelte-only and dead in this plain .css; pointer-events:none/additive; Classic + untouched). Blueprint: docs/superpowers/plans/2026-07-07-nerv-spike-findings.md ══ */ + +/* B1 — scanline + grid overlay. Single fixed root ::after painting STATIC gradients: + composites once, no per-frame backdrop re-sample, so it does NOT reintroduce the + backdrop-filter framerate wall (+page.svelte:777-785). Tiny alphas = texture, not scrim. */ +:root[data-theme="nerv"]::after { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 9999; + background: + repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.02) 0 1px, transparent 1px 3px), + linear-gradient(color-mix(in srgb, var(--accent) 1.5%, transparent), color-mix(in srgb, var(--accent) 1.5%, transparent)); +} +/* Static by design; reduced-motion is a no-op but kept explicit for intent. */ +@media (prefers-reduced-motion: reduce) { + :root[data-theme="nerv"]::after { animation: none; } +} + +/* B3 — corner brackets on the shared .panel / .dialog DOM classes (Svelte keeps the plain + DOM class, so these global rules reach every host). One ::after paints all four L-marks, + FLUSH-INSET (inset:0, at the padding-box corners) so overflow:hidden/auto hosts never clip + them. position:relative is verified layout-neutral (no abs descendant resolves to the root). */ +:root[data-theme="nerv"] .panel, +:root[data-theme="nerv"] .dialog { + position: relative; +} +:root[data-theme="nerv"] .panel::after, +:root[data-theme="nerv"] .dialog::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + z-index: 1; + --b: 14px; + --w: 1.5px; + --c: var(--accent); + background: + linear-gradient(var(--c), var(--c)) 0 0 / var(--b) var(--w), + linear-gradient(var(--c), var(--c)) 0 0 / var(--w) var(--b), + linear-gradient(var(--c), var(--c)) 100% 0 / var(--b) var(--w), + linear-gradient(var(--c), var(--c)) 100% 0 / var(--w) var(--b), + linear-gradient(var(--c), var(--c)) 0 100% / var(--b) var(--w), + linear-gradient(var(--c), var(--c)) 0 100% / var(--w) var(--b), + linear-gradient(var(--c), var(--c)) 100% 100% / var(--b) var(--w), + linear-gradient(var(--c), var(--c)) 100% 100% / var(--w) var(--b); + background-repeat: no-repeat; +} +/* GitHub screen is tokens-only — suppress its one .panel (ReviewBar) frame AND its + materialize overlay (nerv-motion.css .panel::before), so the frameless GitHub panel + never flashes a boot fill/outline. */ +:root[data-theme="nerv"] .gh .panel::after, +:root[data-theme="nerv"] .gh .panel::before { content: none; } + +/* Context menu (ContextMenu.svelte) — same B3 corner-bracket treatment as .panel/ + .dialog, so the right-click popover reads as a NERV console menu. .menu is + already position:fixed (main popover) / position:absolute (.submenu) — BOTH are + positioned, so the ::after (position:absolute; inset:0) anchors to the menu box + directly. We must NOT override `position` here: a global `position:relative` + out-specifies the component-scoped `position:fixed` and drops the menu into + document flow (it lands bottom-right and shoves panels around). No pre-existing + ::after on .menu to collide with. */ +:root[data-theme="nerv"] .menu::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + z-index: 1; + --b: 14px; + --w: 1.5px; + --c: var(--accent); + background: + linear-gradient(var(--c), var(--c)) 0 0 / var(--b) var(--w), + linear-gradient(var(--c), var(--c)) 0 0 / var(--w) var(--b), + linear-gradient(var(--c), var(--c)) 100% 0 / var(--b) var(--w), + linear-gradient(var(--c), var(--c)) 100% 0 / var(--w) var(--b), + linear-gradient(var(--c), var(--c)) 0 100% / var(--b) var(--w), + linear-gradient(var(--c), var(--c)) 0 100% / var(--w) var(--b), + linear-gradient(var(--c), var(--c)) 100% 100% / var(--b) var(--w), + linear-gradient(var(--c), var(--c)) 100% 100% / var(--w) var(--b); + background-repeat: no-repeat; +} + +/* Collapsed panel (CollapsiblePanel's header-only state) — extend each corner's + two gradient arms past the box's midpoint so opposite arms meet, turning the + four L-marks into one continuous frame around the header. State rule (always + on in NERV, not gated on data-motion); the gated --b transition in + nerv-motion.css (same .panel::after selector) animates the sweep when motion + is on, and this snaps straight to the closed frame when it's off. */ +:root[data-theme="nerv"] .panel.collapsed::after { + --b: 51%; /* arms meet → outline (51% closes the seam) */ +} + +/* B2 — status bar as a mono console readout (uppercase, tracked, mono). */ +:root[data-theme="nerv"] .status-bar { + font-family: var(--font-mono); + text-transform: uppercase; + letter-spacing: 0.1em; +} +/* Op spinner → phosphor "● LIVE" dot (only visible during an operation). This + is the STATIC appearance; the blink animation lives in nerv-motion.css, + gated on data-motion="on" + prefers-reduced-motion:no-preference (Task 7) — + motion-off/reduced-motion still shows a solid dot, just not blinking. + `animation: none` is load-bearing: StatusBar.svelte's base `.spinner` carries + `animation: pulse …`, which this rule does NOT otherwise override — so without it + the Classic pulse leaks through in static NERV (motion-off / reduced-motion). The + gated nerv-motion.css rule ([data-motion="on"]) is more specific and re-enables the + blink when motion is on. */ +:root[data-theme="nerv"] .spinner { + border: none; + border-radius: 50%; + background: var(--status-add); + animation: none; +} + +/* Persistent "live" dot — always present in NERV (unlike .spinner above, + which only renders during an in-flight op), signalling the console is + live at idle too. Base display:none lives in StatusBar.svelte so Classic + never shows it. Idle: phosphor green; busy: switches to the scheme accent. + The pulse animation itself is gated in nerv-motion.css. */ +:root[data-theme="nerv"] .live-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--status-add); +} +:root[data-theme="nerv"] .live-dot.busy { + background: var(--accent); +} + +/* B4 — console header sigil (◇) on panel + dialog titles (Anton already applied in + Phase A). Sparingly: only the shared panel-header

and dialog

. */ +:root[data-theme="nerv"] .panel-header h2::before, +:root[data-theme="nerv"] .dialog h3::before { + content: "◇ "; + color: var(--accent); + font-family: var(--font-mono); +} +/* Hazard-stripe divider under panel headers — replaces the 1px border with a thin + orange/void diagonal band (the primary, highest-reach section divider). Kept subtle + so it reads alongside the corner brackets rather than fighting them. */ +:root[data-theme="nerv"] .panel:not(.collapsed) .panel-header { + border-bottom-color: transparent; + background-image: repeating-linear-gradient( + -45deg, color-mix(in srgb, var(--accent) 35%, transparent) 0 5px, transparent 5px 10px); + background-repeat: no-repeat; + background-position: bottom left; + background-size: 100% 2px; +} + +/* B5 — toolbar buttons (Fetch/Pull/Push [+ split push-main/push-arrow], Manage/ + Settings gears) styled as website-style ghost console buttons — mirrors + website/assets/css/styles.css .btn-ghost (mono, uppercase, tracked, hairline + border, corner-bracket L-marks). Compact toolbar sizing (padding/font-size/ + gear dimensions) is UNCHANGED — set in +page.svelte's untouched Classic rules + (~958-1044) and simply inherited here; only family/case/tracking/border/fill/ + radius/pseudo-marks are added. All buttons stay ghost (no accent fill) for a + uniform, clean toolbar. Verified: .fetch-btn/.push-main/.push-arrow/.gear-btn + have no pre-existing ::before/::after in Classic, so no collision. The bracket + marks are opacity:0 at rest; the gated hover reveal + lift live in + nerv-motion.css (same selectors). */ +:root[data-theme="nerv"] .fetch-btn, +:root[data-theme="nerv"] .gear-btn { + position: relative; + font-family: var(--font-mono); + text-transform: uppercase; + letter-spacing: 0.05em; + border-color: var(--border); + background: var(--btn-bg); + color: var(--text); + border-radius: var(--radius-sm); +} +:root[data-theme="nerv"] .fetch-btn::before, +:root[data-theme="nerv"] .fetch-btn::after, +:root[data-theme="nerv"] .gear-btn::before, +:root[data-theme="nerv"] .gear-btn::after { + content: ""; + position: absolute; + width: 4px; + height: 4px; + opacity: 0; + pointer-events: none; +} +:root[data-theme="nerv"] .fetch-btn::before, +:root[data-theme="nerv"] .gear-btn::before { + left: 2px; + top: 2px; + border-left: 1px solid var(--accent); + border-top: 1px solid var(--accent); +} +:root[data-theme="nerv"] .fetch-btn::after, +:root[data-theme="nerv"] .gear-btn::after { + right: 2px; + bottom: 2px; + border-right: 1px solid var(--accent); + border-bottom: 1px solid var(--accent); +} + +/* ── Scheme presets — recolor --accent/--accent-hover only; every other NERV + token (including --err/--status-mod, decoupled to amber above) is untouched, + so warnings/detached-HEAD/modified stay amber regardless of scheme. Bare + selectors (NOT :global() — that's Svelte-only and silently dropped in a plain + .css file); specificity (0,3,0) beats the base :root[data-theme="nerv"] block + (0,2,0). "orange" is the base NERV palette itself, so it has no block here. */ +:root[data-theme="nerv"][data-scheme="phosphor"] { --accent: #46E88B; --accent-hover: #6cf3a5; } +:root[data-theme="nerv"][data-scheme="steel"] { --accent: #5AA9E6; --accent-hover: #7cbef0; } +:root[data-theme="nerv"][data-scheme="amber"] { --accent: #E8A33D; --accent-hover: #f2b85c; } +:root[data-theme="nerv"][data-scheme="violet"] { --accent: #9B7FE0; --accent-hover: #b49bee; } +:root[data-theme="nerv"][data-scheme="crimson"] { --accent: #E0445A; --accent-hover: #ec6376; } diff --git a/src/lib/theme/themeMode.ts b/src/lib/theme/themeMode.ts new file mode 100644 index 0000000..dacd598 --- /dev/null +++ b/src/lib/theme/themeMode.ts @@ -0,0 +1,70 @@ +// Sets data-theme="nerv" on BEFORE first paint when the saved theme is NERV, +// so launch never flashes Classic. Mirrors tauriMode.ts: a synchronous module +// side-effect, guarded for SSR/build/test (no document/localStorage). The durable +// source of truth is the Tauri store (hydrated a tick later by store.svelte.ts); we +// also persist the theme to localStorage (see store.svelte.ts persistTheme, Task A4) +// so this synchronous read is current on the next launch. +const THEME_KEY = "gitit.theme.v1"; + +// Same pre-paint mechanism for the NERV color scheme preset (see store.svelte.ts +// persistScheme, Task 4). Setting data-scheme unconditionally (even "orange", the +// default) is harmless — it's only consumed by CSS when data-theme="nerv" is also set. +const SCHEME_KEY = "gitit.scheme.v1"; +const VALID_SCHEMES = new Set(["orange", "phosphor", "steel", "amber", "violet", "crimson"]); + +// Same pre-paint mechanism for the NERV motion setting (see store.svelte.ts +// persistMotion, Task 7). INVERTED default vs theme/scheme: motion defaults to +// ON (attribute PRESENT by default) — only an explicit "off" removes it, so an +// absent/malformed key must not silently disable motion. +const MOTION_KEY = "gitit.motion.v1"; + +function applySavedTheme(): void { + if (typeof document === "undefined") return; + try { + if (typeof localStorage === "undefined") return; + if (localStorage.getItem(THEME_KEY) === "nerv") { + document.documentElement.setAttribute("data-theme", "nerv"); + } + // "classic" / null → attribute absent (Classic is the default) + } catch { + // ignore — Classic is a safe default + } +} + +function applySavedScheme(): void { + if (typeof document === "undefined") return; + try { + if (typeof localStorage === "undefined") return; + const saved = localStorage.getItem(SCHEME_KEY); + document.documentElement.dataset.scheme = saved && VALID_SCHEMES.has(saved) ? saved : "orange"; + } catch { + // ignore — "orange" is a safe default + } +} + +function applySavedMotion(): void { + if (typeof document === "undefined") return; + try { + if (typeof localStorage === "undefined") { + document.documentElement.setAttribute("data-motion", "on"); + return; + } + // "off" → attribute absent; anything else (incl. absent key) → attribute + // present ("on" is the default). + if (localStorage.getItem(MOTION_KEY) !== "off") { + document.documentElement.setAttribute("data-motion", "on"); + } + } catch { + document.documentElement.setAttribute("data-motion", "on"); // ignore — motion-on is the safe default + } +} + +// NOTE: the one-shot NERV "boot reveal" pulse is NOT fired here. This module runs +// at import time — BEFORE SvelteKit mounts the app — so document.querySelectorAll(".panel") +// would find nothing and the pulse would be a no-op (this is the bug that made the +// boot animation appear absent). It is now fired from +page.svelte's onMount via +// appState.bootPulse(), when the panels exist and just before the first paint. + +applySavedTheme(); +applySavedScheme(); +applySavedMotion(); diff --git a/src/lib/types.ts b/src/lib/types.ts index 693743d..f97a496 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -34,6 +34,8 @@ export type SafetyRef = { export type PrerequisiteCheck = { git: boolean; gitVersion: string | null; + python3: boolean; + python3Version: string | null; filterRepo: boolean; filterRepoVersion: string | null; }; diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index 79289c4..8d226d1 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -3,5 +3,8 @@ // See: https://svelte.dev/docs/kit/single-page-apps // See: https://v2.tauri.app/start/frontend/sveltekit/ for more info import "$lib/tauriMode"; +import "$lib/theme/themeMode"; // sets data-theme before paint (no-FOUC) +import "$lib/theme/nerv.css"; // global NERV stylesheet +import "$lib/theme/nerv-motion.css"; // NERV ambient/interaction motion (gated on data-motion) export const ssr = false; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index e8ffdc1..76d1e26 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -39,7 +39,7 @@ import { anyOverlayOpen } from "$lib/overlays"; import { pickRepoFolder, api } from "$lib/api"; import { onWindowDragMouseDown } from "$lib/tauriDrag"; - import { onMount, untrack } from "svelte"; + import { onMount, untrack, tick } from "svelte"; import { slide } from "svelte/transition"; import { appState } from "$lib/store.svelte"; import { githubState } from "$lib/githubState.svelte"; @@ -197,6 +197,12 @@ const repo = appState.repo; // tracked untrack(() => { const changed = view !== prevView || repo !== prevRepo; + // Task 9: arm the one-shot timeline reveal on a genuine Local Changes→Timeline + // switch WITHIN the same repo. Computed from the OLD prevView/prevRepo (before + // the reassignment below). The repo === prevRepo guard means a repo switch does + // NOT arm here — setGraphCommits already arms on the repo-identity change, so + // this avoids a double-arm. + const revealOnViewSwitch = view === "timeline" && prevView !== "timeline" && repo === prevRepo; prevView = view; prevRepo = repo; if (firstSwap) { @@ -214,6 +220,7 @@ ], { duration: 260, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }, ); + if (revealOnViewSwitch) appState.armReveal(); }); }); @@ -251,11 +258,26 @@ if (!inTauri && appState.graphCommits.length === 0) { appState.setGraphCommits(SAMPLE_GRAPH); } + // NERV materialize boot on the BROWSER launch path only. Wait for the reactive + // flush (tick) first: the commits panel renders from the setGraphCommits above, so + // it isn't in the DOM on this synchronous tick — without the wait it'd miss its + // measured --boot-i/scale. tick() resolves in a microtask (before paint), flash-free. + // Gated on !inTauri: under Tauri the sample isn't loaded here — the real repo loads + // async and its setGraphCommits(repoChanged) owns the launch boot, so firing here too + // would double-boot. themeMode.ts painted data-theme/data-motion before this ran; read + // them off so we don't depend on store hydration order. No-op in Classic/motion-off. + const rootEl = document.documentElement; + if (!inTauri && rootEl.dataset.theme === "nerv" && rootEl.dataset.motion === "on") { + void tick().then(() => appState.bootPulse()); + } // Desktop: kick off the once-per-launch auto-update check, and route the // macOS app-menu items (emitted by src-tauri/src/lib.rs) to the same flows // the in-app UI uses. Both no-op outside Tauri / in dev. const unlistenMenu: Array<() => void> = []; if (inTauri) { + // NOTE: the hidden window (visible:false) is revealed natively from lib.rs's + // on_page_load hook, NOT here — WebKit throttles JS timers/rAF while a window is + // ordered-out, so any webview-side reveal is unreliable and lands late. void startupUpdateCheck(); import("@tauri-apps/api/event").then(async ({ listen }) => { unlistenMenu.push(await listen("menu:check-updates", () => manualCheckForUpdates())); @@ -628,6 +650,17 @@ --danger: #dc2626; --danger-hover: #b91c1c; --err: #b45309; + /* Type + shape + on-accent + diff tokens. Classic values == the current literals + (Classic renders identically); NERV overrides them in its block below. + Diff sigils are constant today (no @media dark override) — keep them constant. */ + --on-accent: #fff; + --font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, "Helvetica Neue", sans-serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace; + --font-display: var(--font-sans); + --radius-sm: 4px; --radius-md: 6px; --radius-lg: 10px; --radius-dialog: 8px; + --diff-add-bg: rgba(46, 160, 67, 0.18); --diff-del-bg: rgba(210, 35, 35, 0.18); + --diff-add-bg-dark: rgba(46, 160, 67, 0.24); --diff-del-bg-dark: rgba(210, 35, 35, 0.24); + --diff-add-fg: #2da44e; --diff-del-fg: #cf222e; /* File-status glyph colours (A/M/D…), keyed by what the change MEANS: add = green, modify = yellow/amber (a legible gold on white), remove = red. */ --status-add: #2da44e; @@ -635,9 +668,7 @@ --status-del: #cf222e; /* tells native form controls (spinners, scrollbars, etc.) to follow light/dark */ color-scheme: light; - font-family: - -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, "Helvetica Neue", - sans-serif; + font-family: var(--font-sans); font-size: 14px; } @@ -703,6 +734,53 @@ } } + /* ===== NERV theme tokens — MUST remain AFTER both [data-tauri] glass blocks + (equal specificity → later source order wins). NERV is dark-only + opaque and + ignores prefers-color-scheme; it re-declares the full token superset. ===== */ + :global(:root[data-theme="nerv"]) { + --bg: #0A0C0F; + --panel-bg: #12171C; + --popover-bg: #0E1216; + --header-bg: #0E1216; + --input-bg: #0E1216; + --btn-bg: #0E1216; + --btn-hover: #14181d; + --border: #2E3742; + --border-subtle: #232A31; + --text: #EAE6DA; + --text-muted: #8A94A0; + --row-hover: color-mix(in srgb, var(--accent) 6%, transparent); + --row-selected: color-mix(in srgb, var(--accent) 10%, transparent); + --row-selected-border: var(--accent); + --accent: #F2542D; + --accent-hover: #ff6a44; + --on-accent: #0A0C0F; /* dark text on orange (AA) */ + --danger: #FF4438; + --danger-hover: #ff5a4f; + --err: #D9922E; /* warning = amber, decoupled from the accent so it stays legible in every scheme */ + --status-add: #46E88B; + --status-mod: #D9922E; + --status-del: #FF4438; + /* diff — BOTH tiers (NERV is dark-only but @media dark still matches on a dark-mode Mac) */ + --diff-add-bg: rgba(70, 232, 139, 0.15); --diff-add-bg-dark: rgba(70, 232, 139, 0.15); + --diff-del-bg: rgba(255, 68, 56, 0.15); --diff-del-bg-dark: rgba(255, 68, 56, 0.15); + --diff-add-fg: #46E88B; --diff-del-fg: #FF4438; + /* type */ + --font-sans: "IBM Plex Sans", system-ui, -apple-system, sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + --font-display: "Anton", "Arial Narrow", Impact, sans-serif; + /* shape — near-sharp */ + --radius-sm: 0px; --radius-md: 2px; --radius-lg: 2px; --radius-dialog: 2px; + color-scheme: dark; + font-family: var(--font-sans); + } + /* opaque-over-glass: cover BOTH root and body (the transparent reset ~715-718 targets + body too). Combined selectors (0,3,0)/(0,3,1) → order-independent for this rule. */ + :global(:root[data-theme="nerv"][data-tauri="true"]), + :global(:root[data-theme="nerv"][data-tauri="true"] body) { + background: var(--bg); + } + :global(html, body) { margin: 0; padding: 0; @@ -895,7 +973,7 @@ .fetch-btn { align-self: center; padding: 4px 12px; - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--btn-bg); color: var(--text); @@ -1023,7 +1101,7 @@ border-radius: 7px; border: none; background: var(--accent); - color: #fff; + color: var(--on-accent); font-size: 13px; font-weight: 600; cursor: pointer; diff --git a/static/fonts/Anton-OFL.txt b/static/fonts/Anton-OFL.txt new file mode 100644 index 0000000..93fedde --- /dev/null +++ b/static/fonts/Anton-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Anton Project Authors (https://github.com/googlefonts/AntonFont.git) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/static/fonts/IBMPlex-OFL.txt b/static/fonts/IBMPlex-OFL.txt new file mode 100644 index 0000000..c35c4c6 --- /dev/null +++ b/static/fonts/IBMPlex-OFL.txt @@ -0,0 +1,93 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/static/fonts/anton-latin-400-normal.woff2 b/static/fonts/anton-latin-400-normal.woff2 new file mode 100644 index 0000000..69d48b9 Binary files /dev/null and b/static/fonts/anton-latin-400-normal.woff2 differ diff --git a/static/fonts/ibm-plex-mono-latin-400-normal.woff2 b/static/fonts/ibm-plex-mono-latin-400-normal.woff2 new file mode 100644 index 0000000..0804aaf Binary files /dev/null and b/static/fonts/ibm-plex-mono-latin-400-normal.woff2 differ diff --git a/static/fonts/ibm-plex-mono-latin-500-normal.woff2 b/static/fonts/ibm-plex-mono-latin-500-normal.woff2 new file mode 100644 index 0000000..090f82f Binary files /dev/null and b/static/fonts/ibm-plex-mono-latin-500-normal.woff2 differ diff --git a/static/fonts/ibm-plex-sans-latin-400-normal.woff2 b/static/fonts/ibm-plex-sans-latin-400-normal.woff2 new file mode 100644 index 0000000..f0ee65d Binary files /dev/null and b/static/fonts/ibm-plex-sans-latin-400-normal.woff2 differ diff --git a/static/fonts/ibm-plex-sans-latin-500-normal.woff2 b/static/fonts/ibm-plex-sans-latin-500-normal.woff2 new file mode 100644 index 0000000..6d5527e Binary files /dev/null and b/static/fonts/ibm-plex-sans-latin-500-normal.woff2 differ diff --git a/static/fonts/ibm-plex-sans-latin-600-normal.woff2 b/static/fonts/ibm-plex-sans-latin-600-normal.woff2 new file mode 100644 index 0000000..08c0d5a Binary files /dev/null and b/static/fonts/ibm-plex-sans-latin-600-normal.woff2 differ diff --git a/website/.nojekyll b/website/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/website/404.html b/website/404.html new file mode 100644 index 0000000..b972359 --- /dev/null +++ b/website/404.html @@ -0,0 +1,113 @@ + + + + + +Git It — Not found + + + + + + + + + + + + + + + +
+ +
+
+
+ // SIGNAL LOST +

404

+

This route isn't in the MAGI system.

+ +
+
+
+ +
+
+ + +
+ + + Git It + + · + This site collects nothing — no analytics, no cookies, no third-party requests. + · + Bundles git-filter-repo (MIT). + · + Made for macOS + · + © 2026 + · + git-it.app +
+
+
+
+ + + + diff --git a/website/CNAME b/website/CNAME new file mode 100644 index 0000000..ef1ff19 --- /dev/null +++ b/website/CNAME @@ -0,0 +1 @@ +git-it.app diff --git a/website/assets/css/styles.css b/website/assets/css/styles.css new file mode 100644 index 0000000..b718210 --- /dev/null +++ b/website/assets/css/styles.css @@ -0,0 +1,400 @@ +/* Git It — design tokens, reset, page ground. + Ported verbatim from design/hero-mockup-v1.html (:root tokens, reset, + .page background, .wrap container). Only the font vars are swapped to + the self-hosted stacks (see assets/fonts/fonts.css). */ + +:root{ + --void:#0A0C0F; --void2:#0E1216; --panel:#12171C; + --line:#232A31; --line2:#2E3742; + --bone:#EAE6DA; --haze:#8A94A0; --haze-dim:#7C8794; + --orange:#F2542D; --orange-deep:#C23A18; + --phosphor:#46E88B; --alert:#FF4438; + --font-display:"Anton","Arial Narrow",Impact,sans-serif; + --font-body:"IBM Plex Sans",system-ui,-apple-system,sans-serif; + --font-mono:"IBM Plex Mono",ui-monospace,"SF Mono",Menlo,monospace; + --edge:14px; +} +*{box-sizing:border-box;} +html{-webkit-text-size-adjust:100%;} +body{margin:0;} +.page{ + background: + radial-gradient(1100px 600px at 78% -8%, rgba(242,84,45,.10), transparent 60%), + radial-gradient(900px 700px at 8% 110%, rgba(70,232,139,.05), transparent 55%), + var(--void); + color:var(--bone); + font-family:var(--font-body); + -webkit-font-smoothing:antialiased; + min-height:100vh; + position:relative; + overflow-x:hidden; +} +/* ambient scanlines + grid */ +.page::before{ + content:"";position:fixed;inset:0;pointer-events:none;z-index:2; + background: + linear-gradient(rgba(255,255,255,.02) 1px, transparent 1px) 0 0/100% 3px, + linear-gradient(90deg, rgba(255,255,255,.015) 1px, transparent 1px) 0 0/64px 100%; + mix-blend-mode:screen;opacity:.5; +} +.page::after{ + content:"";position:fixed;inset:0;pointer-events:none;z-index:3; + background:radial-gradient(120% 90% at 50% 0%, transparent 60%, rgba(0,0,0,.55) 100%); +} +.wrap{max-width:1240px;margin:0 auto;padding:0 clamp(20px,4vw,48px);position:relative;z-index:5;} + +/* ---- nav (Task 2) — scoped to the primary header nav so the footer + nav (nav.foot-cols[aria-label="Footer"]) doesn't inherit sticky/z-index ---- */ +nav[aria-label="Primary"]{position:sticky;top:0;z-index:20; + backdrop-filter:blur(8px);background:linear-gradient(var(--void),rgba(10,12,15,.72)); + border-bottom:1px solid transparent;transition:border-color .2s;} +nav[aria-label="Primary"].is-scrolled{border-bottom-color:var(--line);} +.nav-in{display:flex;align-items:center;justify-content:space-between;gap:20px; + height:60px;} +.brand{display:flex;align-items:center;gap:10px;font-family:var(--font-mono); + font-size:14px;letter-spacing:.16em;color:var(--bone);text-transform:uppercase; + text-decoration:none;white-space:nowrap;flex-shrink:0;} +.brand svg{display:block;} +.brand:focus-visible{outline:2px solid var(--orange);outline-offset:3px;} +.nav-links{display:flex;align-items:center;gap:26px;font-family:var(--font-mono); + font-size:12px;letter-spacing:.14em;text-transform:uppercase;} +.nav-links a{color:var(--haze);text-decoration:none;position:relative;padding:4px 0;} +.nav-links a::before{content:"";position:absolute;left:-12px;top:50%;transform:translateY(-50%); + color:var(--orange);opacity:0;transition:opacity .18s;} +.nav-links a:hover{color:var(--bone);} +.nav-links a:hover::before{content:"›";opacity:1;} +.nav-links a:focus-visible{outline:2px solid var(--orange);outline-offset:3px;color:var(--bone);} +.nav-dl{color:var(--bone)!important;border:1px solid var(--orange);padding:8px 14px!important; + background:rgba(242,84,45,.08);white-space:nowrap;} +.nav-dl:hover{background:var(--orange);color:var(--void)!important;} +@media(max-width:720px){.hide-sm{display:none;}} +@media(max-width:400px){ + /* very narrow phones: also hide the plain GitHub link (still reachable from the + hero + footer), tighten the gap, and keep the CTA compact so the brand + CTA + never exceed the ~280px content area and overflow/clip. */ + .nav-links a:not(.hide-sm):not(.nav-dl){display:none;} + .nav-links{gap:14px;} + .nav-dl{padding:8px 12px!important;} +} + +/* ---- button components (Task 2) ---- */ +.btn{font-family:var(--font-mono);font-size:13px;letter-spacing:.12em;text-transform:uppercase; + text-decoration:none;padding:15px 22px;display:inline-flex;align-items:center;gap:12px; + position:relative;border:1px solid transparent;transition:transform .12s,background .18s,color .18s;} +.btn:focus-visible{outline:2px solid var(--bone);outline-offset:3px;} +.btn-primary{background:var(--orange);color:var(--void);font-weight:600;} +.btn-primary:hover{background:#ff6a44;transform:translateY(-1px);} +.btn-ghost{color:var(--bone);border-color:var(--line2);background:var(--void2);} +.btn-ghost:hover{border-color:var(--bone);} +/* corner brackets */ +.btn::before,.btn::after{content:"";position:absolute;width:8px;height:8px;opacity:0;transition:opacity .18s,inset .18s;} +.btn-ghost::before{left:5px;top:5px;border-left:2px solid var(--orange);border-top:2px solid var(--orange);} +.btn-ghost::after{right:5px;bottom:5px;border-right:2px solid var(--orange);border-bottom:2px solid var(--orange);} +.btn-ghost:hover::before,.btn-ghost:hover::after{opacity:1;} + +/* ---- boot reveal (Task 2) ---- */ +.reveal{opacity:1;} +@media(prefers-reduced-motion:no-preference){ + .reveal{animation:rise .7s cubic-bezier(.2,.8,.2,1) both;} + .d1{animation-delay:.06s}.d2{animation-delay:.16s}.d3{animation-delay:.28s} + .d4{animation-delay:.42s}.d5{animation-delay:.56s}.d6{animation-delay:.7s} + .headline .line > span{transform:translateY(110%);animation:slideup .9s cubic-bezier(.16,1,.3,1) both;} + .headline .l1 > span{animation-delay:.18s}.headline .l2 > span{animation-delay:.30s} +} +@keyframes rise{from{opacity:0;transform:translateY(14px);}to{opacity:1;transform:none;}} +@keyframes slideup{from{transform:translateY(110%);}to{transform:translateY(0);}} + +/* ---- scroll reveal (Task 2) — hidden only when motion is allowed ---- */ +[data-reveal]{opacity:1;transform:none;} +@media(prefers-reduced-motion:no-preference){ + html.js [data-reveal]{opacity:0;transform:translateY(16px);} + html.js [data-reveal].in-view{opacity:1;transform:none;transition:.6s cubic-bezier(.2,.8,.2,1);} +} + +/* ---- visually-hidden (a11y text alternatives) ---- */ +.visually-hidden{position:absolute;width:1px;height:1px;padding:0;margin:-1px; + overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0;} + +/* ---- hero (Task 3) ---- */ +.hero{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(0,.85fr); + gap:clamp(28px,4vw,56px);align-items:center; + padding:clamp(48px,8vh,104px) 0 clamp(40px,6vh,72px);} +@media(max-width:900px){.hero{grid-template-columns:1fr;gap:36px;}} + +.eyebrow{display:inline-flex;align-items:center;gap:10px;font-family:var(--font-mono); + font-size:12.5px;letter-spacing:.2em;text-transform:uppercase;color:var(--bone); + border:1px solid var(--line2);padding:7px 12px;border-radius:2px;background:var(--void2);} + +h1.headline{font-family:var(--font-display);font-weight:400;color:var(--bone); + margin:22px 0 0;line-height:.9;letter-spacing:.005em;text-transform:uppercase; + font-size:clamp(3.2rem,12vw,8.4rem);text-wrap:balance;} +.headline .line{display:block;overflow:hidden;} +.headline .line > span{display:block;} +.headline em{font-style:normal;color:var(--orange);} + +.anno{font-family:var(--font-mono);font-size:12.5px;letter-spacing:.06em;color:var(--haze); + margin:20px 0 0;text-transform:uppercase;border-left:2px solid var(--orange);padding-left:12px;} +.anno b{color:var(--bone);font-weight:600;} + +.subhead{max-width:52ch;margin:20px 0 0;color:#C7CDD4;font-size:clamp(1rem,1.15vw,1.12rem); + line-height:1.6;} +.subhead .strong{color:var(--bone);} + +.microline{margin:14px 0 0;font-family:var(--font-mono);font-size:12.5px;letter-spacing:.06em; + text-transform:uppercase;} +.microline a{color:var(--haze);text-decoration:none;border-bottom:1px solid var(--line2); + padding-bottom:2px;transition:color .18s,border-color .18s;} +.microline a:hover{color:var(--bone);border-color:var(--orange);} +.microline a:focus-visible{outline:2px solid var(--orange);outline-offset:3px;} + +.cta-row{display:flex;flex-wrap:wrap;gap:14px;margin-top:30px;} + +.readout{display:flex;flex-wrap:wrap;gap:22px;margin-top:34px;font-family:var(--font-mono); + font-size:12.5px;letter-spacing:.08em;color:var(--haze);text-transform:uppercase;} +.readout .g{color:var(--phosphor);} +.readout span{display:inline-flex;gap:8px;align-items:baseline;} + +/* ---- console / HUD panel (Task 3) ---- */ +.console{position:relative;background:linear-gradient(var(--panel),var(--void2)); + border:1px solid var(--line2);padding:0;min-width:0;} +.console .brk{position:absolute;width:14px;height:14px;z-index:2;} +.brk.tl{left:-1px;top:-1px;border-left:2px solid var(--orange);border-top:2px solid var(--orange);} +.brk.tr{right:-1px;top:-1px;border-right:2px solid var(--orange);border-top:2px solid var(--orange);} +.brk.bl{left:-1px;bottom:-1px;border-left:2px solid var(--orange);border-bottom:2px solid var(--orange);} +.brk.br{right:-1px;bottom:-1px;border-right:2px solid var(--orange);border-bottom:2px solid var(--orange);} +.con-head{display:flex;align-items:center;justify-content:space-between; + padding:11px 14px;border-bottom:1px solid var(--line);font-family:var(--font-mono); + font-size:11.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--haze);} +.con-head .live{display:inline-flex;align-items:center;gap:7px;color:var(--phosphor);} +.con-head .dot{width:7px;height:7px;border-radius:50%;background:var(--phosphor); + box-shadow:0 0 8px var(--phosphor);} +@media(prefers-reduced-motion:no-preference){.con-head .dot{animation:blink 2.4s steps(1) infinite;}} +@keyframes blink{0%,70%{opacity:1;}71%,100%{opacity:.25;}} +.con-body{padding:8px 6px 4px;position:relative;overflow:hidden;} +.con-hex{position:absolute;right:-38px;top:50%;transform:translateY(-50%);width:230px;height:230px; + opacity:.10;pointer-events:none;} +@media(prefers-reduced-motion:no-preference){.con-hex{animation:spin 60s linear infinite;transform-origin:50% 50%;}} +@keyframes spin{to{transform:translateY(-50%) rotate(360deg);}} +.con-foot{display:flex;justify-content:space-between;gap:10px;padding:11px 14px; + border-top:1px solid var(--line);font-family:var(--font-mono);font-size:11.5px; + letter-spacing:.08em;color:var(--haze-dim);text-transform:uppercase;} +.con-foot b{color:var(--bone);} +svg.graph{width:100%;height:auto;display:block;} +svg.graph text{font-family:var(--font-mono);} + +/* ---- hazard divider (Task 3 — shared; label set via data-label) ---- */ +.hazard{height:26px;margin-top:8px;position:relative; + background:repeating-linear-gradient(-45deg,var(--orange) 0 14px,var(--void) 14px 28px); + opacity:.9;} +.hazard::after{content:attr(data-label);position:absolute;left:50%;top:50%; + transform:translate(-50%,-50%);background:var(--void);color:var(--haze); + font-family:var(--font-mono);font-size:11px;letter-spacing:.24em;padding:5px 16px; + border:1px solid var(--line2);text-transform:uppercase;white-space:nowrap;} + +/* ---- what's-different strip (Task 4) ---- */ +.why{padding:clamp(40px,7vh,72px) 0 clamp(8px,3vh,24px);} +.why-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:clamp(16px,2vw,24px); + margin-top:clamp(36px,5vh,52px);} +@media(max-width:760px){.why-grid{grid-template-columns:1fr;}} +.why-item{position:relative;background:linear-gradient(var(--panel),var(--void2)); + border:1px solid var(--line);padding:26px 24px;} +.why-item::before,.why-item::after{content:"";position:absolute;width:12px;height:12px;} +.why-item::before{left:-1px;top:-1px;border-left:2px solid var(--orange);border-top:2px solid var(--orange);} +.why-item::after{right:-1px;bottom:-1px;border-right:2px solid var(--orange);border-bottom:2px solid var(--orange);} +.why-item .tag{font-family:var(--font-mono);font-size:11px;letter-spacing:.2em; + color:var(--haze-dim);text-transform:uppercase;} +.why-item h3{font-family:var(--font-body);font-weight:700;font-size:1.14rem;color:var(--bone); + margin:12px 0 0;line-height:1.25;letter-spacing:.005em;} +.why-item p{margin:12px 0 0;color:#C7CDD4;font-size:.98rem;line-height:1.6;} +.why-item p em{font-style:italic;color:var(--bone);} +@media(prefers-reduced-motion:no-preference){ + .why-grid [data-reveal]:nth-child(1){transition-delay:0s;} + .why-grid [data-reveal]:nth-child(2){transition-delay:.12s;} + .why-grid [data-reveal]:nth-child(3){transition-delay:.24s;} +} + +/* ---- spec band (Task 5 — ported from mockup) ---- */ +.spec{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--line); + border:1px solid var(--line);margin:40px 0 64px;} +@media(max-width:760px){.spec{grid-template-columns:repeat(2,1fr);}} +.cell{background:var(--void2);padding:20px 18px;} +.cell .lbl{font-family:var(--font-mono);font-size:11px;letter-spacing:.18em;color:var(--haze-dim); + text-transform:uppercase;} +.cell .val{font-family:var(--font-mono);font-size:14.5px;letter-spacing:.02em;color:var(--bone); + margin-top:8px;text-transform:uppercase;} +.cell .val.o{color:var(--orange);} + +/* ---- feature panels (Task 6) — .op-panel component ---- */ +#features{padding:clamp(48px,7vh,88px) 0 clamp(24px,4vh,48px); + display:flex;flex-direction:column;gap:clamp(56px,9vh,104px);} +.op-panel{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.05fr); + gap:clamp(28px,4vw,64px);align-items:center;} +.op-panel.reverse .op-copy{order:2;} +.op-panel.reverse .op-illo{order:1;} +/* the signature TIME panel: give the illustration extra width */ +.op-panel--wide{grid-template-columns:minmax(0,.9fr) minmax(0,1.25fr);} +@media(max-width:900px){ + .op-panel,.op-panel--wide{grid-template-columns:1fr;gap:28px;} + /* stack: copy first, illustration below (override .reverse order too) */ + .op-panel .op-copy,.op-panel.reverse .op-copy{order:1;} + .op-panel .op-illo,.op-panel.reverse .op-illo{order:2;} +} +.op-code{font-family:var(--font-mono);font-size:12px;letter-spacing:.24em; + text-transform:uppercase;color:var(--orange);} +.op-copy h2{font-family:var(--font-display);font-weight:400;text-transform:uppercase; + color:var(--bone);line-height:.96;letter-spacing:.01em; + font-size:clamp(2rem,4.4vw,3.1rem);margin:14px 0 0;text-wrap:balance;} +.op-copy p{max-width:46ch;margin:18px 0 0;color:#C7CDD4; + font-size:clamp(1rem,1.1vw,1.08rem);line-height:1.6;} +.op-copy p code{font-family:var(--font-mono);font-size:.9em;color:var(--bone); + background:var(--void2);border:1px solid var(--line2);border-radius:2px;padding:1px 5px;} +.op-list{list-style:none;margin:22px 0 0;padding:0;display:flex;flex-direction:column;gap:10px; + font-family:var(--font-mono);font-size:12.5px;letter-spacing:.04em;color:var(--haze); + text-transform:uppercase;} +.op-list li{position:relative;padding-left:20px;line-height:1.4;} +.op-list li::before{content:"›";position:absolute;left:0;top:0;color:var(--orange);} +.op-illo .console{max-width:100%;} +.op-illo svg.graph{max-height:420px;} + +/* ---- multi-repo line (Task 8) — compact centered mono note under the grid ---- */ +.multi-repo{margin:clamp(28px,5vh,56px) 0 0;text-align:center; + font-family:var(--font-mono);font-size:12.5px;letter-spacing:.08em; + color:var(--haze);text-transform:uppercase;} + +/* ---- download section (Task 9) — the primary CTA ---- */ +.dl{padding:clamp(48px,8vh,96px) 0 clamp(40px,7vh,80px);text-align:center; + display:flex;flex-direction:column;align-items:center;} +.dl-head{font-family:var(--font-display);font-weight:400;text-transform:uppercase; + color:var(--bone);line-height:.96;letter-spacing:.01em; + font-size:clamp(2.4rem,6vw,4rem);margin:14px 0 0;} +.dl-sub{max-width:52ch;margin:20px 0 0;color:#C7CDD4; + font-size:clamp(1rem,1.1vw,1.08rem);line-height:1.6;} +.dl-sub code{font-family:var(--font-mono);font-size:.9em;color:var(--bone); + background:var(--void2);border:1px solid var(--line2);border-radius:2px;padding:1px 5px;} +.dl-btns{display:flex;flex-wrap:wrap;gap:16px;justify-content:center;margin-top:clamp(28px,5vh,44px);} +.dl-btn{padding:19px 30px;font-size:14px;} +.dl-note{margin:16px 0 0;font-family:var(--font-mono);font-size:12px;letter-spacing:.06em; + color:var(--haze);} +.dl-trust{margin:clamp(24px,4vh,36px) 0 0;font-family:var(--font-mono);font-size:13px; + letter-spacing:.06em;color:var(--phosphor);} +.dl-req{margin-top:14px;font-family:var(--font-mono);font-size:12px;letter-spacing:.06em; + color:var(--haze);} + +/* ---- in-page guides section (Task 10) — two briefing cards ---- */ +#guides{padding:clamp(48px,7vh,88px) 0 clamp(24px,4vh,48px);} +.guides-head{font-family:var(--font-display);font-weight:400;text-transform:uppercase; + color:var(--bone);line-height:.96;letter-spacing:.01em; + font-size:clamp(2rem,4.4vw,3.1rem);margin:14px 0 0;} +.guide-cards{display:grid;grid-template-columns:repeat(2,1fr);gap:clamp(16px,2vw,24px); + margin-top:clamp(32px,5vh,48px);} +@media(max-width:760px){.guide-cards{grid-template-columns:1fr;}} +.guide-card{display:block;text-decoration:none;transition:transform .12s,border-color .18s;} +.guide-card:hover{transform:translateY(-2px);border-color:var(--orange);} +.guide-card:focus-visible{outline:2px solid var(--orange);outline-offset:3px;} +.guide-card-body{padding:24px 24px 26px;} +.guide-card-body h3{font-family:var(--font-body);font-weight:700;font-size:1.14rem; + color:var(--bone);margin:0;line-height:1.25;letter-spacing:.005em;} +.guide-card-body p{margin:12px 0 0;color:#C7CDD4;font-size:.98rem;line-height:1.6;} +.guide-card-go{display:inline-block;margin-top:18px;font-family:var(--font-mono); + font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--orange);} +.guide-card:hover .guide-card-go{color:var(--bone);} + +/* ---- guides.html page (Task 10) — briefings with mono step numbers ---- */ +.guides-page{padding:clamp(40px,7vh,80px) 0 clamp(48px,8vh,88px);} +.guides-title{font-family:var(--font-display);font-weight:400;text-transform:uppercase; + color:var(--bone);line-height:.96;letter-spacing:.01em; + font-size:clamp(2.6rem,7vw,4.4rem);margin:14px 0 0;} +.guides-lede{max-width:56ch;margin:18px 0 0;color:#C7CDD4; + font-size:clamp(1rem,1.15vw,1.12rem);line-height:1.6;} +.guide{margin-top:clamp(48px,8vh,80px);scroll-margin-top:80px;} +.guide h2{font-family:var(--font-display);font-weight:400;text-transform:uppercase; + color:var(--bone);line-height:.96;letter-spacing:.01em; + font-size:clamp(1.9rem,4.4vw,3rem);margin:12px 0 0;} +.guide-sub{max-width:52ch;margin:16px 0 0;color:#C7CDD4; + font-size:clamp(1rem,1.1vw,1.08rem);line-height:1.6;} +.steps{list-style:none;margin:clamp(24px,4vh,36px) 0 0;padding:0; + display:flex;flex-direction:column;gap:clamp(16px,2.5vh,24px);} +.step{display:grid;grid-template-columns:auto minmax(0,1fr);gap:20px;align-items:start; + position:relative;background:linear-gradient(var(--panel),var(--void2)); + border:1px solid var(--line);padding:22px 24px;} +.step-n{font-family:var(--font-mono);font-weight:500;font-size:1.4rem;letter-spacing:.06em; + color:var(--orange);line-height:1;} +.step-body h3{font-family:var(--font-body);font-weight:700;font-size:1.05rem;color:var(--bone); + margin:0;line-height:1.3;letter-spacing:.005em;} +.step-body p{margin:8px 0 0;color:#C7CDD4;font-size:.98rem;line-height:1.6;} +.step-body code{font-family:var(--font-mono);font-size:.9em;color:var(--bone); + background:var(--void2);border:1px solid var(--line2);border-radius:2px;padding:1px 5px;} +.step-body strong{color:var(--bone);font-weight:600;} + +/* ---- community section (Task 12) — "Built in the open." ---- */ +#community{padding:clamp(48px,7vh,88px) 0 clamp(24px,4vh,48px);} +.community-head{font-family:var(--font-display);font-weight:400;text-transform:uppercase; + color:var(--bone);line-height:.96;letter-spacing:.01em; + font-size:clamp(2rem,4.4vw,3.1rem);margin:14px 0 0;} +.community-lede{max-width:56ch;margin:18px 0 0;color:#C7CDD4; + font-size:clamp(1rem,1.1vw,1.08rem);line-height:1.6;} +.community-links{display:flex;flex-wrap:wrap;gap:14px;margin-top:clamp(24px,4vh,36px);} +.community-note{margin:clamp(24px,4vh,36px) 0 0;font-family:var(--font-mono); + font-size:12.5px;letter-spacing:.04em;color:var(--haze);line-height:1.7;} +.community-note a{color:var(--bone);text-decoration:none;border-bottom:1px solid var(--line2); + padding-bottom:1px;transition:color .18s,border-color .18s;} +.community-note a:hover{color:var(--bone);border-color:var(--orange);} +.community-note a:focus-visible{outline:2px solid var(--orange);outline-offset:3px;} + +/* ---- footer (Task 12) — shared across all pages ---- */ +.foot{border-top:1px solid var(--line);margin-top:clamp(40px,7vh,72px); + padding:clamp(36px,6vh,56px) 0 clamp(28px,4vh,40px);} +.foot-cols{display:grid;grid-template-columns:repeat(3,1fr);gap:clamp(24px,4vw,48px);} +@media(max-width:640px){.foot-cols{grid-template-columns:1fr;gap:28px;}} +.foot-col h2{font-family:var(--font-mono);font-size:11px;font-weight:400;letter-spacing:.24em; + text-transform:uppercase;color:var(--haze-dim);margin:0 0 16px;} +.foot-col ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:11px;} +.foot-col a{font-family:var(--font-mono);font-size:12.5px;letter-spacing:.06em; + color:var(--haze);text-decoration:none;transition:color .18s;} +.foot-col a:hover{color:var(--bone);} +.foot-col a:focus-visible{outline:2px solid var(--orange);outline-offset:3px;color:var(--bone);} +.foot-bar{margin-top:clamp(32px,5vh,48px);padding-top:clamp(22px,3vh,28px); + border-top:1px solid var(--line); + display:flex;flex-wrap:wrap;align-items:center;gap:10px 20px; + font-family:var(--font-mono);font-size:11.5px;letter-spacing:.08em; + color:var(--haze);text-transform:uppercase;} +.foot-brand{display:inline-flex;align-items:center;gap:9px;color:var(--bone);} +.foot-brand svg{display:block;} +.foot-bar .sep{color:var(--line2);} +.foot-priv{text-transform:none;letter-spacing:.04em;color:var(--haze);} + +/* ---- FAQ section (Task 11) — accessible native details/summary ---- */ +#faq{padding:clamp(48px,7vh,88px) 0 clamp(24px,4vh,48px);} +.faq-head{font-family:var(--font-display);font-weight:400;text-transform:uppercase; + color:var(--bone);line-height:.96;letter-spacing:.01em; + font-size:clamp(2rem,4.4vw,3.1rem);margin:14px 0 0;} +.faq-list{margin-top:clamp(28px,5vh,44px);border:1px solid var(--line); + background:linear-gradient(var(--panel),var(--void2));} +.faq-item{border-bottom:1px solid var(--line);} +.faq-item:last-child{border-bottom:0;} +.faq-item summary{list-style:none;cursor:pointer;padding:18px 22px; + display:flex;align-items:center;gap:12px; + font-family:var(--font-mono);font-size:13px;letter-spacing:.06em; + text-transform:uppercase;color:var(--bone); + transition:color .18s,background .18s;} +.faq-item summary::-webkit-details-marker{display:none;} +.faq-item summary::before{content:"›";color:var(--orange);font-size:1.1em;line-height:1; + transition:transform .18s;} +.faq-item[open] summary::before{transform:rotate(90deg);} +.faq-item summary:hover{color:var(--bone);background:rgba(242,84,45,.05);} +.faq-item summary:focus-visible{outline:2px solid var(--orange);outline-offset:-2px;} +.faq-a{padding:0 22px 20px 46px;} +.faq-a p{margin:0;max-width:70ch;color:#C7CDD4;font-size:.98rem;line-height:1.65;} + +/* ---- 404 / not-found page (Task 13) — "signal lost" ---- */ +.notfound{min-height:52vh;display:flex;flex-direction:column;align-items:center; + justify-content:center;text-align:center; + padding:clamp(56px,12vh,120px) 0 clamp(48px,9vh,96px);} +.notfound-chip{display:inline-flex;align-items:center;font-family:var(--font-mono); + font-size:11.5px;letter-spacing:.24em;text-transform:uppercase;color:var(--haze); + border:1px solid var(--line2);padding:7px 14px;border-radius:2px;background:var(--void2);} +.notfound-code{font-family:var(--font-display);font-weight:400;color:var(--bone); + line-height:.9;letter-spacing:.02em;font-size:clamp(5rem,22vw,12rem);margin:22px 0 0;} +.notfound-line{font-family:var(--font-mono);font-size:13px;letter-spacing:.06em; + color:var(--haze);margin:16px 0 0;} +.notfound-back{margin-top:clamp(28px,5vh,44px);} diff --git a/website/assets/fonts/Anton-OFL.txt b/website/assets/fonts/Anton-OFL.txt new file mode 100644 index 0000000..93fedde --- /dev/null +++ b/website/assets/fonts/Anton-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Anton Project Authors (https://github.com/googlefonts/AntonFont.git) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/website/assets/fonts/IBMPlex-OFL.txt b/website/assets/fonts/IBMPlex-OFL.txt new file mode 100644 index 0000000..c35c4c6 --- /dev/null +++ b/website/assets/fonts/IBMPlex-OFL.txt @@ -0,0 +1,93 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/website/assets/fonts/anton-latin-400-normal.woff2 b/website/assets/fonts/anton-latin-400-normal.woff2 new file mode 100644 index 0000000..69d48b9 Binary files /dev/null and b/website/assets/fonts/anton-latin-400-normal.woff2 differ diff --git a/website/assets/fonts/fonts.css b/website/assets/fonts/fonts.css new file mode 100644 index 0000000..8929b90 --- /dev/null +++ b/website/assets/fonts/fonts.css @@ -0,0 +1,45 @@ +/* Self-hosted fonts — zero third-party requests at runtime. + Anton (© 2020 The Anton Project Authors) and IBM Plex Sans + IBM Plex Mono + (© 2017 IBM Corp., Reserved Font Name "Plex") are licensed under the SIL Open + Font License 1.1. Full license + notices: Anton-OFL.txt and IBMPlex-OFL.txt + (this directory), shipped with the font binaries per the OFL. */ + +@font-face { + font-family: "Anton"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../fonts/anton-latin-400-normal.woff2") format("woff2"); +} + +@font-face { + font-family: "IBM Plex Sans"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../fonts/ibm-plex-sans-latin-400-normal.woff2") format("woff2"); +} + +@font-face { + font-family: "IBM Plex Sans"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("../fonts/ibm-plex-sans-latin-600-normal.woff2") format("woff2"); +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2"); +} + +@font-face { + font-family: "IBM Plex Mono"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("../fonts/ibm-plex-mono-latin-500-normal.woff2") format("woff2"); +} diff --git a/website/assets/fonts/ibm-plex-mono-latin-400-normal.woff2 b/website/assets/fonts/ibm-plex-mono-latin-400-normal.woff2 new file mode 100644 index 0000000..0804aaf Binary files /dev/null and b/website/assets/fonts/ibm-plex-mono-latin-400-normal.woff2 differ diff --git a/website/assets/fonts/ibm-plex-mono-latin-500-normal.woff2 b/website/assets/fonts/ibm-plex-mono-latin-500-normal.woff2 new file mode 100644 index 0000000..090f82f Binary files /dev/null and b/website/assets/fonts/ibm-plex-mono-latin-500-normal.woff2 differ diff --git a/website/assets/fonts/ibm-plex-sans-latin-400-normal.woff2 b/website/assets/fonts/ibm-plex-sans-latin-400-normal.woff2 new file mode 100644 index 0000000..f0ee65d Binary files /dev/null and b/website/assets/fonts/ibm-plex-sans-latin-400-normal.woff2 differ diff --git a/website/assets/fonts/ibm-plex-sans-latin-600-normal.woff2 b/website/assets/fonts/ibm-plex-sans-latin-600-normal.woff2 new file mode 100644 index 0000000..08c0d5a Binary files /dev/null and b/website/assets/fonts/ibm-plex-sans-latin-600-normal.woff2 differ diff --git a/website/assets/img/apple-touch-icon.png b/website/assets/img/apple-touch-icon.png new file mode 100644 index 0000000..b89b65a Binary files /dev/null and b/website/assets/img/apple-touch-icon.png differ diff --git a/website/assets/img/favicon.ico b/website/assets/img/favicon.ico new file mode 100644 index 0000000..5fc7bdb Binary files /dev/null and b/website/assets/img/favicon.ico differ diff --git a/website/assets/img/favicon.svg b/website/assets/img/favicon.svg new file mode 100644 index 0000000..8a8530e --- /dev/null +++ b/website/assets/img/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/website/assets/img/og.png b/website/assets/img/og.png new file mode 100644 index 0000000..fd9247d Binary files /dev/null and b/website/assets/img/og.png differ diff --git a/website/assets/js/app.js b/website/assets/js/app.js new file mode 100644 index 0000000..b9c5fa3 --- /dev/null +++ b/website/assets/js/app.js @@ -0,0 +1,37 @@ +/* Git It marketing site — client behavior. + Progressive enhancement only: the page is fully functional without JS. */ +(function () { + "use strict"; + + /* (a) Sticky-nav border: add .is-scrolled once the page moves past 8px. */ + var nav = document.querySelector("nav"); + if (nav) { + var onScroll = function () { + nav.classList.toggle("is-scrolled", window.scrollY > 8); + }; + onScroll(); + window.addEventListener("scroll", onScroll, { passive: true }); + } + + /* (b) Scroll reveal: fade [data-reveal] elements in as they enter view — + only when the user has not asked to reduce motion. Otherwise reveal + everything immediately so no content stays hidden. */ + var reveals = document.querySelectorAll("[data-reveal]"); + var allowMotion = window.matchMedia("(prefers-reduced-motion: no-preference)").matches; + + if (!allowMotion || !("IntersectionObserver" in window)) { + for (var i = 0; i < reveals.length; i++) reveals[i].classList.add("in-view"); + return; + } + + var io = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (entry.isIntersecting) { + entry.target.classList.add("in-view"); + io.unobserve(entry.target); + } + }); + }, { rootMargin: "0px 0px -10% 0px", threshold: 0.1 }); + + reveals.forEach(function (el) { io.observe(el); }); +})(); diff --git a/website/guides.html b/website/guides.html new file mode 100644 index 0000000..4a7f245 --- /dev/null +++ b/website/guides.html @@ -0,0 +1,203 @@ + + + + + +Git It — Guides + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ OP // GUIDES +

Guides.

+

Two short briefings — get Git It running, then bend the timeline with confidence.

+ +
+ BRIEFING 01 +

Getting started

+

Install prerequisites, grab the build, open your first repo.

+
    +
  1. + 01 +
    +

    Install the tools

    +

    Run xcode-select --install — this gives you git and python3, the two things Git It expects on your PATH.

    +
    +
  2. +
  3. + 02 +
    +

    Download the build

    +

    Grab the signed & notarized .dmg from the latest release — Apple Silicon or Intel, whichever matches your Mac.

    +
    +
  4. +
  5. + 03 +
    +

    Drag into Applications

    +

    Drag Git It into Applications and launch — it opens with a normal double-click.

    +
    +
  6. +
  7. + 04 +
    +

    Open your first repository

    +

    Open a repo from the app and explore the commit graph.

    +
    +
  8. +
+
+ +
+ BRIEFING 02 +

Commit-time editing

+

Shift, set, or compress commit times — with a preview and one-click undo.

+
    +
  1. + 01 +
    +

    Select a range

    +

    Select a commit or a range in the graph.

    +
    +
  2. +
  3. + 02 +
    +

    Choose a mode

    +

    Pick Offset (shift by a delta), Exact (set a specific time), or Compress (squeeze the range into a new window).

    +
    +
  4. +
  5. + 03 +
    +

    Preview

    +

    Preview the result before anything is written.

    +
    +
  6. +
  7. + 04 +
    +

    Apply

    +

    Apply — a git-bundle backup is taken automatically first.

    +
    +
  8. +
  9. + 05 +
    +

    Undo if needed

    +

    Changed your mind? Undo in one click.

    +
    +
  10. +
+
+
+
+
+ +
+
+ + +
+ + + Git It + + · + This site collects nothing — no analytics, no cookies, no third-party requests. + · + Bundles git-filter-repo (MIT). + · + Made for macOS + · + © 2026 + · + git-it.app +
+
+
+
+ + + + diff --git a/website/index.html b/website/index.html new file mode 100644 index 0000000..770a2cc --- /dev/null +++ b/website/index.html @@ -0,0 +1,943 @@ + + + + + +Git It — a native macOS git client + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+ ◇ native macOS git client · Apple Silicon + Intel + +

+ Bend the + timeline. +

+ +

Commit-time editing — shift, set, or compress any range. Preview, then undo.

+ +

A fast, native git client for macOS — commit graph, branches, merges, per-hunk diffs, remotes, and a built-in GitHub dashboard. Local-first: no account, no analytics, no tracking.

+ +

Real app, shipping — see it on GitHub ↗

+ + + +
+ No account + No tracking + Native — no Electron + Auto-updates +
+
+ + +

Diagram: a commit graph with one commit's timestamp being rewritten from 14:22 to 09:05.

+
+
+ + + +
+
+

Why Git It

+
+
+ 01 +

Commit-time editing

+

Reshape when history happened, right in the client. A rarity in any git GUI.

+
+
+ 02 +

A safety net for every rewrite

+

Automatic backup (a git bundle) + one-click undo before any destructive op, plus a reflog browser.

+
+
+ 03 +

Local-first & free

+

No account, no analytics; native (no Electron); source-available for noncommercial use.

+
+
+
+
+ + + +
+
+
Platform
macOS 12.3+
+
Architecture
Apple Silicon · Intel
+
Engine
Rust · Tauri 2 · Svelte 5 (no Electron)
+
Price
Free · Source-available
+
+
+ + + +
+
+ + +
+
+ OP // GRAPH +

Read history at a glance.

+

Multi-lane commit graph with curved or angular edges, ref badges (branches, tags, remotes, HEAD), infinite-scroll loading, right-click checkout / branch / tag / fetch.

+
    +
  • Curved & angular lanes
  • +
  • Ref badges — branch · tag · remote · HEAD
  • +
  • Infinite-scroll history
  • +
  • Right-click: checkout · branch · tag · fetch
  • +
+
+
+ +

Diagram: a multi-lane commit graph. The tip commit "ship: release v1.0" carries HEAD, main, and origin/main badges; a second lane branches and rejoins with green edges; the base commit is tagged v1.0. One row is highlighted as selected.

+
+
+ + +
+
+ OP // TIME +

Commit-time editing.

+

Select a range and shift by an offset, set an exact time, or compress the range proportionally into a new window. Preview before it rewrites; undo in one click. Powered by a bundled git-filter-repo.

+
    +
  • OFFSET — shift the whole range
  • +
  • EXACT — set a precise timestamp
  • +
  • COMPRESS — squeeze into a new window
  • +
  • Preview → apply → one-click undo
  • +
+
+
+ +

Diagram: a vertical timeline of seven commits, each with a timestamp. A bracket labeled SELECTED marks a three-commit range; two of those commits show their times being rewritten — 14:22 to 09:05 and 11:58 to 08:30, old times struck through, new times in green. A REWRITE control offers three modes — OFFSET (active), EXACT, and COMPRESS — with a delta of minus five hours seventeen minutes, and a PREVIEW / UNDO strip below, noting an automatic backup bundle was written.

+
+
+ + +
+
+ OP // MERGE +

Integrate, and resolve.

+

Merge (plain or --no-ff), cherry-pick, and revert — with an in-app conflict resolver: use-ours, use-theirs, continue, or abort, file by file.

+
    +
  • Merge · cherry-pick · revert
  • +
  • Plain or --no-ff
  • +
  • Resolver: ours / theirs / continue / abort
  • +
  • File-by-file, in-app
  • +
+
+
+ +

Diagram: two branch lanes, OURS and THEIRS, converging into a single merge commit that reports one conflict. A conflict resolver panel for the file src/app.rs shows the two conflicting versions with use-ours and use-theirs toggle chips (use-ours selected), plus continue and abort controls.

+
+
+ + +
+
+ OP // REWRITE +

Rewrite history — safely.

+

Soft/mixed/hard reset, amend, rebase-onto, and interactive rebase (reorder / squash / drop / reword). Every destructive op takes a configurable auto-backup (a git bundle) first, with one-click undo and a reflog browser.

+
    +
  • Reset · amend · rebase-onto
  • +
  • Interactive: reorder / squash / drop / reword
  • +
  • Auto-backup bundle before every op
  • +
  • One-click undo · reflog browser
  • +
+
+
+ +

Diagram: an interactive-rebase todo list of five commits, each with a drag handle and an action chip — pick, reword, squash (highlighted), drop (struck through), and pick. Below, a green BACKUP CREATED confirmation names a pre-rebase git bundle, alongside a one-click UNDO control.

+
+
+ + +
+
+ OP // DIFF +

Stage exactly what you mean.

+

A working-copy file list with whole-file and per-hunk stage / unstage / discard, syntax-highlighted diffs (unified or split, highlighted offline via bundled Shiki), a commit composer, and stash.

+
    +
  • Per-hunk stage · unstage · discard
  • +
  • Unified or split view
  • +
  • Offline syntax highlighting
  • +
  • Commit composer · stash
  • +
+
+
+ +

Diagram: a side-by-side split diff of a file. The first hunk is unstaged with a STAGE chip: a removed line "timeout(30)" in red on the old side, and two added lines "timeout(60)" and "retry(3)" in green on the new side. The second hunk is already staged (STAGED chip) with an added "fsync()" line. Below, a commit composer shows one hunk staged, with commit and stash controls.

+
+
+ + +
+
+ OP // REMOTE +

Push, pull, and GitHub — in one place.

+

Streamed pull (merge or rebase) and push (--force-with-lease, --set-upstream) with live progress, cancel, and an ahead/behind indicator. Plus a built-in GitHub dashboard: pull requests, issues, releases, and CI runs. Credentials are prompted on demand and never stored (a transient 0600 file, deleted immediately after).

+
    +
  • Streamed pull & push — live progress
  • +
  • Ahead / behind indicator
  • +
  • GitHub: PRs · issues · releases · CI
  • +
  • Credentials never stored on disk
  • +
+
+
+ +

Diagram: a remote sync readout for origin/main showing an ahead-by-two, behind-by-one indicator, a streaming git push log with a progress bar at 69% and a cancel control, and a note that credentials are a transient 0600 file. Below, a mini GitHub dashboard shows a pull-request chip, an issue chip, and a green CI-passing chip.

+
+
+ + +
+
+ OP // THEME +

Everything here is a theme.

+

The console you’ve been reading — orange on void, corner brackets, scanlines — isn’t just the website. It’s NERV, one of Git It’s two built-in themes. Prefer something quieter? Classic wears native macOS vibrancy: frosted glass over your desktop. Switch anytime, and tune the accent across six schemes, in Settings → Appearance.

+
    +
  • Classic — native macOS glass
  • +
  • NERV — this exact console
  • +
  • Six accent schemes
  • +
  • Switch in Settings → Appearance
  • +
+
+
+ +

Diagram: two theme swatches side by side — a light frosted-glass “Classic” panel and a dark orange-on-void “NERV” console with corner brackets and a mini commit graph — with a swap arrow between them, showing you can switch from one theme to the other.

+
+
+ +
+ +

Also — open several repos at once, as tabs or a sidebar list, your choice.

+
+ + + +
+
+ OP // DOWNLOAD +

Download Git It.

+

Open the .dmg, drag Git It into Applications, launch. It keeps itself up to date after that — with an optional beta channel in Settings → Updates.

+ + + +

each runs fully native; in-app updates track the right build.

+ +

Signed & notarized by Apple — opens with a normal double-click.

+ +
macOS 12.3+ · needs git + python3 on PATH (xcode-select --install)
+
+
+ + + + + + + +
+
+ OP // FAQ +

Questions, answered.

+
+
+ Is it really private? +

No account, no backend, no analytics. Your repos and credentials stay on your Mac. The only network calls are the git/GitHub operations you trigger, plus a version check for updates. Credentials are prompted on demand and never written to disk beyond a transient 0600 file that's deleted right after.

+
+
+ Do I have to bypass Gatekeeper? +

No — the release is signed and notarized by Apple; it opens with a normal double-click. (Only self-built ad-hoc copies trigger the Gatekeeper prompt.)

+
+
+ Why macOS only? +

Native vibrancy and title-bar handling are macOS-specific. Apple Silicon and Intel builds are both fully native (no Electron).

+
+
+ Is rewriting history safe? +

Destructive ops take an automatic git-bundle backup first, force-push is always --force-with-lease, and there's one-click undo plus a reflog browser.

+
+
+ What do I need installed? +

git and python3 — both from the Xcode Command Line Tools (xcode-select --install). git-filter-repo is bundled.

+
+
+ How do updates work? +

In-app auto-updates, with an optional beta channel in Settings → Updates.

+
+
+ Apple Silicon or Intel? +

Download the .dmg that matches your Mac; each runs fully native. Updates then track the right build.

+
+
+ What's the license? +

Free and source-available under CC BY-NC-SA 4.0 — use it for any work (including at your job); you just can't resell or repackage the app itself, and forks stay under the same license. Not an OSI open-source license.

+
+
+
+
+ + + +
+
+ OP // COMMUNITY +

Built in the open.

+

Git It is developed in public. Read the source, file an issue, or open a pull request — everything happens on GitHub.

+ +

+ Source-available under CC BY-NC-SA 4.0 — not an OSI open-source license.
+ Contributions welcome — accepted under the project's CC BY-NC-SA 4.0 license. +

+
+
+
+ +
+
+ + +
+ + + Git It + + · + This site collects nothing — no analytics, no cookies, no third-party requests. + · + Bundles git-filter-repo (MIT). + · + Made for macOS + · + © 2026 + · + git-it.app +
+
+
+
+ + + + diff --git a/website/robots.txt b/website/robots.txt new file mode 100644 index 0000000..044a05d --- /dev/null +++ b/website/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://git-it.app/sitemap.xml diff --git a/website/site.webmanifest b/website/site.webmanifest new file mode 100644 index 0000000..5622aaf --- /dev/null +++ b/website/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "Git It", + "short_name": "Git It", + "description": "A native macOS git client.", + "start_url": "/", + "display": "standalone", + "theme_color": "#0A0C0F", + "background_color": "#0A0C0F", + "icons": [ + { + "src": "/assets/img/favicon.svg", + "sizes": "any", + "type": "image/svg+xml" + }, + { + "src": "/assets/img/apple-touch-icon.png", + "sizes": "180x180", + "type": "image/png" + } + ] +} diff --git a/website/sitemap.xml b/website/sitemap.xml new file mode 100644 index 0000000..fe6564a --- /dev/null +++ b/website/sitemap.xml @@ -0,0 +1,13 @@ + + + + https://git-it.app/ + weekly + 1.0 + + + https://git-it.app/guides.html + monthly + 0.6 + +