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>
72 changed files +4,282 −1,865
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 | /** |