SPB Git

spb/spbgit

Public MIT Pinned

SPB Git — the platform hosting itself

feat: SPB Git 2.0 — design system (light/dark/midnight), mobile-first AppShell, core screens, streamed raw files, bounded git concurrency, stamp-validated caches, scoped search

Phase 0–2 of docs/SPBGIT-2-PLAN.md. Fixes the production memory-restart loop
(unbuffered /raw, metadata-only blob probes, 12-way git semaphore), the stale
all-time commit total (overview cache validated against live refs), the
600 MB request log (completion-only logging), and the 24 px horizontal
overflow on every phone page. Adds /about/metrics, /api/v1/search,
/api/v1/repos/:name/tree, split diffs, lazy file tree, clone sheet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Nov 1, 2026) parent 810e402 Browse files

72 changed files +4,282 −1,865

72 changed files
added docs/SPBGIT-2-PLAN.md +176 −0
@@ -0,0 +1,176 @@
1 +<!--
2 + ─────────────────────────────────────────────
3 + SPB Git — Personal Git Platform
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : docs/SPBGIT-2-PLAN.md
8 + Purpose : SPB Git 2.0 — architecture, feature inventory, risks, phased plan, acceptance criteria
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +-->
12 +# SPB Git 2.0 — The Intelligent Git Forge · Plan
13 +
14 +Written 2026-11-01 after Phase 0 (reconnaissance) on the real repository
15 +(`spbgit` 1.1.0, commit `810e402`, deployed on M1M32 `~/srv/spbgit/app`,
16 +public at https://git.spboucher.ai through the MacLustr Tunnel).
17 +
18 +## 1. Architecture as found
19 +
20 +| Layer | Implementation | Notes |
21 +|---|---|---|
22 +| Runtime | Node ≥ 20 (25.9 on M1M32), ESM, Fastify 5, pino | PM2 `spbgit-server`, port 7420, loopback; TLS at the BHS64 gateway |
23 +| Git hosting | `src/git/smart-http.mjs` spawns `git upload-pack / receive-pack --stateless-rpc`, streamed, gzip bodies | Anonymous clone/fetch, PAT (argon2id) for push, HTTP Basic user `spb` |
24 +| Repo model | `src/git/repo.mjs` shells out to `git` (refs, tree, blob, log, diff parser, blame, archive) | `maxBuffer` 64 MB per call, blobs fully buffered |
25 +| Storage | bare repos `~/srv/git/*.git` (157), `data/meta.json` (desc/topics/pins), `data/activity.jsonl`, `data/clones.json`, `data/tokens.json`, `data/releases/` | filesystem is the database |
26 +| Cache | `~/srv/spbgit/cache/repos/<repo>/<sha>/*`, `repos/<repo>/_repo/overview-v4.json`, `global/*` | post-receive hook POSTs to `/internal/hooks/post-receive` (loopback only) → busts repo + global |
27 +| Rendering | Nunjucks SSR (`src/web/views`), hand-written CSS (`tokens.css`, `app.css`, `markdown.css`), vanilla JS (`app.js`, `theme-boot.js`), shiki dual-theme, markdown-it + sanitize-html, satori/resvg OG cards | No bundler, no framework, CSP `script-src 'self'` (no inline JS) |
28 +| API | `/api/v1/*` public reads, Bearer PAT writes, `/healthz`, `/feed.atom`, `/sitemap.xml`, `/og/*.png`, `/raw/*`, `/archive/*`, `/releases/*` | JSON error envelope, rate limit 200/min/IP |
29 +| CLI | `cli/spbgit.mjs` (commander) | untouched by this plan |
30 +| Tests | vitest: 6 unit files + 1 e2e (real `git push` round trip, releases) — **59/59 green** at baseline; `eslint`, `check:headers` green | no browser tests yet |
31 +| Deploy | `mld` manifest `M1M32:~/dispatch/apps/spbgit.json` (pinned to M1M32), PM2 `max_memory_restart 750M`, nightly `deploy/backup.sh` | ngrok removed 2026-09-10; `deploy/ecosystem.config.cjs` still documents a tunnel app (dead) |
32 +
33 +Visibility model (CLAUDE.md §0): **every repository is public, read-only**; the
34 +only writes are owner PAT operations through the API/CLI. The web UI has no
35 +login and no authenticated action by design — "authenticated actions on a phone"
36 +therefore means: clone URLs, release downloads and the public API, all of which
37 +must work from Safari/iOS. This plan does not add a web login.
38 +
39 +## 2. Feature inventory (existing — must keep working)
40 +
41 +Home (hero, stats, pinned bento, repo grid with sparklines + client filters,
42 +activity feed, language ring, heatmap) · Repo overview (header, 6 stat tiles,
43 +language bar, tabs, branch select, clone box, zip/tar.gz, file table with last
44 +commit, README + TOC) · Tree · Blob (shiki, line anchors + ranges, wrap, copy,
45 +raw, blame, history, permalink, markdown render/source, images, PDF inline,
46 +binaries, notebooks as JSON) · Commits (grouped by day, stats, pagination,
47 +path filter) · Commit (diff with per-hunk highlighting, file nav, rename/binary)
48 +· Blame (age-coloured) · Branches (ahead/behind) · Tags/releases (assets with
49 +sha256) · Search (names/desc/topics/README) · ⌘K palette (repos + commands) ·
50 +OG images, favicon, sitemap, robots, Atom · 404/500 · Smart HTTP · API v1 ·
51 +releases upload · hooks · CLI.
52 +
53 +## 3. Baseline measurements (2026-11-01, production, Playwright Chromium)
54 +
55 +Mobile = 375×812, CPU ×4, ~1.6 Mb/s. Desktop = 1440×900, no throttling.
56 +
57 +| Page | Mobile LCP | Mobile TTFB | Desktop LCP | HTML | CSS | JS | Fonts |
58 +|---|---|---|---|---|---|---|---|
59 +| `/` | 1 216 ms | 182 ms | 320 ms | 34 KB | 18.7 KB | 9 KB | 209 KB (5 files preloaded) |
60 +| `/spbgit` | 276 ms | 144 ms | 236 ms | 9.7 KB | cached | cached | cached |
61 +| blob `src/server.mjs` | 552 ms | 424 ms | 328 ms | 13 KB | | | |
62 +| commit `810e402` | 1 612 ms | **1 463 ms** | 1 336 ms | **121 KB** | | | |
63 +
64 +CLS = 0 everywhere. Server timing on M1M32: `/` 64 ms warm, repo 58 ms,
65 +`/api/v1/repos` 10 ms. 157 repos, cache 2.0 GB / 9 684 files.
66 +
67 +Responsive audit (`document.documentElement.scrollWidth` at 375 px, Chromium +
68 +WebKit): **every page overflows horizontally** (399 px in production, 377–383 px
69 +locally). Causes found by bisection: footer grid `1fr` track inheriting the
70 +`min-content` of `pre.footer-cmd` (fix: `minmax(0,1fr)` / `min-width:0`), the
71 +hero `canvas` positioned `inset:-24px` + `width:calc(100%+48px)`, and the
72 +repo tab strip. Baseline screenshots: `docs/screenshots/baseline/`.
73 +
74 +## 4. Critical risks found in Phase 0
75 +
76 +1. **Memory restart loop (production incident).** PM2 restarted `spbgit-server`
77 + **2 861 times** (`exceeds --max-memory-restart`, RSS up to 2.1 GB vs 750 MB
78 + limit). Every restart drops in-flight pushes/clones and makes the post-receive
79 + hook POST fail (hook has a 15 s timeout and swallows errors). Request mix
80 + before a restart: crawlers on `/<repo>/blob/*` and `/raw/*` of large files
81 + (`phd_thesis` PDFs). `Repos.blob()` buffers whole blobs (up to 64 MB) even
82 + when the view only needs size/type, `/raw` buffers again; concurrency is
83 + unbounded. Fix in Phase 1 (reliability track): stream `/raw`, metadata-only
84 + blob probe, bounded git concurrency, cheaper `/healthz`.
85 +2. **Commit totals inconsistent** ("all-time" 3 332 < "52 weeks" 3 346). Root
86 + cause verified: `overview-v4.json` is keyed by repo, not by HEAD, and is only
87 + invalidated by the hook; 8 repos were pushed while the server was restarting
88 + (risk 1) so their cached `commitCount` is stale (e.g. `llm-api` 0 vs 5), while
89 + the heatmap is rebuilt from live `rev-list` whenever *any* push clears
90 + `global/`. Fix: validate overview cache against live HEAD (cheap ref stamp),
91 + derive both numbers from the same per-repo timestamp source, document metric
92 + definitions.
93 +3. **Unbounded logs.** pino logs every request at info; `~/.pm2/logs/spbgit-server-out.log`
94 + is 599 MB with no rotation. Fix: compact completion-only logging, exclude
95 + assets, document `pm2-logrotate`.
96 +4. **Commit page cost.** Per-hunk shiki highlighting on every request (1.2–1.5 s
97 + TTFB, 121 KB HTML). Fix: cache the prepared diff per sha, collapse large files.
98 +5. **Mobile layout.** Horizontal overflow on every page; touch targets < 44 px
99 + (tabs 36 px, `.btn-sm` 28 px, copy buttons); desktop-only hover reveal on
100 + heading anchors; `.hero-terminal` and constellation canvas run on phones;
101 + `100vh` without `dvh`; no safe-area insets; sticky header 64 px on phones.
102 +6. **Themes.** Only dark/light; theme toggle is a 2-state button without an
103 + "OS" mode; no `color-scheme` meta per theme.
104 +7. **Dead deploy config.** `deploy/ecosystem.config.cjs` + `deploy/ngrok.yml`
105 + still describe ngrok; CLAUDE.md names host `m3u96a`. Production is M1M32 via
106 + `mld` + MacLustr Tunnel. Documentation only — the running manifest is correct.
107 +8. **Fonts.** 209 KB of preloaded fonts on first visit (5 files). Keep
108 + self-hosting but drop preloads for non-critical weights, subset usage.
109 +
110 +## 5. Design direction (decisions)
111 +
112 +- **Identity kept**: S mark, electric-blue→teal gradient, network motif (static
113 + SVG/CSS, animated only on desktop without `prefers-reduced-motion`).
114 +- **Three themes**: `light` (soft blue-white `#f4f6fb`), `dark` (charcoal/slate
115 + `#15181f`), `midnight` (deep navy `#070b16`, electric blue, restrained
116 + turquoise — the successor of the v4 obsidian look). Default follows the OS
117 + (`auto` → light/dark); explicit override persisted in `localStorage`
118 + (`spbgit-theme` = `auto|light|dark|midnight`), applied before first paint by
119 + `theme-boot.js`, exposed in the header menu and the ⌘K palette.
120 +- **Semantic tokens** in `tokens.css`: surfaces (`--surface-0..3`), foreground
121 + (`--fg`, `--fg-muted`, `--fg-faint`), borders, focus ring, status
122 + (`--ok/--warn/--danger/--info`), spacing scale 4/8 px (`--space-1..10`),
123 + radii, shadows, motion durations, syntax colours (shiki vars). WCAG AA checked
124 + per theme (contrast table in progress log).
125 +- **Density**: Linear-like. Compact sticky header (56 px desktop, 52 px phone),
126 + compact `StatStrip` instead of five hero tiles and six repo tiles, 1-column
127 + phone layouts, bottom-sheet dialogs on phones (palette, menu, branch picker).
128 +- **Component library** (Nunjucks macros in `src/web/views/components/` +
129 + matching CSS sections): AppShell, Header, MobileNavigation (sheet), Breadcrumbs,
130 + StatStrip, RepositoryCard, StatusChip, Tabs, SearchDialog, CodeViewer, FileTree,
131 + CommitTimeline, DiffViewer, EmptyState, LoadingSkeleton, ErrorState, Toast,
132 + ConfirmationDialog (JS, used by destructive CLI-less flows only if they ever
133 + appear; shipped as a primitive).
134 +- **Typography**: Inter (UI) / JetBrains Mono (code) / Bricolage Grotesque
135 + (display, 700 + 800). Instrument Serif italic dropped (one less font).
136 + `font-variant-numeric: tabular-nums` on every statistic.
137 +
138 +## 6. Phased milestones
139 +
140 +| Phase | Scope | Acceptance |
141 +|---|---|---|
142 +| 0 Reconnaissance | this document, baseline numbers, screenshots, risks | done |
143 +| 1 Design system + reliability P0 | tokens/themes, AppShell, header + mobile sheet, buttons/tabs/cards/chips, skeleton/empty/error, a11y foundations; streaming raw, blob probe, bounded concurrency, head-validated overview cache, compact logging, overflow test harness | tests green, no overflow at 320–1920, three themes, memory fix unit-tested |
144 +| 2 Core screens | home, repo overview, catalog, source browser (tree + blob), clone panel, file tree sidebar on desktop | all old URLs 200, README/badges unchanged, screenshots at 9 widths |
145 +| 3 Developer workflows | ⌘K with server-side search (repos/files/commits/branches), commits timeline, diff viewer (unified/split, wrap, cached), branch nav, analytics + metric definitions, keyboard/touch | search API tests, commit-count coherence test, Playwright mobile smoke |
146 +| 4 Research Mode | opt-in `research` flag + auto-detection banner, `/:repo/research`: PDF, LaTeX sources, figures, tables, reproducibility checklist, outputs, version links | no code execution, no LaTeX compile, tests on fixtures |
147 +| 5 AI groundwork | `src/ai/` provider-agnostic (Anthropic, OpenAI-compatible, local OpenAI-compatible e.g. MLX/llama.cpp), incremental file index, grounded Q&A with `path@sha#Lx-Ly` citations, prompt-injection wrapping, token/concurrency budgets, feature-flagged off by default | disabled providers are *absent*, never simulated; tests use an explicit fake provider under `SPBGIT_ENV=test` |
148 +| 6 Agent Workspace | design document only (isolation, scopes, approvals, audit) | `docs/AGENT-WORKSPACE-DESIGN.md` |
149 +| 7 Hardening & release | cross-engine QA (Chromium + WebKit) at 320/360/375/390/430/768/1024/1440/1920, landscape, 200 % zoom, axe audit, perf re-measure, security regression tests, changelog, deploy + rollback runbook | all acceptance criteria below |
150 +
151 +Deployment to M1M32 (`git pull` + `pm2 restart`) is **not** performed by the
152 +implementer without explicit approval (directive: pause before production
153 +changes).
154 +
155 +## 7. Acceptance criteria (from the directive, made checkable)
156 +
157 +1. `npm test` includes the Smart HTTP round trip (clone/push/fetch) and passes;
158 + every pre-2.0 URL pattern is covered by a route test (home, repo, tree, blob,
159 + raw, archive, commits, commit, blame, branches, tags/releases, search, feeds,
160 + OG, API).
161 +2. Playwright run at 320–430 px and desktop, in light/dark/midnight, asserts
162 + `scrollWidth === clientWidth` on every core page and 44×44 px targets on
163 + primary controls.
164 +3. Metric definitions documented (`docs/METRICS.md`), unit test proving
165 + all-time ≥ 52-week on the same source, no placeholder data in production
166 + templates (demo fixtures only under `dev/`).
167 +4. Search/file browsing: path traversal + ref validation tests; search index
168 + built only from repos on disk (all public).
169 +5. Markdown/diff/code: sanitizer tests (script/iframe/event handlers stripped,
170 + badges kept), diff whitespace preserved, keyboard-reachable actions.
171 +6. Automated tests + a manual Safari/iPhone checklist recorded in the progress
172 + log.
173 +7. AI/Research features behind flags; `/healthz` reports feature flags.
174 +8. Deliverables: `CHANGELOG.md`, screenshots (`docs/screenshots/`), tests,
175 + `docs/BENCHMARKS.md` (before/after), `docs/DEPLOY.md` (deploy + rollback),
176 + remaining work list in `docs/SPBGIT-2-PROGRESS.md`.
added docs/SPBGIT-2-PROGRESS.md +92 −0
@@ -0,0 +1,92 @@
1 +<!--
2 + ─────────────────────────────────────────────
3 + SPB Git — Personal Git Platform
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : docs/SPBGIT-2-PROGRESS.md
8 + Purpose : SPB Git 2.0 — per-phase progress log (files, trade-offs, tests, screenshots, open issues)
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +-->
12 +# SPB Git 2.0 — Progress log
13 +
14 +## Phase 0 — Reconnaissance (2026-11-01) ✅
15 +
16 +**Files added**: `docs/SPBGIT-2-PLAN.md`, `docs/SPBGIT-2-PROGRESS.md`,
17 +`docs/screenshots/baseline/*.png` (home, repo, blob, commits, commit, search ×
18 +375/1440 × dark/light).
19 +
20 +**Method**: read every source/template/CSS/test file, the deploy manifest on
21 +M1M32, PM2 state and logs; measured production with Playwright (Chromium) and
22 +WebKit; ran the existing test suite on a laptop mirror with 9 bare repos cloned
23 +from production plus a copy of `meta.json`/`activity.jsonl`/`clones.json`
24 +(`dev/`, git-ignored).
25 +
26 +**Baseline**: tests 59/59, eslint clean, headers clean. Web vitals and server
27 +timings in the plan (§3). Production restarts: 2 861 (memory). Stale overview
28 +caches: 8 repos (`countryatlas`, `hilmacorp-ca`, `ka-apify-actors`, `llm-api`,
29 +`maclustr-agentd`, `wp19_uqo`, `wp20_uqo`, `wp21_uqo`).
30 +
31 +**Trade-offs**: work happens on a laptop mirror (`~/Desktop/Cluster/projets/spbgit`,
32 +origin = spbgit on M1M32) because the production checkout is the live app; the
33 +node is updated only by `git pull` + `pm2 restart` after approval.
34 +
35 +**Open issues carried into Phase 1**: risks 1–8 of the plan.
36 +
37 +## Phase 1 — Design system + reliability P0 (2026-11-01) ✅
38 +
39 +**Files changed**: `src/web/assets/css/tokens.css` (rewritten: semantic tokens,
40 +three themes, type/spacing/motion scales), `src/web/assets/css/app.css`
41 +(rewritten, mobile-first, 13 sections), `src/web/assets/js/theme-boot.js`
42 +(auto/light/dark/midnight before first paint), `src/web/assets/js/app.js`
43 +(rewritten: dialogs/sheets, theme, palette, toast, confirm, clipboard, file
44 +tree, code/diff helpers, TOC), `src/web/views/layout.njk` (AppShell),
45 +`src/web/views/components/ui.njk` (new: icon, chip, statStrip, tabs,
46 +breadcrumbs, refSelect, emptyState, errorState, skeleton, copyButton),
47 +`src/lib/semaphore.mjs` (new), `src/git/repo.mjs` (git semaphore, `blobInfo`,
48 +`blobStream`, `blob({maxBytes})`, `refStamp`, `allPaths`, `grepCommits`),
49 +`src/lib/overview.mjs` (stamp-validated cache `overview-v5.json`, clones read
50 +once, research detection), `src/stats/activity.mjs` (stamp-validated calendar
51 +with `allTime`), `src/server.mjs` (compact completion-only logging via
52 +`LogController`, memoized `/healthz` with memory/git/feature fields),
53 +`src/config.mjs` (AI flags, off by default).
54 +
55 +**Trade-offs**: `Instrument Serif` dropped (one font less, 1 preload less);
56 +the constellation canvas and typewriter are gone (static terminal card on
57 +desktop only) — decorative motion is now limited to a slow mesh drift on
58 +≥768 px without `prefers-reduced-motion`. `git` subprocesses are capped at 12
59 +concurrent (`SPBGIT_GIT_CONCURRENCY`); crawlers now queue instead of growing
60 +RSS. Native `<dialog>` is used for the palette, menu, clone panel and file
61 +tree (bottom sheets on phones, modals on desktop) — requires Safari ≥ 15.4.
62 +
63 +**Tests**: `test/unit/semaphore.test.mjs` (2), `test/unit/routes-helpers.test.mjs`
64 +(8: split rows, day grouping, research signals, sparkline, query terms,
65 +stamps), e2e extended (+6: scoped search API, tree API + traversal, 3 MB
66 +binary streamed from `/raw` with exact bytes + metadata-only blob view,
67 +missed-hook detection with coherent totals, split/history/blame, healthz
68 +flags). **74/74 green**, eslint + headers clean.
69 +
70 +## Phase 2 — Core screens (2026-11-01) ✅
71 +
72 +**Files changed**: `src/web/views/{home,repo,tree,blob,commits,commit,blame,branches,tags,search,error}.njk`
73 +(all rewritten on the component library), `partials/{repo-header,repo-card,file-table}.njk`
74 +(rewritten), `partials/{clone-panel,tree-sheet}.njk` (new), `views/metrics.njk`
75 +(new `/about/metrics`), `src/web/routes.mjs` (rewritten: streamed `/raw`,
76 +metadata-only blob probe, cached highlighted blobs/markdown/blame, cached
77 +prepared diff per sha `commit-v2.json`, split view, large diffs not loaded
78 +by default, `/about/metrics`, scoped search page), `src/api/v1.mjs`
79 +(`/api/v1/search`, `/api/v1/repos/:name/tree`, richer `/api/v1/stats`,
80 +`research` flag on PATCH), `src/lib/search.mjs` (rewritten: files, refs,
81 +commits, stamp-validated index `search-index-v2.json`).
82 +
83 +**Responsive QA** (`scripts/qa/screens.py`, Playwright Chromium, 13 pages ×
84 +320/360/375/390/430/768/1024/1440/1920, light/dark/midnight): **0 horizontal
85 +overflow, 0 essential control under 44×44 px on touch viewports** (was: every
86 +page overflowing at 375 px). Screenshots: `docs/screenshots/v2/` (375 + 1440,
87 +midnight + light; full run archived outside the repo).
88 +
89 +**Open issues carried**: commit pages ship every file's rows in one document
90 +(3 MB uncompressed for the 60-file v4 commit; ~120 KB over the gzip edge) —
91 +per-file lazy loading is listed as remaining work; WebKit run and 200 % zoom
92 +are done in Phase 7.
added docs/screenshots/baseline/blob-1440-dark.png +0 −0

Binary file not shown.

added docs/screenshots/baseline/blob-375-dark.png +0 −0

Binary file not shown.

added docs/screenshots/baseline/commit-1440-dark.png +0 −0

Binary file not shown.

added docs/screenshots/baseline/commit-375-dark.png +0 −0

Binary file not shown.

added docs/screenshots/baseline/home-1440-dark.png +0 −0

Binary file not shown.

added docs/screenshots/baseline/home-375-dark.png +0 −0

Binary file not shown.

added docs/screenshots/baseline/repo-1440-dark.png +0 −0

Binary file not shown.

added docs/screenshots/baseline/repo-375-dark.png +0 −0

Binary file not shown.

added docs/screenshots/v2/blob-1440-light-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/blob-1440-midnight-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/blob-375-light-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/blob-375-midnight-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/commit-1440-light-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/commit-1440-midnight-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/commit-375-light-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/commit-375-midnight-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/commits-1440-light-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/commits-1440-midnight-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/commits-375-light-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/commits-375-midnight-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/home-1440-light-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/home-1440-midnight-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/home-375-light-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/home-375-midnight-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/repo-1440-light-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/repo-1440-midnight-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/repo-375-light-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/repo-375-midnight-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/report.json +1000 −0
@@ -0,0 +1,1000 @@
1 +[
2 + {
3 + "engine": "chromium",
4 + "theme": "midnight",
5 + "width": 320,
6 + "page": "home",
7 + "scrollWidth": 320,
8 + "clientWidth": 320,
9 + "small": [
10 + "a.brand 28x44",
11 + "input#repo-filter.input 288x38"
12 + ]
13 + },
14 + {
15 + "engine": "chromium",
16 + "theme": "midnight",
17 + "width": 320,
18 + "page": "repo",
19 + "scrollWidth": 320,
20 + "clientWidth": 320,
21 + "small": [
22 + "a.brand 28x44"
23 + ]
24 + },
25 + {
26 + "engine": "chromium",
27 + "theme": "midnight",
28 + "width": 320,
29 + "page": "tree",
30 + "scrollWidth": 320,
31 + "clientWidth": 320,
32 + "small": [
33 + "a.brand 28x44"
34 + ]
35 + },
36 + {
37 + "engine": "chromium",
38 + "theme": "midnight",
39 + "width": 320,
40 + "page": "blob",
41 + "scrollWidth": 320,
42 + "clientWidth": 320,
43 + "small": [
44 + "a.brand 28x44"
45 + ]
46 + },
47 + {
48 + "engine": "chromium",
49 + "theme": "midnight",
50 + "width": 320,
51 + "page": "readme",
52 + "scrollWidth": 320,
53 + "clientWidth": 320,
54 + "small": [
55 + "a.brand 28x44"
56 + ]
57 + },
58 + {
59 + "engine": "chromium",
60 + "theme": "midnight",
61 + "width": 320,
62 + "page": "commits",
63 + "scrollWidth": 320,
64 + "clientWidth": 320,
65 + "small": [
66 + "a.brand 28x44"
67 + ]
68 + },
69 + {
70 + "engine": "chromium",
71 + "theme": "midnight",
72 + "width": 320,
73 + "page": "commit",
74 + "scrollWidth": 320,
75 + "clientWidth": 320,
76 + "small": [
77 + "a.brand 28x44"
78 + ]
79 + },
80 + {
81 + "engine": "chromium",
82 + "theme": "midnight",
83 + "width": 320,
84 + "page": "blame",
85 + "scrollWidth": 320,
86 + "clientWidth": 320,
87 + "small": [
88 + "a.brand 28x44"
89 + ]
90 + },
91 + {
92 + "engine": "chromium",
93 + "theme": "midnight",
94 + "width": 320,
95 + "page": "branches",
96 + "scrollWidth": 320,
97 + "clientWidth": 320,
98 + "small": [
99 + "a.brand 28x44"
100 + ]
101 + },
102 + {
103 + "engine": "chromium",
104 + "theme": "midnight",
105 + "width": 320,
106 + "page": "tags",
107 + "scrollWidth": 320,
108 + "clientWidth": 320,
109 + "small": [
110 + "a.brand 28x44"
111 + ]
112 + },
113 + {
114 + "engine": "chromium",
115 + "theme": "midnight",
116 + "width": 320,
117 + "page": "search",
118 + "scrollWidth": 320,
119 + "clientWidth": 320,
120 + "small": [
121 + "a.brand 28x44"
122 + ]
123 + },
124 + {
125 + "engine": "chromium",
126 + "theme": "midnight",
127 + "width": 320,
128 + "page": "metrics",
129 + "scrollWidth": 320,
130 + "clientWidth": 320,
131 + "small": [
132 + "a.brand 28x44"
133 + ]
134 + },
135 + {
136 + "engine": "chromium",
137 + "theme": "midnight",
138 + "width": 320,
139 + "page": "404",
140 + "scrollWidth": 320,
141 + "clientWidth": 320,
142 + "small": [
143 + "a.brand 28x44"
144 + ]
145 + },
146 + {
147 + "engine": "chromium",
148 + "theme": "midnight",
149 + "width": 375,
150 + "page": "home",
151 + "scrollWidth": 375,
152 + "clientWidth": 375,
153 + "small": [
154 + "input#repo-filter.input 343x38"
155 + ]
156 + },
157 + {
158 + "engine": "chromium",
159 + "theme": "midnight",
160 + "width": 375,
161 + "page": "repo",
162 + "scrollWidth": 375,
163 + "clientWidth": 375,
164 + "small": []
165 + },
166 + {
167 + "engine": "chromium",
168 + "theme": "midnight",
169 + "width": 375,
170 + "page": "tree",
171 + "scrollWidth": 375,
172 + "clientWidth": 375,
173 + "small": []
174 + },
175 + {
176 + "engine": "chromium",
177 + "theme": "midnight",
178 + "width": 375,
179 + "page": "blob",
180 + "scrollWidth": 375,
181 + "clientWidth": 375,
182 + "small": []
183 + },
184 + {
185 + "engine": "chromium",
186 + "theme": "midnight",
187 + "width": 375,
188 + "page": "readme",
189 + "scrollWidth": 375,
190 + "clientWidth": 375,
191 + "small": []
192 + },
193 + {
194 + "engine": "chromium",
195 + "theme": "midnight",
196 + "width": 375,
197 + "page": "commits",
198 + "scrollWidth": 375,
199 + "clientWidth": 375,
200 + "small": []
201 + },
202 + {
203 + "engine": "chromium",
204 + "theme": "midnight",
205 + "width": 375,
206 + "page": "commit",
207 + "scrollWidth": 375,
208 + "clientWidth": 375,
209 + "small": []
210 + },
211 + {
212 + "engine": "chromium",
213 + "theme": "midnight",
214 + "width": 375,
215 + "page": "blame",
216 + "scrollWidth": 375,
217 + "clientWidth": 375,
218 + "small": []
219 + },
220 + {
221 + "engine": "chromium",
222 + "theme": "midnight",
223 + "width": 375,
224 + "page": "branches",
225 + "scrollWidth": 375,
226 + "clientWidth": 375,
227 + "small": []
228 + },
229 + {
230 + "engine": "chromium",
231 + "theme": "midnight",
232 + "width": 375,
233 + "page": "tags",
234 + "scrollWidth": 375,
235 + "clientWidth": 375,
236 + "small": []
237 + },
238 + {
239 + "engine": "chromium",
240 + "theme": "midnight",
241 + "width": 375,
242 + "page": "search",
243 + "scrollWidth": 375,
244 + "clientWidth": 375,
245 + "small": []
246 + },
247 + {
248 + "engine": "chromium",
249 + "theme": "midnight",
250 + "width": 375,
251 + "page": "metrics",
252 + "scrollWidth": 375,
253 + "clientWidth": 375,
254 + "small": []
255 + },
256 + {
257 + "engine": "chromium",
258 + "theme": "midnight",
259 + "width": 375,
260 + "page": "404",
261 + "scrollWidth": 375,
262 + "clientWidth": 375,
263 + "small": []
264 + },
265 + {
266 + "engine": "chromium",
267 + "theme": "midnight",
268 + "width": 430,
269 + "page": "home",
270 + "scrollWidth": 430,
271 + "clientWidth": 430,
272 + "small": [
273 + "input#repo-filter.input 398x38"
274 + ]
275 + },
276 + {
277 + "engine": "chromium",
278 + "theme": "midnight",
279 + "width": 430,
280 + "page": "repo",
281 + "scrollWidth": 430,
282 + "clientWidth": 430,
283 + "small": []
284 + },
285 + {
286 + "engine": "chromium",
287 + "theme": "midnight",
288 + "width": 430,
289 + "page": "tree",
290 + "scrollWidth": 430,
291 + "clientWidth": 430,
292 + "small": []
293 + },
294 + {
295 + "engine": "chromium",
296 + "theme": "midnight",
297 + "width": 430,
298 + "page": "blob",
299 + "scrollWidth": 430,
300 + "clientWidth": 430,
301 + "small": []
302 + },
303 + {
304 + "engine": "chromium",
305 + "theme": "midnight",
306 + "width": 430,
307 + "page": "readme",
308 + "scrollWidth": 430,
309 + "clientWidth": 430,
310 + "small": []
311 + },
312 + {
313 + "engine": "chromium",
314 + "theme": "midnight",
315 + "width": 430,
316 + "page": "commits",
317 + "scrollWidth": 430,
318 + "clientWidth": 430,
319 + "small": []
320 + },
321 + {
322 + "engine": "chromium",
323 + "theme": "midnight",
324 + "width": 430,
325 + "page": "commit",
326 + "scrollWidth": 430,
327 + "clientWidth": 430,
328 + "small": []
329 + },
330 + {
331 + "engine": "chromium",
332 + "theme": "midnight",
333 + "width": 430,
334 + "page": "blame",
335 + "scrollWidth": 430,
336 + "clientWidth": 430,
337 + "small": []
338 + },
339 + {
340 + "engine": "chromium",
341 + "theme": "midnight",
342 + "width": 430,
343 + "page": "branches",
344 + "scrollWidth": 430,
345 + "clientWidth": 430,
346 + "small": []
347 + },
348 + {
349 + "engine": "chromium",
350 + "theme": "midnight",
351 + "width": 430,
352 + "page": "tags",
353 + "scrollWidth": 430,
354 + "clientWidth": 430,
355 + "small": []
356 + },
357 + {
358 + "engine": "chromium",
359 + "theme": "midnight",
360 + "width": 430,
361 + "page": "search",
362 + "scrollWidth": 430,
363 + "clientWidth": 430,
364 + "small": []
365 + },
366 + {
367 + "engine": "chromium",
368 + "theme": "midnight",
369 + "width": 430,
370 + "page": "metrics",
371 + "scrollWidth": 430,
372 + "clientWidth": 430,
373 + "small": []
374 + },
375 + {
376 + "engine": "chromium",
377 + "theme": "midnight",
378 + "width": 430,
379 + "page": "404",
380 + "scrollWidth": 430,
381 + "clientWidth": 430,
382 + "small": []
383 + },
384 + {
385 + "engine": "chromium",
386 + "theme": "midnight",
387 + "width": 1440,
388 + "page": "home",
389 + "scrollWidth": 1440,
390 + "clientWidth": 1440,
391 + "small": []
392 + },
393 + {
394 + "engine": "chromium",
395 + "theme": "midnight",
396 + "width": 1440,
397 + "page": "repo",
398 + "scrollWidth": 1440,
399 + "clientWidth": 1440,
400 + "small": []
401 + },
402 + {
403 + "engine": "chromium",
404 + "theme": "midnight",
405 + "width": 1440,
406 + "page": "tree",
407 + "scrollWidth": 1440,
408 + "clientWidth": 1440,
409 + "small": []
410 + },
411 + {
412 + "engine": "chromium",
413 + "theme": "midnight",
414 + "width": 1440,
415 + "page": "blob",
416 + "scrollWidth": 1440,
417 + "clientWidth": 1440,
418 + "small": []
419 + },
420 + {
421 + "engine": "chromium",
422 + "theme": "midnight",
423 + "width": 1440,
424 + "page": "readme",
425 + "scrollWidth": 1440,
426 + "clientWidth": 1440,
427 + "small": []
428 + },
429 + {
430 + "engine": "chromium",
431 + "theme": "midnight",
432 + "width": 1440,
433 + "page": "commits",
434 + "scrollWidth": 1440,
435 + "clientWidth": 1440,
436 + "small": []
437 + },
438 + {
439 + "engine": "chromium",
440 + "theme": "midnight",
441 + "width": 1440,
442 + "page": "commit",
443 + "scrollWidth": 1440,
444 + "clientWidth": 1440,
445 + "small": []
446 + },
447 + {
448 + "engine": "chromium",
449 + "theme": "midnight",
450 + "width": 1440,
451 + "page": "blame",
452 + "scrollWidth": 1440,
453 + "clientWidth": 1440,
454 + "small": []
455 + },
456 + {
457 + "engine": "chromium",
458 + "theme": "midnight",
459 + "width": 1440,
460 + "page": "branches",
461 + "scrollWidth": 1440,
462 + "clientWidth": 1440,
463 + "small": []
464 + },
465 + {
466 + "engine": "chromium",
467 + "theme": "midnight",
468 + "width": 1440,
469 + "page": "tags",
470 + "scrollWidth": 1440,
471 + "clientWidth": 1440,
472 + "small": []
473 + },
474 + {
475 + "engine": "chromium",
476 + "theme": "midnight",
477 + "width": 1440,
478 + "page": "search",
479 + "scrollWidth": 1440,
480 + "clientWidth": 1440,
481 + "small": []
482 + },
483 + {
484 + "engine": "chromium",
485 + "theme": "midnight",
486 + "width": 1440,
487 + "page": "metrics",
488 + "scrollWidth": 1440,
489 + "clientWidth": 1440,
490 + "small": []
491 + },
492 + {
493 + "engine": "chromium",
494 + "theme": "midnight",
495 + "width": 1440,
496 + "page": "404",
497 + "scrollWidth": 1440,
498 + "clientWidth": 1440,
499 + "small": []
500 + },
501 + {
502 + "engine": "chromium",
503 + "theme": "light",
504 + "width": 320,
505 + "page": "home",
506 + "scrollWidth": 320,
507 + "clientWidth": 320,
508 + "small": [
509 + "a.brand 28x44",
510 + "input#repo-filter.input 288x38"
511 + ]
512 + },
513 + {
514 + "engine": "chromium",
515 + "theme": "light",
516 + "width": 320,
517 + "page": "repo",
518 + "scrollWidth": 320,
519 + "clientWidth": 320,
520 + "small": [
521 + "a.brand 28x44"
522 + ]
523 + },
524 + {
525 + "engine": "chromium",
526 + "theme": "light",
527 + "width": 320,
528 + "page": "tree",
529 + "scrollWidth": 320,
530 + "clientWidth": 320,
531 + "small": [
532 + "a.brand 28x44"
533 + ]
534 + },
535 + {
536 + "engine": "chromium",
537 + "theme": "light",
538 + "width": 320,
539 + "page": "blob",
540 + "scrollWidth": 320,
541 + "clientWidth": 320,
542 + "small": [
543 + "a.brand 28x44"
544 + ]
545 + },
546 + {
547 + "engine": "chromium",
548 + "theme": "light",
549 + "width": 320,
550 + "page": "readme",
551 + "scrollWidth": 320,
552 + "clientWidth": 320,
553 + "small": [
554 + "a.brand 28x44"
555 + ]
556 + },
557 + {
558 + "engine": "chromium",
559 + "theme": "light",
560 + "width": 320,
561 + "page": "commits",
562 + "scrollWidth": 320,
563 + "clientWidth": 320,
564 + "small": [
565 + "a.brand 28x44"
566 + ]
567 + },
568 + {
569 + "engine": "chromium",
570 + "theme": "light",
571 + "width": 320,
572 + "page": "commit",
573 + "scrollWidth": 320,
574 + "clientWidth": 320,
575 + "small": [
576 + "a.brand 28x44"
577 + ]
578 + },
579 + {
580 + "engine": "chromium",
581 + "theme": "light",
582 + "width": 320,
583 + "page": "blame",
584 + "scrollWidth": 320,
585 + "clientWidth": 320,
586 + "small": [
587 + "a.brand 28x44"
588 + ]
589 + },
590 + {
591 + "engine": "chromium",
592 + "theme": "light",
593 + "width": 320,
594 + "page": "branches",
595 + "scrollWidth": 320,
596 + "clientWidth": 320,
597 + "small": [
598 + "a.brand 28x44"
599 + ]
600 + },
601 + {
602 + "engine": "chromium",
603 + "theme": "light",
604 + "width": 320,
605 + "page": "tags",
606 + "scrollWidth": 320,
607 + "clientWidth": 320,
608 + "small": [
609 + "a.brand 28x44"
610 + ]
611 + },
612 + {
613 + "engine": "chromium",
614 + "theme": "light",
615 + "width": 320,
616 + "page": "search",
617 + "scrollWidth": 320,
618 + "clientWidth": 320,
619 + "small": [
620 + "a.brand 28x44"
621 + ]
622 + },
623 + {
624 + "engine": "chromium",
625 + "theme": "light",
626 + "width": 320,
627 + "page": "metrics",
628 + "scrollWidth": 320,
629 + "clientWidth": 320,
630 + "small": [
631 + "a.brand 28x44"
632 + ]
633 + },
634 + {
635 + "engine": "chromium",
636 + "theme": "light",
637 + "width": 320,
638 + "page": "404",
639 + "scrollWidth": 320,
640 + "clientWidth": 320,
641 + "small": [
642 + "a.brand 28x44"
643 + ]
644 + },
645 + {
646 + "engine": "chromium",
647 + "theme": "light",
648 + "width": 375,
649 + "page": "home",
650 + "scrollWidth": 375,
651 + "clientWidth": 375,
652 + "small": [
653 + "input#repo-filter.input 343x38"
654 + ]
655 + },
656 + {
657 + "engine": "chromium",
658 + "theme": "light",
659 + "width": 375,
660 + "page": "repo",
661 + "scrollWidth": 375,
662 + "clientWidth": 375,
663 + "small": []
664 + },
665 + {
666 + "engine": "chromium",
667 + "theme": "light",
668 + "width": 375,
669 + "page": "tree",
670 + "scrollWidth": 375,
671 + "clientWidth": 375,
672 + "small": []
673 + },
674 + {
675 + "engine": "chromium",
676 + "theme": "light",
677 + "width": 375,
678 + "page": "blob",
679 + "scrollWidth": 375,
680 + "clientWidth": 375,
681 + "small": []
682 + },
683 + {
684 + "engine": "chromium",
685 + "theme": "light",
686 + "width": 375,
687 + "page": "readme",
688 + "scrollWidth": 375,
689 + "clientWidth": 375,
690 + "small": []
691 + },
692 + {
693 + "engine": "chromium",
694 + "theme": "light",
695 + "width": 375,
696 + "page": "commits",
697 + "scrollWidth": 375,
698 + "clientWidth": 375,
699 + "small": []
700 + },
701 + {
702 + "engine": "chromium",
703 + "theme": "light",
704 + "width": 375,
705 + "page": "commit",
706 + "scrollWidth": 375,
707 + "clientWidth": 375,
708 + "small": []
709 + },
710 + {
711 + "engine": "chromium",
712 + "theme": "light",
713 + "width": 375,
714 + "page": "blame",
715 + "scrollWidth": 375,
716 + "clientWidth": 375,
717 + "small": []
718 + },
719 + {
720 + "engine": "chromium",
721 + "theme": "light",
722 + "width": 375,
723 + "page": "branches",
724 + "scrollWidth": 375,
725 + "clientWidth": 375,
726 + "small": []
727 + },
728 + {
729 + "engine": "chromium",
730 + "theme": "light",
731 + "width": 375,
732 + "page": "tags",
733 + "scrollWidth": 375,
734 + "clientWidth": 375,
735 + "small": []
736 + },
737 + {
738 + "engine": "chromium",
739 + "theme": "light",
740 + "width": 375,
741 + "page": "search",
742 + "scrollWidth": 375,
743 + "clientWidth": 375,
744 + "small": []
745 + },
746 + {
747 + "engine": "chromium",
748 + "theme": "light",
749 + "width": 375,
750 + "page": "metrics",
751 + "scrollWidth": 375,
752 + "clientWidth": 375,
753 + "small": []
754 + },
755 + {
756 + "engine": "chromium",
757 + "theme": "light",
758 + "width": 375,
759 + "page": "404",
760 + "scrollWidth": 375,
761 + "clientWidth": 375,
762 + "small": []
763 + },
764 + {
765 + "engine": "chromium",
766 + "theme": "light",
767 + "width": 430,
768 + "page": "home",
769 + "scrollWidth": 430,
770 + "clientWidth": 430,
771 + "small": [
772 + "input#repo-filter.input 398x38"
773 + ]
774 + },
775 + {
776 + "engine": "chromium",
777 + "theme": "light",
778 + "width": 430,
779 + "page": "repo",
780 + "scrollWidth": 430,
781 + "clientWidth": 430,
782 + "small": []
783 + },
784 + {
785 + "engine": "chromium",
786 + "theme": "light",
787 + "width": 430,
788 + "page": "tree",
789 + "scrollWidth": 430,
790 + "clientWidth": 430,
791 + "small": []
792 + },
793 + {
794 + "engine": "chromium",
795 + "theme": "light",
796 + "width": 430,
797 + "page": "blob",
798 + "scrollWidth": 430,
799 + "clientWidth": 430,
800 + "small": []
801 + },
802 + {
803 + "engine": "chromium",
804 + "theme": "light",
805 + "width": 430,
806 + "page": "readme",
807 + "scrollWidth": 430,
808 + "clientWidth": 430,
809 + "small": []
810 + },
811 + {
812 + "engine": "chromium",
813 + "theme": "light",
814 + "width": 430,
815 + "page": "commits",
816 + "scrollWidth": 430,
817 + "clientWidth": 430,
818 + "small": []
819 + },
820 + {
821 + "engine": "chromium",
822 + "theme": "light",
823 + "width": 430,
824 + "page": "commit",
825 + "scrollWidth": 430,
826 + "clientWidth": 430,
827 + "small": []
828 + },
829 + {
830 + "engine": "chromium",
831 + "theme": "light",
832 + "width": 430,
833 + "page": "blame",
834 + "scrollWidth": 430,
835 + "clientWidth": 430,
836 + "small": []
837 + },
838 + {
839 + "engine": "chromium",
840 + "theme": "light",
841 + "width": 430,
842 + "page": "branches",
843 + "scrollWidth": 430,
844 + "clientWidth": 430,
845 + "small": []
846 + },
847 + {
848 + "engine": "chromium",
849 + "theme": "light",
850 + "width": 430,
851 + "page": "tags",
852 + "scrollWidth": 430,
853 + "clientWidth": 430,
854 + "small": []
855 + },
856 + {
857 + "engine": "chromium",
858 + "theme": "light",
859 + "width": 430,
860 + "page": "search",
861 + "scrollWidth": 430,
862 + "clientWidth": 430,
863 + "small": []
864 + },
865 + {
866 + "engine": "chromium",
867 + "theme": "light",
868 + "width": 430,
869 + "page": "metrics",
870 + "scrollWidth": 430,
871 + "clientWidth": 430,
872 + "small": []
873 + },
874 + {
875 + "engine": "chromium",
876 + "theme": "light",
877 + "width": 430,
878 + "page": "404",
879 + "scrollWidth": 430,
880 + "clientWidth": 430,
881 + "small": []
882 + },
883 + {
884 + "engine": "chromium",
885 + "theme": "light",
886 + "width": 1440,
887 + "page": "home",
888 + "scrollWidth": 1440,
889 + "clientWidth": 1440,
890 + "small": []
891 + },
892 + {
893 + "engine": "chromium",
894 + "theme": "light",
895 + "width": 1440,
896 + "page": "repo",
897 + "scrollWidth": 1440,
898 + "clientWidth": 1440,
899 + "small": []
900 + },
901 + {
902 + "engine": "chromium",
903 + "theme": "light",
904 + "width": 1440,
905 + "page": "tree",
906 + "scrollWidth": 1440,
907 + "clientWidth": 1440,
908 + "small": []
909 + },
910 + {
911 + "engine": "chromium",
912 + "theme": "light",
913 + "width": 1440,
914 + "page": "blob",
915 + "scrollWidth": 1440,
916 + "clientWidth": 1440,
917 + "small": []
918 + },
919 + {
920 + "engine": "chromium",
921 + "theme": "light",
922 + "width": 1440,
923 + "page": "readme",
924 + "scrollWidth": 1440,
925 + "clientWidth": 1440,
926 + "small": []
927 + },
928 + {
929 + "engine": "chromium",
930 + "theme": "light",
931 + "width": 1440,
932 + "page": "commits",
933 + "scrollWidth": 1440,
934 + "clientWidth": 1440,
935 + "small": []
936 + },
937 + {
938 + "engine": "chromium",
939 + "theme": "light",
940 + "width": 1440,
941 + "page": "commit",
942 + "scrollWidth": 1440,
943 + "clientWidth": 1440,
944 + "small": []
945 + },
946 + {
947 + "engine": "chromium",
948 + "theme": "light",
949 + "width": 1440,
950 + "page": "blame",
951 + "scrollWidth": 1440,
952 + "clientWidth": 1440,
953 + "small": []
954 + },
955 + {
956 + "engine": "chromium",
957 + "theme": "light",
958 + "width": 1440,
959 + "page": "branches",
960 + "scrollWidth": 1440,
961 + "clientWidth": 1440,
962 + "small": []
963 + },
964 + {
965 + "engine": "chromium",
966 + "theme": "light",
967 + "width": 1440,
968 + "page": "tags",
969 + "scrollWidth": 1440,
970 + "clientWidth": 1440,
971 + "small": []
972 + },
973 + {
974 + "engine": "chromium",
975 + "theme": "light",
976 + "width": 1440,
977 + "page": "search",
978 + "scrollWidth": 1440,
979 + "clientWidth": 1440,
980 + "small": []
981 + },
982 + {
983 + "engine": "chromium",
984 + "theme": "light",
985 + "width": 1440,
986 + "page": "metrics",
987 + "scrollWidth": 1440,
988 + "clientWidth": 1440,
989 + "small": []
990 + },
991 + {
992 + "engine": "chromium",
993 + "theme": "light",
994 + "width": 1440,
995 + "page": "404",
996 + "scrollWidth": 1440,
997 + "clientWidth": 1440,
998 + "small": []
999 + }
1000 +]
\ No newline at end of file
added docs/screenshots/v2/search-1440-light-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/search-1440-midnight-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/search-375-light-chromium.png +0 −0

Binary file not shown.

added docs/screenshots/v2/search-375-midnight-chromium.png +0 −0

Binary file not shown.

modified package.json +1 −1
@@ -1,6 +1,6 @@
1 1 {
2 2 "name": "spbgit",
3 − "version": "1.1.0",
3 + "version": "2.0.0",
4 4 "description": "SPB Git — Personal Git Platform for Simon-Pierre Boucher",
5 5 "author": "Simon-Pierre Boucher <contact@spboucher.ai>",
6 6 "license": "MIT",
added scripts/qa/screens.py +96 −0
@@ -0,0 +1,96 @@
1 +# ─────────────────────────────────────────────
2 +# SPB Git — Personal Git Platform
3 +# ─────────────────────────────────────────────
4 +# Author : Simon-Pierre Boucher
5 +# Contact : contact@spboucher.ai
6 +# File : scripts/qa/screens.py
7 +# Purpose : Responsive QA — screenshots + overflow + touch-target checks across widths, themes and engines (Playwright for Python)
8 +# License : MIT © Simon-Pierre Boucher
9 +# ─────────────────────────────────────────────
10 +"""
11 +Usage:
12 + python3 -I scripts/qa/screens.py BASE_URL OUT_DIR [--widths 320,375,1440] [--themes light,dark,midnight]
13 + [--engines chromium,webkit] [--pages home,repo,...] [--repo spbgit] [--no-shots]
14 +Exit code 1 when any page overflows horizontally or an essential control is under 44×44 CSS px on a touch viewport.
15 +"""
16 +import sys, os, asyncio, argparse, json
17 +from playwright.async_api import async_playwright
18 +
19 +PAGES = {
20 + "home": "/",
21 + "repo": "/{repo}",
22 + "tree": "/{repo}/tree/main/src",
23 + "blob": "/{repo}/blob/main/src/server.mjs",
24 + "readme": "/{repo}/blob/main/README.md",
25 + "commits": "/{repo}/commits/main",
26 + "commit": "/{repo}/commit/{sha}",
27 + "blame": "/{repo}/blame/main/package.json",
28 + "branches": "/{repo}/branches",
29 + "tags": "/{repo}/tags",
30 + "search": "/search?q=git",
31 + "metrics": "/about/metrics",
32 + "404": "/this-does-not-exist",
33 +}
34 +ESSENTIAL = ".app-header button, .app-header a.brand, .tab, .btn:not(.hide-mobile *), .icon-btn, .sha-chip, .ref-select, #repo-filter, .catalog-filters select, .sheet-nav a, .theme-choices button"
35 +CHECK_JS = """(sel) => {
36 + const de = document.documentElement;
37 + const out = { scrollWidth: de.scrollWidth, clientWidth: de.clientWidth, small: [] };
38 + for (const el of document.querySelectorAll(sel)) {
39 + const r = el.getBoundingClientRect();
40 + if (r.width === 0 || r.height === 0) continue; // hidden
41 + if (el.closest('dialog:not([open])')) continue;
42 + if (r.height < 43.5 || r.width < 43.5) out.small.push(el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (el.className && typeof el.className === 'string' ? '.' + el.className.trim().split(/\\s+/).slice(0, 2).join('.') : '') + ' ' + Math.round(r.width) + 'x' + Math.round(r.height));
43 + }
44 + return out;
45 +}"""
46 +
47 +async def main():
48 + ap = argparse.ArgumentParser()
49 + ap.add_argument("base"); ap.add_argument("out")
50 + ap.add_argument("--widths", default="320,360,375,390,430,768,1024,1440,1920")
51 + ap.add_argument("--themes", default="light,dark,midnight")
52 + ap.add_argument("--engines", default="chromium")
53 + ap.add_argument("--pages", default=",".join(PAGES))
54 + ap.add_argument("--repo", default="spbgit"); ap.add_argument("--sha", default="810e402")
55 + ap.add_argument("--no-shots", action="store_true"); ap.add_argument("--zoom", type=float, default=1.0)
56 + a = ap.parse_args()
57 + os.makedirs(a.out, exist_ok=True)
58 + widths = [int(w) for w in a.widths.split(",")]
59 + themes = a.themes.split(","); engines = a.engines.split(",")
60 + pages = [(k, PAGES[k].format(repo=a.repo, sha=a.sha)) for k in a.pages.split(",") if k in PAGES]
61 + failures = 0; rows = []
62 + async with async_playwright() as p:
63 + for engine in engines:
64 + b = await getattr(p, engine).launch()
65 + for theme in themes:
66 + for w in widths:
67 + mobile = w < 600
68 + h = 812 if mobile else 900
69 + ctx = await b.new_context(viewport={"width": w, "height": h}, is_mobile=mobile and engine == "chromium", has_touch=mobile, device_scale_factor=1)
70 + await ctx.add_init_script(f"try{{localStorage.setItem('spbgit-theme','{theme}')}}catch(e){{}}")
71 + if a.zoom != 1.0:
72 + await ctx.add_init_script(f"document.addEventListener('DOMContentLoaded',()=>{{document.documentElement.style.zoom='{a.zoom}'}})")
73 + pg = await ctx.new_page()
74 + for name, path in pages:
75 + try:
76 + await pg.goto(a.base + path, wait_until="networkidle", timeout=45000)
77 + await pg.wait_for_timeout(300)
78 + r = await pg.evaluate(CHECK_JS, ESSENTIAL)
79 + overflow = r["scrollWidth"] > r["clientWidth"]
80 + small = r["small"] if mobile else []
81 + bad = overflow or bool(small)
82 + failures += int(bad)
83 + rows.append({"engine": engine, "theme": theme, "width": w, "page": name, "scrollWidth": r["scrollWidth"], "clientWidth": r["clientWidth"], "small": small})
84 + flag = ("OVERFLOW " if overflow else "") + (f"SMALL:{small[:3]}" if small else "")
85 + print(f"{engine:8} {theme:8} {w:5} {name:9} sw={r['scrollWidth']:5} cw={r['clientWidth']:5} {flag}")
86 + if not a.no_shots and w in (320, 375, 430, 1440):
87 + await pg.screenshot(path=f"{a.out}/{name}-{w}-{theme}-{engine}.png", full_page=True)
88 + except Exception as e:
89 + failures += 1; print(f"{engine} {theme} {w} {name} ERROR {str(e)[:120]}")
90 + await ctx.close()
91 + await b.close()
92 + with open(f"{a.out}/report.json", "w") as f: json.dump(rows, f, indent=1)
93 + print(f"\n{'FAIL' if failures else 'OK'} — {failures} failing page/width combinations")
94 + sys.exit(1 if failures else 0)
95 +
96 +asyncio.run(main())
modified src/api/v1.mjs +41 −2
@@ -11,10 +11,12 @@
11 11 */
12 12
13 13 import { z } from 'zod';
14 −import { isValidRepoName } from '../lib/util.mjs';
14 +import { isValidRepoName, isValidTreePath } from '../lib/util.mjs';
15 15 import { makeRequireAuth } from '../auth/token.mjs';
16 16 import { installHooks } from '../git/hooks.mjs';
17 17 import { repoOverview, allOverviews, siteStats } from '../lib/overview.mjs';
18 +import { search } from '../lib/search.mjs';
19 +import { contributionCalendar } from '../stats/activity.mjs';
18 20 import { computeLanguages } from '../stats/languages.mjs';
19 21 import { isValidAssetName, isValidTagName } from '../git/releases.mjs';
20 22
@@ -36,6 +38,7 @@ const patchSchema = z.object({
36 38 topics: z.array(z.string().regex(TOPIC_RE)).max(10).optional(),
37 39 homepage: z.union([z.literal(''), z.string().url().max(300)]).optional(),
38 40 pinned: z.boolean().optional(),
41 + research: z.boolean().optional(),
39 42 defaultBranch: z.string().regex(/^[\w./-]{1,100}$/).optional(),
40 43 }).strict();
41 44
@@ -97,7 +100,43 @@ export function registerApi(app, ctx) {
97 100 return { ref: refInput, sha, page, hasNext, commits };
98 101 });
99 102
100 − app.get('/api/v1/stats', async () => siteStats(ctx));
103 + app.get('/api/v1/stats', async () => {
104 + const overviews = await allOverviews(ctx);
105 + const [stats, calendar] = await Promise.all([siteStats(ctx, overviews), contributionCalendar(ctx)]);
106 + return {
107 + ...stats,
108 + commitsLast52Weeks: calendar.total,
109 + streak: calendar.streak,
110 + longestStreak: calendar.longest,
111 + activeDays: calendar.activeDays,
112 + definitions: `${ctx.config.publicUrl}/about/metrics`,
113 + };
114 + });
115 +
116 + // Scoped search powering the ⌘K palette and /search.
117 + app.get('/api/v1/search', async (request) => {
118 + const q = String(request.query.q ?? '').slice(0, 120);
119 + const scope = ['all', 'repos', 'files', 'commits', 'branches', 'readmes'].includes(request.query.scope) ? request.query.scope : 'all';
120 + const repo = typeof request.query.repo === 'string' && isValidRepoName(request.query.repo) && ctx.repos.exists(request.query.repo) ? request.query.repo : null;
121 + const limit = Math.max(1, Math.min(50, Number(request.query.limit) || 20));
122 + const results = await search(ctx, q, { scope, repo, limit });
123 + return { q, scope, repo, ...results };
124 + });
125 +
126 + // One directory level of a tree (feeds the lazy file tree).
127 + app.get('/api/v1/repos/:name/tree', async (request, reply) => {
128 + const { name } = request.params;
129 + if (!isValidRepoName(name) || !ctx.repos.exists(name)) return apiError(reply, 404, 'not_found', 'repository not found');
130 + const refInput = String(request.query.ref ?? '') || (await ctx.repos.defaultBranch(name));
131 + const path = String(request.query.path ?? '').replace(/^\/+|\/+$/g, '');
132 + if (path !== '' && !isValidTreePath(path)) return apiError(reply, 400, 'invalid_path', 'invalid path');
133 + const sha = await ctx.repos.resolveRef(name, refInput);
134 + if (!sha) return apiError(reply, 404, 'ref_not_found', `unknown ref: ${refInput}`);
135 + const entries = await ctx.repos.tree(name, sha, path);
136 + if (!entries) return apiError(reply, 404, 'not_found', 'no such directory');
137 + reply.header('Cache-Control', /^[0-9a-f]{40}$/.test(refInput) ? 'public, max-age=31536000, immutable' : 'public, max-age=60');
138 + return { repo: name, ref: refInput, sha, path, entries: entries.map((e) => ({ name: e.name, path: e.path, type: e.type, size: e.size })) };
139 + });
101 140
102 141 app.post('/api/v1/repos', async (request, reply) => {
103 142 if (!(await requireAuth(request, reply))) return reply;
modified src/config.mjs +19 −0
@@ -36,6 +36,14 @@ const schema = z.object({
36 36 SPBGIT_CACHE_DIR: z.string().min(1).default('/srv/spbgit/cache'),
37 37 SPBGIT_LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),
38 38 SPBGIT_ENV: z.enum(['development', 'production', 'test']).default('production'),
39 + // ── AI features (Phase 5) — OFF unless a provider is configured explicitly ──
40 + SPBGIT_AI_PROVIDER: z.enum(['', 'anthropic', 'openai-compatible', 'fake']).default(''),
41 + SPBGIT_AI_API_KEY: z.string().default(''),
42 + SPBGIT_AI_BASE_URL: z.string().default(''),
43 + SPBGIT_AI_MODEL: z.string().default(''),
44 + SPBGIT_AI_MAX_TOKENS: z.coerce.number().int().min(256).max(32000).default(2048),
45 + SPBGIT_AI_CONCURRENCY: z.coerce.number().int().min(1).max(16).default(2),
46 + SPBGIT_AI_DAILY_BUDGET_TOKENS: z.coerce.number().int().min(0).default(500000),
39 47 });
40 48
41 49 /**
@@ -94,6 +102,17 @@ export function loadConfig(overrides = {}) {
94 102 dataDir: abs(env.SPBGIT_DATA_DIR),
95 103 cacheDir: abs(env.SPBGIT_CACHE_DIR),
96 104 owner: OWNER,
105 + ai: Object.freeze({
106 + // The fake provider is only ever allowed under the test environment.
107 + enabled: env.SPBGIT_AI_PROVIDER !== '' && (env.SPBGIT_AI_PROVIDER !== 'fake' || env.SPBGIT_ENV === 'test'),
108 + provider: env.SPBGIT_AI_PROVIDER || null,
109 + apiKey: env.SPBGIT_AI_API_KEY,
110 + baseUrl: env.SPBGIT_AI_BASE_URL,
111 + model: env.SPBGIT_AI_MODEL,
112 + maxTokens: env.SPBGIT_AI_MAX_TOKENS,
113 + concurrency: env.SPBGIT_AI_CONCURRENCY,
114 + dailyBudgetTokens: env.SPBGIT_AI_DAILY_BUDGET_TOKENS,
115 + }),
97 116 });
98 117 return config;
99 118 }
modified src/git/repo.mjs +158 −11
@@ -15,8 +15,9 @@ import { promisify } from 'node:util';
15 15 import { readdirSync, existsSync, statSync, renameSync, mkdirSync } from 'node:fs';
16 16 import { join } from 'node:path';
17 17 import { isValidRepoName, safeJoin, isValidTreePath } from '../lib/util.mjs';
18 +import { Semaphore } from '../lib/semaphore.mjs';
18 19
19 −const execFileAsync = promisify(execFile);
20 +const execFileRaw = promisify(execFile);
20 21 const MAX_BUFFER = 64 * 1024 * 1024;
21 22 const FS = '\x01';
22 23 const RS = '\x02';
@@ -25,6 +26,21 @@ const RS = '\x02';
25 26 const DIFF_MAX_LINES = 20000;
26 27 /** Commits walked when computing "last commit per path" before falling back. */
27 28 const TREE_LOG_WALK_CAP = 600;
29 +/** Bytes read to decide whether a blob is binary. */
30 +const BINARY_PROBE_BYTES = 8000;
31 +/**
32 + * Global ceiling on concurrent git subprocesses. Crawlers fanning out over
33 + * large blobs used to spawn dozens of 64 MB-buffered git processes at once
34 + * (the 2026-10/11 memory-restart loop); 12 keeps a 10-core gateway busy
35 + * without letting RSS run away.
36 + */
37 +export const GIT_CONCURRENCY = Number(process.env.SPBGIT_GIT_CONCURRENCY) > 0 ? Number(process.env.SPBGIT_GIT_CONCURRENCY) : 12;
38 +export const gitSemaphore = new Semaphore(GIT_CONCURRENCY);
39 +
40 +/** execFile under the global git semaphore. */
41 +function execFileAsync(cmd, args, opts) {
42 + return gitSemaphore.run(() => execFileRaw(cmd, args, opts));
43 +}
28 44
29 45 /**
30 46 * Repository model rooted at `gitRoot`. All read operations shell out to the
@@ -272,20 +288,115 @@ export class Repos {
272 288
273 289 /**
274 290 * Read a blob at `<sha>:<path>`.
275 − * @returns {Promise<{content: Buffer, size: number, binary: boolean}|null>}
291 + * @param {{maxBytes?: number}} [opts] when the blob is larger than `maxBytes`
292 + * the content is NOT read — `content` is null and `tooLarge` is true.
293 + * @returns {Promise<{content: Buffer|null, size: number, binary: boolean, tooLarge: boolean}|null>}
276 294 */
277 − async blob(name, sha, path) {
295 + async blob(name, sha, path, opts = {}) {
278 296 if (!isValidTreePath(path)) return null;
297 + const info = await this.blobInfo(name, sha, path);
298 + if (!info) return null;
299 + if (opts.maxBytes != null && info.size > opts.maxBytes) {
300 + return { content: null, size: info.size, binary: info.binary, tooLarge: true };
301 + }
279 302 try {
280 303 const content = await this.gitBuffer(name, ['cat-file', 'blob', `${sha}:${path}`]);
281 − const probe = content.subarray(0, 8000);
282 − const binary = probe.includes(0);
283 − return { content, size: content.length, binary };
304 + return { content, size: content.length, binary: info.binary, tooLarge: false };
284 305 } catch {
285 306 return null;
286 307 }
287 308 }
288 309
310 + /**
311 + * Size + binary flag of a blob WITHOUT buffering it: `cat-file -s` plus a
312 + * bounded read of the first bytes. Lets views and `/raw` decide what to do
313 + * with multi-megabyte files at near-zero memory cost.
314 + * @returns {Promise<{size: number, binary: boolean}|null>}
315 + */
316 + async blobInfo(name, sha, path) {
317 + if (!isValidTreePath(path)) return null;
318 + const type = await this.tryGit(name, ['cat-file', '-t', `${sha}:${path}`]);
319 + if (!type || type.trim() !== 'blob') return null;
320 + const sizeOut = await this.tryGit(name, ['cat-file', '-s', `${sha}:${path}`]);
321 + const size = Number(sizeOut?.trim());
322 + if (!Number.isFinite(size)) return null;
323 + if (size === 0) return { size: 0, binary: false };
324 + const head = await this.#readHead(name, ['cat-file', 'blob', `${sha}:${path}`], BINARY_PROBE_BYTES);
325 + return { size, binary: head.includes(0) };
326 + }
327 +
328 + /** Spawn git and collect at most `limit` bytes of stdout, then kill it. */
329 + #readHead(name, args, limit) {
330 + return gitSemaphore.run(() => new Promise((resolve) => {
331 + const child = spawn('git', args, { cwd: this.dir(name) });
332 + const chunks = [];
333 + let got = 0;
334 + let done = false;
335 + const finish = () => {
336 + if (done) return;
337 + done = true;
338 + resolve(Buffer.concat(chunks).subarray(0, limit));
339 + };
340 + child.stdout.on('data', (chunk) => {
341 + if (done) return;
342 + chunks.push(chunk);
343 + got += chunk.length;
344 + if (got >= limit) {
345 + child.stdout.destroy();
346 + child.kill('SIGTERM');
347 + finish();
348 + }
349 + });
350 + child.on('close', finish);
351 + child.on('error', finish);
352 + }));
353 + }
354 +
355 + /**
356 + * Stream a blob's content (for `/raw`). The returned stream is git's stdout;
357 + * the semaphore slot is released when the process exits.
358 + * @returns {Promise<import('node:stream').Readable>}
359 + */
360 + async blobStream(name, sha, path) {
361 + if (!isValidTreePath(path)) throw new Error('invalid path');
362 + const release = await gitSemaphore.acquire();
363 + const child = spawn('git', ['cat-file', 'blob', `${sha}:${path}`], { cwd: this.dir(name) });
364 + let released = false;
365 + const done = () => {
366 + if (!released) {
367 + released = true;
368 + release();
369 + }
370 + };
371 + child.on('close', done);
372 + child.on('error', done);
373 + child.stdout.on('close', () => {
374 + if (child.exitCode === null) child.kill('SIGTERM');
375 + });
376 + return child.stdout;
377 + }
378 +
379 + /**
380 + * Cheap change detector for a repository's refs: mtimes of HEAD,
381 + * packed-refs and the refs/heads + refs/tags directories. Any push, tag or
382 + * branch deletion changes at least one of them, so caches keyed on this
383 + * stamp never go stale when the post-receive hook is missed.
384 + * @returns {string}
385 + */
386 + refStamp(name) {
387 + const dir = this.dir(name);
388 + const parts = [];
389 + for (const rel of ['HEAD', 'packed-refs', 'refs/heads', 'refs/tags']) {
390 + try {
391 + const st = statSync(join(dir, rel));
392 + parts.push(`${rel}:${st.mtimeMs}:${st.size}`);
393 + } catch {
394 + parts.push(`${rel}:-`);
395 + }
396 + }
397 + return parts.join('|');
398 + }
399 +
289 400 /** Type of the object at `<sha>:<path>` — 'blob' | 'tree' | null. */
290 401 async objectType(name, sha, path) {
291 402 if (path === '') return 'tree';
@@ -635,14 +746,50 @@ export class Repos {
635 746 .filter(Boolean);
636 747 }
637 748
638 − /** All commit timestamps+subjects on the default branch (for the heatmap). */
639 − async commitTimestamps(name) {
640 − const head = await this.head(name);
641 − if (!head) return [];
642 − const out = await this.tryGit(name, ['rev-list', '--format=%ct', '--no-commit-header', head]);
749 + /**
750 + * Committer timestamps (unix seconds) of every commit reachable from HEAD —
751 + * the single source for commit counts, sparklines and the calendar (see
752 + * /about/metrics).
753 + * @param {string} name
754 + * @param {string} [head] resolved HEAD sha (looked up when omitted)
755 + */
756 + async commitTimestamps(name, head) {
757 + const sha = head ?? (await this.head(name));
758 + if (!sha) return [];
759 + const out = await this.tryGit(name, ['rev-list', '--format=%ct', '--no-commit-header', sha]);
643 760 if (!out) return [];
644 761 return out.split('\n').filter(Boolean).map(Number);
645 762 }
763 +
764 + /**
765 + * Every path at a commit (for the file search index). Capped so one huge
766 + * monorepo cannot dominate the index.
767 + * @returns {Promise<string[]>}
768 + */
769 + async allPaths(name, sha, cap = 20000) {
770 + const out = await this.tryGit(name, ['ls-tree', '-r', '--name-only', '-z', sha]);
771 + if (!out) return [];
772 + const paths = out.split('\0').filter(Boolean);
773 + return paths.length > cap ? paths.slice(0, cap) : paths;
774 + }
775 +
776 + /**
777 + * Commits whose message matches `query` (case-insensitive, fixed string).
778 + * @returns {Promise<Array<{sha: string, shortSha: string, subject: string, authorName: string, date: string}>>}
779 + */
780 + async grepCommits(name, sha, query, limit = 10) {
781 + const q = String(query ?? '').trim();
782 + if (!q || q.startsWith('-')) return [];
783 + const out = await this.tryGit(name, [
784 + 'log', `--max-count=${limit}`, '-i', '--fixed-strings', `--grep=${q}`,
785 + `--format=%H${FS}%h${FS}%s${FS}%an${FS}%cI`, sha,
786 + ]);
787 + if (!out) return [];
788 + return out.split('\n').filter(Boolean).map((line) => {
789 + const [full, short, subject, authorName, date] = line.split(FS);
790 + return { sha: full, shortSha: short, subject, authorName, date };
791 + });
792 + }
646 793 }
647 794
648 795 /**
modified src/lib/overview.mjs +52 −17
Large diff (144 lines) is not loaded by default to keep the page fast.Load diff
modified src/lib/search.mjs +149 −41
Large diff (243 lines) is not loaded by default to keep the page fast.Load diff
added src/lib/semaphore.mjs +63 −0
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
modified src/server.mjs +36 −5
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
modified src/stats/activity.mjs +25 −7
Large diff (53 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/assets/css/app.css +664 −703
Large diff (1,475 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/assets/css/tokens.css +274 −167
Large diff (494 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/assets/js/app.js +375 −411
Large diff (958 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/assets/js/theme-boot.js +11 −8
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/routes.mjs +108 −47
Large diff (341 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/blame.njk +15 −15
Large diff (57 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/blob.njk +32 −32
Large diff (100 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/branches.njk +18 −27
Large diff (55 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/commit.njk +57 −27
Large diff (125 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/commits.njk +19 −32
Large diff (90 lines) is not loaded by default to keep the page fast.Load diff
added src/web/views/components/ui.njk +154 −0
Large diff (154 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/error.njk +8 −7
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/home.njk +63 −76
Large diff (228 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/layout.njk +100 −74
Large diff (251 lines) is not loaded by default to keep the page fast.Load diff
added src/web/views/metrics.njk +52 −0
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
added src/web/views/partials/clone-panel.njk +33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/partials/file-table.njk +14 −13
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/partials/repo-card.njk +9 −12
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/partials/repo-header.njk +35 −49
Large diff (111 lines) is not loaded by default to keep the page fast.Load diff
added src/web/views/partials/tree-sheet.njk +16 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/repo.njk +14 −27
Large diff (70 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/search.njk +42 −18
Large diff (94 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/tags.njk +13 −18
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
modified src/web/views/tree.njk +12 −17
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
modified test/e2e/roundtrip.test.mjs +104 −1
Large diff (117 lines) is not loaded by default to keep the page fast.Load diff
added test/unit/routes-helpers.test.mjs +87 −0
Large diff (87 lines) is not loaded by default to keep the page fast.Load diff
added test/unit/semaphore.test.mjs +45 −0
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme