Commit
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>
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
@@ -5,7 +5,7 @@ | ||
| 5 | 5 | * Author : Simon-Pierre Boucher |
| 6 | 6 | * Contact : contact@spboucher.ai |
| 7 | 7 | * File : src/lib/overview.mjs |
| 8 | − * Purpose : Aggregated per-repo overview (meta + git facts + 26-week sparkline), cached | |
| 8 | + * Purpose : Aggregated per-repo overview (meta + git facts + 26-week sparkline), cached and validated against live refs | |
| 9 | 9 | * License : MIT © Simon-Pierre Boucher |
| 10 | 10 | * ───────────────────────────────────────────── |
| 11 | 11 | */ |
@@ -19,9 +19,11 @@ const SPARK_WEEKS = 26; | ||
| 19 | 19 | const SPARK_W = 120; |
| 20 | 20 | const SPARK_H = 34; |
| 21 | 21 | const WEEK_S = 7 * 86400; |
| 22 | +/** Bump when the overview shape changes — invalidates every cached JSON. */ | |
| 23 | +export const OVERVIEW_CACHE_FILE = 'overview-v5.json'; | |
| 22 | 24 | |
| 23 | −/** @returns {Record<string, number>} clone counts by repo */ | |
| 24 | −function cloneCounts(ctx) { | |
| 25 | +/** @returns {Record<string, number>} clone counts by repo (read once per call) */ | |
| 26 | +export function cloneCounts(ctx) { | |
| 25 | 27 | const path = join(ctx.config.dataDir, 'clones.json'); |
| 26 | 28 | try { |
| 27 | 29 | if (existsSync(path)) return JSON.parse(readFileSync(path, 'utf8')); |
@@ -52,7 +54,6 @@ export function sparkline(timestamps, now = Math.floor(Date.now() / 1000)) { | ||
| 52 | 54 | Number((pad + i * step).toFixed(1)), |
| 53 | 55 | Number((SPARK_H - pad - (c / max) * (SPARK_H - 2 * pad)).toFixed(1)), |
| 54 | 56 | ]); |
| 55 | − // Catmull-Rom → cubic Bézier for a soft line. | |
| 56 | 57 | let d = `M${pts[0][0]},${pts[0][1]}`; |
| 57 | 58 | for (let i = 0; i < pts.length - 1; i += 1) { |
| 58 | 59 | const p0 = pts[i - 1] ?? pts[i]; |
@@ -70,18 +71,38 @@ export function sparkline(timestamps, now = Math.floor(Date.now() / 1000)) { | ||
| 70 | 71 | return { counts, max, total, path: d, area, lastX: last[0], lastY: last[1] }; |
| 71 | 72 | } |
| 72 | 73 | |
| 74 | +/** Heuristic research-repository signals (Research Mode auto-detection). */ | |
| 75 | +export function detectResearchSignals(paths) { | |
| 76 | + const signals = { tex: 0, pdf: 0, bib: 0, figures: 0, notebooks: 0, data: 0 }; | |
| 77 | + for (const p of paths) { | |
| 78 | + const lower = p.toLowerCase(); | |
| 79 | + if (lower.endsWith('.tex')) signals.tex += 1; | |
| 80 | + else if (lower.endsWith('.pdf')) signals.pdf += 1; | |
| 81 | + else if (lower.endsWith('.bib')) signals.bib += 1; | |
| 82 | + else if (lower.endsWith('.ipynb')) signals.notebooks += 1; | |
| 83 | + else if (/(^|\/)(fig|figs|figures|plots|img|images)\/.+\.(png|jpe?g|svg|pdf|gif|webp)$/.test(lower)) signals.figures += 1; | |
| 84 | + else if (/(^|\/)(data|results|outputs?|experiments?)\/.+\.(csv|tsv|json|parquet|txt|npz|npy)$/.test(lower)) signals.data += 1; | |
| 85 | + } | |
| 86 | + const score = (signals.tex > 0 ? 2 : 0) + (signals.pdf > 0 ? 1 : 0) + (signals.bib > 0 ? 2 : 0) + (signals.figures > 0 ? 1 : 0); | |
| 87 | + return { ...signals, likely: score >= 3 }; | |
| 88 | +} | |
| 89 | + | |
| 73 | 90 | /** |
| 74 | 91 | * Build (or read from cache) the full overview of one repository. |
| 92 | + * The cache carries the repository's ref stamp; a different live stamp means a | |
| 93 | + * push happened while the hook was unreachable, and the overview is rebuilt. | |
| 75 | 94 | * @param {{config, repos, meta, cache}} ctx |
| 76 | 95 | * @param {string} name |
| 96 | + * @param {{clones?: Record<string, number>}} [opts] | |
| 77 | 97 | * @returns {Promise<object|null>} |
| 78 | 98 | */ |
| 79 | −export async function repoOverview(ctx, name) { | |
| 99 | +export async function repoOverview(ctx, name, opts = {}) { | |
| 80 | 100 | if (!ctx.repos.exists(name)) return null; |
| 81 | − const cachePath = ctx.cache.path('repos', name, '_repo', 'overview-v4.json'); | |
| 101 | + const cachePath = ctx.cache.path('repos', name, '_repo', OVERVIEW_CACHE_FILE); | |
| 102 | + const clones = (opts.clones ?? cloneCounts(ctx))[name] ?? 0; | |
| 103 | + const stamp = ctx.repos.refStamp(name); | |
| 82 | 104 | const cached = ctx.cache.getJSON(cachePath); |
| 83 | − const clones = cloneCounts(ctx)[name] ?? 0; | |
| 84 | − if (cached) return { ...cached, cloneCount: clones }; | |
| 105 | + if (cached && cached.stamp === stamp) return { ...cached, cloneCount: clones }; | |
| 85 | 106 | |
| 86 | 107 | const meta = ctx.meta.get(name) ?? {}; |
| 87 | 108 | const head = await ctx.repos.head(name); |
@@ -92,11 +113,17 @@ export async function repoOverview(ctx, name) { | ||
| 92 | 113 | let languages = { languages: [], totalBytes: 0 }; |
| 93 | 114 | let license = null; |
| 94 | 115 | let spark = null; |
| 116 | + let research = Boolean(meta.research); | |
| 117 | + let researchSignals = null; | |
| 95 | 118 | if (head) { |
| 96 | − commitCount = await ctx.repos.commitCount(name, head); | |
| 97 | − languages = computeLanguages(await ctx.repos.allFiles(name, head)); | |
| 119 | + const timestamps = await ctx.repos.commitTimestamps(name, head); | |
| 120 | + commitCount = timestamps.length; | |
| 121 | + const files = await ctx.repos.allFiles(name, head); | |
| 122 | + languages = computeLanguages(files); | |
| 98 | 123 | license = await ctx.repos.license(name, head); |
| 99 | − spark = sparkline(await ctx.repos.commitTimestamps(name)); | |
| 124 | + spark = sparkline(timestamps); | |
| 125 | + researchSignals = detectResearchSignals(files.map((f) => f.path)); | |
| 126 | + if (meta.research === undefined && researchSignals.likely) research = false; // detected, not enabled — banner only | |
| 100 | 127 | } |
| 101 | 128 | const overview = { |
| 102 | 129 | name, |
@@ -105,8 +132,11 @@ export async function repoOverview(ctx, name) { | ||
| 105 | 132 | homepage: meta.homepage ?? '', |
| 106 | 133 | pinned: Boolean(meta.pinned), |
| 107 | 134 | created: meta.created ?? null, |
| 135 | + research, | |
| 136 | + researchDetected: Boolean(researchSignals?.likely), | |
| 108 | 137 | defaultBranch, |
| 109 | 138 | head, |
| 139 | + stamp, | |
| 110 | 140 | empty: head === null, |
| 111 | 141 | lastPush: await ctx.repos.lastPushDate(name), |
| 112 | 142 | commitCount, |
@@ -121,6 +151,7 @@ export async function repoOverview(ctx, name) { | ||
| 121 | 151 | spark, |
| 122 | 152 | cloneUrl: `${ctx.config.publicUrl}/${name}.git`, |
| 123 | 153 | url: `${ctx.config.publicUrl}/${name}`, |
| 154 | + computedAt: new Date().toISOString(), | |
| 124 | 155 | }; |
| 125 | 156 | ctx.cache.setJSON(cachePath, overview); |
| 126 | 157 | return { ...overview, cloneCount: clones }; |
@@ -133,26 +164,30 @@ export async function repoOverview(ctx, name) { | ||
| 133 | 164 | */ |
| 134 | 165 | export async function allOverviews(ctx) { |
| 135 | 166 | const names = ctx.repos.list(); |
| 136 | − const overviews = await mapLimit(names, 8, (name) => repoOverview(ctx, name)); | |
| 167 | + const clones = cloneCounts(ctx); | |
| 168 | + const overviews = await mapLimit(names, 4, (name) => repoOverview(ctx, name, { clones })); | |
| 137 | 169 | return overviews |
| 138 | 170 | .filter(Boolean) |
| 139 | 171 | .sort((a, b) => String(b.lastPush ?? '').localeCompare(String(a.lastPush ?? ''))); |
| 140 | 172 | } |
| 141 | 173 | |
| 142 | 174 | /** |
| 143 | − * Site-wide stats for the home hero + /api/v1/stats. | |
| 175 | + * Site-wide stats for the home hero + /api/v1/stats. `commits` is the sum of | |
| 176 | + * per-repository counts (commits reachable from each default branch) — the | |
| 177 | + * same commit set the calendar filters to 52 weeks. See /about/metrics. | |
| 144 | 178 | * @param {{config, repos, meta, cache}} ctx |
| 179 | + * @param {object[]} [overviews] reuse an already-computed list | |
| 145 | 180 | */ |
| 146 | −export async function siteStats(ctx) { | |
| 147 | − const overviews = await allOverviews(ctx); | |
| 181 | +export async function siteStats(ctx, overviews) { | |
| 182 | + const list = overviews ?? (await allOverviews(ctx)); | |
| 148 | 183 | const languages = new Set(); |
| 149 | 184 | let commits = 0; |
| 150 | − for (const o of overviews) { | |
| 185 | + for (const o of list) { | |
| 151 | 186 | commits += o.commitCount; |
| 152 | 187 | for (const lang of o.languages) languages.add(lang.name); |
| 153 | 188 | } |
| 154 | 189 | return { |
| 155 | − repos: overviews.length, | |
| 190 | + repos: list.length, | |
| 156 | 191 | commits, |
| 157 | 192 | languages: languages.size, |
| 158 | 193 | languageNames: [...languages].sort(), |
modified
src/lib/search.mjs
+149 −41
@@ -5,12 +5,20 @@ | ||
| 5 | 5 | * Author : Simon-Pierre Boucher |
| 6 | 6 | * Contact : contact@spboucher.ai |
| 7 | 7 | * File : src/lib/search.mjs |
| 8 | − * Purpose : Tiny inverted index over names, topics, descriptions, READMEs | |
| 8 | + * Purpose : Search index (repos, topics, READMEs, file paths, refs) + scoped query API; commits searched live per repo | |
| 9 | 9 | * License : MIT © Simon-Pierre Boucher |
| 10 | 10 | * ───────────────────────────────────────────── |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { allOverviews } from './overview.mjs'; |
| 14 | +import { sameStamps } from '../stats/activity.mjs'; | |
| 15 | +import { mapLimit } from './util.mjs'; | |
| 16 | + | |
| 17 | +const INDEX_FILE = 'search-index-v2.json'; | |
| 18 | +const README_CHARS = 20000; | |
| 19 | +const PATHS_PER_REPO = 20000; | |
| 20 | +/** Repos scanned with `git log --grep` for a global commit search (most recently pushed first). */ | |
| 21 | +const COMMIT_SEARCH_REPOS = 40; | |
| 14 | 22 | |
| 15 | 23 | /** Strip markdown/code noise from a README for indexing + snippets. */ |
| 16 | 24 | export function stripMarkdown(source) { |
@@ -26,82 +34,182 @@ export function stripMarkdown(source) { | ||
| 26 | 34 | } |
| 27 | 35 | |
| 28 | 36 | /** |
| 29 | − * Build the search index (cached globally, refreshed on push). | |
| 37 | + * Build the search index (cached globally, validated against ref stamps so a | |
| 38 | + * push missed by the hook still refreshes it on the next search). | |
| 30 | 39 | * @param {{config, repos, meta, cache}} ctx |
| 31 | − * @returns {Promise<{entries: object[]}>} | |
| 40 | + * @returns {Promise<{entries: object[], builtAt: string, files: number}>} | |
| 32 | 41 | */ |
| 33 | 42 | export async function buildSearchIndex(ctx) { |
| 34 | − const cachePath = ctx.cache.path('global', 'search-index.json'); | |
| 43 | + const cachePath = ctx.cache.path('global', INDEX_FILE); | |
| 44 | + const stamps = {}; | |
| 45 | + for (const name of ctx.repos.list()) stamps[name] = ctx.repos.refStamp(name); | |
| 35 | 46 | const cached = ctx.cache.getJSON(cachePath); |
| 36 | − if (cached) return cached; | |
| 47 | + if (cached && sameStamps(cached.stamps, stamps)) return cached; | |
| 48 | + | |
| 37 | 49 | const overviews = await allOverviews(ctx); |
| 38 | − const entries = []; | |
| 39 | − for (const o of overviews) { | |
| 50 | + let files = 0; | |
| 51 | + const entries = await mapLimit(overviews, 4, async (o) => { | |
| 40 | 52 | let readmeText = ''; |
| 53 | + let paths = []; | |
| 54 | + let refs = []; | |
| 41 | 55 | if (o.head) { |
| 42 | 56 | const readme = await ctx.repos.readme(o.name, o.head); |
| 43 | − if (readme) readmeText = stripMarkdown(readme.content.toString('utf8')).slice(0, 20000); | |
| 57 | + if (readme) readmeText = stripMarkdown(readme.content.toString('utf8')).slice(0, README_CHARS); | |
| 58 | + paths = await ctx.repos.allPaths(o.name, o.head, PATHS_PER_REPO); | |
| 59 | + const branches = await ctx.repos.branches(o.name); | |
| 60 | + const tags = await ctx.repos.tags(o.name); | |
| 61 | + refs = [ | |
| 62 | + ...branches.map((b) => ({ name: b.name, kind: 'branch', date: b.date })), | |
| 63 | + ...tags.map((t) => ({ name: t.name, kind: 'tag', date: t.date })), | |
| 64 | + ]; | |
| 44 | 65 | } |
| 45 | − entries.push({ | |
| 66 | + files += paths.length; | |
| 67 | + return { | |
| 46 | 68 | name: o.name, |
| 47 | 69 | description: o.description, |
| 48 | 70 | topics: o.topics, |
| 49 | 71 | topLanguage: o.topLanguage, |
| 50 | 72 | lastPush: o.lastPush, |
| 73 | + defaultBranch: o.defaultBranch, | |
| 74 | + head: o.head, | |
| 51 | 75 | readmeText, |
| 52 | − }); | |
| 53 | − } | |
| 54 | − const index = { entries, builtAt: new Date().toISOString() }; | |
| 76 | + paths, | |
| 77 | + refs, | |
| 78 | + }; | |
| 79 | + }); | |
| 80 | + const index = { entries, builtAt: new Date().toISOString(), files, stamps }; | |
| 55 | 81 | ctx.cache.setJSON(cachePath, index); |
| 56 | 82 | return index; |
| 57 | 83 | } |
| 58 | 84 | |
| 85 | +/** Normalize a query into ≤ 8 lowercase terms of ≥ 2 characters. */ | |
| 86 | +export function queryTerms(query) { | |
| 87 | + return String(query ?? '') | |
| 88 | + .toLowerCase() | |
| 89 | + .split(/\s+/) | |
| 90 | + .filter((t) => t.length >= 2) | |
| 91 | + .slice(0, 8); | |
| 92 | +} | |
| 93 | + | |
| 94 | +/** Score a path against terms: filename hits weigh more than directory hits. */ | |
| 95 | +function scorePath(path, terms) { | |
| 96 | + const lower = path.toLowerCase(); | |
| 97 | + const base = lower.slice(lower.lastIndexOf('/') + 1); | |
| 98 | + let score = 0; | |
| 99 | + for (const term of terms) { | |
| 100 | + if (base === term) score += 60; | |
| 101 | + else if (base.startsWith(term)) score += 40; | |
| 102 | + else if (base.includes(term)) score += 25; | |
| 103 | + else if (lower.includes(term)) score += 8; | |
| 104 | + else return 0; | |
| 105 | + } | |
| 106 | + return score; | |
| 107 | +} | |
| 108 | + | |
| 59 | 109 | /** |
| 60 | 110 | * Query the index. Returns grouped, scored results. |
| 61 | 111 | * @param {{config, repos, meta, cache}} ctx |
| 62 | 112 | * @param {string} query |
| 63 | − * @returns {Promise<{repos: object[], readmes: object[]}>} | |
| 113 | + * @param {{scope?: 'all'|'repos'|'files'|'commits'|'branches'|'readmes', repo?: string|null, limit?: number}} [opts] | |
| 114 | + * @returns {Promise<{repos: object[], readmes: object[], files: object[], commits: object[], branches: object[], index: {builtAt: string, repos: number, files: number}}>} | |
| 64 | 115 | */ |
| 65 | −export async function search(ctx, query) { | |
| 66 | − const terms = String(query ?? '') | |
| 67 | − .toLowerCase() | |
| 68 | − .split(/\s+/) | |
| 69 | − .filter((t) => t.length >= 2) | |
| 70 | − .slice(0, 8); | |
| 71 | − if (terms.length === 0) return { repos: [], readmes: [] }; | |
| 72 | − const { entries } = await buildSearchIndex(ctx); | |
| 116 | +export async function search(ctx, query, opts = {}) { | |
| 117 | + const scope = opts.scope ?? 'all'; | |
| 118 | + const limit = Math.max(1, Math.min(50, Number(opts.limit) || 20)); | |
| 119 | + const terms = queryTerms(query); | |
| 120 | + const index = await buildSearchIndex(ctx); | |
| 121 | + const status = { builtAt: index.builtAt, repos: index.entries.length, files: index.files }; | |
| 122 | + const empty = { repos: [], readmes: [], files: [], commits: [], branches: [], index: status }; | |
| 123 | + const entries = opts.repo ? index.entries.filter((e) => e.name === opts.repo) : index.entries; | |
| 124 | + const want = (kind) => scope === 'all' || scope === kind; | |
| 125 | + | |
| 126 | + // No query: the palette shows the most recently pushed repositories. | |
| 127 | + if (terms.length === 0) { | |
| 128 | + return { ...empty, repos: want('repos') ? entries.slice(0, Math.min(limit, 8)).map(publicRepo) : [] }; | |
| 129 | + } | |
| 130 | + | |
| 73 | 131 | const repoHits = []; |
| 74 | 132 | const readmeHits = []; |
| 133 | + const fileHits = []; | |
| 134 | + const refHits = []; | |
| 75 | 135 | for (const entry of entries) { |
| 76 | 136 | const name = entry.name.toLowerCase(); |
| 77 | 137 | const description = (entry.description ?? '').toLowerCase(); |
| 78 | 138 | const topics = (entry.topics ?? []).map((t) => t.toLowerCase()); |
| 79 | 139 | const readme = (entry.readmeText ?? '').toLowerCase(); |
| 80 | − let score = 0; | |
| 81 | − let readmeMatch = false; | |
| 82 | − for (const term of terms) { | |
| 83 | − if (name === term) score += 100; | |
| 84 | − else if (name.includes(term)) score += 40; | |
| 85 | − if (description.includes(term)) score += 15; | |
| 86 | − if (topics.some((t) => t.includes(term))) score += 25; | |
| 87 | − if (readme.includes(term)) { | |
| 88 | − score += 5; | |
| 89 | − readmeMatch = true; | |
| 140 | + if (want('repos') || want('readmes')) { | |
| 141 | + let score = 0; | |
| 142 | + let readmeMatch = false; | |
| 143 | + for (const term of terms) { | |
| 144 | + if (name === term) score += 100; | |
| 145 | + else if (name.includes(term)) score += 40; | |
| 146 | + if (description.includes(term)) score += 15; | |
| 147 | + if (topics.some((t) => t.includes(term))) score += 25; | |
| 148 | + if (readme.includes(term)) { | |
| 149 | + score += 5; | |
| 150 | + readmeMatch = true; | |
| 151 | + } | |
| 152 | + } | |
| 153 | + if (score > 0) { | |
| 154 | + const hit = { ...publicRepo(entry), score }; | |
| 155 | + if (want('repos') && (name.includes(terms[0]) || description.includes(terms[0]) || topics.some((t) => t.includes(terms[0])))) { | |
| 156 | + repoHits.push(hit); | |
| 157 | + } | |
| 158 | + if (want('readmes') && readmeMatch) { | |
| 159 | + const idx = readme.indexOf(terms[0]); | |
| 160 | + const start = Math.max(0, idx - 80); | |
| 161 | + const snippet = entry.readmeText.slice(start, start + 220).trim(); | |
| 162 | + readmeHits.push({ ...hit, snippet: (start > 0 ? '…' : '') + snippet + '…' }); | |
| 163 | + } | |
| 90 | 164 | } |
| 91 | 165 | } |
| 92 | − if (score === 0) continue; | |
| 93 | − const hit = { ...entry, score }; | |
| 94 | − if (name.includes(terms[0]) || description.includes(terms[0]) || topics.some((t) => t.includes(terms[0]))) { | |
| 95 | − repoHits.push(hit); | |
| 166 | + if (want('files')) { | |
| 167 | + for (const path of entry.paths ?? []) { | |
| 168 | + const score = scorePath(path, terms); | |
| 169 | + if (score > 0) fileHits.push({ repo: entry.name, ref: entry.defaultBranch, path, score }); | |
| 170 | + } | |
| 96 | 171 | } |
| 97 | − if (readmeMatch) { | |
| 98 | − const idx = readme.indexOf(terms[0]); | |
| 99 | − const start = Math.max(0, idx - 80); | |
| 100 | − const snippet = entry.readmeText.slice(start, start + 220).trim(); | |
| 101 | − readmeHits.push({ ...hit, snippet: (start > 0 ? '…' : '') + snippet + '…' }); | |
| 172 | + if (want('branches')) { | |
| 173 | + for (const ref of entry.refs ?? []) { | |
| 174 | + const lower = ref.name.toLowerCase(); | |
| 175 | + if (terms.every((t) => lower.includes(t))) refHits.push({ repo: entry.name, name: ref.name, kind: ref.kind, date: ref.date, score: lower === terms.join(' ') ? 100 : 50 - lower.length }); | |
| 176 | + } | |
| 102 | 177 | } |
| 103 | 178 | } |
| 104 | 179 | repoHits.sort((a, b) => b.score - a.score); |
| 105 | 180 | readmeHits.sort((a, b) => b.score - a.score); |
| 106 | − return { repos: repoHits.slice(0, 20), readmes: readmeHits.slice(0, 20) }; | |
| 181 | + fileHits.sort((a, b) => b.score - a.score || a.path.length - b.path.length); | |
| 182 | + refHits.sort((a, b) => b.score - a.score); | |
| 183 | + | |
| 184 | + let commitHits = []; | |
| 185 | + if (want('commits')) { | |
| 186 | + const candidates = (opts.repo ? entries : entries.slice(0, COMMIT_SEARCH_REPOS)).filter((e) => e.head); | |
| 187 | + const raw = String(query ?? '').trim(); | |
| 188 | + const perRepo = await mapLimit(candidates, 4, async (e) => { | |
| 189 | + const commits = await ctx.repos.grepCommits(e.name, e.head, raw, opts.repo ? limit : 5); | |
| 190 | + return commits.map((c) => ({ ...c, repo: e.name })); | |
| 191 | + }); | |
| 192 | + commitHits = perRepo.flat().sort((a, b) => String(b.date).localeCompare(String(a.date))).slice(0, limit); | |
| 193 | + } | |
| 194 | + | |
| 195 | + return { | |
| 196 | + repos: repoHits.slice(0, limit), | |
| 197 | + readmes: readmeHits.slice(0, limit), | |
| 198 | + files: fileHits.slice(0, limit), | |
| 199 | + commits: commitHits, | |
| 200 | + branches: refHits.slice(0, limit), | |
| 201 | + index: status, | |
| 202 | + }; | |
| 203 | +} | |
| 204 | + | |
| 205 | +/** Public projection of an index entry (never leaks paths/README text). */ | |
| 206 | +function publicRepo(entry) { | |
| 207 | + return { | |
| 208 | + name: entry.name, | |
| 209 | + description: entry.description, | |
| 210 | + topics: entry.topics, | |
| 211 | + topLanguage: entry.topLanguage, | |
| 212 | + lastPush: entry.lastPush, | |
| 213 | + defaultBranch: entry.defaultBranch, | |
| 214 | + }; | |
| 107 | 215 | } |
added
src/lib/semaphore.mjs
+63 −0
@@ -0,0 +1,63 @@ | ||
| 1 | +/** | |
| 2 | + * ───────────────────────────────────────────── | |
| 3 | + * SPB Git — Personal Git Platform | |
| 4 | + * ───────────────────────────────────────────── | |
| 5 | + * Author : Simon-Pierre Boucher | |
| 6 | + * Contact : contact@spboucher.ai | |
| 7 | + * File : src/lib/semaphore.mjs | |
| 8 | + * Purpose : Tiny counting semaphore — bounds concurrent git subprocesses so crawlers cannot exhaust memory | |
| 9 | + * License : MIT © Simon-Pierre Boucher | |
| 10 | + * ───────────────────────────────────────────── | |
| 11 | + */ | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * Counting semaphore. `acquire()` resolves with a release function; callers | |
| 15 | + * must release in a `finally`. Waiters are served in FIFO order. | |
| 16 | + */ | |
| 17 | +export class Semaphore { | |
| 18 | + /** @param {number} limit maximum concurrent holders */ | |
| 19 | + constructor(limit) { | |
| 20 | + this.limit = Math.max(1, limit | 0); | |
| 21 | + this.active = 0; | |
| 22 | + this.queue = []; | |
| 23 | + } | |
| 24 | + | |
| 25 | + /** @returns {Promise<() => void>} release function */ | |
| 26 | + acquire() { | |
| 27 | + return new Promise((resolve) => { | |
| 28 | + const grant = () => { | |
| 29 | + this.active += 1; | |
| 30 | + let released = false; | |
| 31 | + resolve(() => { | |
| 32 | + if (released) return; | |
| 33 | + released = true; | |
| 34 | + this.active -= 1; | |
| 35 | + const next = this.queue.shift(); | |
| 36 | + if (next) next(); | |
| 37 | + }); | |
| 38 | + }; | |
| 39 | + if (this.active < this.limit) grant(); | |
| 40 | + else this.queue.push(grant); | |
| 41 | + }); | |
| 42 | + } | |
| 43 | + | |
| 44 | + /** | |
| 45 | + * Run `fn` while holding one slot. | |
| 46 | + * @template T | |
| 47 | + * @param {() => Promise<T>} fn | |
| 48 | + * @returns {Promise<T>} | |
| 49 | + */ | |
| 50 | + async run(fn) { | |
| 51 | + const release = await this.acquire(); | |
| 52 | + try { | |
| 53 | + return await fn(); | |
| 54 | + } finally { | |
| 55 | + release(); | |
| 56 | + } | |
| 57 | + } | |
| 58 | + | |
| 59 | + /** @returns {{active: number, waiting: number, limit: number}} */ | |
| 60 | + stats() { | |
| 61 | + return { active: this.active, waiting: this.queue.length, limit: this.limit }; | |
| 62 | + } | |
| 63 | +} | |
modified
src/server.mjs
+36 −5
@@ -10,14 +10,24 @@ | ||
| 10 | 10 | * ───────────────────────────────────────────── |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | −import Fastify from 'fastify'; | |
| 13 | +import Fastify, { LogController } from 'fastify'; | |
| 14 | 14 | import rateLimit from '@fastify/rate-limit'; |
| 15 | 15 | import fastifyStatic from '@fastify/static'; |
| 16 | 16 | import { join } from 'node:path'; |
| 17 | +import { readFileSync } from 'node:fs'; | |
| 17 | 18 | import { pathToFileURL } from 'node:url'; |
| 18 | 19 | import process from 'node:process'; |
| 19 | 20 | import { loadConfig, ensureDirs, PROJECT_ROOT } from './config.mjs'; |
| 20 | −import { Repos } from './git/repo.mjs'; | |
| 21 | +import { Repos, gitSemaphore } from './git/repo.mjs'; | |
| 22 | + | |
| 23 | +/** package.json version — surfaced by /healthz and the footer. */ | |
| 24 | +export const VERSION = (() => { | |
| 25 | + try { | |
| 26 | + return JSON.parse(readFileSync(join(PROJECT_ROOT, 'package.json'), 'utf8')).version ?? '0'; | |
| 27 | + } catch { | |
| 28 | + return '0'; | |
| 29 | + } | |
| 30 | +})(); | |
| 21 | 31 | import { MetaStore, ActivityFeed } from './lib/store.mjs'; |
| 22 | 32 | import { Cache } from './lib/cache.mjs'; |
| 23 | 33 | import { TokenStore } from './auth/token.mjs'; |
@@ -146,9 +156,24 @@ export async function buildServer(config) { | ||
| 146 | 156 | level: config.logLevel, |
| 147 | 157 | redact: ['req.headers.authorization'], |
| 148 | 158 | }, |
| 159 | + // One compact line per completed request (below) instead of pino's | |
| 160 | + // two-line incoming/completed pair — the production log had reached 600 MB. | |
| 161 | + logController: new LogController({ disableRequestLogging: true }), | |
| 149 | 162 | bodyLimit: 5 * 1024 * 1024, |
| 150 | 163 | }); |
| 151 | 164 | |
| 165 | + app.addHook('onResponse', (request, reply, done) => { | |
| 166 | + const url = request.raw.url ?? ''; | |
| 167 | + const status = reply.statusCode; | |
| 168 | + // Static assets are only logged when they fail. | |
| 169 | + if (status < 400 && (url.startsWith('/assets/') || url === '/favicon.png' || url === '/apple-touch-icon.png')) return done(); | |
| 170 | + const line = { method: request.method, url: url.slice(0, 300), status, ms: Math.round(reply.elapsedTime), ip: request.ip }; | |
| 171 | + if (status >= 500) request.log.error(line, 'request'); | |
| 172 | + else if (status >= 400) request.log.warn(line, 'request'); | |
| 173 | + else request.log.info(line, 'request'); | |
| 174 | + done(); | |
| 175 | + }); | |
| 176 | + | |
| 152 | 177 | app.addContentTypeParser('text/plain', { parseAs: 'string' }, (_req, body, done) => done(null, body)); |
| 153 | 178 | // Release-asset uploads stream straight to disk — never buffered in memory. |
| 154 | 179 | app.addContentTypeParser('application/octet-stream', (_req, payload, done) => done(null, payload)); |
@@ -185,14 +210,20 @@ export async function buildServer(config) { | ||
| 185 | 210 | index: false, |
| 186 | 211 | }); |
| 187 | 212 | |
| 213 | + // The cache footprint walks ~10k files; probes hit this every few minutes. | |
| 214 | + let footprintMemo = { at: 0, value: null }; | |
| 188 | 215 | app.get('/healthz', async () => { |
| 189 | − const footprint = ctx.cache.footprint(); | |
| 216 | + if (Date.now() - footprintMemo.at > 60000) footprintMemo = { at: Date.now(), value: ctx.cache.footprint() }; | |
| 217 | + const mem = process.memoryUsage(); | |
| 190 | 218 | return { |
| 191 | 219 | status: 'ok', |
| 220 | + version: VERSION, | |
| 192 | 221 | uptimeSeconds: Math.round((Date.now() - ctx.startedAt) / 1000), |
| 193 | 222 | repos: ctx.repos.list().length, |
| 194 | − cache: footprint, | |
| 195 | − version: '1.1.0', | |
| 223 | + cache: footprintMemo.value, | |
| 224 | + memory: { rssMb: Math.round(mem.rss / 1048576), heapUsedMb: Math.round(mem.heapUsed / 1048576) }, | |
| 225 | + git: gitSemaphore.stats(), | |
| 226 | + features: { research: true, ai: Boolean(config.ai?.enabled), aiProvider: config.ai?.enabled ? config.ai.provider : null }, | |
| 196 | 227 | }; |
| 197 | 228 | }); |
| 198 | 229 | |
modified
src/stats/activity.mjs
+25 −7
@@ -157,28 +157,46 @@ export function calendarSvg(calendar) { | ||
| 157 | 157 | |
| 158 | 158 | /** |
| 159 | 159 | * Aggregate commit timestamps across every repo, then build calendar + SVG. |
| 160 | − * Cached globally, refreshed on push. | |
| 160 | + * Cached globally with every repository's ref stamp: a push that was missed | |
| 161 | + * by the hook (server down) is detected on the next read and the calendar is | |
| 162 | + * rebuilt, so the 52-week total and the all-time total always come from the | |
| 163 | + * same pass (see /about/metrics). | |
| 161 | 164 | * @param {{repos: object, cache: object}} ctx |
| 162 | − * @returns {Promise<{svg: string, total: number, streak: number, longest: number, activeDays: number, busiest: object|null}>} | |
| 165 | + * @returns {Promise<{svg: string, total: number, allTime: number, streak: number, longest: number, activeDays: number, busiest: object|null, builtAt: string}>} | |
| 163 | 166 | */ |
| 164 | 167 | export async function contributionCalendar(ctx) { |
| 165 | − const cachePath = ctx.cache.path('global', 'heatmap-v4.json'); | |
| 168 | + const cachePath = ctx.cache.path('global', 'heatmap-v5.json'); | |
| 169 | + const names = ctx.repos.list(); | |
| 170 | + const stamps = {}; | |
| 171 | + for (const name of names) stamps[name] = ctx.repos.refStamp(name); | |
| 166 | 172 | const cached = ctx.cache.getJSON(cachePath); |
| 167 | − if (cached) return cached; | |
| 173 | + if (cached && sameStamps(cached.stamps, stamps)) return cached; | |
| 168 | 174 | const all = []; |
| 169 | − for (const name of ctx.repos.list()) { | |
| 170 | − const stamps = await ctx.repos.commitTimestamps(name); | |
| 171 | − all.push(...stamps); | |
| 175 | + for (const name of names) { | |
| 176 | + const stamps_ = await ctx.repos.commitTimestamps(name); | |
| 177 | + all.push(...stamps_); | |
| 172 | 178 | } |
| 173 | 179 | const calendar = buildCalendar(bucketByDay(all)); |
| 174 | 180 | const result = { |
| 175 | 181 | svg: calendarSvg(calendar), |
| 176 | 182 | total: calendar.total, |
| 183 | + allTime: all.length, | |
| 177 | 184 | streak: calendar.streak, |
| 178 | 185 | longest: calendar.longest, |
| 179 | 186 | activeDays: calendar.activeDays, |
| 180 | 187 | busiest: calendar.busiest, |
| 188 | + builtAt: new Date().toISOString(), | |
| 189 | + stamps, | |
| 181 | 190 | }; |
| 182 | 191 | ctx.cache.setJSON(cachePath, result); |
| 183 | 192 | return result; |
| 184 | 193 | } |
| 194 | + | |
| 195 | +/** Shallow equality of two {repo: stamp} maps. */ | |
| 196 | +export function sameStamps(a, b) { | |
| 197 | + if (!a || !b) return false; | |
| 198 | + const ka = Object.keys(a); | |
| 199 | + const kb = Object.keys(b); | |
| 200 | + if (ka.length !== kb.length) return false; | |
| 201 | + return ka.every((k) => a[k] === b[k]); | |
| 202 | +} | |
modified
src/web/assets/css/app.css
+664 −703
@@ -5,640 +5,587 @@ | ||
| 5 | 5 | * Author : Simon-Pierre Boucher |
| 6 | 6 | * Contact : contact@spboucher.ai |
| 7 | 7 | * File : src/web/assets/css/app.css |
| 8 | − * Purpose : Application styles v4 "Monolith" — cinematic hero, bento, sparklines, every page | |
| 8 | + * Purpose : Application styles v2.0 — mobile-first AppShell, component library, every page | |
| 9 | 9 | * License : MIT © Simon-Pierre Boucher |
| 10 | 10 | * ───────────────────────────────────────────── |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | −*, *::before, *::after { box-sizing: border-box; } | |
| 14 | − | |
| 15 | −html { -webkit-text-size-adjust: 100%; scroll-padding-top: 84px; scroll-behavior: smooth; } | |
| 16 | − | |
| 13 | +/* ═══════════════════════════════════════════ | |
| 14 | + 0. Reset & base | |
| 15 | + ═══════════════════════════════════════════ */ | |
| 16 | +*, *::before, *::after { box-sizing: border-box; min-width: 0; } | |
| 17 | +html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; scroll-padding-top: calc(var(--header-h) + 12px); overflow-x: clip; } | |
| 18 | +@media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth; } } | |
| 17 | 19 | body { |
| 18 | − margin: 0; | |
| 19 | − background: var(--bg); | |
| 20 | − color: var(--text); | |
| 21 | − font-family: var(--font-ui); | |
| 22 | − font-size: 15px; | |
| 23 | − line-height: 1.55; | |
| 24 | − min-height: 100vh; | |
| 25 | − display: flex; | |
| 26 | − flex-direction: column; | |
| 27 | − font-feature-settings: 'cv02', 'cv03', 'ss01'; | |
| 28 | − position: relative; | |
| 29 | − overflow-x: clip; | |
| 30 | −} | |
| 31 | − | |
| 32 | −/* ── Background stack: mesh + grid + grain ─── */ | |
| 33 | −.bg { position: fixed; inset: 0; z-index: -2; overflow: hidden; pointer-events: none; } | |
| 34 | −.bg-mesh { | |
| 35 | − position: absolute; inset: -25%; | |
| 36 | − background: | |
| 37 | − radial-gradient(38% 30% at 16% 10%, var(--mesh-1), transparent 70%), | |
| 38 | − radial-gradient(30% 26% at 84% 6%, var(--mesh-2), transparent 70%), | |
| 39 | − radial-gradient(26% 24% at 60% 22%, var(--mesh-3), transparent 72%), | |
| 40 | − radial-gradient(32% 28% at 40% 0%, var(--mesh-4), transparent 70%), | |
| 41 | − radial-gradient(22% 18% at 92% 40%, var(--mesh-5), transparent 70%); | |
| 42 | − animation: mesh-drift 32s ease-in-out infinite alternate; | |
| 43 | − will-change: transform; | |
| 44 | −} | |
| 45 | −@keyframes mesh-drift { | |
| 46 | − from { transform: translate3d(-2%, -1%, 0) scale(1); } | |
| 47 | − 50% { transform: translate3d(1.5%, 1.5%, 0) scale(1.05); } | |
| 48 | − to { transform: translate3d(3%, -1.5%, 0) scale(1.02); } | |
| 49 | −} | |
| 50 | −.bg-grid { | |
| 51 | − position: absolute; inset: 0; | |
| 52 | − background-image: | |
| 53 | − linear-gradient(var(--grid-line) 1px, transparent 1px), | |
| 54 | − linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); | |
| 55 | − background-size: 56px 56px; | |
| 56 | − mask: radial-gradient(70% 55% at 50% 0%, black, transparent 100%); | |
| 57 | − -webkit-mask: radial-gradient(70% 55% at 50% 0%, black, transparent 100%); | |
| 58 | −} | |
| 59 | −.bg-noise { | |
| 60 | − position: absolute; inset: 0; opacity: 0.06; mix-blend-mode: overlay; | |
| 61 | − background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); | |
| 62 | −} | |
| 63 | −html[data-theme='light'] .bg-noise { opacity: 0.04; } | |
| 64 | − | |
| 65 | −a { color: var(--accent); text-decoration: none; transition: color 0.15s ease; } | |
| 20 | + margin: 0; background: var(--bg); color: var(--fg); | |
| 21 | + font-family: var(--font-ui); font-size: var(--text-base); line-height: var(--leading); | |
| 22 | + min-height: 100vh; min-height: 100dvh; display: flex; flex-direction: column; | |
| 23 | + font-feature-settings: 'cv02', 'cv03', 'ss01'; overflow-x: clip; | |
| 24 | + -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; | |
| 25 | +} | |
| 26 | +img, svg, video, canvas { max-width: 100%; height: auto; display: inline-block; vertical-align: middle; } | |
| 27 | +svg.ic { height: auto; flex: none; } | |
| 28 | +a { color: var(--accent); text-decoration: none; transition: color var(--dur-fast) var(--ease); } | |
| 66 | 29 | a:hover { color: var(--accent-hover); text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1px; } |
| 67 | −code, pre, kbd { font-family: var(--font-mono); } | |
| 68 | −button { font-family: inherit; } | |
| 30 | +code, pre, kbd, samp { font-family: var(--font-mono); } | |
| 31 | +pre { margin: 0; } | |
| 32 | +button, input, select, textarea { font: inherit; color: inherit; } | |
| 33 | +button { cursor: pointer; background: none; border: 0; padding: 0; } | |
| 34 | +h1, h2, h3, h4 { margin: 0; line-height: 1.2; } | |
| 35 | +p { margin: 0; } | |
| 36 | +ul, ol { margin: 0; padding: 0; } | |
| 69 | 37 | ::selection { background: var(--accent-soft); } |
| 70 | −:focus-visible { outline: none; box-shadow: var(--ring); border-radius: 6px; } | |
| 71 | − | |
| 38 | +:focus-visible { outline: 2px solid transparent; box-shadow: var(--ring); border-radius: var(--radius-xs); } | |
| 39 | +:focus:not(:focus-visible) { outline: none; } | |
| 72 | 40 | * { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; } |
| 73 | 41 | *::-webkit-scrollbar { width: 10px; height: 10px; } |
| 74 | 42 | *::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } |
| 75 | 43 | *::-webkit-scrollbar-track { background: transparent; } |
| 76 | 44 | |
| 77 | −.container { max-width: 1240px; margin: 0 auto; padding: 0 24px; width: 100%; } | |
| 78 | −main.container { flex: 1; padding-top: 28px; padding-bottom: 72px; } | |
| 79 | − | |
| 80 | −.sr-only, .skip-link { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } | |
| 81 | −.skip-link:focus { | |
| 82 | − position: fixed; top: 8px; left: 8px; width: auto; height: auto; clip: auto; | |
| 83 | − background: var(--surface); border: 1px solid var(--accent); padding: 8px 12px; | |
| 84 | − border-radius: var(--radius); z-index: 100; | |
| 45 | +.container { | |
| 46 | + max-width: var(--container); margin: 0 auto; width: 100%; | |
| 47 | + padding-inline: max(var(--gutter), env(safe-area-inset-left)) max(var(--gutter), env(safe-area-inset-right)); | |
| 85 | 48 | } |
| 86 | −.muted { color: var(--muted); } | |
| 49 | +main.container { flex: 1; padding-top: var(--space-6); padding-bottom: var(--space-10); } | |
| 50 | +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } | |
| 51 | +.skip-link { position: absolute; left: 8px; top: -60px; z-index: 200; background: var(--surface-0); color: var(--fg); border: 1px solid var(--accent); padding: 10px 14px; border-radius: var(--radius); } | |
| 52 | +.skip-link:focus { top: 8px; } | |
| 53 | +.muted { color: var(--fg-muted); } | |
| 54 | +.faint { color: var(--fg-faint); } | |
| 87 | 55 | .mono { font-family: var(--font-mono); } |
| 88 | −.display { font-family: var(--font-display); letter-spacing: -0.02em; } | |
| 89 | − | |
| 90 | −/* ── Card primitive ──────────────────────── */ | |
| 91 | −.card, | |
| 92 | −.repo-card, .release-card, .search-results li, .commit-list, .file-table, | |
| 93 | −.readme-section, .blob-card, .ref-table, .commit-detail-header, .diff-file, | |
| 94 | −.stat, .clone-box, .error-art, .side-card, .hero-terminal, .rstat, .empty-state { | |
| 95 | − position: relative; | |
| 96 | − background: var(--surface-grad); | |
| 97 | − border: 1px solid var(--border); | |
| 98 | − box-shadow: inset 0 1px 0 var(--edge-light), var(--shadow-1); | |
| 99 | − backdrop-filter: blur(14px); | |
| 100 | − -webkit-backdrop-filter: blur(14px); | |
| 101 | −} | |
| 102 | −/* mouse spotlight — --mx/--my set by app.js on any .spot */ | |
| 103 | −.spot::after { | |
| 104 | − content: ''; position: absolute; inset: 0; pointer-events: none; border-radius: inherit; | |
| 105 | − background: radial-gradient(420px circle at var(--mx, 50%) var(--my, -60%), var(--accent-soft), transparent 62%); | |
| 106 | − opacity: 0; transition: opacity 0.3s ease; | |
| 56 | +.num { font-variant-numeric: tabular-nums; } | |
| 57 | +.only-mobile { display: none !important; } | |
| 58 | +.hide-mobile { display: initial; } | |
| 59 | +.text-sm { font-size: var(--text-sm); } | |
| 60 | +.text-xs { font-size: var(--text-xs); } | |
| 61 | + | |
| 62 | +/* ═══════════════════════════════════════════ | |
| 63 | + 1. Background — soft mesh + faint grid (static on phones / reduced motion) | |
| 64 | + ═══════════════════════════════════════════ */ | |
| 65 | +.bg { position: fixed; inset: 0; z-index: -1; overflow: hidden; pointer-events: none; } | |
| 66 | +.bg-mesh { | |
| 67 | + position: absolute; inset: -20%; | |
| 68 | + background: | |
| 69 | + radial-gradient(36% 28% at 14% 8%, var(--mesh-1), transparent 70%), | |
| 70 | + radial-gradient(28% 24% at 86% 4%, var(--mesh-2), transparent 70%), | |
| 71 | + radial-gradient(24% 22% at 58% 20%, var(--mesh-3), transparent 72%); | |
| 107 | 72 | } |
| 108 | −.spot:hover::after { opacity: 1; } | |
| 109 | − | |
| 110 | −/* ── Top nav ─────────────────────────────── */ | |
| 111 | −.topnav { | |
| 112 | − position: sticky; top: 0; z-index: 50; | |
| 113 | − background: var(--nav-bg); | |
| 114 | − backdrop-filter: blur(22px) saturate(1.7); | |
| 115 | − -webkit-backdrop-filter: blur(22px) saturate(1.7); | |
| 73 | +.bg-grid { | |
| 74 | + position: absolute; inset: 0; | |
| 75 | + background-image: linear-gradient(var(--grid-line) 1px, transparent 1px), linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); | |
| 76 | + background-size: 48px 48px; | |
| 77 | + mask: radial-gradient(70% 50% at 50% 0%, black, transparent 100%); | |
| 78 | + -webkit-mask: radial-gradient(70% 50% at 50% 0%, black, transparent 100%); | |
| 79 | +} | |
| 80 | +@media (min-width: 768px) and (prefers-reduced-motion: no-preference) { | |
| 81 | + .bg-mesh { animation: mesh-drift 40s ease-in-out infinite alternate; will-change: transform; } | |
| 82 | +} | |
| 83 | +@keyframes mesh-drift { from { transform: translate3d(-1.5%, -1%, 0); } to { transform: translate3d(1.5%, 1%, 0) scale(1.03); } } | |
| 84 | + | |
| 85 | +/* ═══════════════════════════════════════════ | |
| 86 | + 2. AppShell — header | |
| 87 | + ═══════════════════════════════════════════ */ | |
| 88 | +.app-header { | |
| 89 | + position: sticky; top: 0; z-index: 60; | |
| 90 | + padding-top: env(safe-area-inset-top); | |
| 91 | + background: var(--nav-bg); backdrop-filter: blur(18px) saturate(1.5); -webkit-backdrop-filter: blur(18px) saturate(1.5); | |
| 116 | 92 | border-bottom: 1px solid var(--border); |
| 117 | 93 | } |
| 118 | −.topnav::after { | |
| 119 | − content: ''; position: absolute; left: 0; right: 0; bottom: -1px; height: 1px; | |
| 120 | − background: var(--gradient-hot); opacity: 0.45; | |
| 121 | − mask: linear-gradient(90deg, transparent, black 25%, black 75%, transparent); | |
| 122 | − -webkit-mask: linear-gradient(90deg, transparent, black 25%, black 75%, transparent); | |
| 123 | −} | |
| 124 | −.topnav-inner { display: flex; align-items: center; gap: 18px; height: 64px; } | |
| 125 | −.brand { display: flex; align-items: center; gap: 11px; color: var(--text-bright); } | |
| 126 | −.brand:hover { text-decoration: none; color: var(--text-bright); } | |
| 127 | −.brand-mark { flex: none; filter: drop-shadow(0 2px 12px var(--accent-glow)); transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); } | |
| 128 | −.brand:hover .brand-mark { transform: rotate(-10deg) scale(1.1); } | |
| 129 | −.brand-name { font-family: var(--font-display); font-weight: 800; font-size: 18px; letter-spacing: -0.02em; } | |
| 130 | −.brand-tag { | |
| 131 | − font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; | |
| 132 | − color: var(--accent-2); border: 1px solid var(--accent-2-soft); background: var(--accent-2-soft); | |
| 133 | − border-radius: 999px; padding: 2px 8px; margin-left: 2px; | |
| 134 | −} | |
| 135 | −.topnav-primary { display: flex; gap: 4px; margin-left: 8px; } | |
| 136 | −.topnav-primary a { | |
| 137 | − color: var(--muted); font-size: 14px; font-weight: 500; padding: 7px 11px; border-radius: 9px; | |
| 138 | − transition: color 0.15s ease, background 0.15s ease; | |
| 139 | −} | |
| 140 | −.topnav-primary a:hover { color: var(--text-bright); background: var(--surface-2); text-decoration: none; } | |
| 94 | +.app-header::after { | |
| 95 | + content: ''; position: absolute; left: 0; right: 0; bottom: -1px; height: 1px; background: var(--gradient-brand); opacity: 0.5; | |
| 96 | + mask: linear-gradient(90deg, transparent, black 25%, black 75%, transparent); -webkit-mask: linear-gradient(90deg, transparent, black 25%, black 75%, transparent); | |
| 97 | +} | |
| 98 | +.app-header-inner { display: flex; align-items: center; gap: var(--space-3); height: var(--header-h); } | |
| 99 | +.brand { display: inline-flex; align-items: center; gap: 10px; color: var(--fg-bright); min-height: var(--touch); } | |
| 100 | +.brand:hover { text-decoration: none; color: var(--fg-bright); } | |
| 101 | +.brand-mark { flex: none; filter: drop-shadow(0 2px 10px var(--accent-glow)); } | |
| 102 | +.brand-name { font-family: var(--font-display); font-weight: 800; font-size: 17px; letter-spacing: var(--tracking-tight); white-space: nowrap; } | |
| 103 | +.nav-primary { display: flex; gap: 2px; margin-left: var(--space-2); } | |
| 104 | +.nav-primary a { | |
| 105 | + color: var(--fg-muted); font-size: 14px; font-weight: 500; padding: 8px 10px; border-radius: var(--radius-sm); white-space: nowrap; | |
| 106 | + transition: color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease); | |
| 107 | +} | |
| 108 | +.nav-primary a:hover, .nav-primary a[aria-current='page'] { color: var(--fg-bright); background: var(--surface-2); text-decoration: none; } | |
| 141 | 109 | .search-trigger { |
| 142 | 110 | margin-left: auto; display: inline-flex; align-items: center; gap: 10px; |
| 143 | − background: var(--surface); color: var(--muted); border: 1px solid var(--border); | |
| 144 | − border-radius: 11px; padding: 8px 10px 8px 12px; font-size: 14px; cursor: pointer; width: 280px; | |
| 111 | + background: var(--surface-0); color: var(--fg-muted); border: 1px solid var(--border); border-radius: var(--radius); | |
| 112 | + padding: 0 10px 0 12px; height: 38px; font-size: 14px; width: 320px; max-width: 100%; | |
| 145 | 113 | box-shadow: inset 0 1px 0 var(--edge-light); |
| 146 | − transition: border-color 0.15s ease, box-shadow 0.15s ease, color 0.15s ease; | |
| 147 | −} | |
| 148 | −.search-trigger:hover { border-color: var(--border-strong); color: var(--text); box-shadow: inset 0 1px 0 var(--edge-light), 0 0 22px -8px var(--accent-glow); } | |
| 149 | −.search-trigger svg { flex: none; } | |
| 150 | −.search-trigger span { flex: 1; text-align: left; } | |
| 151 | −.search-trigger kbd, .kbd { | |
| 152 | − font-family: var(--font-mono); font-size: 11px; color: var(--muted); | |
| 153 | − background: var(--surface-2); border: 1px solid var(--border); border-radius: 6px; padding: 2px 6px; | |
| 154 | −} | |
| 155 | −.topnav-links { display: flex; align-items: center; gap: 10px; } | |
| 156 | −.topnav-ext { color: var(--muted); font-size: 14px; font-weight: 500; white-space: nowrap; } | |
| 157 | −.topnav-ext:hover { color: var(--text); text-decoration: none; } | |
| 114 | + transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease); | |
| 115 | +} | |
| 116 | +.search-trigger:hover { border-color: var(--border-strong); color: var(--fg); } | |
| 117 | +.search-trigger-label { flex: 1; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 118 | +.kbd, kbd.kbd { font-family: var(--font-mono); font-size: 11px; color: var(--fg-muted); background: var(--surface-2); border: 1px solid var(--border); border-radius: 6px; padding: 1px 6px; line-height: 1.5; } | |
| 119 | +.header-actions { display: flex; align-items: center; gap: var(--space-2); } | |
| 120 | +.header-ext { color: var(--fg-muted); font-size: 14px; font-weight: 500; white-space: nowrap; display: inline-flex; align-items: center; gap: 5px; padding: 8px 6px; } | |
| 121 | +.header-ext:hover { color: var(--fg); text-decoration: none; } | |
| 158 | 122 | .icon-btn { |
| 159 | − background: var(--surface); color: var(--muted); border: 1px solid var(--border); | |
| 160 | − border-radius: 11px; padding: 9px 10px; cursor: pointer; line-height: 0; | |
| 161 | − transition: border-color 0.15s ease, color 0.15s ease, transform 0.12s ease, box-shadow 0.15s ease; | |
| 162 | −} | |
| 163 | −.icon-btn:hover { border-color: var(--border-strong); color: var(--text); box-shadow: 0 0 18px -6px var(--accent-glow); } | |
| 164 | −.icon-btn:active { transform: scale(0.92) rotate(-14deg); } | |
| 165 | −html[data-theme='dark'] .icon-moon { display: none; } | |
| 166 | −html[data-theme='light'] .icon-sun { display: none; } | |
| 167 | − | |
| 168 | −/* ── Buttons & chips ─────────────────────── */ | |
| 123 | + display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; flex: none; | |
| 124 | + background: var(--surface-0); color: var(--fg-muted); border: 1px solid var(--border); border-radius: var(--radius); | |
| 125 | + transition: border-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease); | |
| 126 | +} | |
| 127 | +.icon-btn:hover { border-color: var(--border-strong); color: var(--fg); background: var(--surface-1); } | |
| 128 | +.icon-btn.copied { border-color: var(--accent-2); color: var(--accent-2); } | |
| 129 | +.theme-ic { display: none; line-height: 0; } | |
| 130 | +html[data-theme='light'] .theme-ic-light, html[data-theme='dark'] .theme-ic-dark, html[data-theme='midnight'] .theme-ic-midnight { display: inline-flex; } | |
| 131 | + | |
| 132 | +/* ═══════════════════════════════════════════ | |
| 133 | + 3. Primitives — buttons, chips, counts, cards, inputs | |
| 134 | + ═══════════════════════════════════════════ */ | |
| 169 | 135 | .btn { |
| 170 | − display: inline-flex; align-items: center; gap: 7px; | |
| 171 | − background: var(--surface); color: var(--text); | |
| 172 | − border: 1px solid var(--border); border-radius: 11px; | |
| 173 | − padding: 8px 15px; font-size: 14px; font-weight: 500; cursor: pointer; line-height: 1.3; | |
| 136 | + display: inline-flex; align-items: center; justify-content: center; gap: 7px; | |
| 137 | + background: var(--surface-0); color: var(--fg); border: 1px solid var(--border); border-radius: var(--radius); | |
| 138 | + padding: 0 14px; min-height: 38px; font-size: 14px; font-weight: 500; line-height: 1.2; white-space: nowrap; | |
| 174 | 139 | box-shadow: inset 0 1px 0 var(--edge-light), var(--shadow-1); |
| 175 | − transition: border-color 0.15s ease, transform 0.12s ease, background 0.15s ease, box-shadow 0.15s ease, color 0.15s ease; | |
| 176 | − white-space: nowrap; | |
| 177 | −} | |
| 178 | −.btn:hover { | |
| 179 | − border-color: var(--border-strong); background: var(--surface-2); | |
| 180 | − text-decoration: none; color: var(--text-bright); transform: translateY(-1px); | |
| 181 | − box-shadow: inset 0 1px 0 var(--edge-light), 0 8px 24px -10px var(--accent-glow); | |
| 140 | + transition: border-color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease), transform var(--dur-fast) var(--ease); | |
| 182 | 141 | } |
| 183 | −.btn:active { transform: translateY(0) scale(0.98); } | |
| 184 | −.btn-primary { | |
| 185 | − position: relative; overflow: hidden; isolation: isolate; | |
| 186 | − background: var(--gradient-brand); border-color: transparent; color: #fff; font-weight: 600; | |
| 187 | − box-shadow: 0 6px 22px -6px var(--accent-glow), inset 0 1px 0 rgba(255, 255, 255, 0.35); | |
| 188 | −} | |
| 189 | −.btn-primary::before { | |
| 190 | − content: ''; position: absolute; inset: 0; z-index: -1; | |
| 191 | − background: linear-gradient(100deg, transparent 30%, rgba(255, 255, 255, 0.4) 50%, transparent 70%); | |
| 192 | − transform: translateX(-130%); transition: transform 0.6s ease; | |
| 193 | −} | |
| 194 | −.btn-primary:hover { background: var(--gradient-brand); color: #fff; filter: brightness(1.07); } | |
| 195 | −.btn-primary:hover::before { transform: translateX(130%); } | |
| 142 | +.btn:hover { border-color: var(--border-strong); background: var(--surface-1); color: var(--fg-bright); text-decoration: none; } | |
| 143 | +.btn:active { transform: translateY(1px); } | |
| 144 | +.btn[aria-pressed='true'], .btn.is-active { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); } | |
| 145 | +.btn-primary { background: var(--gradient-brand); border-color: transparent; color: #fff; font-weight: 600; box-shadow: 0 6px 18px -8px var(--accent-glow); } | |
| 146 | +.btn-primary:hover { background: var(--gradient-brand); color: #fff; filter: brightness(1.06); } | |
| 196 | 147 | .btn-ghost { background: transparent; border-color: transparent; box-shadow: none; } |
| 197 | −.btn-sm { padding: 5px 11px; font-size: 13px; border-radius: 9px; } | |
| 198 | −.copy-btn.copied { border-color: var(--accent-2); color: var(--accent-2); box-shadow: 0 0 18px -6px var(--accent-2); } | |
| 148 | +.btn-ghost:hover { background: var(--surface-2); } | |
| 149 | +.btn-sm { min-height: 32px; padding: 0 10px; font-size: 13px; border-radius: var(--radius-sm); } | |
| 150 | +.btn-icon { padding: 0; width: 38px; } | |
| 151 | +.btn-sm.btn-icon { width: 32px; } | |
| 152 | +.btn-block { width: 100%; } | |
| 153 | +.copy-btn.copied, .btn.copied { border-color: var(--accent-2); color: var(--accent-2); background: var(--accent-2-soft); } | |
| 154 | +.btn-row { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; } | |
| 199 | 155 | |
| 200 | 156 | .chip { |
| 201 | − display: inline-flex; align-items: center; gap: 5px; | |
| 202 | − border-radius: 999px; padding: 3px 11px; font-size: 12px; font-weight: 500; line-height: 1.4; | |
| 203 | − background: var(--chip-bg); color: var(--accent); border: 1px solid transparent; | |
| 204 | − transition: border-color 0.15s ease, transform 0.12s ease, box-shadow 0.15s ease; | |
| 205 | −} | |
| 206 | −.chip-topic:hover { border-color: var(--accent); text-decoration: none; transform: translateY(-1px); box-shadow: 0 4px 14px -6px var(--accent-glow); } | |
| 207 | −.chip-public { color: var(--muted); background: transparent; border-color: var(--border-strong); } | |
| 208 | −.chip-license { color: var(--accent-2); background: var(--accent-2-soft); } | |
| 209 | −.chip-default { color: var(--accent-2); background: var(--accent-2-soft); } | |
| 210 | −.chip-row { display: flex; flex-wrap: wrap; gap: 6px; } | |
| 157 | + display: inline-flex; align-items: center; gap: 5px; border-radius: var(--radius-pill); padding: 2px 10px; min-height: 24px; | |
| 158 | + font-size: 12px; font-weight: 500; line-height: 1.4; border: 1px solid transparent; white-space: nowrap; max-width: 100%; | |
| 159 | + background: var(--surface-2); color: var(--fg-muted); | |
| 160 | +} | |
| 161 | +.chip.chip-topic, a.chip { background: var(--chip-bg); color: var(--accent); } | |
| 162 | +a.chip:hover { border-color: var(--accent); text-decoration: none; } | |
| 163 | +.chip-neutral { background: transparent; border-color: var(--border-strong); color: var(--fg-muted); } | |
| 164 | +.chip-ok { background: var(--ok-soft); color: var(--ok); } | |
| 165 | +.chip-warn { background: var(--warn-soft); color: var(--warn); } | |
| 166 | +.chip-danger { background: var(--danger-soft); color: var(--danger); } | |
| 167 | +.chip-info { background: var(--info-soft); color: var(--info); } | |
| 168 | +.chip-ember { background: var(--ember-soft); color: var(--ember); } | |
| 169 | +.chip-row { display: flex; flex-wrap: wrap; gap: 6px; min-width: 0; } | |
| 211 | 170 | .count { |
| 212 | − background: var(--surface-2); border: 1px solid var(--border); border-radius: 999px; padding: 0 8px; | |
| 213 | − font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; line-height: 1.6; | |
| 171 | + display: inline-flex; align-items: center; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-pill); | |
| 172 | + padding: 0 7px; min-height: 20px; font-size: 12px; color: var(--fg-muted); font-variant-numeric: tabular-nums; line-height: 1.4; font-family: var(--font-mono); | |
| 214 | 173 | } |
| 215 | 174 | |
| 216 | −/* ── Section scaffolding ─────────────────── */ | |
| 217 | −.section { margin-bottom: 52px; } | |
| 218 | −.section-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 18px; } | |
| 219 | −.section-title { | |
| 220 | − font-family: var(--font-display); font-size: 24px; font-weight: 800; letter-spacing: -0.025em; | |
| 221 | − margin: 0; display: flex; align-items: center; gap: 12px; color: var(--text-bright); line-height: 1.1; | |
| 222 | −} | |
| 223 | −.section-title .count { font-family: var(--font-mono); font-size: 12px; } | |
| 224 | −.section-sub { margin: 4px 0 0; color: var(--muted); font-size: 14px; } | |
| 225 | −.eyebrow { | |
| 226 | − display: inline-flex; align-items: center; gap: 9px; margin: 0 0 18px; | |
| 227 | − font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted); | |
| 175 | +.card { | |
| 176 | + position: relative; background: var(--surface-0); border: 1px solid var(--border); border-radius: var(--radius-lg); | |
| 177 | + box-shadow: inset 0 1px 0 var(--edge-light), var(--shadow-1); | |
| 228 | 178 | } |
| 229 | −.eyebrow-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent-2); box-shadow: 0 0 12px 2px var(--accent-2); animation: pulse-dot 2.4s ease infinite; } | |
| 179 | +.card-pad { padding: var(--space-5); } | |
| 180 | +.card-head { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-5); border-bottom: 1px solid var(--border); font-size: 14px; } | |
| 181 | +.card-head h2, .card-head h3 { font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 8px; } | |
| 182 | +.card-head .count { margin-left: auto; } | |
| 230 | 183 | |
| 231 | −/* ── Hero ────────────────────────────────── */ | |
| 232 | −.hero { | |
| 233 | − position: relative; margin: 8px 0 60px; padding: 56px 0 0; | |
| 234 | − isolation: isolate; | |
| 235 | −} | |
| 236 | −.hero-canvas { | |
| 237 | − position: absolute; inset: -40px -24px 0; z-index: -1; width: calc(100% + 48px); height: calc(100% + 40px); | |
| 238 | − pointer-events: none; opacity: 0.9; | |
| 239 | − mask: radial-gradient(80% 90% at 60% 30%, black 40%, transparent 100%); | |
| 240 | − -webkit-mask: radial-gradient(80% 90% at 60% 30%, black 40%, transparent 100%); | |
| 241 | −} | |
| 242 | −.hero-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: 48px; align-items: center; } | |
| 243 | −.hero-name { | |
| 244 | − margin: 0; font-family: var(--font-display); font-weight: 800; | |
| 245 | − font-size: clamp(44px, 6vw, 84px); line-height: 0.98; letter-spacing: -0.04em; | |
| 246 | − background: var(--gradient-text); background-size: 200% 100%; | |
| 247 | − -webkit-background-clip: text; background-clip: text; | |
| 248 | − -webkit-text-fill-color: transparent; color: transparent; | |
| 249 | − animation: gradient-pan 10s ease infinite; | |
| 250 | − text-wrap: balance; | |
| 251 | −} | |
| 252 | −.hero-tagline { | |
| 253 | − margin: 22px 0 0; font-family: var(--font-serif); font-style: italic; | |
| 254 | − font-size: clamp(22px, 2.4vw, 32px); line-height: 1.25; color: var(--text); max-width: 26ch; text-wrap: balance; | |
| 255 | −} | |
| 256 | −.hero-tagline em { font-style: italic; background: var(--gradient-ember); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent; } | |
| 257 | −.hero-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 30px; } | |
| 258 | −.hero-actions .btn { padding: 11px 18px; font-size: 15px; } | |
| 259 | −.hero-contact { margin: 18px 0 0; color: var(--muted); font-size: 14px; } | |
| 260 | −.hero-contact a { color: var(--muted); } | |
| 184 | +.input, .select, input[type='search'].input { | |
| 185 | + background: var(--surface-0); color: var(--fg); border: 1px solid var(--border); border-radius: var(--radius); | |
| 186 | + padding: 0 12px; min-height: 38px; font-size: 14px; outline: none; width: 100%; | |
| 187 | + box-shadow: inset 0 1px 0 var(--edge-light); | |
| 188 | + transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease); | |
| 189 | +} | |
| 190 | +.input:focus, .select:focus { border-color: var(--accent); box-shadow: var(--ring); } | |
| 191 | +.select { appearance: none; -webkit-appearance: none; padding-right: 30px; background-image: none; cursor: pointer; } | |
| 192 | +.select-wrap { position: relative; display: inline-flex; min-width: 0; } | |
| 193 | +.select-wrap > .ic { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); pointer-events: none; color: var(--fg-muted); } | |
| 194 | +input[type='search']::-webkit-search-cancel-button { -webkit-appearance: none; } | |
| 195 | + | |
| 196 | +.snippet { | |
| 197 | + background: var(--code-bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; | |
| 198 | + font-size: 13px; line-height: 1.6; overflow-x: auto; color: var(--fg); white-space: pre; max-width: 100%; | |
| 199 | +} | |
| 200 | +.snippet .prompt { color: var(--accent-2); margin-right: 8px; user-select: none; } | |
| 201 | + | |
| 202 | +/* ═══════════════════════════════════════════ | |
| 203 | + 4. Components — stat strip, tabs, crumbs, ref picker, states, sheet, popover, toast | |
| 204 | + ═══════════════════════════════════════════ */ | |
| 205 | +.stat-strip { display: flex; gap: var(--space-2); margin: 0; overflow-x: auto; scroll-snap-type: x proximity; padding-bottom: 2px; -webkit-overflow-scrolling: touch; } | |
| 206 | +.stat-item { | |
| 207 | + flex: 1 1 0; min-width: 118px; scroll-snap-align: start; display: flex; flex-direction: column; gap: 2px; | |
| 208 | + padding: 10px 14px; border-radius: var(--radius); background: var(--surface-0); border: 1px solid var(--border); color: inherit; | |
| 209 | + box-shadow: inset 0 1px 0 var(--edge-light); | |
| 210 | + transition: border-color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease); | |
| 211 | +} | |
| 212 | +a.stat-item:hover { text-decoration: none; color: inherit; border-color: var(--border-strong); background: var(--surface-1); } | |
| 213 | +.stat-value { margin: 0; font-family: var(--font-display); font-weight: 800; font-size: 22px; letter-spacing: var(--tracking-tight); line-height: 1.1; color: var(--fg-bright); font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | |
| 214 | +.stat-label { font-size: 12px; color: var(--fg-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | |
| 215 | +.stat-sub { color: var(--fg-faint); font-family: var(--font-mono); font-size: 11px; } | |
| 216 | +.stat-item.tone-ember .stat-value { background: var(--gradient-ember); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent; } | |
| 217 | +.stat-item.tone-accent .stat-value { color: var(--accent); } | |
| 218 | +.stat-strip.stat-strip-hero .stat-item { min-width: 140px; padding: 14px 16px; } | |
| 219 | +.stat-strip.stat-strip-hero .stat-value { font-size: 28px; } | |
| 220 | + | |
| 221 | +.tabs { border-bottom: 1px solid var(--border); position: relative; margin-bottom: var(--space-5); } | |
| 222 | +.tabs-track { display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; -ms-overflow-style: none; -webkit-overflow-scrolling: touch; } | |
| 223 | +.tabs-track::-webkit-scrollbar { display: none; } | |
| 224 | +.tab { | |
| 225 | + color: var(--fg-muted); padding: 0 12px; min-height: 44px; position: relative; flex: none; | |
| 226 | + display: inline-flex; gap: 7px; align-items: center; white-space: nowrap; font-size: 14px; font-weight: 500; | |
| 227 | + border-radius: var(--radius-sm) var(--radius-sm) 0 0; transition: color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease); | |
| 228 | +} | |
| 229 | +.tab::after { content: ''; position: absolute; left: 8px; right: 8px; bottom: -1px; height: 2px; background: var(--gradient-brand); border-radius: 2px 2px 0 0; opacity: 0; transition: opacity var(--dur-fast) var(--ease); } | |
| 230 | +.tab:hover { color: var(--fg); background: var(--surface-1); text-decoration: none; } | |
| 231 | +.tab.is-active { color: var(--fg-bright); font-weight: 600; } | |
| 232 | +.tab.is-active::after { opacity: 1; } | |
| 233 | + | |
| 234 | +.crumbs ol { list-style: none; display: flex; flex-wrap: wrap; align-items: center; gap: 2px 4px; font-family: var(--font-mono); font-size: 13px; min-width: 0; } | |
| 235 | +.crumbs li { display: inline-flex; align-items: center; gap: 4px; min-width: 0; } | |
| 236 | +.crumbs li + li::before { content: '/'; color: var(--fg-faint); } | |
| 237 | +.crumbs a { color: var(--fg-muted); padding: 4px 2px; } | |
| 238 | +.crumbs a:hover { color: var(--accent); } | |
| 239 | +.crumbs [aria-current='page'] { color: var(--fg-bright); font-weight: 600; word-break: break-all; } | |
| 240 | + | |
| 241 | +.ref-select-wrap { position: relative; display: inline-flex; align-items: center; max-width: 100%; } | |
| 242 | +.ref-select { appearance: none; -webkit-appearance: none; background: var(--surface-0); color: var(--fg); border: 1px solid var(--border); border-radius: var(--radius); padding: 0 30px 0 32px; min-height: 38px; font-size: 14px; font-family: var(--font-mono); cursor: pointer; max-width: 260px; text-overflow: ellipsis; box-shadow: inset 0 1px 0 var(--edge-light); } | |
| 243 | +.ref-select:hover { border-color: var(--border-strong); } | |
| 244 | +.ref-select-icon { position: absolute; left: 10px; color: var(--accent); pointer-events: none; } | |
| 245 | +.ref-select-caret { position: absolute; right: 9px; color: var(--fg-muted); pointer-events: none; } | |
| 246 | + | |
| 247 | +.empty-state, .error-state { text-align: center; padding: var(--space-9) var(--space-5); color: var(--fg-muted); border: 1px dashed var(--border-strong); border-radius: var(--radius-lg); display: flex; flex-direction: column; align-items: center; gap: var(--space-2); } | |
| 248 | +.empty-icon { width: 44px; height: 44px; border-radius: 12px; display: inline-flex; align-items: center; justify-content: center; background: var(--surface-2); color: var(--accent); margin-bottom: var(--space-1); } | |
| 249 | +.empty-icon.tone-danger { color: var(--danger); background: var(--danger-soft); } | |
| 250 | +.empty-title { font-weight: 600; color: var(--fg); font-size: 15px; } | |
| 251 | +.empty-hint { font-size: 14px; max-width: 48ch; } | |
| 252 | +.empty-state .snippet { text-align: left; margin-top: var(--space-2); } | |
| 253 | +.skeleton { display: flex; flex-direction: column; gap: 10px; padding: 12px; } | |
| 254 | +.skeleton-line { display: block; height: 12px; border-radius: 6px; background: linear-gradient(90deg, var(--surface-2) 25%, var(--surface-3) 50%, var(--surface-2) 75%); background-size: 200% 100%; animation: shimmer 1.4s linear infinite; } | |
| 255 | +@keyframes shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } } | |
| 256 | + | |
| 257 | +/* dialogs: sheet (bottom on phones, centered on desktop) + popover */ | |
| 258 | +dialog { color: inherit; } | |
| 259 | +dialog::backdrop { background: var(--backdrop); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); } | |
| 260 | +.sheet { | |
| 261 | + position: fixed; inset: auto 0 0 0; margin: 0; width: 100%; max-width: 100%; max-height: min(88dvh, 88vh); | |
| 262 | + background: var(--surface-0); color: var(--fg); border: 1px solid var(--border); border-bottom: 0; border-radius: var(--radius-xl) var(--radius-xl) 0 0; | |
| 263 | + padding: 10px 16px calc(16px + env(safe-area-inset-bottom)); box-shadow: var(--shadow-pop); overflow-y: auto; overscroll-behavior: contain; | |
| 264 | +} | |
| 265 | +.sheet[open] { animation: sheet-up var(--dur-slow) var(--ease-out) both; } | |
| 266 | +@keyframes sheet-up { from { transform: translateY(24px); opacity: 0; } } | |
| 267 | +.sheet-handle { width: 36px; height: 4px; border-radius: 2px; background: var(--border-strong); margin: 0 auto 8px; } | |
| 268 | +.sheet-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; font-size: 15px; } | |
| 269 | +.sheet-nav { display: flex; flex-direction: column; } | |
| 270 | +.sheet-nav a { display: flex; align-items: center; gap: 12px; min-height: var(--touch); padding: 0 8px; border-radius: var(--radius); color: var(--fg); font-size: 15px; } | |
| 271 | +.sheet-nav a:hover { background: var(--surface-2); text-decoration: none; } | |
| 272 | +.sheet-nav .ic { color: var(--fg-muted); } | |
| 273 | +.sheet-section { border-top: 1px solid var(--border); margin-top: 10px; padding-top: 12px; } | |
| 274 | +.sheet-section h2 { font-size: 11px; letter-spacing: var(--tracking-caps); text-transform: uppercase; color: var(--fg-faint); font-family: var(--font-mono); font-weight: 500; margin-bottom: 8px; } | |
| 275 | +.theme-choices { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; } | |
| 276 | +.theme-choices button { display: inline-flex; align-items: center; gap: 8px; min-height: var(--touch); padding: 0 12px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--surface-1); color: var(--fg); font-size: 14px; } | |
| 277 | +.theme-choices button[aria-checked='true'] { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); font-weight: 600; } | |
| 278 | +.popover { position: fixed; inset: auto; margin: 0; top: calc(var(--header-h) + env(safe-area-inset-top) + 6px); right: max(var(--gutter), env(safe-area-inset-right)); width: 230px; background: var(--surface-0); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); padding: 8px; box-shadow: var(--shadow-pop); } | |
| 279 | +.popover .theme-choices { grid-template-columns: 1fr; } | |
| 280 | +.popover .theme-choices button { min-height: 38px; } | |
| 281 | +.popover::backdrop { background: transparent; backdrop-filter: none; } | |
| 282 | + | |
| 283 | +.toasts { position: fixed; left: 50%; bottom: calc(16px + env(safe-area-inset-bottom)); transform: translateX(-50%); z-index: 120; display: flex; flex-direction: column; gap: 8px; pointer-events: none; width: min(420px, calc(100vw - 32px)); } | |
| 284 | +.toast { pointer-events: auto; display: flex; align-items: center; gap: 10px; background: var(--surface-0); color: var(--fg); border: 1px solid var(--border-strong); border-radius: var(--radius); padding: 10px 14px; font-size: 14px; box-shadow: var(--shadow-pop); animation: rise-in var(--dur) var(--ease-out) both; } | |
| 285 | +.toast.is-ok { border-color: var(--accent-2); } | |
| 286 | +.toast.is-error { border-color: var(--danger); } | |
| 287 | +.toast .ic { color: var(--accent-2); } | |
| 288 | +.toast.is-error .ic { color: var(--danger); } | |
| 289 | + | |
| 290 | +.confirm { position: fixed; inset: 0; margin: auto; width: min(440px, calc(100vw - 32px)); max-height: 80dvh; background: var(--surface-0); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); padding: var(--space-5); box-shadow: var(--shadow-pop); } | |
| 291 | +.confirm h2 { font-size: 17px; margin-bottom: 8px; } | |
| 292 | +.confirm p { color: var(--fg-muted); font-size: 14px; margin-bottom: var(--space-4); } | |
| 293 | +.confirm .btn-row { justify-content: flex-end; } | |
| 294 | +.btn-danger { background: var(--danger); border-color: transparent; color: #fff; } | |
| 295 | + | |
| 296 | +/* ═══════════════════════════════════════════ | |
| 297 | + 5. Sections, hero, home | |
| 298 | + ═══════════════════════════════════════════ */ | |
| 299 | +.section { margin-bottom: var(--space-8); } | |
| 300 | +.section-head { display: flex; align-items: flex-end; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap; margin-bottom: var(--space-4); } | |
| 301 | +.section-title { font-family: var(--font-display); font-size: 22px; font-weight: 800; letter-spacing: var(--tracking-tight); display: flex; align-items: center; gap: 10px; color: var(--fg-bright); } | |
| 302 | +.section-sub { margin-top: 2px; color: var(--fg-muted); font-size: 14px; } | |
| 303 | +.eyebrow { display: inline-flex; align-items: center; gap: 8px; font-family: var(--font-mono); font-size: 12px; letter-spacing: var(--tracking-caps); text-transform: uppercase; color: var(--fg-muted); } | |
| 304 | +.eyebrow-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent-2); box-shadow: 0 0 10px 1px var(--accent-2); } | |
| 305 | + | |
| 306 | +.hero { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-6); align-items: center; padding: var(--space-4) 0 var(--space-6); } | |
| 307 | +.hero-title { font-family: var(--font-display); font-weight: 800; font-size: var(--text-hero); line-height: 1; letter-spacing: -0.035em; margin: 10px 0 0; background: var(--gradient-text); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent; text-wrap: balance; overflow-wrap: anywhere; } | |
| 308 | +.hero-lede { margin-top: 14px; font-size: clamp(17px, 2vw, 21px); color: var(--fg-muted); max-width: 40ch; text-wrap: balance; } | |
| 309 | +.hero-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: var(--space-5); } | |
| 310 | +.hero-contact { margin-top: 14px; color: var(--fg-muted); font-size: 14px; display: flex; flex-wrap: wrap; gap: 6px 14px; } | |
| 311 | +.hero-contact a { color: var(--fg-muted); } | |
| 261 | 312 | .hero-contact a:hover { color: var(--accent); } |
| 262 | − | |
| 263 | −/* terminal card */ | |
| 264 | −.hero-terminal { | |
| 265 | − border-radius: var(--radius-lg); overflow: hidden; font-family: var(--font-mono); font-size: 13.5px; | |
| 266 | − box-shadow: inset 0 1px 0 var(--edge-light), var(--shadow-card); | |
| 267 | − transform: perspective(1400px) rotateY(-6deg) rotateX(2deg); | |
| 268 | − transition: transform 0.6s cubic-bezier(0.2, 0.9, 0.3, 1); | |
| 269 | −} | |
| 270 | −.hero-terminal:hover { transform: perspective(1400px) rotateY(0) rotateX(0); } | |
| 271 | −.term-bar { display: flex; align-items: center; gap: 8px; padding: 11px 14px; border-bottom: 1px solid var(--border); background: var(--code-head); } | |
| 272 | −.term-dot { width: 11px; height: 11px; border-radius: 50%; background: var(--surface-3); } | |
| 313 | +.hero-aside { display: none; } | |
| 314 | +.terminal { border-radius: var(--radius-lg); overflow: hidden; border: 1px solid var(--border); background: var(--code-bg); font-family: var(--font-mono); font-size: 13px; box-shadow: var(--shadow-card); } | |
| 315 | +.term-bar { display: flex; align-items: center; gap: 7px; padding: 10px 14px; border-bottom: 1px solid var(--border); background: var(--code-head); } | |
| 316 | +.term-dot { width: 10px; height: 10px; border-radius: 50%; } | |
| 273 | 317 | .term-dot:nth-child(1) { background: #ff5f57; } .term-dot:nth-child(2) { background: #febc2e; } .term-dot:nth-child(3) { background: #28c840; } |
| 274 | −.term-title { margin-left: auto; color: var(--faint); font-size: 12px; } | |
| 275 | −.term-body { padding: 18px 18px 20px; background: var(--code-bg); min-height: 218px; line-height: 1.75; } | |
| 276 | −.term-line { display: flex; gap: 10px; white-space: pre-wrap; word-break: break-all; } | |
| 318 | +.term-title { margin-left: auto; color: var(--fg-faint); font-size: 12px; } | |
| 319 | +.term-body { padding: 14px 16px; line-height: 1.7; } | |
| 320 | +.term-line { display: flex; gap: 10px; white-space: pre-wrap; overflow-wrap: anywhere; } | |
| 277 | 321 | .term-prompt { color: var(--accent-2); flex: none; } |
| 278 | −.term-cmd { color: var(--text-bright); } | |
| 279 | −.term-out { color: var(--muted); padding-left: 20px; } | |
| 322 | +.term-cmd { color: var(--fg-bright); } | |
| 323 | +.term-out { color: var(--fg-muted); padding-left: 18px; } | |
| 280 | 324 | .term-ok { color: var(--accent-2); } |
| 281 | −.term-caret { display: inline-block; width: 8px; height: 1.1em; background: var(--accent); vertical-align: -3px; margin-left: 2px; animation: caret 1s steps(1) infinite; box-shadow: 0 0 10px var(--accent-glow); } | |
| 325 | +.term-caret { display: inline-block; width: 8px; height: 1.1em; background: var(--accent); vertical-align: -3px; } | |
| 326 | +@media (prefers-reduced-motion: no-preference) { .term-caret { animation: caret 1s steps(1) infinite; } } | |
| 282 | 327 | @keyframes caret { 50% { opacity: 0; } } |
| 283 | 328 | |
| 284 | −/* stats row */ | |
| 285 | −.hero-stats { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-top: 52px; } | |
| 286 | −.stat { | |
| 287 | − border-radius: var(--radius-lg); padding: 18px 20px 16px; | |
| 288 | − display: flex; flex-direction: column; gap: 4px; overflow: hidden; | |
| 289 | − transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; | |
| 290 | −} | |
| 291 | −.stat:hover { transform: translateY(-3px); border-color: var(--border-strong); box-shadow: inset 0 1px 0 var(--edge-light), var(--shadow-card); } | |
| 292 | −.stat::before { content: ''; position: absolute; inset: 0 0 auto 0; height: 2px; background: var(--gradient-brand); opacity: 0.7; } | |
| 293 | −.stat-ember::before { background: var(--gradient-ember); } | |
| 294 | −.stat-value { | |
| 295 | − font-family: var(--font-display); font-weight: 800; font-size: 34px; letter-spacing: -0.03em; line-height: 1; | |
| 296 | − font-variant-numeric: tabular-nums; color: var(--text-bright); | |
| 297 | −} | |
| 298 | −.stat-ember .stat-value { background: var(--gradient-ember); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent; } | |
| 299 | −.stat-label { font-size: 12.5px; color: var(--muted); letter-spacing: 0.02em; } | |
| 300 | −.stat-sub { font-size: 12px; color: var(--faint); font-family: var(--font-mono); } | |
| 301 | − | |
| 302 | −@keyframes gradient-pan { 0%, 100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } } | |
| 303 | −@keyframes pulse-dot { 0%, 100% { box-shadow: 0 0 8px 0 var(--accent-2); } 50% { box-shadow: 0 0 16px 4px var(--accent-2); } } | |
| 304 | − | |
| 305 | −/* ── Bento (pinned) ──────────────────────── */ | |
| 306 | −.bento { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; } | |
| 307 | −.bento > .repo-card:first-child { grid-column: span 2; } | |
| 308 | −.bento > .repo-card:first-child .repo-card-name a { font-size: 24px; font-family: var(--font-display); letter-spacing: -0.02em; } | |
| 309 | −.bento > .repo-card:first-child .repo-card-desc { font-size: 15px; max-width: 60ch; } | |
| 310 | −.bento > .repo-card:first-child .spark { width: 220px; height: 44px; } | |
| 311 | − | |
| 312 | −/* ── Repo cards ──────────────────────────── */ | |
| 313 | −.repo-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } | |
| 314 | −.repo-card { | |
| 315 | − border-radius: var(--radius-lg); padding: 20px 22px 18px; | |
| 316 | − display: flex; flex-direction: column; gap: 10px; | |
| 317 | − transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; | |
| 318 | − overflow: hidden; | |
| 319 | −} | |
| 320 | −.repo-card::before { | |
| 321 | − content: ''; position: absolute; inset: 0 0 auto 0; height: 2px; | |
| 322 | − background: var(--gradient-hot); opacity: 0; transition: opacity 0.2s ease; | |
| 323 | −} | |
| 324 | −.repo-card:hover { border-color: var(--border-strong); transform: translateY(-3px); box-shadow: inset 0 1px 0 var(--edge-light), var(--shadow-card); } | |
| 325 | −.repo-card:hover::before { opacity: 1; } | |
| 326 | −.repo-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; } | |
| 327 | −.repo-card-name { margin: 0; font-size: 17px; display: flex; align-items: center; gap: 8px; min-width: 0; flex-wrap: wrap; } | |
| 328 | −.repo-card-name a { color: var(--text-bright); font-weight: 700; letter-spacing: -0.01em; } | |
| 329 | +.home-columns { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-6); align-items: start; margin-top: var(--space-7); } | |
| 330 | +.home-side { display: flex; flex-direction: column; gap: var(--space-4); } | |
| 331 | +.side-card h2 { font-family: var(--font-display); font-size: 15px; font-weight: 800; display: flex; align-items: center; gap: 8px; } | |
| 332 | + | |
| 333 | +.catalog-filters { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; width: 100%; } | |
| 334 | +.catalog-filters .select-wrap { width: 100%; } | |
| 335 | +.catalog-filters .select { width: 100%; } | |
| 336 | + | |
| 337 | +.repo-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-3); } | |
| 338 | +.repo-card { padding: var(--space-4); display: flex; flex-direction: column; gap: 10px; transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur) var(--ease), transform var(--dur) var(--ease); } | |
| 339 | +.repo-card:hover { border-color: var(--border-strong); box-shadow: inset 0 1px 0 var(--edge-light), var(--shadow-card); } | |
| 340 | +.repo-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); } | |
| 341 | +.repo-card-name { font-size: 16px; display: flex; align-items: center; gap: 8px; min-width: 0; } | |
| 342 | +.repo-card-name a { color: var(--fg-bright); font-weight: 700; letter-spacing: -0.01em; overflow-wrap: anywhere; } | |
| 343 | +.repo-card-name a::after { content: ''; position: absolute; inset: 0; z-index: 1; border-radius: inherit; } | |
| 329 | 344 | .repo-card-name a:hover { color: var(--accent); text-decoration: none; } |
| 330 | −.repo-card-name a::after { content: ''; position: absolute; inset: 0; z-index: 1; } /* whole card clickable */ | |
| 331 | −.repo-card .chip-topic, .repo-card .repo-card-meta { position: relative; z-index: 2; } | |
| 332 | −.pin { color: var(--ember-2); font-size: 13px; filter: drop-shadow(0 0 6px var(--ember-glow)); } | |
| 333 | −.repo-card-desc { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } | |
| 334 | −.repo-card-meta { display: flex; gap: 16px; color: var(--muted); font-size: 13px; flex-wrap: wrap; align-items: center; margin-top: auto; padding-top: 4px; } | |
| 335 | −.repo-card-meta .pushed { margin-left: auto; font-variant-numeric: tabular-nums; } | |
| 336 | −.lang-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex: none; box-shadow: 0 0 8px 0 currentColor; opacity: 0.95; } | |
| 345 | +.repo-card .chip-row, .repo-card .repo-card-meta { position: relative; z-index: 2; } | |
| 346 | +.pin { color: var(--ember-2); display: inline-flex; } | |
| 347 | +.repo-card-desc { color: var(--fg-muted); font-size: 14px; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } | |
| 348 | +.repo-card-meta { display: flex; gap: 6px 14px; color: var(--fg-muted); font-size: 13px; flex-wrap: wrap; align-items: center; margin-top: auto; } | |
| 349 | +.repo-card-meta .pushed { margin-left: auto; font-variant-numeric: tabular-nums; white-space: nowrap; } | |
| 350 | +.lang-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex: none; } | |
| 337 | 351 | .lang-dot-label { display: inline-flex; align-items: center; gap: 6px; } |
| 338 | −.spark { width: 120px; height: 34px; flex: none; overflow: visible; } | |
| 339 | −.spark-line { fill: none; stroke: var(--accent); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; filter: drop-shadow(0 0 6px var(--accent-glow)); } | |
| 340 | −.spark-area { fill: url(#spark-fill); opacity: 0.9; } | |
| 341 | −.spark-dot { fill: var(--accent-2); filter: drop-shadow(0 0 6px var(--accent-2)); } | |
| 342 | −.spark-quiet .spark-line { stroke: var(--faint); filter: none; } | |
| 343 | −.spark-quiet .spark-dot { display: none; } | |
| 344 | −.spark-defs { position: absolute; width: 0; height: 0; } | |
| 345 | − | |
| 346 | −/* repo controls */ | |
| 347 | −.repo-filters { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } | |
| 348 | −.repo-filters input, .search-page-form input { | |
| 349 | − background: var(--surface); color: var(--text); | |
| 350 | − border: 1px solid var(--border); border-radius: 11px; | |
| 351 | − padding: 9px 13px; font-size: 14px; outline: none; width: 220px; | |
| 352 | − box-shadow: inset 0 1px 0 var(--edge-light); | |
| 353 | − transition: border-color 0.15s ease, box-shadow 0.15s ease; | |
| 354 | −} | |
| 355 | −.repo-filters input:focus, .search-page-form input:focus { border-color: var(--accent); box-shadow: var(--ring); } | |
| 356 | −.repo-filters select, .ref-select { | |
| 357 | − background: var(--surface); color: var(--text); border: 1px solid var(--border); | |
| 358 | − border-radius: 11px; padding: 9px 12px; font-size: 14px; cursor: pointer; | |
| 359 | − box-shadow: inset 0 1px 0 var(--edge-light); | |
| 360 | − transition: border-color 0.15s ease; | |
| 361 | −} | |
| 362 | −.repo-filters select:hover, .ref-select:hover { border-color: var(--border-strong); } | |
| 363 | −.empty-state { text-align: center; padding: 48px 20px; color: var(--muted); border-radius: var(--radius-lg); } | |
| 364 | − | |
| 365 | −/* ── Home columns + side cards ───────────── */ | |
| 366 | −.home-columns { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 32px; align-items: start; } | |
| 367 | −.home-side { position: sticky; top: 84px; display: flex; flex-direction: column; gap: 16px; } | |
| 368 | −.side-card { border-radius: var(--radius-lg); padding: 18px 20px; } | |
| 369 | −.side-card h2 { margin: 0 0 14px; font-family: var(--font-display); font-size: 16px; font-weight: 800; letter-spacing: -0.01em; display: flex; align-items: center; gap: 8px; } | |
| 370 | −.side-card h2 .count { margin-left: auto; } | |
| 371 | − | |
| 372 | −/* activity */ | |
| 373 | −.activity-feed { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; font-size: 13.5px; max-height: 420px; overflow-y: auto; margin-right: -8px; padding-right: 8px; } | |
| 352 | +.spark { width: 96px; height: 30px; flex: none; overflow: visible; } | |
| 353 | +.spark-line { fill: none; stroke: var(--accent); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; } | |
| 354 | +.spark-area { fill: var(--accent); opacity: 0.12; } | |
| 355 | +.spark-dot { fill: var(--accent-2); } | |
| 356 | +.spark-quiet .spark-line { stroke: var(--fg-faint); } | |
| 357 | +.spark-quiet .spark-dot, .spark-quiet .spark-area { display: none; } | |
| 358 | +.bento .repo-card { min-height: 150px; } | |
| 359 | + | |
| 360 | +.activity-feed { list-style: none; display: flex; flex-direction: column; font-size: 13.5px; } | |
| 374 | 361 | .activity-feed li { display: flex; gap: 12px; align-items: baseline; position: relative; padding: 7px 0 7px 2px; line-height: 1.45; } |
| 375 | −.activity-feed li:not(:last-child)::before { | |
| 376 | − content: ''; position: absolute; left: 5px; top: 22px; bottom: -8px; width: 2px; border-radius: 1px; | |
| 377 | − background: linear-gradient(180deg, var(--accent-2-soft), var(--border)); | |
| 378 | −} | |
| 379 | −.activity-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent-2); flex: none; position: relative; top: -1px; box-shadow: 0 0 10px 1px var(--accent-2); } | |
| 380 | −.activity-feed li:first-child .activity-dot { animation: pulse-dot 2.4s ease infinite; } | |
| 381 | −.activity-feed li.is-tag .activity-dot { background: var(--ember-2); box-shadow: 0 0 10px 1px var(--ember-glow); } | |
| 382 | −.activity-feed li.is-del .activity-dot { background: var(--danger); box-shadow: none; } | |
| 383 | −.activity-feed a { font-weight: 600; color: var(--text); } | |
| 362 | +.activity-feed li:not(:last-child)::before { content: ''; position: absolute; left: 5px; top: 22px; bottom: -8px; width: 2px; border-radius: 1px; background: var(--border); } | |
| 363 | +.activity-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent-2); flex: none; position: relative; top: -1px; } | |
| 364 | +.activity-feed li.is-tag .activity-dot { background: var(--ember-2); } | |
| 365 | +.activity-feed li.is-del .activity-dot { background: var(--danger); } | |
| 366 | +.activity-feed a { font-weight: 600; color: var(--fg); overflow-wrap: anywhere; } | |
| 384 | 367 | .activity-feed a:hover { color: var(--accent); } |
| 385 | −.ref-inline { font-size: 12px; color: var(--muted); } | |
| 386 | − | |
| 387 | −/* stack ring */ | |
| 388 | −.stack { display: grid; grid-template-columns: 112px 1fr; gap: 16px; align-items: center; } | |
| 389 | −.ring { position: relative; width: 112px; height: 112px; border-radius: 50%; background: conic-gradient(var(--ring-stops, var(--border) 0 100%)); box-shadow: 0 0 30px -10px var(--accent-glow); } | |
| 390 | −.ring::before { content: ''; position: absolute; inset: 14px; border-radius: 50%; background: var(--surface); border: 1px solid var(--border); } | |
| 391 | −.ring-center { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; font-family: var(--font-display); font-weight: 800; font-size: 20px; letter-spacing: -0.02em; color: var(--text-bright); line-height: 1; } | |
| 392 | −.ring-center small { font-family: var(--font-mono); font-size: 10px; color: var(--muted); font-weight: 400; margin-top: 3px; letter-spacing: 0.08em; } | |
| 393 | −.stack-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; font-size: 13px; } | |
| 368 | +.activity-feed .ref-inline { font-size: 12px; color: var(--fg-muted); } | |
| 369 | +.activity-feed .activity-time { color: var(--fg-faint); white-space: nowrap; } | |
| 370 | + | |
| 371 | +.stack { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: var(--space-4); align-items: center; } | |
| 372 | +.ring { position: relative; width: 96px; height: 96px; border-radius: 50%; background: conic-gradient(var(--ring-stops, var(--border) 0 100%)); } | |
| 373 | +.ring::before { content: ''; position: absolute; inset: 12px; border-radius: 50%; background: var(--surface-0); border: 1px solid var(--border); } | |
| 374 | +.ring-center { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; font-family: var(--font-display); font-weight: 800; font-size: 18px; color: var(--fg-bright); line-height: 1; } | |
| 375 | +.ring-center small { font-family: var(--font-mono); font-size: 10px; color: var(--fg-muted); font-weight: 400; margin-top: 3px; letter-spacing: var(--tracking-caps); } | |
| 376 | +.stack-list { list-style: none; display: flex; flex-direction: column; gap: 5px; font-size: 13px; } | |
| 394 | 377 | .stack-list li { display: flex; align-items: center; gap: 8px; } |
| 395 | −.stack-list .pct { margin-left: auto; font-family: var(--font-mono); font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; } | |
| 396 | − | |
| 397 | −/* ── Heatmap ─────────────────────────────── */ | |
| 398 | −.heatmap-section { margin-top: 8px; } | |
| 399 | −.heatmap-card { border-radius: var(--radius-lg); padding: 20px 22px 16px; } | |
| 400 | −.heatmap-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 14px; } | |
| 401 | −.heatmap-stats { display: flex; gap: 22px; flex-wrap: wrap; font-size: 13px; color: var(--muted); } | |
| 402 | −.heatmap-stats strong { color: var(--text-bright); font-family: var(--font-display); font-size: 17px; letter-spacing: -0.02em; margin-right: 4px; } | |
| 403 | −.heatmap-stats .ember { color: var(--ember-2); } | |
| 404 | −.heatmap-scroll { overflow-x: auto; padding-bottom: 6px; } | |
| 378 | +.stack-list .pct { margin-left: auto; font-family: var(--font-mono); font-size: 12px; color: var(--fg-muted); font-variant-numeric: tabular-nums; } | |
| 379 | + | |
| 380 | +.heatmap-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap; margin-bottom: var(--space-3); } | |
| 381 | +.heatmap-stats { display: flex; gap: 8px 18px; flex-wrap: wrap; font-size: 13px; color: var(--fg-muted); } | |
| 382 | +.heatmap-stats strong { color: var(--fg-bright); font-family: var(--font-display); font-size: 16px; margin-right: 4px; font-variant-numeric: tabular-nums; } | |
| 383 | +.heatmap-stats .ember { color: var(--ember); } | |
| 384 | +.heatmap-scroll { overflow-x: auto; padding-bottom: 6px; -webkit-overflow-scrolling: touch; } | |
| 405 | 385 | .heatmap-svg { display: block; min-width: 760px; width: 100%; height: auto; } |
| 406 | −.heatmap-label { fill: var(--muted); font-size: 9px; font-family: var(--font-ui); } | |
| 407 | −.heatmap-cell { transition: transform 0.1s ease; transform-origin: center; transform-box: fill-box; rx: 2.5px; } | |
| 408 | −.heatmap-cell:hover { transform: scale(1.45); stroke: var(--text-bright); stroke-width: 1; } | |
| 386 | +.heatmap-label { fill: var(--fg-muted); font-size: 9px; font-family: var(--font-ui); } | |
| 387 | +.heatmap-cell { rx: 2.5px; } | |
| 388 | +.heatmap-cell:hover { stroke: var(--fg-bright); stroke-width: 1; } | |
| 409 | 389 | .heatmap-cell[data-level='0'] { fill: var(--heat-0); } |
| 410 | 390 | .heatmap-cell[data-level='1'] { fill: var(--heat-1); } |
| 411 | 391 | .heatmap-cell[data-level='2'] { fill: var(--heat-2); } |
| 412 | 392 | .heatmap-cell[data-level='3'] { fill: var(--heat-3); } |
| 413 | −.heatmap-cell[data-level='4'] { fill: var(--heat-4); filter: drop-shadow(0 0 3px var(--heat-glow)); } | |
| 414 | −.heatmap-legend { display: flex; align-items: center; gap: 4px; color: var(--muted); font-size: 12px; margin-top: 10px; justify-content: flex-end; } | |
| 393 | +.heatmap-cell[data-level='4'] { fill: var(--heat-4); } | |
| 394 | +.heatmap-legend { display: flex; align-items: center; gap: 4px; color: var(--fg-muted); font-size: 12px; margin-top: 8px; justify-content: flex-end; flex-wrap: wrap; } | |
| 415 | 395 | .heatmap-cell-demo { width: 11px; height: 11px; border-radius: 3px; display: inline-block; } |
| 416 | 396 | .heatmap-cell-demo[data-level='0'] { background: var(--heat-0); } |
| 417 | 397 | .heatmap-cell-demo[data-level='1'] { background: var(--heat-1); } |
| 418 | 398 | .heatmap-cell-demo[data-level='2'] { background: var(--heat-2); } |
| 419 | 399 | .heatmap-cell-demo[data-level='3'] { background: var(--heat-3); } |
| 420 | −.heatmap-cell-demo[data-level='4'] { background: var(--heat-4); box-shadow: 0 0 5px var(--heat-glow); } | |
| 421 | − | |
| 422 | −/* ── Repo header ─────────────────────────── */ | |
| 423 | −.repo-header { position: relative; margin-bottom: 24px; padding-top: 6px; } | |
| 424 | −.repo-title-row { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-bottom: 8px; } | |
| 425 | −.repo-title { | |
| 426 | − margin: 0; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; | |
| 427 | − font-family: var(--font-display); font-weight: 800; font-size: clamp(28px, 3.4vw, 40px); letter-spacing: -0.03em; line-height: 1.05; | |
| 428 | −} | |
| 429 | −.repo-owner { color: var(--muted); font-weight: 700; } | |
| 400 | +.heatmap-cell-demo[data-level='4'] { background: var(--heat-4); } | |
| 401 | +.metric-note { font-size: 12px; color: var(--fg-faint); margin-top: 8px; } | |
| 402 | +.metric-note a { color: var(--fg-muted); } | |
| 403 | + | |
| 404 | +/* ═══════════════════════════════════════════ | |
| 405 | + 6. Repository header, toolbar, clone panel, file table, file tree | |
| 406 | + ═══════════════════════════════════════════ */ | |
| 407 | +.repo-header { margin-bottom: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3); } | |
| 408 | +.repo-title-row { display: flex; align-items: center; gap: 10px 14px; flex-wrap: wrap; } | |
| 409 | +.repo-title { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; font-family: var(--font-display); font-weight: 800; font-size: clamp(24px, 3vw, 34px); letter-spacing: -0.03em; line-height: 1.1; min-width: 0; } | |
| 410 | +.repo-owner { color: var(--fg-muted); font-weight: 700; } | |
| 430 | 411 | .repo-owner:hover { color: var(--accent); text-decoration: none; } |
| 431 | −.repo-sep { color: var(--faint); font-weight: 400; } | |
| 432 | −.repo-name { color: var(--text-bright); } | |
| 412 | +.repo-sep { color: var(--fg-faint); font-weight: 400; } | |
| 413 | +.repo-name { color: var(--fg-bright); overflow-wrap: anywhere; } | |
| 433 | 414 | .repo-name:hover { color: var(--accent); text-decoration: none; } |
| 434 | −.repo-badges { display: flex; gap: 6px; align-items: center; } | |
| 435 | −.repo-desc { margin: 0 0 12px; color: var(--text); max-width: 78ch; font-size: 16px; line-height: 1.55; } | |
| 436 | −.repo-meta-row { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; margin-bottom: 18px; } | |
| 437 | −.repo-homepage { font-size: 14px; font-weight: 500; display: inline-flex; align-items: center; gap: 6px; } | |
| 438 | − | |
| 439 | −.repo-stats { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px; margin-bottom: 18px; } | |
| 440 | −.rstat { | |
| 441 | − border-radius: 14px; padding: 12px 14px; display: flex; flex-direction: column; gap: 2px; color: inherit; | |
| 442 | − transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; | |
| 443 | −} | |
| 444 | −a.rstat:hover { text-decoration: none; color: inherit; transform: translateY(-2px); border-color: var(--border-strong); box-shadow: inset 0 1px 0 var(--edge-light), var(--shadow-card); } | |
| 445 | −.rstat-v { font-family: var(--font-display); font-weight: 800; font-size: 20px; letter-spacing: -0.02em; color: var(--text-bright); font-variant-numeric: tabular-nums; line-height: 1.1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | |
| 446 | −.rstat-l { font-size: 11.5px; color: var(--muted); letter-spacing: 0.04em; text-transform: uppercase; font-family: var(--font-mono); } | |
| 447 | − | |
| 448 | −.lang-bar { display: flex; height: 8px; border-radius: 5px; overflow: hidden; background: var(--surface-2); margin: 0 0 8px; gap: 2px; } | |
| 449 | −.lang-bar-seg { display: block; height: 100%; min-width: 3px; transition: filter 0.15s ease, transform 0.15s ease; transform-origin: center; } | |
| 450 | −.lang-bar-seg:hover { filter: brightness(1.3) saturate(1.2); } | |
| 451 | −.lang-legend { display: flex; flex-wrap: wrap; gap: 14px; font-size: 13px; margin-bottom: 14px; } | |
| 452 | − | |
| 453 | −.repo-tabs { display: flex; gap: 2px; align-items: center; border-bottom: 1px solid var(--border); overflow-x: auto; position: relative; } | |
| 454 | −.repo-tabs::after { | |
| 455 | − content: ''; position: absolute; left: 0; right: 0; bottom: -1px; height: 1px; | |
| 456 | − background: var(--gradient-hot); opacity: 0.4; | |
| 457 | − mask: linear-gradient(90deg, black, transparent 60%); -webkit-mask: linear-gradient(90deg, black, transparent 60%); | |
| 458 | −} | |
| 459 | −.repo-tab { | |
| 460 | − color: var(--muted); padding: 11px 16px; position: relative; | |
| 461 | − display: inline-flex; gap: 8px; align-items: center; white-space: nowrap; font-size: 14px; font-weight: 500; | |
| 462 | − border-radius: 10px 10px 0 0; transition: color 0.15s ease, background 0.15s ease; | |
| 463 | −} | |
| 464 | −.repo-tab::after { | |
| 465 | − content: ''; position: absolute; left: 12px; right: 12px; bottom: -1px; height: 2px; z-index: 1; | |
| 466 | − background: var(--gradient-brand); border-radius: 2px 2px 0 0; box-shadow: 0 -1px 14px -2px var(--accent-glow); | |
| 467 | − opacity: 0; transform: scaleX(0.4); transition: opacity 0.18s ease, transform 0.18s ease; | |
| 468 | −} | |
| 469 | −.repo-tab:hover { color: var(--text); background: var(--surface); text-decoration: none; } | |
| 470 | −.repo-tab.active { color: var(--text-bright); font-weight: 600; } | |
| 471 | −.repo-tab.active::after { opacity: 1; transform: scaleX(1); } | |
| 472 | − | |
| 473 | −/* ── Repo toolbar / clone box ────────────── */ | |
| 474 | −.repo-toolbar { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; margin-bottom: 16px; } | |
| 475 | −.clone-box { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; border-radius: 14px; padding: 5px 5px 5px 14px; } | |
| 476 | −.clone-prompt { color: var(--accent-2); font-family: var(--font-mono); font-size: 13px; user-select: none; } | |
| 477 | −.clone-url { | |
| 478 | − background: transparent; border: none; color: var(--text); padding: 6px 4px; font-family: var(--font-mono); | |
| 479 | − font-size: 13px; width: 360px; max-width: 60vw; outline: none; | |
| 480 | −} | |
| 481 | −.clone-snippet { | |
| 482 | − background: var(--code-bg); border: 1px solid var(--border); border-radius: var(--radius); | |
| 483 | − padding: 14px 16px; font-size: 13px; overflow-x: auto; display: inline-block; text-align: left; margin: 0; | |
| 484 | −} | |
| 485 | −.path-breadcrumb { font-family: var(--font-mono); font-size: 14px; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; } | |
| 486 | −.crumb-sep { color: var(--faint); } | |
| 487 | − | |
| 488 | −/* ── File table ──────────────────────────── */ | |
| 489 | −.file-table { width: 100%; border-collapse: collapse; border-radius: var(--radius-lg); overflow: hidden; font-size: 14px; margin-bottom: 28px; } | |
| 490 | −.file-table td { padding: 10px 16px; border-top: 1px solid var(--border); } | |
| 415 | +.repo-badges { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } | |
| 416 | +.repo-desc { color: var(--fg); max-width: 78ch; font-size: 15px; line-height: 1.55; } | |
| 417 | +.repo-meta-row { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: center; } | |
| 418 | +.repo-homepage { font-size: 14px; font-weight: 500; display: inline-flex; align-items: center; gap: 6px; min-height: 28px; overflow-wrap: anywhere; } | |
| 419 | +.lang-bar { display: flex; height: 8px; border-radius: 5px; overflow: hidden; background: var(--surface-2); gap: 2px; } | |
| 420 | +.lang-bar-seg { display: block; height: 100%; min-width: 3px; } | |
| 421 | +.lang-legend { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 13px; } | |
| 422 | +.lang-block { display: flex; flex-direction: column; gap: 8px; } | |
| 423 | + | |
| 424 | +.repo-toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; justify-content: space-between; margin-bottom: var(--space-4); } | |
| 425 | +.repo-toolbar-left { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; min-width: 0; flex: 1 1 auto; } | |
| 426 | +.repo-toolbar-right { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } | |
| 427 | +.clone-panel { width: min(520px, calc(100vw - 32px)); padding: var(--space-4); } | |
| 428 | +.clone-panel h2 { font-size: 15px; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; } | |
| 429 | +.clone-row { display: flex; gap: 6px; align-items: stretch; margin-bottom: 8px; } | |
| 430 | +.clone-url { flex: 1; min-width: 0; background: var(--code-bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--fg); padding: 0 12px; font-family: var(--font-mono); font-size: 13px; min-height: 40px; user-select: all; -webkit-user-select: all; } | |
| 431 | +.clone-hint { font-size: 12.5px; color: var(--fg-muted); margin: 6px 0 12px; } | |
| 432 | +.clone-panel .btn-row { margin-top: 8px; } | |
| 433 | +.clone-panel.popover { right: auto; top: auto; } | |
| 434 | + | |
| 435 | +.file-table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; } | |
| 436 | +.file-table td, .file-table th { padding: 0 14px; border-top: 1px solid var(--border); height: 44px; vertical-align: middle; } | |
| 491 | 437 | .file-table tr:first-child td { border-top: 0; } |
| 492 | −.file-row { transition: background 0.12s ease, box-shadow 0.12s ease; } | |
| 493 | −.file-row:hover { background: var(--surface-2); box-shadow: inset 3px 0 0 var(--accent); } | |
| 494 | −.file-name { white-space: nowrap; width: 34%; } | |
| 495 | −.file-name a { color: var(--text); font-weight: 500; } | |
| 438 | +.file-row { transition: background var(--dur-fast) var(--ease); } | |
| 439 | +.file-row:hover { background: var(--surface-1); } | |
| 440 | +.file-name { width: 42%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | |
| 441 | +.file-name a { color: var(--fg); font-weight: 500; display: inline-flex; align-items: center; gap: 9px; max-width: 100%; } | |
| 442 | +.file-name a span { overflow: hidden; text-overflow: ellipsis; } | |
| 496 | 443 | .file-name a:hover { color: var(--accent); text-decoration: none; } |
| 497 | −.file-icon { vertical-align: -3px; margin-right: 9px; color: var(--muted); } | |
| 498 | −.icon-dir { color: var(--accent); filter: drop-shadow(0 0 6px var(--accent-glow)); } | |
| 499 | −.file-commit { overflow: hidden; text-overflow: ellipsis; max-width: 460px; white-space: nowrap; } | |
| 444 | +.file-icon { color: var(--fg-muted); flex: none; } | |
| 445 | +.icon-dir { color: var(--accent); } | |
| 446 | +.file-commit { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg-muted); } | |
| 447 | +.file-commit a { color: var(--fg-muted); } | |
| 500 | 448 | .file-commit a:hover { color: var(--accent); } |
| 501 | −.file-date { text-align: right; white-space: nowrap; font-size: 13px; font-variant-numeric: tabular-nums; } | |
| 502 | − | |
| 503 | −/* ── README + TOC rail ───────────────────── */ | |
| 504 | −.readme-section { border-radius: var(--radius-lg); margin-bottom: 32px; overflow: clip; } | |
| 505 | −.readme-header { | |
| 506 | − display: flex; align-items: center; gap: 8px; padding: 12px 22px; | |
| 507 | − border-bottom: 1px solid var(--border); color: var(--muted); font-size: 13px; | |
| 508 | − position: sticky; top: 64px; background: var(--glass); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); | |
| 509 | − z-index: 10; border-radius: var(--radius-lg) var(--radius-lg) 0 0; | |
| 510 | −} | |
| 449 | +.file-date { text-align: right; white-space: nowrap; font-size: 13px; font-variant-numeric: tabular-nums; color: var(--fg-muted); width: 110px; } | |
| 450 | +.file-card { overflow: clip; margin-bottom: var(--space-6); } | |
| 451 | + | |
| 452 | +.source-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-5); align-items: start; } | |
| 453 | +.file-tree-panel { display: none; } | |
| 454 | +.file-tree { font-size: 13px; font-family: var(--font-ui); } | |
| 455 | +.file-tree ul { list-style: none; } | |
| 456 | +.file-tree ul ul { padding-left: 14px; border-left: 1px solid var(--border); margin-left: 9px; } | |
| 457 | +.file-tree li { min-width: 0; } | |
| 458 | +.file-tree .tree-row { display: flex; align-items: center; gap: 6px; min-height: 32px; padding: 0 6px; border-radius: var(--radius-xs); color: var(--fg); width: 100%; text-align: left; overflow: hidden; } | |
| 459 | +.file-tree .tree-row:hover { background: var(--surface-2); text-decoration: none; } | |
| 460 | +.file-tree .tree-row.is-current { background: var(--accent-soft); color: var(--accent); font-weight: 600; } | |
| 461 | +.file-tree .tree-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 462 | +.file-tree .tree-caret { transition: transform var(--dur-fast) var(--ease); color: var(--fg-faint); } | |
| 463 | +.file-tree li[aria-expanded='true'] > .tree-row .tree-caret { transform: rotate(90deg); } | |
| 464 | +.file-tree .tree-loading { color: var(--fg-faint); padding: 4px 12px; font-size: 12px; } | |
| 465 | + | |
| 466 | +/* ═══════════════════════════════════════════ | |
| 467 | + 7. README / blob / code view | |
| 468 | + ═══════════════════════════════════════════ */ | |
| 469 | +.readme-section { overflow: clip; margin-bottom: var(--space-6); } | |
| 470 | +.readme-header { display: flex; align-items: center; gap: 8px; padding: 10px var(--space-5); border-bottom: 1px solid var(--border); color: var(--fg-muted); font-size: 13px; position: sticky; top: calc(var(--header-h) + env(safe-area-inset-top)); background: var(--glass); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); z-index: 10; } | |
| 511 | 471 | .readme-header .count { margin-left: auto; } |
| 512 | 472 | .readme-layout { display: grid; grid-template-columns: minmax(0, 1fr); } |
| 513 | −.readme-layout.has-toc { grid-template-columns: minmax(0, 1fr) 240px; } | |
| 514 | −.readme-section .markdown-body { padding: 32px 40px 40px; min-width: 0; } | |
| 515 | −.readme-toc { position: sticky; top: 112px; align-self: start; max-height: calc(100vh - 140px); overflow-y: auto; padding: 26px 20px 26px 0; font-size: 13px; } | |
| 516 | −.readme-toc h4 { margin: 0 0 10px; font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--faint); } | |
| 517 | −.readme-toc ol { list-style: none; margin: 0; padding: 0; border-left: 1px solid var(--border); } | |
| 518 | −.readme-toc li a { display: block; padding: 4px 12px; color: var(--muted); border-left: 2px solid transparent; margin-left: -1px; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 519 | −.readme-toc li.depth-3 a { padding-left: 24px; font-size: 12.5px; } | |
| 520 | −.readme-toc li a:hover { color: var(--text); text-decoration: none; } | |
| 521 | −.readme-toc li a.active { color: var(--text-bright); border-left-color: var(--accent); } | |
| 522 | −.empty-repo { text-align: center; padding: 56px 20px; } | |
| 523 | −.notice { background: var(--accent-soft); border: 1px solid var(--accent); border-radius: var(--radius); padding: 8px 14px; font-size: 13px; margin: 12px; } | |
| 524 | − | |
| 525 | −/* ── Blob view ───────────────────────────── */ | |
| 526 | −.blob-card { border-radius: var(--radius-lg); overflow: clip; } /* clip, not hidden — hidden would turn the card into the sticky header's scrollport */ | |
| 527 | −.blob-header { | |
| 528 | − display: flex; flex-wrap: wrap; gap: 10px; align-items: center; padding: 10px 16px; border-bottom: 1px solid var(--border); | |
| 529 | − position: sticky; top: 64px; background: var(--glass); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); z-index: 10; | |
| 530 | −} | |
| 531 | −.blob-meta { display: flex; gap: 8px; align-items: center; font-size: 13px; } | |
| 532 | −.blob-actions { margin-left: auto; display: flex; gap: 6px; flex-wrap: wrap; } | |
| 533 | −.blob-image { text-align: center; padding: 32px; background: var(--surface-2); } | |
| 473 | +.readme-section .markdown-body { padding: var(--space-5); } | |
| 474 | +.readme-toc { display: none; } | |
| 475 | +.notice { background: var(--info-soft); border: 1px solid var(--accent); border-radius: var(--radius); padding: 8px 14px; font-size: 13px; margin: 12px; } | |
| 476 | + | |
| 477 | +.blob-card { overflow: clip; } | |
| 478 | +.blob-header { display: flex; flex-direction: column; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--border); position: sticky; top: calc(var(--header-h) + env(safe-area-inset-top)); background: var(--glass); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); z-index: 10; } | |
| 479 | +.blob-header-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; min-width: 0; } | |
| 480 | +.blob-meta { display: flex; gap: 8px; align-items: center; font-size: 13px; color: var(--fg-muted); flex-wrap: wrap; } | |
| 481 | +.blob-actions { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; margin: 0 -14px; padding: 0 14px 2px; } | |
| 482 | +.blob-actions::-webkit-scrollbar { display: none; } | |
| 483 | +.blob-actions .btn { flex: none; } | |
| 484 | +.blob-image { text-align: center; padding: var(--space-7) var(--space-4); background: var(--surface-1); } | |
| 534 | 485 | .blob-image img { max-width: 100%; border-radius: var(--radius); box-shadow: var(--shadow-2); } |
| 535 | −.blob-binary { text-align: center; padding: 56px 20px; color: var(--muted); } | |
| 536 | −.blob-markdown { padding: 32px 40px 40px; } | |
| 537 | −.pdf-viewer { background: var(--surface-2); } | |
| 538 | −.pdf-frame { display: block; width: 100%; height: calc(100vh - 220px); min-height: 560px; border: 0; background: var(--surface-2); } | |
| 539 | −.pdf-fallback { text-align: center; padding: 52px 20px; color: var(--muted); } | |
| 540 | − | |
| 541 | −.code-view { overflow-x: auto; background: var(--code-bg); } | |
| 542 | −.code-view pre.shiki { margin: 0; padding: 14px 0; background: transparent !important; font-size: 13px; line-height: 1.65; min-width: max-content; } | |
| 486 | +.blob-binary { text-align: center; padding: var(--space-9) var(--space-4); color: var(--fg-muted); display: flex; flex-direction: column; align-items: center; gap: 12px; } | |
| 487 | +.blob-markdown { padding: var(--space-5); } | |
| 488 | +.pdf-viewer { background: var(--surface-1); } | |
| 489 | +.pdf-frame { display: block; width: 100%; height: calc(100dvh - 240px); min-height: 480px; border: 0; background: var(--surface-1); } | |
| 490 | +.pdf-fallback { text-align: center; padding: var(--space-9) var(--space-4); color: var(--fg-muted); } | |
| 491 | + | |
| 492 | +.code-view { overflow-x: auto; background: var(--code-bg); -webkit-overflow-scrolling: touch; font-size: 13px; } | |
| 493 | +.code-view pre.shiki { padding: 12px 0; background: transparent !important; line-height: 1.65; min-width: max-content; font-size: inherit; } | |
| 543 | 494 | .code-view.wrap pre.shiki { min-width: 0; } |
| 544 | 495 | .code-view.wrap .line { white-space: pre-wrap; word-break: break-all; } |
| 545 | −.code-view .line { display: block; padding: 0 16px 0 0; min-height: 1.65em; transition: background 0.12s ease; } | |
| 546 | −.code-view .line:hover { background: var(--surface-2); } | |
| 547 | −.code-view .line:target { background: var(--accent-soft); animation: line-flash 1.2s ease 1; } | |
| 548 | −.code-view .line.range-hl { background: var(--accent-soft); box-shadow: inset 3px 0 0 var(--accent); } | |
| 549 | −@keyframes line-flash { 0% { background: var(--accent-glow); } 100% { background: var(--accent-soft); } } | |
| 550 | −.code-view .ln { | |
| 551 | − display: inline-block; width: 60px; padding-right: 20px; text-align: right; color: var(--faint); user-select: none; font-size: 12px; | |
| 552 | − text-decoration: none; position: sticky; left: 0; background: var(--code-bg); font-variant-numeric: tabular-nums; | |
| 553 | −} | |
| 496 | +.code-view .line { display: block; padding: 0 16px 0 0; min-height: 1.65em; } | |
| 497 | +.code-view .line:hover { background: var(--surface-1); } | |
| 498 | +.code-view .line:target, .code-view .line.range-hl { background: var(--accent-soft); box-shadow: inset 3px 0 0 var(--accent); } | |
| 499 | +.code-view .ln { display: inline-block; width: 56px; padding-right: 16px; text-align: right; color: var(--fg-faint); user-select: none; -webkit-user-select: none; font-size: 12px; text-decoration: none; position: sticky; left: 0; background: var(--code-bg); font-variant-numeric: tabular-nums; } | |
| 554 | 500 | .code-view .ln:hover { color: var(--accent); } |
| 555 | −.plain-readme { padding: 20px; overflow-x: auto; font-size: 13px; } | |
| 556 | − | |
| 557 | −/* ── Commits ─────────────────────────────── */ | |
| 558 | −.commit-days { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 22px; } | |
| 559 | −.commit-day-label { | |
| 560 | − margin: 0 0 10px; display: flex; align-items: center; gap: 10px; font-family: var(--font-mono); font-size: 12px; | |
| 561 | − letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); | |
| 562 | −} | |
| 563 | −.commit-day-label::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 10px var(--accent-glow); } | |
| 501 | +.plain-readme { padding: var(--space-5); overflow-x: auto; font-size: 13px; white-space: pre; } | |
| 502 | + | |
| 503 | +/* ═══════════════════════════════════════════ | |
| 504 | + 8. Commits, diffs, blame, refs, releases, search, errors | |
| 505 | + ═══════════════════════════════════════════ */ | |
| 506 | +.commit-days { list-style: none; display: flex; flex-direction: column; gap: var(--space-5); } | |
| 507 | +.commit-day-label { display: flex; align-items: center; gap: 10px; font-family: var(--font-mono); font-size: 12px; letter-spacing: var(--tracking-caps); text-transform: uppercase; color: var(--fg-muted); margin-bottom: 8px; } | |
| 508 | +.commit-day-label::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); flex: none; } | |
| 564 | 509 | .commit-day-label::after { content: ''; flex: 1; height: 1px; background: var(--border); } |
| 565 | −.commit-list { list-style: none; margin: 0; padding: 0; border-radius: var(--radius-lg); overflow: hidden; } | |
| 566 | −.commit-row { display: flex; gap: 14px; align-items: flex-start; padding: 14px 18px; border-top: 1px solid var(--border); transition: background 0.12s ease, box-shadow 0.12s ease; } | |
| 567 | −.commit-row:hover { background: var(--surface-2); box-shadow: inset 3px 0 0 var(--accent); } | |
| 510 | +.commit-list { list-style: none; overflow: clip; } | |
| 511 | +.commit-row { display: grid; grid-template-columns: 32px minmax(0, 1fr); gap: 8px 12px; align-items: start; padding: 12px 14px; border-top: 1px solid var(--border); } | |
| 568 | 512 | .commit-row:first-child { border-top: 0; } |
| 569 | −.commit-avatar { flex: none; border-radius: 9px; overflow: hidden; background: var(--surface-2); line-height: 0; padding: 3px; border: 1px solid var(--border); } | |
| 570 | −.commit-main { flex: 1; min-width: 0; } | |
| 571 | −.commit-subject { font-weight: 600; display: flex; gap: 8px; align-items: baseline; } | |
| 572 | −.commit-subject a { color: var(--text); } | |
| 513 | +.commit-row:hover { background: var(--surface-1); } | |
| 514 | +.commit-avatar { flex: none; border-radius: 8px; overflow: hidden; background: var(--surface-2); line-height: 0; padding: 2px; border: 1px solid var(--border); display: inline-block; } | |
| 515 | +.commit-main { min-width: 0; } | |
| 516 | +.commit-subject { font-weight: 600; display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; } | |
| 517 | +.commit-subject a { color: var(--fg); overflow-wrap: anywhere; } | |
| 573 | 518 | .commit-subject a:hover { color: var(--accent); text-decoration: none; } |
| 574 | −.commit-body-toggle summary { cursor: pointer; color: var(--muted); background: var(--surface-2); border-radius: 5px; padding: 0 8px; display: inline-block; list-style: none; border: 1px solid var(--border); font-size: 12px; } | |
| 519 | +.commit-body-toggle summary { cursor: pointer; color: var(--fg-muted); background: var(--surface-2); border-radius: 5px; padding: 0 8px; display: inline-block; list-style: none; border: 1px solid var(--border); font-size: 12px; min-height: 22px; } | |
| 575 | 520 | .commit-body-toggle summary::-webkit-details-marker { display: none; } |
| 576 | −.commit-body { font-size: 13px; color: var(--muted); white-space: pre-wrap; margin: 8px 0 0; font-family: var(--font-mono); } | |
| 577 | −.commit-meta { font-size: 13px; margin-top: 4px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } | |
| 578 | −.sha-chip { | |
| 579 | − font-family: var(--font-mono); font-size: 12px; background: var(--surface-2); color: var(--accent); border: 1px solid var(--border); | |
| 580 | − border-radius: 8px; padding: 4px 10px; cursor: pointer; flex: none; transition: border-color 0.15s ease, transform 0.12s ease, box-shadow 0.15s ease; | |
| 581 | −} | |
| 582 | −.sha-chip:hover { border-color: var(--accent); transform: translateY(-1px); box-shadow: 0 4px 14px -6px var(--accent-glow); } | |
| 583 | −.pagination { display: flex; gap: 10px; justify-content: center; margin: 26px 0; } | |
| 584 | − | |
| 585 | −/* ── Commit detail / diffs ───────────────── */ | |
| 586 | −.commit-detail-header { border-radius: var(--radius-lg); padding: 22px 26px; margin-bottom: 18px; overflow: hidden; } | |
| 587 | −.commit-detail-header::before { content: ''; position: absolute; inset: 0 0 auto 0; height: 2px; background: var(--gradient-hot); } | |
| 588 | −.commit-detail-subject { margin: 0 0 10px; font-family: var(--font-display); font-size: 24px; font-weight: 800; letter-spacing: -0.02em; line-height: 1.2; } | |
| 589 | −.commit-detail-meta { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; font-size: 14px; } | |
| 590 | −.commit-stats { margin: 14px 0 0; font-size: 14px; color: var(--muted); display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } | |
| 591 | −.diff-bar { display: inline-flex; height: 8px; width: 120px; border-radius: 4px; overflow: hidden; background: var(--surface-3); } | |
| 521 | +.commit-body { font-size: 13px; color: var(--fg-muted); white-space: pre-wrap; overflow-wrap: anywhere; margin: 8px 0 0; font-family: var(--font-mono); } | |
| 522 | +.commit-meta { font-size: 13px; margin-top: 4px; display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; color: var(--fg-muted); } | |
| 523 | +.commit-meta strong { color: var(--fg); } | |
| 524 | +.commit-row .sha-chip { grid-column: 2; justify-self: start; } | |
| 525 | +.sha-chip { font-family: var(--font-mono); font-size: 12px; background: var(--surface-2); color: var(--accent); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 0 10px; min-height: 28px; display: inline-flex; align-items: center; gap: 6px; cursor: pointer; flex: none; } | |
| 526 | +.sha-chip:hover { border-color: var(--accent); } | |
| 527 | +.pagination { display: flex; gap: 10px; justify-content: center; margin: var(--space-6) 0; } | |
| 528 | + | |
| 529 | +.commit-detail-header { padding: var(--space-5); margin-bottom: var(--space-4); overflow: hidden; } | |
| 530 | +.commit-detail-header::before { content: ''; position: absolute; inset: 0 0 auto 0; height: 2px; background: var(--gradient-brand); } | |
| 531 | +.commit-detail-subject { font-family: var(--font-display); font-size: 21px; font-weight: 800; letter-spacing: var(--tracking-tight); line-height: 1.25; margin-bottom: 10px; overflow-wrap: anywhere; } | |
| 532 | +.commit-detail-meta { display: flex; flex-wrap: wrap; gap: 8px 10px; align-items: center; font-size: 14px; } | |
| 533 | +.commit-stats { margin-top: 12px; font-size: 14px; color: var(--fg-muted); display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } | |
| 534 | +.diff-bar { display: inline-flex; height: 8px; width: 100px; border-radius: 4px; overflow: hidden; background: var(--surface-3); } | |
| 592 | 535 | .diff-bar i { display: block; height: 100%; } |
| 593 | 536 | .diff-bar .add { background: var(--add-text); } |
| 594 | 537 | .diff-bar .del { background: var(--del-text); } |
| 595 | 538 | .diff-add { color: var(--add-text); font-weight: 600; font-variant-numeric: tabular-nums; } |
| 596 | 539 | .diff-del { color: var(--del-text); font-weight: 600; font-variant-numeric: tabular-nums; } |
| 597 | −.diff-nav { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 18px; } | |
| 598 | −.diff-nav a { | |
| 599 | − display: inline-flex; align-items: center; gap: 8px; font-family: var(--font-mono); font-size: 12px; color: var(--muted); | |
| 600 | − background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 4px 10px; max-width: 100%; | |
| 601 | −} | |
| 602 | −.diff-nav a span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 603 | −.diff-nav a:hover { border-color: var(--accent); color: var(--text); text-decoration: none; } | |
| 604 | − | |
| 605 | −.diff-file { border-radius: var(--radius-lg); margin-bottom: 16px; overflow: clip; } | |
| 606 | −.diff-file-header { | |
| 607 | − display: flex; gap: 10px; align-items: center; padding: 10px 16px; cursor: pointer; background: var(--code-head); font-size: 13px; list-style: none; | |
| 608 | − position: sticky; top: 64px; z-index: 5; transition: background 0.12s ease; border-bottom: 1px solid var(--border); | |
| 609 | −} | |
| 610 | −.diff-file-header:hover { background: var(--surface-3); } | |
| 540 | +.diff-toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; margin-bottom: var(--space-4); } | |
| 541 | +.diff-nav { margin-bottom: var(--space-4); overflow: clip; } | |
| 542 | +.diff-nav summary { list-style: none; cursor: pointer; display: flex; align-items: center; gap: 8px; padding: 10px 14px; font-size: 14px; font-weight: 600; min-height: 44px; } | |
| 543 | +.diff-nav summary::-webkit-details-marker { display: none; } | |
| 544 | +.diff-nav ul { list-style: none; border-top: 1px solid var(--border); max-height: 40vh; overflow-y: auto; } | |
| 545 | +.diff-nav li a { display: flex; align-items: center; gap: 10px; padding: 0 14px; min-height: 40px; font-family: var(--font-mono); font-size: 12.5px; color: var(--fg-muted); border-top: 1px solid var(--border); } | |
| 546 | +.diff-nav li:first-child a { border-top: 0; } | |
| 547 | +.diff-nav li a span:first-child { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; } | |
| 548 | +.diff-nav li a:hover { background: var(--surface-1); color: var(--fg); text-decoration: none; } | |
| 549 | + | |
| 550 | +.diff-file { margin-bottom: var(--space-4); overflow: clip; } | |
| 551 | +.diff-file-header { display: flex; gap: 10px; align-items: center; padding: 8px 14px; min-height: 44px; cursor: pointer; background: var(--code-head); font-size: 13px; list-style: none; position: sticky; top: calc(var(--header-h) + env(safe-area-inset-top)); z-index: 5; border-bottom: 1px solid var(--border); } | |
| 611 | 552 | .diff-file-header::-webkit-details-marker { display: none; } |
| 612 | −.diff-file-header::before { content: '▾'; color: var(--muted); transition: transform 0.15s ease; } | |
| 613 | −.diff-file:not([open]) .diff-file-header::before { content: '▸'; } | |
| 614 | −.diff-path { font-size: 13px; word-break: break-all; color: var(--text); } | |
| 615 | −.diff-counts { margin-left: auto; white-space: nowrap; } | |
| 616 | −.diff-status { border-radius: 6px; padding: 2px 8px; font-size: 10.5px; text-transform: uppercase; font-weight: 700; letter-spacing: 0.06em; } | |
| 553 | +.diff-file-header .ic { color: var(--fg-muted); transition: transform var(--dur-fast) var(--ease); flex: none; } | |
| 554 | +.diff-file:not([open]) .diff-file-header .ic.caret { transform: rotate(-90deg); } | |
| 555 | +.diff-path { font-size: 13px; overflow-wrap: anywhere; color: var(--fg); flex: 1; min-width: 0; } | |
| 556 | +.diff-counts { margin-left: auto; white-space: nowrap; flex: none; } | |
| 557 | +.diff-status { border-radius: 6px; padding: 2px 7px; font-size: 10.5px; text-transform: uppercase; font-weight: 700; letter-spacing: 0.06em; flex: none; } | |
| 617 | 558 | .diff-status-added { background: var(--add-bg); color: var(--add-text); } |
| 618 | 559 | .diff-status-deleted { background: var(--del-bg); color: var(--del-text); } |
| 619 | 560 | .diff-status-modified { background: var(--accent-soft); color: var(--accent); } |
| 620 | 561 | .diff-status-renamed { background: var(--chip-bg); color: var(--accent-2); } |
| 621 | −.diff-table-wrap { overflow-x: auto; background: var(--code-bg); } | |
| 562 | +.diff-table-wrap { overflow-x: auto; background: var(--code-bg); -webkit-overflow-scrolling: touch; } | |
| 622 | 563 | .diff-table { border-collapse: collapse; width: 100%; font-family: var(--font-mono); font-size: 12.5px; line-height: 1.6; } |
| 623 | −.diff-gutter { width: 44px; min-width: 44px; text-align: right; padding: 0 8px; color: var(--faint); user-select: none; border-right: 1px solid var(--border); font-size: 11.5px; vertical-align: top; font-variant-numeric: tabular-nums; } | |
| 564 | +.diff-gutter { width: 44px; min-width: 44px; text-align: right; padding: 0 8px; color: var(--fg-faint); user-select: none; -webkit-user-select: none; border-right: 1px solid var(--border); font-size: 11.5px; vertical-align: top; font-variant-numeric: tabular-nums; } | |
| 624 | 565 | .diff-code { padding: 0 10px; white-space: pre; } |
| 625 | −.diff-sign { display: inline-block; width: 14px; user-select: none; color: var(--muted); } | |
| 566 | +.diff-wrap .diff-code { white-space: pre-wrap; word-break: break-all; } | |
| 567 | +.diff-sign { display: inline-block; width: 14px; user-select: none; -webkit-user-select: none; color: var(--fg-muted); } | |
| 626 | 568 | .diff-line-add { background: var(--add-bg); } |
| 627 | 569 | .diff-line-add .diff-gutter { background: var(--add-bg-strong); border-right-color: transparent; } |
| 628 | 570 | .diff-line-add .diff-sign { color: var(--add-text); } |
| 629 | 571 | .diff-line-del { background: var(--del-bg); } |
| 630 | 572 | .diff-line-del .diff-gutter { background: var(--del-bg-strong); border-right-color: transparent; } |
| 631 | 573 | .diff-line-del .diff-sign { color: var(--del-text); } |
| 632 | −.diff-hunk-header td { background: var(--hunk-bg); color: var(--muted); padding: 4px 10px; font-size: 12px; } | |
| 633 | −.diff-binary { padding: 16px; } | |
| 574 | +.diff-hunk-header td { background: var(--hunk-bg); color: var(--fg-muted); padding: 4px 10px; font-size: 12px; } | |
| 575 | +.diff-binary, .diff-truncated { padding: 12px 16px; color: var(--fg-muted); font-size: 13px; } | |
| 576 | +.diff-split .diff-code { width: 50%; } | |
| 577 | +.diff-split td.diff-gutter + td.diff-code { border-right: 1px solid var(--border); } | |
| 578 | +.diff-split .diff-cell-empty { background: var(--surface-1); } | |
| 579 | +.diff-collapsed { padding: 14px 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; color: var(--fg-muted); font-size: 13px; background: var(--code-bg); } | |
| 634 | 580 | |
| 635 | −/* ── Blame ───────────────────────────────── */ | |
| 636 | 581 | .blame-table { border-collapse: collapse; width: 100%; font-size: 13px; } |
| 637 | −.blame-commit { border-top: 1px solid var(--border); border-right: 1px solid var(--border); padding: 4px 10px; width: 320px; min-width: 240px; vertical-align: top; background: var(--surface-2); } | |
| 582 | +.blame-commit { border-top: 1px solid var(--border); border-right: 1px solid var(--border); padding: 4px 10px; width: 280px; min-width: 200px; vertical-align: top; background: var(--surface-1); } | |
| 638 | 583 | .blame-commit-inner { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } |
| 584 | +.blame-commit-inner a { overflow-wrap: anywhere; } | |
| 639 | 585 | .blame-date { font-size: 12px; width: 100%; padding-left: 30px; } |
| 640 | −.blame-ln { width: 44px; text-align: right; color: var(--faint); padding: 0 8px; user-select: none; font-family: var(--font-mono); font-size: 12px; } | |
| 641 | −.blame-code pre { margin: 0; font-size: 12.5px; line-height: 1.6; white-space: pre; } | |
| 586 | +.blame-ln { width: 44px; text-align: right; color: var(--fg-faint); padding: 0 8px; user-select: none; font-family: var(--font-mono); font-size: 12px; } | |
| 587 | +.blame-code pre { font-size: 12.5px; line-height: 1.6; white-space: pre; } | |
| 588 | +.blame-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; } | |
| 642 | 589 | .blame-line[data-age='0'] .blame-commit { box-shadow: inset 3px 0 0 var(--ember); } |
| 643 | 590 | .blame-line[data-age='1'] .blame-commit { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--ember) 85%, var(--accent)); } |
| 644 | 591 | .blame-line[data-age='2'] .blame-commit { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--ember) 60%, var(--accent)); } |
@@ -650,171 +597,185 @@ a.rstat:hover { text-decoration: none; color: inherit; transform: translateY(-2p | ||
| 650 | 597 | .blame-line[data-age='8'] .blame-commit { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--accent) 26%, transparent); } |
| 651 | 598 | .blame-line[data-age='9'] .blame-commit { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--accent) 12%, transparent); } |
| 652 | 599 | |
| 653 | −/* ── Branch/tag tables ───────────────────── */ | |
| 654 | −.ref-table { width: 100%; border-collapse: collapse; border-radius: var(--radius-lg); overflow: hidden; font-size: 14px; } | |
| 655 | −.ref-table th { text-align: left; padding: 12px 16px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); border-bottom: 1px solid var(--border); font-family: var(--font-mono); font-weight: 500; background: var(--code-head); } | |
| 656 | −.ref-table td { padding: 12px 16px; border-top: 1px solid var(--border); } | |
| 657 | −.ref-table tbody tr { transition: background 0.12s ease; } | |
| 658 | −.ref-table tbody tr:hover { background: var(--surface-2); } | |
| 659 | −.ref-name { font-family: var(--font-mono); font-size: 13px; font-weight: 600; } | |
| 660 | −.ahead-behind { display: inline-flex; gap: 10px; font-family: var(--font-mono); font-size: 13px; } | |
| 661 | −.archive-links { display: flex; gap: 6px; flex-wrap: wrap; } | |
| 662 | − | |
| 663 | −/* ── Releases ────────────────────────────── */ | |
| 664 | −.release-list { display: flex; flex-direction: column; gap: 18px; position: relative; } | |
| 665 | −.release-card { border-radius: var(--radius-lg); padding: 22px 26px; transition: border-color 0.18s ease, box-shadow 0.18s ease; overflow: hidden; } | |
| 666 | −.release-card:hover { border-color: var(--border-strong); box-shadow: inset 0 1px 0 var(--edge-light), var(--shadow-card); } | |
| 600 | +.ref-list { list-style: none; overflow: clip; } | |
| 601 | +.ref-list li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 12px; align-items: center; padding: 10px 14px; border-top: 1px solid var(--border); min-height: 52px; } | |
| 602 | +.ref-list li:first-child { border-top: 0; } | |
| 603 | +.ref-list li:hover { background: var(--surface-1); } | |
| 604 | +.ref-name { font-family: var(--font-mono); font-size: 13.5px; font-weight: 600; overflow-wrap: anywhere; display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; } | |
| 605 | +.ref-sub { font-size: 13px; color: var(--fg-muted); grid-column: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 606 | +.ahead-behind { display: inline-flex; gap: 10px; font-family: var(--font-mono); font-size: 13px; grid-row: span 2; } | |
| 607 | + | |
| 608 | +.release-list { display: flex; flex-direction: column; gap: var(--space-4); } | |
| 609 | +.release-card { padding: var(--space-5); overflow: hidden; } | |
| 667 | 610 | .release-card:first-child::before { content: ''; position: absolute; inset: 0 0 auto 0; height: 2px; background: var(--gradient-ember); } |
| 668 | −.release-header { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; } | |
| 669 | −.release-tag { margin: 0; font-size: 26px; font-family: var(--font-display); font-weight: 800; letter-spacing: -0.02em; } | |
| 670 | −.release-tag a { color: var(--text-bright); } | |
| 671 | −.release-latest { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ember-2); background: var(--ember-soft); border-radius: 999px; padding: 3px 9px; } | |
| 672 | −.release-subject { color: var(--muted); } | |
| 673 | −.release-assets { list-style: none; margin: 0 0 14px; padding: 0; display: flex; flex-direction: column; gap: 8px; } | |
| 674 | −.release-assets li { | |
| 675 | − display: flex; align-items: center; gap: 10px; background: var(--surface-2); border: 1px solid var(--border); | |
| 676 | − border-radius: var(--radius); padding: 10px 14px; font-size: 14px; transition: border-color 0.15s ease, box-shadow 0.15s ease; | |
| 677 | −} | |
| 678 | −.release-assets li:hover { border-color: var(--accent); box-shadow: 0 4px 18px -8px var(--accent-glow); } | |
| 679 | −.release-assets li svg { color: var(--accent-2); flex: none; } | |
| 680 | −.release-assets li a { font-family: var(--font-mono); font-size: 13px; font-weight: 600; } | |
| 681 | − | |
| 682 | −/* ── Search page ─────────────────────────── */ | |
| 683 | −.search-hero { margin: 24px 0 36px; } | |
| 684 | −.search-page-form { display: flex; gap: 8px; margin-top: 18px; max-width: 640px; } | |
| 685 | −.search-page-form input { flex: 1; width: auto; padding: 13px 16px; font-size: 16px; border-radius: 14px; } | |
| 686 | −.search-results { list-style: none; margin: 0 0 32px; padding: 0; display: flex; flex-direction: column; gap: 12px; } | |
| 687 | −.search-results li { border-radius: var(--radius-lg); padding: 16px 20px; transition: border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease; } | |
| 688 | −.search-results li:hover { border-color: var(--border-strong); transform: translateY(-1px); box-shadow: inset 0 1px 0 var(--edge-light), var(--shadow-card); } | |
| 689 | −.search-hit-name { font-weight: 700; font-size: 16px; color: var(--text-bright); } | |
| 690 | −.search-snippet { color: var(--muted); font-size: 13.5px; margin: 6px 0 0; } | |
| 611 | +.release-header { display: flex; align-items: baseline; gap: 8px 12px; flex-wrap: wrap; margin-bottom: 12px; } | |
| 612 | +.release-tag { font-size: 22px; font-family: var(--font-display); font-weight: 800; letter-spacing: var(--tracking-tight); overflow-wrap: anywhere; } | |
| 613 | +.release-tag a { color: var(--fg-bright); } | |
| 614 | +.release-subject { color: var(--fg-muted); } | |
| 615 | +.release-assets { list-style: none; margin-bottom: 12px; display: flex; flex-direction: column; gap: 8px; } | |
| 616 | +.release-assets li { display: flex; align-items: center; gap: 10px; background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--radius); padding: 8px 12px; font-size: 14px; min-height: 44px; flex-wrap: wrap; } | |
| 617 | +.release-assets li .ic { color: var(--accent-2); } | |
| 618 | +.release-assets li a { font-family: var(--font-mono); font-size: 13px; font-weight: 600; overflow-wrap: anywhere; } | |
| 619 | + | |
| 620 | +.search-hero { margin: var(--space-4) 0 var(--space-6); } | |
| 621 | +.search-form { display: flex; gap: 8px; margin-top: var(--space-4); max-width: 640px; } | |
| 622 | +.search-form .input { font-size: 16px; min-height: 44px; } | |
| 623 | +.search-results { list-style: none; display: flex; flex-direction: column; gap: 10px; margin-bottom: var(--space-6); } | |
| 624 | +.search-results li { padding: 14px 16px; } | |
| 625 | +.search-hit-name { font-weight: 700; font-size: 15px; color: var(--fg-bright); overflow-wrap: anywhere; } | |
| 626 | +.search-hit-path { font-family: var(--font-mono); font-size: 13px; overflow-wrap: anywhere; } | |
| 627 | +.search-snippet { color: var(--fg-muted); font-size: 13.5px; margin-top: 6px; overflow-wrap: anywhere; } | |
| 628 | +.search-snippet mark { background: var(--warn-soft); color: inherit; border-radius: 3px; padding: 0 2px; } | |
| 691 | 629 | .search-results .chip-row { margin-top: 8px; } |
| 692 | 630 | |
| 693 | −/* ── ⌘K command palette ──────────────────── */ | |
| 694 | −.cmdk[hidden] { display: none; } | |
| 695 | −.cmdk { position: fixed; inset: 0; z-index: 90; } | |
| 696 | −.cmdk-backdrop { position: absolute; inset: 0; background: var(--backdrop); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); animation: fade-in 0.16s ease both; } | |
| 697 | −.cmdk-panel { | |
| 698 | − position: relative; margin: 11vh auto 0; width: min(660px, calc(100vw - 32px)); | |
| 699 | − background: var(--surface-grad); border: 1px solid var(--border-strong); border-radius: var(--radius-xl); overflow: hidden; | |
| 700 | − box-shadow: inset 0 1px 0 var(--edge-light), var(--shadow-pop), 0 0 100px -30px var(--accent-glow); | |
| 701 | − animation: pop-in 0.2s cubic-bezier(0.2, 1.2, 0.4, 1) both; | |
| 702 | − backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); | |
| 703 | −} | |
| 704 | −@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } | |
| 705 | −@keyframes pop-in { from { opacity: 0; transform: translateY(12px) scale(0.98); } to { opacity: 1; transform: none; } } | |
| 706 | −.cmdk-input-row { display: flex; align-items: center; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--border); } | |
| 707 | −.cmdk-input-row svg { color: var(--muted); flex: none; } | |
| 708 | −.cmdk-input-row input { flex: 1; background: transparent; border: 0; outline: none; color: var(--text-bright); font-size: 17px; font-family: var(--font-ui); } | |
| 709 | −.cmdk-input-row input::placeholder { color: var(--faint); } | |
| 710 | −.cmdk-list { list-style: none; margin: 0; padding: 8px; max-height: 48vh; overflow-y: auto; } | |
| 711 | −.cmdk-group { padding: 10px 12px 4px; font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--faint); } | |
| 712 | −.cmdk-item { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 12px; cursor: pointer; font-size: 14px; color: var(--text); position: relative; } | |
| 631 | +.error-page { text-align: center; padding: var(--space-10) var(--space-4); display: flex; flex-direction: column; align-items: center; gap: var(--space-3); } | |
| 632 | +.error-code { font-size: clamp(80px, 18vw, 160px); font-weight: 800; font-family: var(--font-display); line-height: 0.9; letter-spacing: -0.06em; background: var(--gradient-brand); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent; } | |
| 633 | +.error-message { font-size: 20px; color: var(--fg); max-width: 40ch; text-wrap: balance; } | |
| 634 | +.error-art { display: inline-block; text-align: left; color: var(--fg-muted); font-size: 13px; } | |
| 635 | + | |
| 636 | +/* ═══════════════════════════════════════════ | |
| 637 | + 9. ⌘K palette | |
| 638 | + ═══════════════════════════════════════════ */ | |
| 639 | +.cmdk { position: fixed; inset: auto 0 0 0; margin: 0; width: 100%; max-width: 100%; height: min(92dvh, 92vh); max-height: none; background: var(--surface-0); color: var(--fg); border: 1px solid var(--border-strong); border-bottom: 0; border-radius: var(--radius-xl) var(--radius-xl) 0 0; padding: 0; box-shadow: var(--shadow-pop); overflow: hidden; display: none; } | |
| 640 | +.cmdk[open] { display: flex; flex-direction: column; animation: sheet-up var(--dur-slow) var(--ease-out) both; } | |
| 641 | +.cmdk-panel { display: flex; flex-direction: column; min-height: 0; flex: 1; } | |
| 642 | +.cmdk-input-row { display: flex; align-items: center; gap: 10px; padding: 10px 12px 10px 16px; border-bottom: 1px solid var(--border); } | |
| 643 | +.cmdk-input-row > .ic { color: var(--fg-muted); flex: none; } | |
| 644 | +.cmdk-input-row input { flex: 1; min-width: 0; background: transparent; border: 0; outline: none; color: var(--fg-bright); font-size: 16px; font-family: var(--font-ui); min-height: 40px; } | |
| 645 | +.cmdk-input-row input::placeholder { color: var(--fg-faint); } | |
| 646 | +.cmdk-close { border-color: transparent; background: transparent; } | |
| 647 | +.cmdk-scopes { display: flex; gap: 4px; padding: 8px 12px; border-bottom: 1px solid var(--border); overflow-x: auto; scrollbar-width: none; } | |
| 648 | +.cmdk-scopes::-webkit-scrollbar { display: none; } | |
| 649 | +.cmdk-scopes button { flex: none; min-height: 32px; padding: 0 12px; border-radius: var(--radius-pill); border: 1px solid var(--border); background: var(--surface-1); color: var(--fg-muted); font-size: 13px; font-weight: 500; } | |
| 650 | +.cmdk-scopes button[aria-selected='true'] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); } | |
| 651 | +.cmdk-list { list-style: none; padding: 8px; overflow-y: auto; flex: 1; min-height: 0; overscroll-behavior: contain; } | |
| 652 | +.cmdk-group { padding: 10px 12px 4px; font-family: var(--font-mono); font-size: 10.5px; letter-spacing: var(--tracking-caps); text-transform: uppercase; color: var(--fg-faint); } | |
| 653 | +.cmdk-item { display: flex; align-items: center; gap: 12px; padding: 8px 12px; min-height: 44px; border-radius: var(--radius); cursor: pointer; font-size: 14px; color: var(--fg); position: relative; } | |
| 713 | 654 | .cmdk-item:hover, .cmdk-item[aria-selected='true'] { background: var(--accent-soft); } |
| 714 | −.cmdk-item[aria-selected='true']::before { content: ''; position: absolute; left: 0; top: 9px; bottom: 9px; width: 3px; border-radius: 2px; background: var(--gradient-brand); box-shadow: 0 0 10px -1px var(--accent-glow); } | |
| 715 | −.cmdk-item-icon { width: 26px; height: 26px; border-radius: 8px; display: inline-flex; align-items: center; justify-content: center; background: var(--surface-2); border: 1px solid var(--border); color: var(--muted); flex: none; } | |
| 716 | −.cmdk-item-name { font-weight: 600; color: var(--text-bright); white-space: nowrap; } | |
| 717 | −.cmdk-item-desc { color: var(--muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 718 | −.cmdk-item-hint { margin-left: auto; color: var(--faint); font-size: 12px; font-family: var(--font-mono); flex: none; } | |
| 719 | −.cmdk-empty { padding: 28px 12px; text-align: center; color: var(--muted); font-size: 14px; } | |
| 720 | −.cmdk-footer { display: flex; gap: 14px; padding: 10px 18px; border-top: 1px solid var(--border); color: var(--faint); font-size: 12px; } | |
| 655 | +.cmdk-item[aria-selected='true']::before { content: ''; position: absolute; left: 0; top: 9px; bottom: 9px; width: 3px; border-radius: 2px; background: var(--gradient-brand); } | |
| 656 | +.cmdk-item-icon { width: 26px; height: 26px; border-radius: 8px; display: inline-flex; align-items: center; justify-content: center; background: var(--surface-2); border: 1px solid var(--border); color: var(--fg-muted); flex: none; } | |
| 657 | +.cmdk-item-main { display: flex; flex-direction: column; min-width: 0; flex: 1; gap: 1px; } | |
| 658 | +.cmdk-item-name { font-weight: 600; color: var(--fg-bright); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | |
| 659 | +.cmdk-item-desc { color: var(--fg-muted); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 660 | +.cmdk-item-hint { margin-left: auto; color: var(--fg-faint); font-size: 12px; font-family: var(--font-mono); flex: none; } | |
| 661 | +.cmdk-empty, .cmdk-status { padding: 24px 12px; text-align: center; color: var(--fg-muted); font-size: 14px; } | |
| 662 | +.cmdk-footer { display: none; gap: 14px; padding: 8px 16px; border-top: 1px solid var(--border); color: var(--fg-faint); font-size: 12px; } | |
| 721 | 663 | .cmdk-footer kbd { background: var(--surface-2); border: 1px solid var(--border); border-radius: 5px; padding: 0 5px; font-size: 11px; margin-right: 4px; } |
| 722 | −body.cmdk-open { overflow: hidden; } | |
| 723 | − | |
| 724 | −/* ── Error pages ─────────────────────────── */ | |
| 725 | −.error-page { text-align: center; padding: 90px 20px; } | |
| 726 | −.error-code { | |
| 727 | − font-size: clamp(96px, 18vw, 180px); font-weight: 800; font-family: var(--font-display); line-height: 0.9; letter-spacing: -0.06em; | |
| 728 | − background: var(--gradient-hot); background-size: 200% 200%; animation: gradient-pan 6s ease infinite; | |
| 729 | − -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent; | |
| 730 | − filter: drop-shadow(0 10px 40px var(--accent-glow)); | |
| 731 | −} | |
| 732 | −.error-message { font-family: var(--font-serif); font-style: italic; font-size: 26px; color: var(--text); margin: 18px 0 8px; } | |
| 733 | −.error-art { display: inline-block; text-align: left; color: var(--muted); border-radius: var(--radius); padding: 14px 18px; font-size: 13px; margin: 18px 0 26px; } | |
| 734 | − | |
| 735 | −/* ── Mermaid blocks ──────────────────────── */ | |
| 736 | −.mermaid-block { margin: 16px 0; } | |
| 737 | −.mermaid-src { display: none; } | |
| 738 | −.mermaid-block[data-rendered='0'] .mermaid-src { display: block; } | |
| 739 | −.mermaid-target svg { max-width: 100%; } | |
| 740 | − | |
| 741 | −/* ── Footer ──────────────────────────────── */ | |
| 742 | −.footer { position: relative; border-top: 1px solid var(--border); color: var(--muted); font-size: 13.5px; background: linear-gradient(180deg, transparent, var(--bg-elev)); } | |
| 743 | −.footer::before { | |
| 744 | − content: ''; position: absolute; left: 0; right: 0; top: -1px; height: 1px; background: var(--gradient-hot); opacity: 0.35; | |
| 745 | − mask: linear-gradient(90deg, transparent, black 30%, black 70%, transparent); -webkit-mask: linear-gradient(90deg, transparent, black 30%, black 70%, transparent); | |
| 746 | −} | |
| 747 | −.footer-grid { display: grid; grid-template-columns: 1.6fr 1fr 1fr 1fr; gap: 32px; padding: 44px 24px 32px; } | |
| 748 | −.footer-col h4 { margin: 0 0 12px; font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--faint); font-weight: 500; } | |
| 749 | −.footer-col a { display: block; color: var(--muted); padding: 3px 0; } | |
| 750 | −.footer-col a:hover { color: var(--text-bright); text-decoration: none; } | |
| 751 | −.footer-about p { margin: 12px 0 14px; max-width: 42ch; line-height: 1.55; } | |
| 752 | −.footer-brand { display: flex; align-items: center; gap: 9px; font-weight: 800; color: var(--text-bright); font-family: var(--font-display); font-size: 16px; } | |
| 753 | −.footer-cmd { margin: 0; background: var(--code-bg); border: 1px solid var(--border); border-radius: 10px; padding: 10px 14px; font-size: 12.5px; color: var(--text); overflow-x: auto; } | |
| 754 | −.footer-cmd .term-prompt { margin-right: 8px; } | |
| 755 | −.footer-bottom { display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; padding: 16px 24px 26px; border-top: 1px solid var(--border); font-size: 12.5px; } | |
| 756 | −.footer-bottom a { color: var(--muted); } | |
| 757 | −.footer-bottom a:hover { color: var(--accent); } | |
| 664 | +.cmdk-footer-right { margin-left: auto; } | |
| 665 | +body.dialog-open { overflow: hidden; } | |
| 666 | + | |
| 667 | +/* ═══════════════════════════════════════════ | |
| 668 | + 10. Footer | |
| 669 | + ═══════════════════════════════════════════ */ | |
| 670 | +.footer { border-top: 1px solid var(--border); color: var(--fg-muted); font-size: 13.5px; background: linear-gradient(180deg, transparent, var(--bg-elev)); padding-bottom: env(safe-area-inset-bottom); } | |
| 671 | +.footer-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-5); padding-top: var(--space-7); padding-bottom: var(--space-5); } | |
| 672 | +.footer-col { min-width: 0; } | |
| 673 | +.footer-col h2 { font-family: var(--font-mono); font-size: 11px; letter-spacing: var(--tracking-caps); text-transform: uppercase; color: var(--fg-faint); font-weight: 500; margin-bottom: 10px; } | |
| 674 | +.footer-col a { display: inline-flex; align-items: center; color: var(--fg-muted); min-height: 32px; overflow-wrap: anywhere; } | |
| 675 | +.footer-col > a { display: flex; } | |
| 676 | +.footer-col a:hover { color: var(--fg-bright); text-decoration: none; } | |
| 677 | +.footer-about p { margin: 10px 0 12px; max-width: 42ch; line-height: 1.55; } | |
| 678 | +.footer-brand { font-weight: 800; color: var(--fg-bright); font-family: var(--font-display); font-size: 16px; } | |
| 679 | +.footer-cmd { font-size: 12.5px; } | |
| 680 | +.footer-bottom { display: flex; align-items: center; justify-content: space-between; gap: 8px 14px; flex-wrap: wrap; padding-top: 14px; padding-bottom: 22px; border-top: 1px solid var(--border); font-size: 12.5px; } | |
| 681 | +.footer-bottom a { color: var(--fg-muted); } | |
| 758 | 682 | .footer-status { display: inline-flex; align-items: center; gap: 8px; font-family: var(--font-mono); font-size: 12px; } |
| 759 | −.status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent-2); box-shadow: 0 0 10px 1px var(--accent-2); animation: pulse-dot 2.4s ease infinite; } | |
| 760 | − | |
| 761 | −/* ── Entrance & scroll reveal ────────────── */ | |
| 762 | −@keyframes rise-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } } | |
| 763 | −main.container > * { animation: rise-in 0.5s cubic-bezier(0.2, 0.9, 0.3, 1) both; } | |
| 764 | −main.container > *:nth-child(2) { animation-delay: 0.06s; } | |
| 765 | −main.container > *:nth-child(3) { animation-delay: 0.12s; } | |
| 766 | −main.container > *:nth-child(4) { animation-delay: 0.16s; } | |
| 767 | −.reveal { opacity: 0; transform: translateY(14px); transition: opacity 0.55s cubic-bezier(0.2, 0.9, 0.3, 1), transform 0.55s cubic-bezier(0.2, 0.9, 0.3, 1); } | |
| 768 | −.reveal.in { opacity: 1; transform: none; } | |
| 769 | −html.no-js .reveal { opacity: 1; transform: none; } | |
| 770 | − | |
| 771 | −/* ── Responsive ──────────────────────────── */ | |
| 772 | −@media (max-width: 1100px) { | |
| 773 | − .readme-layout.has-toc { grid-template-columns: minmax(0, 1fr); } | |
| 774 | − .readme-toc { display: none; } | |
| 775 | − .hero-stats { grid-template-columns: repeat(3, 1fr); } | |
| 776 | − .repo-stats { grid-template-columns: repeat(3, minmax(0, 1fr)); } | |
| 777 | − .footer-grid { grid-template-columns: 1fr 1fr; } | |
| 778 | −} | |
| 779 | −@media (max-width: 960px) { | |
| 780 | − .home-columns { grid-template-columns: 1fr; } | |
| 781 | − .home-side { position: static; } | |
| 683 | +.status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent-2); box-shadow: 0 0 8px 1px var(--accent-2); } | |
| 684 | + | |
| 685 | +/* ═══════════════════════════════════════════ | |
| 686 | + 11. Motion | |
| 687 | + ═══════════════════════════════════════════ */ | |
| 688 | +@keyframes rise-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } | |
| 689 | +@media (prefers-reduced-motion: no-preference) { | |
| 690 | + main.container > * { animation: rise-in var(--dur-slow) var(--ease-out) both; } | |
| 691 | + main.container > *:nth-child(2) { animation-delay: 40ms; } | |
| 692 | + main.container > *:nth-child(3) { animation-delay: 80ms; } | |
| 693 | +} | |
| 694 | + | |
| 695 | +/* ═══════════════════════════════════════════ | |
| 696 | + 12. Touch & narrow-screen contract (mobile-first: base styles above are the phone layout) | |
| 697 | + ═══════════════════════════════════════════ */ | |
| 698 | +@media (pointer: coarse) { | |
| 699 | + .btn, .btn-sm, .icon-btn, .tab, .sha-chip, .copy-btn, .ref-select, .select, .input, input.input, input[type='search'].input, .search-trigger { min-height: var(--touch); } | |
| 700 | + .icon-btn, .btn-icon { width: var(--touch); height: var(--touch); } | |
| 701 | + .btn-sm.btn-icon { width: var(--touch); } | |
| 702 | + .chip-topic { min-height: 32px; padding-inline: 12px; } | |
| 703 | + .heading-anchor { opacity: 1 !important; } | |
| 704 | +} | |
| 705 | +@media (max-width: 767px) { | |
| 706 | + :root { --header-h: 52px; --gutter: 16px; } | |
| 707 | + main.container { padding-top: var(--space-4); padding-bottom: var(--space-8); } | |
| 708 | + .only-mobile { display: inline-flex !important; } | |
| 709 | + .hide-mobile, .nav-primary, .header-ext, .search-trigger-label, .search-trigger .kbd { display: none; } | |
| 710 | + .search-trigger { width: 44px; height: 44px; padding: 0; justify-content: center; margin-left: auto; } | |
| 711 | + .brand-name { font-size: 16px; } | |
| 712 | + .stat-strip { margin-inline: calc(-1 * var(--gutter)); padding-inline: var(--gutter); } | |
| 713 | + .stat-item { min-width: 128px; flex: 0 0 auto; } | |
| 782 | 714 | .file-commit { display: none; } |
| 783 | − .blame-commit { width: 200px; min-width: 150px; } | |
| 784 | − .hero-grid { grid-template-columns: 1fr; gap: 30px; } | |
| 785 | − .hero-terminal { transform: none; } | |
| 786 | − .topnav-primary { display: none; } | |
| 787 | − .search-trigger { width: auto; } | |
| 788 | − .search-trigger span { display: none; } | |
| 789 | − .bento { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| 715 | + .file-name { width: auto; } | |
| 716 | + .file-date { width: 90px; } | |
| 717 | + .diff-gutter { width: 36px; min-width: 36px; padding: 0 5px; } | |
| 718 | + .diff-split .diff-gutter + .diff-code { border-right: 0; } | |
| 719 | + .blame-commit { width: 160px; min-width: 140px; } | |
| 720 | + .blame-date { padding-left: 0; } | |
| 721 | + .commit-detail-subject { font-size: 18px; } | |
| 722 | + .footer-bottom { flex-direction: column; align-items: flex-start; } | |
| 723 | + .section-head { flex-direction: column; align-items: stretch; } | |
| 724 | + .hero-actions .btn { flex: 1 1 auto; } | |
| 725 | + .readme-section .markdown-body, .blob-markdown { padding: var(--space-4); } | |
| 726 | + .clone-panel { width: 100%; } | |
| 727 | +} | |
| 728 | +@media (max-width: 359px) { | |
| 729 | + .brand-name { display: none; } | |
| 730 | + .brand { min-width: var(--touch); justify-content: center; } | |
| 731 | + .stat-item { min-width: 112px; } | |
| 732 | +} | |
| 733 | + | |
| 734 | +/* ═══════════════════════════════════════════ | |
| 735 | + 13. Tablet & desktop enhancements | |
| 736 | + ═══════════════════════════════════════════ */ | |
| 737 | +@media (min-width: 560px) { | |
| 738 | + .catalog-filters { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } | |
| 739 | + .repo-grid, .bento { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| 740 | + .theme-choices { grid-template-columns: repeat(4, minmax(0, 1fr)); } | |
| 741 | +} | |
| 742 | +@media (min-width: 768px) { | |
| 743 | + .sheet { inset: 0; margin: auto; width: min(480px, calc(100vw - 32px)); max-height: 80vh; border-radius: var(--radius-lg); border-bottom: 1px solid var(--border); padding: 12px 16px 16px; } | |
| 744 | + .sheet-handle { display: none; } | |
| 745 | + .sheet[open] { animation: rise-in var(--dur) var(--ease-out) both; } | |
| 746 | + .cmdk { inset: 0; margin: 10vh auto 0; width: min(680px, calc(100vw - 32px)); height: auto; max-height: 72vh; border-radius: var(--radius-xl); border-bottom: 1px solid var(--border-strong); } | |
| 747 | + .cmdk[open] { animation: rise-in var(--dur) var(--ease-out) both; } | |
| 748 | + .cmdk-footer { display: flex; } | |
| 749 | + .cmdk-list { max-height: 48vh; } | |
| 750 | + .catalog-filters { grid-template-columns: minmax(220px, 1.4fr) repeat(3, minmax(0, 1fr)); } | |
| 751 | + .commit-row { grid-template-columns: 32px minmax(0, 1fr) auto; } | |
| 752 | + .commit-row .sha-chip { grid-column: 3; grid-row: 1; } | |
| 753 | + .ref-list li { grid-template-columns: minmax(0, 1fr) auto auto; } | |
| 754 | + .ref-sub { grid-column: auto; } | |
| 755 | + .ahead-behind { grid-row: auto; } | |
| 756 | + .footer-grid { grid-template-columns: 1.6fr 1fr 1fr 1fr; } | |
| 757 | + .hero { grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); padding-top: var(--space-7); } | |
| 758 | + .hero-aside { display: block; } | |
| 759 | + .blob-header { flex-direction: row; align-items: center; flex-wrap: wrap; } | |
| 760 | + .blob-actions { margin: 0; padding: 0; margin-left: auto; overflow: visible; flex-wrap: wrap; } | |
| 761 | + .stat-strip { overflow: visible; flex-wrap: wrap; } | |
| 762 | +} | |
| 763 | +@media (min-width: 1024px) { | |
| 764 | + .home-columns { grid-template-columns: minmax(0, 1fr) 320px; } | |
| 765 | + .home-side { position: sticky; top: calc(var(--header-h) + 16px); } | |
| 766 | + .bento { grid-template-columns: repeat(3, minmax(0, 1fr)); } | |
| 790 | 767 | .bento > .repo-card:first-child { grid-column: span 2; } |
| 791 | −} | |
| 792 | −@media (max-width: 640px) { | |
| 793 | − .container { padding: 0 16px; } | |
| 794 | − .topnav-inner { gap: 10px; } | |
| 795 | − .brand-tag, .topnav-ext, .eyebrow-more { display: none; } | |
| 796 | − .hero { padding-top: 28px; margin-bottom: 44px; } | |
| 797 | − .hero-stats { grid-template-columns: repeat(2, 1fr); gap: 10px; margin-top: 34px; } | |
| 798 | − .stat-value { font-size: 28px; } | |
| 799 | − .bento, .repo-grid { grid-template-columns: 1fr; } | |
| 800 | − .bento > .repo-card:first-child { grid-column: auto; } | |
| 801 | − .bento > .repo-card:first-child .spark { width: 120px; height: 34px; } | |
| 802 | − .repo-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| 803 | − .clone-url { width: 100%; max-width: none; } | |
| 804 | − .clone-box { padding-left: 12px; } | |
| 805 | − .repo-toolbar { flex-direction: column; align-items: stretch; } | |
| 806 | − .file-date { display: none; } | |
| 807 | − .readme-section .markdown-body, .blob-markdown { padding: 18px; } | |
| 808 | − .pdf-frame { height: 70vh; min-height: 420px; } | |
| 809 | − .footer-grid { grid-template-columns: 1fr; gap: 24px; padding: 32px 16px 20px; } | |
| 810 | − .footer-bottom { flex-direction: column; align-items: flex-start; gap: 8px; padding: 14px 16px 22px; } | |
| 811 | − .repo-filters input { width: 100%; } | |
| 812 | − .repo-filters { width: 100%; } | |
| 813 | − .repo-filters select { flex: 1; } | |
| 814 | − .commit-row { flex-wrap: wrap; } | |
| 815 | − .commit-row .sha-chip { order: 3; margin-left: 42px; } | |
| 816 | − .hero-terminal { display: none; } | |
| 817 | − .term-body { min-height: 0; } | |
| 818 | − .stack { grid-template-columns: 96px 1fr; } | |
| 819 | − .ring { width: 96px; height: 96px; } | |
| 768 | + .source-layout { grid-template-columns: 280px minmax(0, 1fr); } | |
| 769 | + .file-tree-panel { display: block; position: sticky; top: calc(var(--header-h) + 16px); max-height: calc(100vh - var(--header-h) - 32px); overflow: auto; padding: 10px; } | |
| 770 | + .readme-layout.has-toc { grid-template-columns: minmax(0, 1fr) 220px; } | |
| 771 | + .readme-toc { display: block; position: sticky; top: calc(var(--header-h) + 56px); align-self: start; max-height: calc(100vh - 140px); overflow-y: auto; padding: 24px 18px 24px 0; font-size: 13px; } | |
| 772 | + .readme-toc h4 { margin-bottom: 10px; font-family: var(--font-mono); font-size: 11px; letter-spacing: var(--tracking-caps); text-transform: uppercase; color: var(--fg-faint); font-weight: 500; } | |
| 773 | + .readme-toc ol { list-style: none; border-left: 1px solid var(--border); } | |
| 774 | + .readme-toc li a { display: block; padding: 4px 12px; color: var(--fg-muted); border-left: 2px solid transparent; margin-left: -1px; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 775 | + .readme-toc li.depth-3 a { padding-left: 24px; font-size: 12.5px; } | |
| 776 | + .readme-toc li a:hover { color: var(--fg); text-decoration: none; } | |
| 777 | + .readme-toc li a.active { color: var(--fg-bright); border-left-color: var(--accent); } | |
| 778 | +} | |
| 779 | +@media (min-width: 1280px) { | |
| 780 | + .search-trigger { width: 360px; } | |
| 820 | 781 | } |
modified
src/web/assets/css/tokens.css
+274 −167
@@ -5,13 +5,13 @@ | ||
| 5 | 5 | * Author : Simon-Pierre Boucher |
| 6 | 6 | * Contact : contact@spboucher.ai |
| 7 | 7 | * File : src/web/assets/css/tokens.css |
| 8 | − * Purpose : Design tokens v4 "Monolith" — obsidian canvas, electric + ember accents, display type | |
| 8 | + * Purpose : Design tokens v2.0 — semantic surfaces/foregrounds, three themes (light · dark · midnight), type, spacing, motion | |
| 9 | 9 | * License : MIT © Simon-Pierre Boucher |
| 10 | 10 | * ───────────────────────────────────────────── |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | −/* ── Self-hosted type ──────────────────────── | |
| 14 | − Inter (UI) · JetBrains Mono (code) · Bricolage Grotesque (display) · Instrument Serif (accent italic) */ | |
| 13 | +/* ── Self-hosted type ─────────────────────── | |
| 14 | + Inter (UI) · JetBrains Mono (code) · Bricolage Grotesque (display) */ | |
| 15 | 15 | @font-face { font-family: 'Inter'; src: url('/assets/fonts/inter-400.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; } |
| 16 | 16 | @font-face { font-family: 'Inter'; src: url('/assets/fonts/inter-500.woff2') format('woff2'); font-weight: 500; font-style: normal; font-display: swap; } |
| 17 | 17 | @font-face { font-family: 'Inter'; src: url('/assets/fonts/inter-600.woff2') format('woff2'); font-weight: 600; font-style: normal; font-display: swap; } |
@@ -20,207 +20,314 @@ | ||
| 20 | 20 | @font-face { font-family: 'JetBrains Mono'; src: url('/assets/fonts/jetbrains-mono-600.woff2') format('woff2'); font-weight: 600; font-style: normal; font-display: swap; } |
| 21 | 21 | @font-face { font-family: 'Bricolage Grotesque'; src: url('/assets/fonts/bricolage-700.woff2') format('woff2'); font-weight: 700; font-style: normal; font-display: swap; } |
| 22 | 22 | @font-face { font-family: 'Bricolage Grotesque'; src: url('/assets/fonts/bricolage-800.woff2') format('woff2'); font-weight: 800; font-style: normal; font-display: swap; } |
| 23 | −@font-face { font-family: 'Instrument Serif'; src: url('/assets/fonts/instrument-serif-italic.woff2') format('woff2'); font-weight: 400; font-style: italic; font-display: swap; } | |
| 24 | 23 | |
| 24 | +/* ── Theme-independent scales ────────────── */ | |
| 25 | +:root { | |
| 26 | + /* type */ | |
| 27 | + --font-ui: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; | |
| 28 | + --font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; | |
| 29 | + --font-display: 'Bricolage Grotesque', 'Inter', -apple-system, system-ui, sans-serif; | |
| 30 | + --text-xs: 12px; | |
| 31 | + --text-sm: 13px; | |
| 32 | + --text-base: 15px; | |
| 33 | + --text-md: 16px; | |
| 34 | + --text-lg: 18px; | |
| 35 | + --text-xl: 22px; | |
| 36 | + --text-2xl: 28px; | |
| 37 | + --text-3xl: clamp(32px, 4.2vw, 44px); | |
| 38 | + --text-hero: clamp(36px, 5.4vw, 64px); | |
| 39 | + --leading: 1.55; | |
| 40 | + --tracking-tight: -0.02em; | |
| 41 | + --tracking-caps: 0.08em; | |
| 42 | + | |
| 43 | + /* spacing — 4/8 px system */ | |
| 44 | + --space-1: 4px; | |
| 45 | + --space-2: 8px; | |
| 46 | + --space-3: 12px; | |
| 47 | + --space-4: 16px; | |
| 48 | + --space-5: 20px; | |
| 49 | + --space-6: 24px; | |
| 50 | + --space-7: 32px; | |
| 51 | + --space-8: 40px; | |
| 52 | + --space-9: 48px; | |
| 53 | + --space-10: 64px; | |
| 54 | + | |
| 55 | + /* geometry */ | |
| 56 | + --radius-xs: 6px; | |
| 57 | + --radius-sm: 8px; | |
| 58 | + --radius: 10px; | |
| 59 | + --radius-lg: 14px; | |
| 60 | + --radius-xl: 20px; | |
| 61 | + --radius-pill: 999px; | |
| 62 | + --touch: 44px; /* minimum essential touch target */ | |
| 63 | + --header-h: 56px; | |
| 64 | + --container: 1240px; | |
| 65 | + --gutter: 24px; | |
| 66 | + | |
| 67 | + /* motion */ | |
| 68 | + --dur-fast: 120ms; | |
| 69 | + --dur: 180ms; | |
| 70 | + --dur-slow: 320ms; | |
| 71 | + --ease: cubic-bezier(0.2, 0.8, 0.2, 1); | |
| 72 | + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); | |
| 73 | + | |
| 74 | + /* brand (constant across themes — used only for signature elements) */ | |
| 75 | + --brand-1: #6f9bff; | |
| 76 | + --brand-2: #46d6ff; | |
| 77 | + --brand-3: #35e0c0; | |
| 78 | + --gradient-brand: linear-gradient(120deg, #4f7cff 0%, #38c8ff 55%, #35e0c0 100%); | |
| 79 | + --gradient-ember: linear-gradient(120deg, #ff5c7a 0%, #ff8a3d 55%, #ffc857 100%); | |
| 80 | +} | |
| 81 | + | |
| 82 | +/* ── Light — soft blue-white ─────────────── */ | |
| 25 | 83 | :root, |
| 84 | +html[data-theme='light'] { | |
| 85 | + color-scheme: light; | |
| 86 | + --bg: #f4f6fb; | |
| 87 | + --bg-elev: #ffffff; | |
| 88 | + --surface-0: #ffffff; | |
| 89 | + --surface-1: #f8f9fd; | |
| 90 | + --surface-2: #eef2f8; | |
| 91 | + --surface-3: #e2e8f3; | |
| 92 | + --glass: rgba(255, 255, 255, 0.82); | |
| 93 | + --border: rgba(16, 28, 60, 0.10); | |
| 94 | + --border-strong: rgba(16, 28, 60, 0.20); | |
| 95 | + --edge-light: rgba(255, 255, 255, 0.9); | |
| 96 | + --grid-line: rgba(20, 40, 90, 0.06); | |
| 97 | + | |
| 98 | + --fg: #16203a; | |
| 99 | + --fg-bright: #0a1020; | |
| 100 | + --fg-muted: #5b6680; | |
| 101 | + --fg-faint: #8a93a8; | |
| 102 | + --on-accent: #ffffff; | |
| 103 | + | |
| 104 | + --accent: #2f5bea; | |
| 105 | + --accent-hover: #2448c4; | |
| 106 | + --accent-soft: rgba(47, 91, 234, 0.10); | |
| 107 | + --accent-glow: rgba(47, 91, 234, 0.28); | |
| 108 | + --accent-2: #0b8f72; | |
| 109 | + --accent-2-soft: rgba(11, 143, 114, 0.12); | |
| 110 | + --accent-3: #7c3fd6; | |
| 111 | + --accent-3-soft: rgba(124, 63, 214, 0.10); | |
| 112 | + --cyan: #0a8fd0; | |
| 113 | + --ember: #e06a1a; | |
| 114 | + --ember-2: #b57a00; | |
| 115 | + --ember-soft: rgba(224, 106, 26, 0.12); | |
| 116 | + --ember-glow: rgba(224, 106, 26, 0.3); | |
| 117 | + | |
| 118 | + --ok: #1a7f37; | |
| 119 | + --ok-soft: rgba(26, 127, 55, 0.12); | |
| 120 | + --warn: #9a6700; | |
| 121 | + --warn-soft: rgba(154, 103, 0, 0.12); | |
| 122 | + --danger: #cf222e; | |
| 123 | + --danger-soft: rgba(207, 34, 46, 0.10); | |
| 124 | + --info: #2f5bea; | |
| 125 | + --info-soft: rgba(47, 91, 234, 0.10); | |
| 126 | + | |
| 127 | + --add-bg: rgba(26, 127, 55, 0.10); | |
| 128 | + --add-bg-strong: rgba(26, 127, 55, 0.22); | |
| 129 | + --add-text: #1a7f37; | |
| 130 | + --del-bg: rgba(207, 34, 46, 0.09); | |
| 131 | + --del-bg-strong: rgba(207, 34, 46, 0.2); | |
| 132 | + --del-text: #cf222e; | |
| 133 | + --hunk-bg: rgba(47, 91, 234, 0.07); | |
| 134 | + | |
| 135 | + --code-bg: #f8f9fd; | |
| 136 | + --code-head: #eef1f8; | |
| 137 | + --chip-bg: rgba(47, 91, 234, 0.10); | |
| 138 | + | |
| 139 | + --heat-0: rgba(20, 40, 90, 0.07); | |
| 140 | + --heat-1: #bfdcff; | |
| 141 | + --heat-2: #6fb0f5; | |
| 142 | + --heat-3: #2b7fd8; | |
| 143 | + --heat-4: #e06a1a; | |
| 144 | + | |
| 145 | + --gradient-text: linear-gradient(100deg, #0a1020 0%, #1f2f6e 50%, #2f5bea 100%); | |
| 146 | + --shadow-1: 0 1px 2px rgba(16, 24, 40, 0.06); | |
| 147 | + --shadow-2: 0 8px 24px -12px rgba(16, 24, 40, 0.18); | |
| 148 | + --shadow-card: 0 18px 50px -24px rgba(16, 24, 40, 0.28); | |
| 149 | + --shadow-pop: 0 30px 80px -30px rgba(16, 24, 40, 0.45); | |
| 150 | + --ring: 0 0 0 3px rgba(47, 91, 234, 0.32); | |
| 151 | + --nav-bg: rgba(244, 246, 251, 0.82); | |
| 152 | + --backdrop: rgba(18, 24, 40, 0.45); | |
| 153 | + --mesh-1: rgba(79, 124, 255, 0.14); | |
| 154 | + --mesh-2: rgba(53, 224, 192, 0.10); | |
| 155 | + --mesh-3: rgba(124, 63, 214, 0.08); | |
| 156 | + --noise-opacity: 0.035; | |
| 157 | +} | |
| 158 | + | |
| 159 | +/* ── Dark — charcoal / slate ─────────────── */ | |
| 26 | 160 | html[data-theme='dark'] { |
| 27 | − /* canvas — obsidian, near-black with a cold blue cast */ | |
| 28 | − --bg: #04060b; | |
| 29 | − --bg-elev: #070a12; | |
| 30 | − --mesh-1: rgba(124, 154, 255, 0.16); | |
| 31 | − --mesh-2: rgba(52, 227, 176, 0.10); | |
| 32 | − --mesh-3: rgba(192, 132, 252, 0.13); | |
| 33 | − --mesh-4: rgba(56, 207, 253, 0.11); | |
| 34 | − --mesh-5: rgba(255, 138, 61, 0.07); | |
| 161 | + color-scheme: dark; | |
| 162 | + --bg: #15181f; | |
| 163 | + --bg-elev: #1b1f27; | |
| 164 | + --surface-0: #1b1f27; | |
| 165 | + --surface-1: #20252f; | |
| 166 | + --surface-2: #272d38; | |
| 167 | + --surface-3: #303744; | |
| 168 | + --glass: rgba(27, 31, 39, 0.78); | |
| 169 | + --border: rgba(255, 255, 255, 0.08); | |
| 170 | + --border-strong: rgba(255, 255, 255, 0.16); | |
| 171 | + --edge-light: rgba(255, 255, 255, 0.05); | |
| 35 | 172 | --grid-line: rgba(255, 255, 255, 0.035); |
| 36 | 173 | |
| 37 | − /* surfaces & lines */ | |
| 38 | − --surface: #0b0f18; | |
| 39 | − --surface-2: #111726; | |
| 40 | − --surface-3: #182034; | |
| 41 | − --surface-grad: linear-gradient(180deg, rgba(20, 26, 42, 0.92) 0%, rgba(10, 14, 23, 0.94) 100%); | |
| 42 | − --glass: rgba(11, 15, 24, 0.66); | |
| 43 | − --border: rgba(255, 255, 255, 0.075); | |
| 44 | − --border-strong: rgba(255, 255, 255, 0.15); | |
| 45 | − --edge-light: rgba(255, 255, 255, 0.07); | |
| 46 | − | |
| 47 | − /* text */ | |
| 48 | − --text: #e6e9f2; | |
| 49 | − --text-bright: #ffffff; | |
| 50 | − --muted: #8f97ad; | |
| 51 | − --faint: #5a637a; | |
| 52 | − | |
| 53 | − /* accents — electric family */ | |
| 54 | − --accent: #86a4ff; | |
| 55 | − --accent-hover: #a7bcff; | |
| 56 | − --accent-soft: rgba(134, 164, 255, 0.14); | |
| 57 | − --accent-glow: rgba(134, 164, 255, 0.42); | |
| 58 | − --accent-2: #3ff0bd; | |
| 59 | − --accent-2-soft: rgba(63, 240, 189, 0.13); | |
| 174 | + --fg: #e8ebf1; | |
| 175 | + --fg-bright: #ffffff; | |
| 176 | + --fg-muted: #9aa3b5; | |
| 177 | + --fg-faint: #6b7383; | |
| 178 | + --on-accent: #0b1020; | |
| 179 | + | |
| 180 | + --accent: #7ea2ff; | |
| 181 | + --accent-hover: #9db8ff; | |
| 182 | + --accent-soft: rgba(126, 162, 255, 0.14); | |
| 183 | + --accent-glow: rgba(126, 162, 255, 0.35); | |
| 184 | + --accent-2: #3fd3b3; | |
| 185 | + --accent-2-soft: rgba(63, 211, 179, 0.14); | |
| 186 | + --accent-3: #c39bff; | |
| 187 | + --accent-3-soft: rgba(195, 155, 255, 0.14); | |
| 188 | + --cyan: #4fd0ff; | |
| 189 | + --ember: #ff8a3d; | |
| 190 | + --ember-2: #ffc857; | |
| 191 | + --ember-soft: rgba(255, 138, 61, 0.14); | |
| 192 | + --ember-glow: rgba(255, 138, 61, 0.4); | |
| 193 | + | |
| 194 | + --ok: #4ad46f; | |
| 195 | + --ok-soft: rgba(74, 212, 111, 0.14); | |
| 196 | + --warn: #f2c14e; | |
| 197 | + --warn-soft: rgba(242, 193, 78, 0.14); | |
| 198 | + --danger: #ff6b6b; | |
| 199 | + --danger-soft: rgba(255, 107, 107, 0.14); | |
| 200 | + --info: #7ea2ff; | |
| 201 | + --info-soft: rgba(126, 162, 255, 0.14); | |
| 202 | + | |
| 203 | + --add-bg: rgba(74, 212, 111, 0.12); | |
| 204 | + --add-bg-strong: rgba(74, 212, 111, 0.26); | |
| 205 | + --add-text: #6ee88e; | |
| 206 | + --del-bg: rgba(255, 107, 107, 0.11); | |
| 207 | + --del-bg-strong: rgba(255, 107, 107, 0.26); | |
| 208 | + --del-text: #ff8f8f; | |
| 209 | + --hunk-bg: rgba(126, 162, 255, 0.09); | |
| 210 | + | |
| 211 | + --code-bg: #171a21; | |
| 212 | + --code-head: #1e222b; | |
| 213 | + --chip-bg: rgba(126, 162, 255, 0.14); | |
| 214 | + | |
| 215 | + --heat-0: rgba(255, 255, 255, 0.05); | |
| 216 | + --heat-1: #243a63; | |
| 217 | + --heat-2: #2d6cc0; | |
| 218 | + --heat-3: #4fd0ff; | |
| 219 | + --heat-4: #ffc857; | |
| 220 | + | |
| 221 | + --gradient-text: linear-gradient(100deg, #ffffff 0%, #d7e0ff 45%, #7ea2ff 100%); | |
| 222 | + --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.4); | |
| 223 | + --shadow-2: 0 8px 24px -12px rgba(0, 0, 0, 0.6); | |
| 224 | + --shadow-card: 0 18px 50px -24px rgba(0, 0, 0, 0.7); | |
| 225 | + --shadow-pop: 0 30px 80px -30px rgba(0, 0, 0, 0.85); | |
| 226 | + --ring: 0 0 0 3px rgba(126, 162, 255, 0.4); | |
| 227 | + --nav-bg: rgba(21, 24, 31, 0.8); | |
| 228 | + --backdrop: rgba(4, 6, 12, 0.6); | |
| 229 | + --mesh-1: rgba(126, 162, 255, 0.12); | |
| 230 | + --mesh-2: rgba(63, 211, 179, 0.08); | |
| 231 | + --mesh-3: rgba(195, 155, 255, 0.08); | |
| 232 | + --noise-opacity: 0.05; | |
| 233 | +} | |
| 234 | + | |
| 235 | +/* ── Midnight — deep navy, electric blue, restrained turquoise ── */ | |
| 236 | +html[data-theme='midnight'] { | |
| 237 | + color-scheme: dark; | |
| 238 | + --bg: #070b16; | |
| 239 | + --bg-elev: #0c1222; | |
| 240 | + --surface-0: #0c1222; | |
| 241 | + --surface-1: #111a2e; | |
| 242 | + --surface-2: #172238; | |
| 243 | + --surface-3: #1e2b45; | |
| 244 | + --glass: rgba(12, 18, 34, 0.76); | |
| 245 | + --border: rgba(140, 170, 255, 0.12); | |
| 246 | + --border-strong: rgba(140, 170, 255, 0.24); | |
| 247 | + --edge-light: rgba(160, 190, 255, 0.06); | |
| 248 | + --grid-line: rgba(140, 170, 255, 0.05); | |
| 249 | + | |
| 250 | + --fg: #e6ebf7; | |
| 251 | + --fg-bright: #ffffff; | |
| 252 | + --fg-muted: #93a0bd; | |
| 253 | + --fg-faint: #5f6b88; | |
| 254 | + --on-accent: #06091a; | |
| 255 | + | |
| 256 | + --accent: #6f9bff; | |
| 257 | + --accent-hover: #92b3ff; | |
| 258 | + --accent-soft: rgba(111, 155, 255, 0.15); | |
| 259 | + --accent-glow: rgba(111, 155, 255, 0.42); | |
| 260 | + --accent-2: #35e0c0; | |
| 261 | + --accent-2-soft: rgba(53, 224, 192, 0.13); | |
| 60 | 262 | --accent-3: #c996ff; |
| 61 | 263 | --accent-3-soft: rgba(201, 150, 255, 0.13); |
| 62 | 264 | --cyan: #46d6ff; |
| 63 | − /* ember — the warm counterpoint (activity, heat, streaks) */ | |
| 64 | 265 | --ember: #ff8a3d; |
| 65 | 266 | --ember-2: #ffc857; |
| 66 | 267 | --ember-soft: rgba(255, 138, 61, 0.14); |
| 67 | 268 | --ember-glow: rgba(255, 138, 61, 0.45); |
| 68 | 269 | |
| 69 | − /* gradients */ | |
| 70 | − --gradient-brand: linear-gradient(120deg, #86a4ff 0%, #46d6ff 52%, #3ff0bd 100%); | |
| 71 | − --gradient-hot: linear-gradient(115deg, #c996ff 0%, #86a4ff 30%, #46d6ff 62%, #3ff0bd 100%); | |
| 72 | − --gradient-ember: linear-gradient(120deg, #ff5c7a 0%, #ff8a3d 55%, #ffc857 100%); | |
| 73 | − --gradient-text: linear-gradient(100deg, #ffffff 0%, #d7e0ff 40%, #86a4ff 75%, #46d6ff 100%); | |
| 74 | − --gradient-border: linear-gradient(140deg, rgba(134, 164, 255, 0.9), rgba(70, 214, 255, 0.5) 45%, rgba(63, 240, 189, 0.8)); | |
| 75 | − | |
| 76 | − /* status */ | |
| 270 | + --ok: #3fd06a; | |
| 271 | + --ok-soft: rgba(63, 208, 106, 0.14); | |
| 272 | + --warn: #ffc857; | |
| 273 | + --warn-soft: rgba(255, 200, 87, 0.14); | |
| 77 | 274 | --danger: #ff6b6b; |
| 78 | − --success: #3fd06a; | |
| 79 | − --warning: #ffc857; | |
| 275 | + --danger-soft: rgba(255, 107, 107, 0.14); | |
| 276 | + --info: #6f9bff; | |
| 277 | + --info-soft: rgba(111, 155, 255, 0.15); | |
| 80 | 278 | |
| 81 | − /* diff */ | |
| 82 | 279 | --add-bg: rgba(63, 208, 106, 0.12); |
| 83 | 280 | --add-bg-strong: rgba(63, 208, 106, 0.26); |
| 84 | 281 | --add-text: #5fe888; |
| 85 | 282 | --del-bg: rgba(255, 99, 99, 0.11); |
| 86 | 283 | --del-bg-strong: rgba(255, 99, 99, 0.26); |
| 87 | 284 | --del-text: #ff8a8a; |
| 88 | − --hunk-bg: rgba(134, 164, 255, 0.08); | |
| 285 | + --hunk-bg: rgba(111, 155, 255, 0.09); | |
| 89 | 286 | |
| 90 | − /* code */ | |
| 91 | − --code-bg: #070a11; | |
| 92 | − --code-head: #0d1220; | |
| 93 | − --chip-bg: rgba(134, 164, 255, 0.13); | |
| 287 | + --code-bg: #080d1a; | |
| 288 | + --code-head: #0e1526; | |
| 289 | + --chip-bg: rgba(111, 155, 255, 0.14); | |
| 94 | 290 | |
| 95 | − /* heatmap — cold to ember */ | |
| 96 | − --heat-0: rgba(255, 255, 255, 0.045); | |
| 291 | + --heat-0: rgba(140, 170, 255, 0.07); | |
| 97 | 292 | --heat-1: #173a5e; |
| 98 | 293 | --heat-2: #1c6aa8; |
| 99 | 294 | --heat-3: #46d6ff; |
| 100 | 295 | --heat-4: #ffc857; |
| 101 | − --heat-glow: rgba(255, 200, 87, 0.65); | |
| 102 | 296 | |
| 103 | − /* geometry & type */ | |
| 104 | − --radius-sm: 8px; | |
| 105 | − --radius: 12px; | |
| 106 | − --radius-lg: 18px; | |
| 107 | − --radius-xl: 26px; | |
| 108 | − --font-ui: 'Inter', -apple-system, 'Segoe UI', sans-serif; | |
| 109 | − --font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace; | |
| 110 | − --font-display: 'Bricolage Grotesque', 'Inter', -apple-system, sans-serif; | |
| 111 | − --font-serif: 'Instrument Serif', 'Iowan Old Style', Georgia, serif; | |
| 112 | − | |
| 113 | − /* depth */ | |
| 297 | + --gradient-text: linear-gradient(100deg, #ffffff 0%, #d7e0ff 40%, #6f9bff 75%, #46d6ff 100%); | |
| 114 | 298 | --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.5); |
| 115 | 299 | --shadow-2: 0 10px 30px -12px rgba(0, 0, 0, 0.7); |
| 116 | 300 | --shadow-card: 0 24px 60px -24px rgba(0, 0, 0, 0.85), 0 0 50px -20px var(--accent-glow); |
| 117 | 301 | --shadow-pop: 0 30px 90px -26px rgba(0, 0, 0, 0.9); |
| 118 | − --ring: 0 0 0 3px rgba(134, 164, 255, 0.35); | |
| 119 | − --nav-bg: rgba(4, 6, 11, 0.62); | |
| 302 | + --ring: 0 0 0 3px rgba(111, 155, 255, 0.42); | |
| 303 | + --nav-bg: rgba(7, 11, 22, 0.72); | |
| 120 | 304 | --backdrop: rgba(2, 3, 7, 0.7); |
| 121 | − color-scheme: dark; | |
| 305 | + --mesh-1: rgba(111, 155, 255, 0.16); | |
| 306 | + --mesh-2: rgba(53, 224, 192, 0.10); | |
| 307 | + --mesh-3: rgba(201, 150, 255, 0.12); | |
| 308 | + --noise-opacity: 0.06; | |
| 122 | 309 | } |
| 123 | 310 | |
| 124 | −html[data-theme='light'] { | |
| 125 | − --bg: #f3f5fb; | |
| 126 | − --bg-elev: #ffffff; | |
| 127 | − --mesh-1: rgba(76, 111, 255, 0.13); | |
| 128 | − --mesh-2: rgba(12, 166, 120, 0.08); | |
| 129 | − --mesh-3: rgba(147, 51, 234, 0.08); | |
| 130 | − --mesh-4: rgba(2, 148, 212, 0.09); | |
| 131 | − --mesh-5: rgba(255, 120, 40, 0.06); | |
| 132 | − --grid-line: rgba(20, 30, 60, 0.05); | |
| 133 | − | |
| 134 | − --surface: #ffffff; | |
| 135 | − --surface-2: #f1f4fa; | |
| 136 | − --surface-3: #e7ecf6; | |
| 137 | − --surface-grad: linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(250, 251, 254, 0.96) 100%); | |
| 138 | − --glass: rgba(255, 255, 255, 0.7); | |
| 139 | − --border: rgba(20, 30, 60, 0.09); | |
| 140 | − --border-strong: rgba(20, 30, 60, 0.18); | |
| 141 | − --edge-light: rgba(255, 255, 255, 0.9); | |
| 142 | − | |
| 143 | − --text: #1a2133; | |
| 144 | − --text-bright: #0a1020; | |
| 145 | − --muted: #58627b; | |
| 146 | − --faint: #8b94aa; | |
| 147 | − | |
| 148 | − --accent: #4360f0; | |
| 149 | − --accent-hover: #2f4bd6; | |
| 150 | − --accent-soft: rgba(67, 96, 240, 0.1); | |
| 151 | − --accent-glow: rgba(67, 96, 240, 0.3); | |
| 152 | − --accent-2: #0a9f74; | |
| 153 | − --accent-2-soft: rgba(10, 159, 116, 0.11); | |
| 154 | − --accent-3: #8b32e0; | |
| 155 | − --accent-3-soft: rgba(139, 50, 224, 0.1); | |
| 156 | − --cyan: #0a8fd0; | |
| 157 | − --ember: #f0741e; | |
| 158 | − --ember-2: #d99a00; | |
| 159 | − --ember-soft: rgba(240, 116, 30, 0.12); | |
| 160 | − --ember-glow: rgba(240, 116, 30, 0.35); | |
| 161 | − | |
| 162 | − --gradient-brand: linear-gradient(120deg, #4360f0 0%, #0a8fd0 52%, #0a9f74 100%); | |
| 163 | − --gradient-hot: linear-gradient(115deg, #8b32e0 0%, #4360f0 30%, #0a8fd0 62%, #0a9f74 100%); | |
| 164 | − --gradient-ember: linear-gradient(120deg, #e8365d 0%, #f0741e 55%, #d99a00 100%); | |
| 165 | − --gradient-text: linear-gradient(100deg, #0a1020 0%, #1f2f6e 45%, #4360f0 85%); | |
| 166 | − --gradient-border: linear-gradient(140deg, rgba(67, 96, 240, 0.7), rgba(10, 143, 208, 0.45) 45%, rgba(10, 159, 116, 0.6)); | |
| 167 | − | |
| 168 | − --danger: #d33636; | |
| 169 | − --success: #1a7f37; | |
| 170 | − --warning: #9a6700; | |
| 171 | − | |
| 172 | − --add-bg: rgba(26, 127, 55, 0.1); | |
| 173 | − --add-bg-strong: rgba(26, 127, 55, 0.22); | |
| 174 | − --add-text: #1a7f37; | |
| 175 | − --del-bg: rgba(207, 34, 46, 0.09); | |
| 176 | − --del-bg-strong: rgba(207, 34, 46, 0.2); | |
| 177 | − --del-text: #cf222e; | |
| 178 | − --hunk-bg: rgba(67, 96, 240, 0.07); | |
| 179 | − | |
| 180 | − --code-bg: #f8f9fd; | |
| 181 | − --code-head: #eef1f8; | |
| 182 | − --chip-bg: rgba(67, 96, 240, 0.1); | |
| 183 | − | |
| 184 | − --heat-0: rgba(20, 30, 60, 0.07); | |
| 185 | − --heat-1: #bfe0ff; | |
| 186 | − --heat-2: #63b3f5; | |
| 187 | − --heat-3: #1f7fd6; | |
| 188 | − --heat-4: #f0741e; | |
| 189 | − --heat-glow: rgba(240, 116, 30, 0.45); | |
| 190 | − | |
| 191 | − --shadow-1: 0 1px 2px rgba(16, 24, 40, 0.06); | |
| 192 | − --shadow-2: 0 10px 30px -14px rgba(16, 24, 40, 0.2); | |
| 193 | − --shadow-card: 0 24px 60px -28px rgba(16, 24, 40, 0.3), 0 0 40px -22px var(--accent-glow); | |
| 194 | − --shadow-pop: 0 30px 90px -30px rgba(16, 24, 40, 0.4); | |
| 195 | − --ring: 0 0 0 3px rgba(67, 96, 240, 0.28); | |
| 196 | − --nav-bg: rgba(243, 245, 251, 0.7); | |
| 197 | − --backdrop: rgba(18, 24, 40, 0.42); | |
| 198 | − color-scheme: light; | |
| 199 | −} | |
| 200 | − | |
| 201 | −/* Shiki dual-theme switch */ | |
| 202 | −html[data-theme='dark'] .shiki, | |
| 203 | −html[data-theme='dark'] .shiki span { | |
| 204 | − color: var(--shiki-dark) !important; | |
| 205 | − background-color: transparent !important; | |
| 206 | −} | |
| 207 | −html[data-theme='light'] .shiki, | |
| 208 | −html[data-theme='light'] .shiki span { | |
| 209 | − color: var(--shiki-light) !important; | |
| 210 | − background-color: transparent !important; | |
| 311 | +/* ── Legacy aliases (v4 names still used by markdown.css / older rules) ── */ | |
| 312 | +:root, html[data-theme] { | |
| 313 | + --text: var(--fg); | |
| 314 | + --text-bright: var(--fg-bright); | |
| 315 | + --muted: var(--fg-muted); | |
| 316 | + --faint: var(--fg-faint); | |
| 317 | + --surface: var(--surface-0); | |
| 318 | + --surface-grad: var(--surface-0); | |
| 319 | + --success: var(--ok); | |
| 320 | + --warning: var(--warn); | |
| 321 | + --radius-sm-legacy: var(--radius-sm); | |
| 211 | 322 | } |
| 212 | 323 | |
| 213 | −/* Cross-document view transitions (Chromium/Safari) — soft crossfade between pages */ | |
| 214 | −@view-transition { navigation: auto; } | |
| 215 | −::view-transition-old(root) { animation: 160ms ease-out both vt-fade-out; } | |
| 216 | −::view-transition-new(root) { animation: 220ms ease-out both vt-fade-in; } | |
| 217 | −@keyframes vt-fade-out { to { opacity: 0; } } | |
| 218 | −@keyframes vt-fade-in { from { opacity: 0; } } | |
| 324 | +/* ── Shiki dual-theme switch ─────────────── */ | |
| 325 | +html[data-theme='light'] .shiki, html[data-theme='light'] .shiki span { color: var(--shiki-light) !important; background-color: transparent !important; } | |
| 326 | +html[data-theme='dark'] .shiki, html[data-theme='dark'] .shiki span, | |
| 327 | +html[data-theme='midnight'] .shiki, html[data-theme='midnight'] .shiki span { color: var(--shiki-dark) !important; background-color: transparent !important; } | |
| 219 | 328 | |
| 329 | +/* ── Motion preferences ──────────────────── */ | |
| 220 | 330 | @media (prefers-reduced-motion: reduce) { |
| 221 | − *, *::before, *::after { | |
| 222 | − animation-duration: 0.01ms !important; | |
| 223 | − transition-duration: 0.01ms !important; | |
| 224 | − } | |
| 225 | − ::view-transition-old(root), ::view-transition-new(root) { animation: none !important; } | |
| 331 | + :root { --dur-fast: 0ms; --dur: 0ms; --dur-slow: 0ms; } | |
| 332 | + *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } | |
| 226 | 333 | } |
modified
src/web/assets/js/app.js
+375 −411
@@ -5,7 +5,7 @@ | ||
| 5 | 5 | * Author : Simon-Pierre Boucher |
| 6 | 6 | * Contact : contact@spboucher.ai |
| 7 | 7 | * File : src/web/assets/js/app.js |
| 8 | − * Purpose : Vanilla JS layer v4 — theme, ⌘K commands, constellation hero, count-up, reveal, TOC, terminal | |
| 8 | + * Purpose : Vanilla JS layer v2.0 — themes, dialogs/sheets, ⌘K search, copy/toast, file tree, code & diff helpers, TOC | |
| 9 | 9 | * License : MIT © Simon-Pierre Boucher |
| 10 | 10 | * ───────────────────────────────────────────── |
| 11 | 11 | */ |
@@ -13,99 +13,183 @@ | ||
| 13 | 13 | (function () { |
| 14 | 14 | 'use strict'; |
| 15 | 15 | |
| 16 | − document.documentElement.classList.remove('no-js'); | |
| 16 | + var doc = document; | |
| 17 | + var root = doc.documentElement; | |
| 17 | 18 | var reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches; |
| 18 | 19 | var fmt = function (n) { return new Intl.NumberFormat('en-US').format(n); }; |
| 20 | + var $ = function (sel, scope) { return (scope || doc).querySelector(sel); }; | |
| 21 | + var $$ = function (sel, scope) { return Array.prototype.slice.call((scope || doc).querySelectorAll(sel)); }; | |
| 19 | 22 | |
| 20 | − /* ── Theme toggle ─────────────────────────── */ | |
| 21 | − var toggle = document.getElementById('theme-toggle'); | |
| 22 | − var setTheme = function (next) { | |
| 23 | − document.documentElement.setAttribute('data-theme', next); | |
| 24 | − try { localStorage.setItem('spbgit-theme', next); } catch (e) { /* ignore */ } | |
| 25 | − document.dispatchEvent(new CustomEvent('spbgit:theme', { detail: next })); | |
| 23 | + /* ── Toast ────────────────────────────────── */ | |
| 24 | + var toastHost = $('#toasts'); | |
| 25 | + var ICONS = { | |
| 26 | + ok: '<svg class="ic" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/></svg>', | |
| 27 | + error: '<svg class="ic" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2.343 13.657A8 8 0 1 1 13.658 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042L6.94 8 4.97 9.97a.749.749 0 0 0 .326 1.275.749.749 0 0 0 .734-.215L8 9.06l1.97 1.97a.749.749 0 0 0 1.275-.326.749.749 0 0 0-.215-.734L9.06 8l1.97-1.97a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215L8 6.94Z"/></svg>' | |
| 26 | 28 | }; |
| 27 | − var flipTheme = function () { | |
| 28 | − setTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'); | |
| 29 | + window.spbToast = function (message, kind) { | |
| 30 | + if (!toastHost) return; | |
| 31 | + var el = doc.createElement('div'); | |
| 32 | + el.className = 'toast is-' + (kind || 'ok'); | |
| 33 | + el.setAttribute('role', 'status'); | |
| 34 | + el.innerHTML = ICONS[kind === 'error' ? 'error' : 'ok'] + '<span></span>'; | |
| 35 | + el.lastChild.textContent = message; | |
| 36 | + toastHost.appendChild(el); | |
| 37 | + setTimeout(function () { el.remove(); }, 2600); | |
| 29 | 38 | }; |
| 30 | − if (toggle) toggle.addEventListener('click', flipTheme); | |
| 31 | 39 | |
| 32 | − /* ── Copy buttons ─────────────────────────── */ | |
| 33 | − document.addEventListener('click', function (event) { | |
| 34 | − var btn = event.target.closest('.copy-btn'); | |
| 35 | − if (!btn) return; | |
| 36 | − var text = btn.getAttribute('data-copy'); | |
| 37 | − if (!text) return; | |
| 38 | − navigator.clipboard.writeText(text).then(function () { | |
| 39 | − btn.classList.add('copied'); | |
| 40 | − var prev = btn.textContent; | |
| 41 | − btn.textContent = '✓ Copied'; | |
| 42 | − setTimeout(function () { | |
| 43 | − btn.classList.remove('copied'); | |
| 44 | − btn.textContent = prev; | |
| 45 | − }, 1300); | |
| 40 | + /* ── Confirmation dialog primitive ────────── */ | |
| 41 | + window.spbConfirm = function (opts) { | |
| 42 | + return new Promise(function (resolve) { | |
| 43 | + var dlg = doc.createElement('dialog'); | |
| 44 | + dlg.className = 'confirm'; | |
| 45 | + dlg.innerHTML = '<h2></h2><p></p><div class="btn-row"><button type="button" class="btn" value="cancel"></button><button type="button" class="btn btn-danger" value="ok"></button></div>'; | |
| 46 | + $('h2', dlg).textContent = opts.title || 'Are you sure?'; | |
| 47 | + $('p', dlg).textContent = opts.message || ''; | |
| 48 | + $('[value=cancel]', dlg).textContent = opts.cancelLabel || 'Cancel'; | |
| 49 | + $('[value=ok]', dlg).textContent = opts.confirmLabel || 'Confirm'; | |
| 50 | + dlg.addEventListener('click', function (e) { | |
| 51 | + var b = e.target.closest('button'); | |
| 52 | + if (b) { dlg.close(b.value); } | |
| 53 | + }); | |
| 54 | + dlg.addEventListener('close', function () { resolve(dlg.returnValue === 'ok'); dlg.remove(); }); | |
| 55 | + doc.body.appendChild(dlg); | |
| 56 | + dlg.showModal(); | |
| 46 | 57 | }); |
| 47 | − }); | |
| 58 | + }; | |
| 48 | 59 | |
| 49 | − /* ── Copy code blocks (fences + blob view) ── */ | |
| 50 | − document.addEventListener('click', function (event) { | |
| 60 | + /* ── Clipboard ────────────────────────────── */ | |
| 61 | + var copyText = function (text) { | |
| 62 | + if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(text); | |
| 63 | + return new Promise(function (resolve, reject) { | |
| 64 | + var ta = doc.createElement('textarea'); | |
| 65 | + ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.opacity = '0'; | |
| 66 | + doc.body.appendChild(ta); ta.select(); | |
| 67 | + try { doc.execCommand('copy'); resolve(); } catch (e) { reject(e); } | |
| 68 | + ta.remove(); | |
| 69 | + }); | |
| 70 | + }; | |
| 71 | + var flash = function (btn, label) { | |
| 72 | + btn.classList.add('copied'); | |
| 73 | + var span = btn.querySelector('.copy-label') || btn.querySelector('.code-copy-label'); | |
| 74 | + var prev = span ? span.textContent : null; | |
| 75 | + if (span) span.textContent = label || 'Copied'; | |
| 76 | + setTimeout(function () { btn.classList.remove('copied'); if (span && prev !== null) span.textContent = prev; }, 1400); | |
| 77 | + }; | |
| 78 | + doc.addEventListener('click', function (event) { | |
| 79 | + var btn = event.target.closest('.copy-btn[data-copy]'); | |
| 80 | + if (!btn) return; | |
| 81 | + copyText(btn.getAttribute('data-copy')).then(function () { flash(btn); window.spbToast('Copied to clipboard'); }) | |
| 82 | + .catch(function () { window.spbToast('Copy failed — select the text manually', 'error'); }); | |
| 83 | + }); | |
| 84 | + doc.addEventListener('click', function (event) { | |
| 51 | 85 | var btn = event.target.closest('[data-code-copy]'); |
| 52 | 86 | if (!btn) return; |
| 53 | 87 | var scope = btn.getAttribute('data-code-target'); |
| 54 | − var container = scope ? document.querySelector(scope) : btn.closest('.code-block'); | |
| 88 | + var container = scope ? $(scope) : btn.closest('.code-block'); | |
| 55 | 89 | var pre = container ? container.querySelector('pre') : null; |
| 56 | 90 | if (!pre) return; |
| 57 | 91 | var clone = pre.cloneNode(true); |
| 58 | − clone.querySelectorAll('a.ln').forEach(function (a) { a.remove(); }); | |
| 59 | − navigator.clipboard.writeText(clone.textContent).then(function () { | |
| 60 | − btn.classList.add('copied'); | |
| 61 | − var label = btn.querySelector('.code-copy-label'); | |
| 62 | − var prev = label ? label.textContent : null; | |
| 63 | − if (label) label.textContent = 'Copied!'; | |
| 64 | − setTimeout(function () { | |
| 65 | − btn.classList.remove('copied'); | |
| 66 | − if (label && prev) label.textContent = prev; | |
| 67 | − }, 1400); | |
| 92 | + $$('a.ln', clone).forEach(function (a) { a.remove(); }); | |
| 93 | + copyText(clone.textContent).then(function () { flash(btn, 'Copied!'); }).catch(function () { window.spbToast('Copy failed', 'error'); }); | |
| 94 | + }); | |
| 95 | + | |
| 96 | + /* ── Dialogs: sheet / popover / palette ───── */ | |
| 97 | + var openDialog = function (dlg) { | |
| 98 | + if (!dlg || dlg.open) return; | |
| 99 | + if (typeof dlg.showModal !== 'function') { dlg.setAttribute('open', ''); return; } | |
| 100 | + dlg.showModal(); | |
| 101 | + doc.body.classList.add('dialog-open'); | |
| 102 | + }; | |
| 103 | + var closeDialog = function (dlg) { | |
| 104 | + if (!dlg || !dlg.open) return; | |
| 105 | + dlg.close(); | |
| 106 | + }; | |
| 107 | + $$('dialog').forEach(function (dlg) { | |
| 108 | + dlg.addEventListener('close', function () { if (!$('dialog[open]')) doc.body.classList.remove('dialog-open'); }); | |
| 109 | + dlg.addEventListener('click', function (event) { | |
| 110 | + if (event.target.closest('[data-close]')) { closeDialog(dlg); return; } | |
| 111 | + // click on the backdrop (outside the dialog's own box) | |
| 112 | + var r = dlg.getBoundingClientRect(); | |
| 113 | + var inside = event.clientX >= r.left && event.clientX <= r.right && event.clientY >= r.top && event.clientY <= r.bottom; | |
| 114 | + if (event.target === dlg && !inside) closeDialog(dlg); | |
| 68 | 115 | }); |
| 69 | 116 | }); |
| 117 | + var navSheet = $('#nav-sheet'); | |
| 118 | + var navOpen = $('#nav-open'); | |
| 119 | + if (navOpen && navSheet) navOpen.addEventListener('click', function () { openDialog(navSheet); }); | |
| 70 | 120 | |
| 71 | − /* ── Home: filter + sort repo list ────────── */ | |
| 72 | − var list = document.getElementById('repo-list'); | |
| 73 | − if (list) { | |
| 74 | − var filterInput = document.getElementById('repo-filter'); | |
| 75 | − var sortSelect = document.getElementById('repo-sort'); | |
| 76 | − var langSelect = document.getElementById('repo-lang'); | |
| 77 | − var topicSelect = document.getElementById('repo-topic'); | |
| 78 | − var emptyNote = document.getElementById('repo-list-empty'); | |
| 79 | − var countBadge = document.getElementById('repo-count'); | |
| 121 | + /* ── Theme (auto · light · dark · midnight) ── */ | |
| 122 | + var THEMES = ['light', 'dark', 'midnight']; | |
| 123 | + var mql = matchMedia('(prefers-color-scheme: dark)'); | |
| 124 | + var resolveTheme = function (pref) { return pref === 'auto' ? (mql.matches ? 'dark' : 'light') : pref; }; | |
| 125 | + var syncThemeUi = function () { | |
| 126 | + var pref = root.getAttribute('data-theme-pref') || 'auto'; | |
| 127 | + $$('[data-theme-choice]').forEach(function (b) { b.setAttribute('aria-checked', b.getAttribute('data-theme-choice') === pref ? 'true' : 'false'); }); | |
| 128 | + }; | |
| 129 | + var setTheme = function (pref) { | |
| 130 | + if (pref !== 'auto' && THEMES.indexOf(pref) === -1) pref = 'auto'; | |
| 131 | + root.setAttribute('data-theme-pref', pref); | |
| 132 | + root.setAttribute('data-theme', resolveTheme(pref)); | |
| 133 | + try { if (pref === 'auto') localStorage.removeItem('spbgit-theme'); else localStorage.setItem('spbgit-theme', pref); } catch (e) { /* ignore */ } | |
| 134 | + syncThemeUi(); | |
| 135 | + doc.dispatchEvent(new CustomEvent('spbgit:theme', { detail: root.getAttribute('data-theme') })); | |
| 136 | + }; | |
| 137 | + mql.addEventListener('change', function () { if ((root.getAttribute('data-theme-pref') || 'auto') === 'auto') setTheme('auto'); }); | |
| 138 | + doc.addEventListener('click', function (event) { | |
| 139 | + var b = event.target.closest('[data-theme-choice]'); | |
| 140 | + if (!b) return; | |
| 141 | + setTheme(b.getAttribute('data-theme-choice')); | |
| 142 | + var dlg = b.closest('dialog.popover'); | |
| 143 | + if (dlg) closeDialog(dlg); | |
| 144 | + }); | |
| 145 | + var themeMenu = $('#theme-menu'); | |
| 146 | + var themeOpen = $('#theme-open'); | |
| 147 | + if (themeOpen && themeMenu) { | |
| 148 | + themeOpen.addEventListener('click', function () { | |
| 149 | + if (matchMedia('(max-width: 767px)').matches && navSheet) { openDialog(navSheet); return; } | |
| 150 | + openDialog(themeMenu); | |
| 151 | + }); | |
| 152 | + } | |
| 153 | + var cycleTheme = function () { | |
| 154 | + var order = ['auto', 'light', 'dark', 'midnight']; | |
| 155 | + var cur = root.getAttribute('data-theme-pref') || 'auto'; | |
| 156 | + setTheme(order[(order.indexOf(cur) + 1) % order.length]); | |
| 157 | + window.spbToast('Theme: ' + (root.getAttribute('data-theme-pref') === 'auto' ? 'system (' + root.getAttribute('data-theme') + ')' : root.getAttribute('data-theme'))); | |
| 158 | + }; | |
| 159 | + syncThemeUi(); | |
| 80 | 160 | |
| 161 | + /* ── Home: filter + sort repo catalog ─────── */ | |
| 162 | + var list = $('#repo-list'); | |
| 163 | + if (list) { | |
| 164 | + var filterInput = $('#repo-filter'); | |
| 165 | + var sortSelect = $('#repo-sort'); | |
| 166 | + var langSelect = $('#repo-lang'); | |
| 167 | + var topicSelect = $('#repo-topic'); | |
| 168 | + var emptyNote = $('#repo-list-empty'); | |
| 169 | + var countBadge = $('#repo-count'); | |
| 81 | 170 | var apply = function () { |
| 82 | − var q = (filterInput.value || '').toLowerCase(); | |
| 171 | + var q = (filterInput.value || '').toLowerCase().trim(); | |
| 83 | 172 | var lang = langSelect.value; |
| 84 | 173 | var topic = topicSelect.value; |
| 85 | − var cards = Array.prototype.slice.call(list.querySelectorAll('.repo-card')); | |
| 174 | + var cards = $$('.repo-card', list); | |
| 86 | 175 | var visible = 0; |
| 87 | 176 | cards.forEach(function (card) { |
| 88 | 177 | var name = card.getAttribute('data-name') || ''; |
| 89 | 178 | var desc = (card.getAttribute('data-description') || '').toLowerCase(); |
| 90 | 179 | var cardLang = card.getAttribute('data-language') || ''; |
| 91 | 180 | var topics = (card.getAttribute('data-topics') || '').split(','); |
| 92 | − var show = | |
| 93 | − (!q || name.toLowerCase().indexOf(q) !== -1 || desc.indexOf(q) !== -1) && | |
| 94 | − (!lang || cardLang === lang) && | |
| 95 | − (!topic || topics.indexOf(topic) !== -1); | |
| 181 | + var show = (!q || name.toLowerCase().indexOf(q) !== -1 || desc.indexOf(q) !== -1) && (!lang || cardLang === lang) && (!topic || topics.indexOf(topic) !== -1); | |
| 96 | 182 | card.hidden = !show; |
| 97 | 183 | if (show) visible += 1; |
| 98 | 184 | }); |
| 99 | 185 | var key = sortSelect.value; |
| 100 | 186 | var attr = key === 'name' ? 'data-name' : key === 'created' ? 'data-created' : key === 'commits' ? 'data-commits' : 'data-pushed'; |
| 101 | − cards | |
| 102 | − .sort(function (a, b) { | |
| 103 | − var av = a.getAttribute(attr) || ''; | |
| 104 | − var bv = b.getAttribute(attr) || ''; | |
| 105 | − if (key === 'commits') return (parseInt(bv, 10) || 0) - (parseInt(av, 10) || 0); | |
| 106 | − return key === 'name' ? av.localeCompare(bv) : bv.localeCompare(av); | |
| 107 | − }) | |
| 108 | − .forEach(function (card) { list.appendChild(card); }); | |
| 187 | + cards.sort(function (a, b) { | |
| 188 | + var av = a.getAttribute(attr) || ''; | |
| 189 | + var bv = b.getAttribute(attr) || ''; | |
| 190 | + if (key === 'commits') return (parseInt(bv, 10) || 0) - (parseInt(av, 10) || 0); | |
| 191 | + return key === 'name' ? av.localeCompare(bv) : bv.localeCompare(av); | |
| 192 | + }).forEach(function (card) { list.appendChild(card); }); | |
| 109 | 193 | if (emptyNote) emptyNote.hidden = visible > 0; |
| 110 | 194 | if (countBadge) countBadge.textContent = fmt(visible); |
| 111 | 195 | }; |
@@ -114,48 +198,60 @@ | ||
| 114 | 198 | el.addEventListener('input', apply); |
| 115 | 199 | el.addEventListener('change', apply); |
| 116 | 200 | }); |
| 201 | + // Deep-link filters: /#repos?lang=Python | |
| 202 | + var hashQuery = /^#repos\?(.+)$/.exec(location.hash); | |
| 203 | + if (hashQuery) { | |
| 204 | + var params = new URLSearchParams(hashQuery[1]); | |
| 205 | + if (params.get('lang') && langSelect) langSelect.value = params.get('lang'); | |
| 206 | + if (params.get('topic') && topicSelect) topicSelect.value = params.get('topic'); | |
| 207 | + if (params.get('q') && filterInput) filterInput.value = params.get('q'); | |
| 208 | + apply(); | |
| 209 | + } | |
| 117 | 210 | } |
| 118 | 211 | |
| 119 | 212 | /* ── Ref selector navigation ──────────────── */ |
| 120 | − var refSelect = document.getElementById('ref-select'); | |
| 213 | + var refSelect = $('#ref-select'); | |
| 121 | 214 | if (refSelect) { |
| 122 | 215 | refSelect.addEventListener('change', function () { |
| 123 | 216 | var repo = refSelect.getAttribute('data-repo'); |
| 124 | 217 | var kind = refSelect.getAttribute('data-kind') || 'tree'; |
| 125 | 218 | var path = refSelect.getAttribute('data-path') || ''; |
| 126 | − var ref = refSelect.value; | |
| 127 | − location.href = '/' + repo + '/' + kind + '/' + ref + (path ? '/' + path : ''); | |
| 219 | + location.href = '/' + repo + '/' + kind + '/' + refSelect.value + (path ? '/' + path : ''); | |
| 128 | 220 | }); |
| 129 | 221 | } |
| 130 | 222 | |
| 223 | + /* ── Clone panel ──────────────────────────── */ | |
| 224 | + var clonePanel = $('#clone-panel'); | |
| 225 | + var cloneOpen = $('#clone-open'); | |
| 226 | + if (clonePanel && cloneOpen) { | |
| 227 | + cloneOpen.addEventListener('click', function () { openDialog(clonePanel); var u = $('.clone-url', clonePanel); if (u && !matchMedia('(pointer: coarse)').matches) u.select(); }); | |
| 228 | + $$('.clone-url', clonePanel).forEach(function (u) { u.addEventListener('focus', function () { u.select(); }); }); | |
| 229 | + } | |
| 230 | + | |
| 131 | 231 | /* ── Blob: soft-wrap toggle + line ranges ─── */ |
| 132 | − var codeView = document.getElementById('code-view'); | |
| 133 | − var wrapToggle = document.getElementById('wrap-toggle'); | |
| 232 | + var codeView = $('#code-view'); | |
| 233 | + var wrapToggle = $('#wrap-toggle'); | |
| 134 | 234 | if (codeView && wrapToggle) { |
| 135 | 235 | var wrapPref = null; |
| 136 | 236 | try { wrapPref = localStorage.getItem('spbgit-wrap'); } catch (e) { /* ignore */ } |
| 137 | 237 | var setWrap = function (on) { |
| 138 | 238 | codeView.classList.toggle('wrap', on); |
| 139 | 239 | wrapToggle.setAttribute('aria-pressed', on ? 'true' : 'false'); |
| 140 | − wrapToggle.classList.toggle('copied', on); | |
| 141 | 240 | try { localStorage.setItem('spbgit-wrap', on ? '1' : '0'); } catch (e) { /* ignore */ } |
| 142 | 241 | }; |
| 143 | − if (wrapPref === '1') setWrap(true); | |
| 242 | + if (wrapPref === '1' || (wrapPref === null && matchMedia('(max-width: 767px)').matches)) setWrap(true); | |
| 144 | 243 | wrapToggle.addEventListener('click', function () { setWrap(!codeView.classList.contains('wrap')); }); |
| 145 | 244 | } |
| 146 | 245 | if (codeView) { |
| 147 | 246 | var applyRange = function () { |
| 148 | − codeView.querySelectorAll('.line.range-hl').forEach(function (el) { el.classList.remove('range-hl'); }); | |
| 247 | + $$('.line.range-hl', codeView).forEach(function (el) { el.classList.remove('range-hl'); }); | |
| 149 | 248 | var match = /^#L(\d+)(?:-L(\d+))?$/.exec(location.hash); |
| 150 | 249 | if (!match) return; |
| 151 | 250 | var start = parseInt(match[1], 10); |
| 152 | 251 | var end = match[2] ? parseInt(match[2], 10) : start; |
| 153 | 252 | if (end < start) { var t = start; start = end; end = t; } |
| 154 | − for (var i = start; i <= end; i += 1) { | |
| 155 | − var line = document.getElementById('L' + i); | |
| 156 | − if (line) line.classList.add('range-hl'); | |
| 157 | − } | |
| 158 | − var first = document.getElementById('L' + start); | |
| 253 | + for (var i = start; i <= end; i += 1) { var line = doc.getElementById('L' + i); if (line) line.classList.add('range-hl'); } | |
| 254 | + var first = doc.getElementById('L' + start); | |
| 159 | 255 | if (first && match[2]) first.scrollIntoView({ block: 'center' }); |
| 160 | 256 | }; |
| 161 | 257 | applyRange(); |
@@ -166,219 +262,118 @@ | ||
| 166 | 262 | event.preventDefault(); |
| 167 | 263 | var current = /^#L(\d+)/.exec(location.hash); |
| 168 | 264 | var clicked = /^#?L?(\d+)/.exec(anchor.getAttribute('href').slice(1)); |
| 169 | − if (current && clicked) { | |
| 170 | − history.replaceState(null, '', '#L' + current[1] + '-L' + clicked[1]); | |
| 171 | − applyRange(); | |
| 172 | − } | |
| 265 | + if (current && clicked) { history.replaceState(null, '', '#L' + current[1] + '-L' + clicked[1]); applyRange(); } | |
| 173 | 266 | }); |
| 174 | 267 | } |
| 175 | 268 | |
| 176 | − /* ── Spotlight (mouse-tracking glow) on .spot ─ */ | |
| 177 | − document.addEventListener('pointermove', function (event) { | |
| 178 | − var card = event.target.closest ? event.target.closest('.spot') : null; | |
| 179 | − if (!card) return; | |
| 180 | − var rect = card.getBoundingClientRect(); | |
| 181 | − card.style.setProperty('--mx', (event.clientX - rect.left) + 'px'); | |
| 182 | − card.style.setProperty('--my', (event.clientY - rect.top) + 'px'); | |
| 183 | − }, { passive: true }); | |
| 184 | − | |
| 185 | − /* ── Scroll reveal ────────────────────────── */ | |
| 186 | − var revealEls = document.querySelectorAll('.reveal'); | |
| 187 | − if (revealEls.length && 'IntersectionObserver' in window && !reduceMotion) { | |
| 188 | − var io = new IntersectionObserver(function (entries) { | |
| 189 | − entries.forEach(function (entry) { | |
| 190 | − if (entry.isIntersecting) { entry.target.classList.add('in'); io.unobserve(entry.target); } | |
| 191 | − }); | |
| 192 | − }, { rootMargin: '0px 0px -8% 0px', threshold: 0.05 }); | |
| 193 | − revealEls.forEach(function (el) { io.observe(el); }); | |
| 194 | − } else { | |
| 195 | − revealEls.forEach(function (el) { el.classList.add('in'); }); | |
| 196 | − } | |
| 197 | − | |
| 198 | − /* ── Count-up numerals ────────────────────── */ | |
| 199 | − var counters = document.querySelectorAll('[data-count]'); | |
| 200 | − if (counters.length && !reduceMotion) { | |
| 201 | − counters.forEach(function (el) { | |
| 202 | − var target = parseInt(el.getAttribute('data-count'), 10); | |
| 203 | − if (!Number.isFinite(target) || target <= 0) return; | |
| 204 | − var start = null; | |
| 205 | − var dur = 1100 + Math.min(900, target / 40); | |
| 206 | − var step = function (ts) { | |
| 207 | − if (start === null) start = ts; | |
| 208 | − var p = Math.min(1, (ts - start) / dur); | |
| 209 | − var eased = 1 - Math.pow(2, -10 * p); | |
| 210 | − el.textContent = fmt(Math.round(target * eased)); | |
| 211 | − if (p < 1) requestAnimationFrame(step); else el.textContent = fmt(target); | |
| 212 | − }; | |
| 213 | − el.textContent = '0'; | |
| 214 | − requestAnimationFrame(step); | |
| 215 | − }); | |
| 269 | + /* ── Diff: wrap toggle + collapse large files ─ */ | |
| 270 | + var diffWrap = $('#diff-wrap'); | |
| 271 | + var diffRoot = $('#diffs'); | |
| 272 | + if (diffWrap && diffRoot) { | |
| 273 | + var setDiffWrap = function (on) { diffRoot.classList.toggle('diff-wrap', on); diffWrap.setAttribute('aria-pressed', on ? 'true' : 'false'); try { localStorage.setItem('spbgit-diff-wrap', on ? '1' : '0'); } catch (e) { /* ignore */ } }; | |
| 274 | + var dw = null; | |
| 275 | + try { dw = localStorage.getItem('spbgit-diff-wrap'); } catch (e) { /* ignore */ } | |
| 276 | + if (dw === '1') setDiffWrap(true); | |
| 277 | + diffWrap.addEventListener('click', function () { setDiffWrap(!diffRoot.classList.contains('diff-wrap')); }); | |
| 216 | 278 | } |
| 279 | + doc.addEventListener('click', function (event) { | |
| 280 | + var b = event.target.closest('[data-expand-diff]'); | |
| 281 | + if (!b) return; | |
| 282 | + var wrap = b.closest('.diff-collapsed'); | |
| 283 | + var table = wrap && wrap.nextElementSibling; | |
| 284 | + if (table) { table.hidden = false; wrap.hidden = true; } | |
| 285 | + }); | |
| 217 | 286 | |
| 218 | − /* ── Hero constellation (git graph drifting) ─ */ | |
| 219 | − var canvas = document.getElementById('hero-canvas'); | |
| 220 | − if (canvas && canvas.getContext) { | |
| 221 | − var ctx = canvas.getContext('2d'); | |
| 222 | − var nodes = []; | |
| 223 | − var W = 0; | |
| 224 | − var H = 0; | |
| 225 | − var dpr = Math.min(2, window.devicePixelRatio || 1); | |
| 226 | − var running = true; | |
| 227 | − var last = 0; | |
| 228 | − var palette = function () { | |
| 229 | − var light = document.documentElement.getAttribute('data-theme') === 'light'; | |
| 230 | − return light | |
| 231 | − ? ['67,96,240', '10,143,208', '10,159,116', '139,50,224', '240,116,30'] | |
| 232 | − : ['134,164,255', '70,214,255', '63,240,189', '201,150,255', '255,138,61']; | |
| 233 | − }; | |
| 234 | − var colors = palette(); | |
| 235 | − document.addEventListener('spbgit:theme', function () { colors = palette(); }); | |
| 236 | − var resize = function () { | |
| 237 | − var rect = canvas.getBoundingClientRect(); | |
| 238 | − W = rect.width; H = rect.height; | |
| 239 | − canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr); | |
| 240 | − ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| 241 | − var count = Math.max(28, Math.min(80, Math.round((W * H) / 14000))); | |
| 242 | − nodes = []; | |
| 243 | − for (var i = 0; i < count; i += 1) { | |
| 244 | − nodes.push({ | |
| 245 | − x: Math.random() * W, y: Math.random() * H, | |
| 246 | − vx: (Math.random() - 0.5) * 0.16, vy: (Math.random() - 0.5) * 0.12, | |
| 247 | − r: 1.2 + Math.random() * 1.8, c: Math.floor(Math.random() * colors.length), | |
| 248 | − phase: Math.random() * Math.PI * 2, | |
| 249 | − }); | |
| 250 | − } | |
| 251 | − }; | |
| 252 | − var LINK = 150; | |
| 253 | − var draw = function (ts) { | |
| 254 | − if (!running) return; | |
| 255 | − var dt = last ? Math.min(40, ts - last) : 16; | |
| 256 | − last = ts; | |
| 257 | − ctx.clearRect(0, 0, W, H); | |
| 258 | − var i; var j; | |
| 259 | − for (i = 0; i < nodes.length; i += 1) { | |
| 260 | − var n = nodes[i]; | |
| 261 | − n.x += n.vx * dt * 0.06; n.y += n.vy * dt * 0.06; | |
| 262 | − if (n.x < -10) n.x = W + 10; else if (n.x > W + 10) n.x = -10; | |
| 263 | − if (n.y < -10) n.y = H + 10; else if (n.y > H + 10) n.y = -10; | |
| 264 | − } | |
| 265 | − ctx.lineWidth = 1; | |
| 266 | − for (i = 0; i < nodes.length; i += 1) { | |
| 267 | − for (j = i + 1; j < nodes.length; j += 1) { | |
| 268 | − var a = nodes[i]; var b = nodes[j]; | |
| 269 | − var dx = a.x - b.x; var dy = a.y - b.y; | |
| 270 | − var d2 = dx * dx + dy * dy; | |
| 271 | − if (d2 > LINK * LINK) continue; | |
| 272 | − var alpha = (1 - Math.sqrt(d2) / LINK) * 0.28; | |
| 273 | − ctx.strokeStyle = 'rgba(' + colors[a.c] + ',' + alpha.toFixed(3) + ')'; | |
| 274 | − ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); | |
| 287 | + /* ── File tree (lazy, desktop sidebar + mobile sheet) ─ */ | |
| 288 | + var tree = $('#file-tree'); | |
| 289 | + if (tree) { | |
| 290 | + var repo = tree.getAttribute('data-repo'); | |
| 291 | + var ref = tree.getAttribute('data-ref'); | |
| 292 | + var current = tree.getAttribute('data-current') || ''; | |
| 293 | + var esc = function (s) { return s.split('/').map(encodeURIComponent).join('/'); }; | |
| 294 | + var ICON_CARET = '<svg class="ic tree-caret" width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M6.22 3.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L9.94 8 6.22 4.28a.75.75 0 0 1 0-1.06Z"/></svg>'; | |
| 295 | + var ICON_DIR = '<svg class="ic icon-dir" width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2A1.75 1.75 0 0 0 5 1z"/></svg>'; | |
| 296 | + var ICON_FILE = '<svg class="ic file-icon" width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914z"/></svg>'; | |
| 297 | + var renderLevel = function (entries) { | |
| 298 | + var ul = doc.createElement('ul'); | |
| 299 | + entries.forEach(function (e) { | |
| 300 | + var li = doc.createElement('li'); | |
| 301 | + var isDir = e.type === 'tree'; | |
| 302 | + if (isDir) { | |
| 303 | + li.setAttribute('aria-expanded', 'false'); | |
| 304 | + var btn = doc.createElement('button'); | |
| 305 | + btn.type = 'button'; btn.className = 'tree-row'; btn.setAttribute('data-path', e.path); | |
| 306 | + btn.innerHTML = ICON_CARET + ICON_DIR + '<span></span>'; | |
| 307 | + btn.lastChild.textContent = e.name; btn.title = e.path; | |
| 308 | + li.appendChild(btn); | |
| 309 | + } else { | |
| 310 | + var a = doc.createElement('a'); | |
| 311 | + a.className = 'tree-row' + (e.path === current ? ' is-current' : ''); | |
| 312 | + a.href = '/' + repo + '/blob/' + esc(ref) + '/' + esc(e.path); | |
| 313 | + a.innerHTML = '<span class="tree-caret-gap" style="width:12px;flex:none"></span>' + ICON_FILE + '<span></span>'; | |
| 314 | + a.lastChild.textContent = e.name; a.title = e.path; | |
| 315 | + if (e.path === current) a.setAttribute('aria-current', 'page'); | |
| 316 | + li.appendChild(a); | |
| 275 | 317 | } |
| 276 | − } | |
| 277 | − for (i = 0; i < nodes.length; i += 1) { | |
| 278 | − var p = nodes[i]; | |
| 279 | − var pulse = 0.65 + 0.35 * Math.sin(ts / 900 + p.phase); | |
| 280 | − ctx.fillStyle = 'rgba(' + colors[p.c] + ',' + (0.55 * pulse + 0.25).toFixed(3) + ')'; | |
| 281 | − ctx.shadowColor = 'rgba(' + colors[p.c] + ',0.9)'; | |
| 282 | − ctx.shadowBlur = 8 * pulse; | |
| 283 | − ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill(); | |
| 284 | − } | |
| 285 | − ctx.shadowBlur = 0; | |
| 286 | − if (!reduceMotion) requestAnimationFrame(draw); | |
| 318 | + ul.appendChild(li); | |
| 319 | + }); | |
| 320 | + return ul; | |
| 287 | 321 | }; |
| 288 | − resize(); | |
| 289 | − window.addEventListener('resize', function () { resize(); if (reduceMotion) draw(0); }); | |
| 290 | − requestAnimationFrame(draw); | |
| 291 | − document.addEventListener('visibilitychange', function () { | |
| 292 | − running = !document.hidden; | |
| 293 | − if (running) { last = 0; requestAnimationFrame(draw); } | |
| 294 | − }); | |
| 295 | − if ('IntersectionObserver' in window) { | |
| 296 | − new IntersectionObserver(function (entries) { | |
| 297 | − var visible = entries[0].isIntersecting; | |
| 298 | − if (visible && !running) { running = true; last = 0; requestAnimationFrame(draw); } | |
| 299 | − if (!visible) running = false; | |
| 300 | − }).observe(canvas); | |
| 301 | − } | |
| 302 | − } | |
| 303 | − | |
| 304 | − /* ── Hero terminal typewriter ─────────────── */ | |
| 305 | − var term = document.getElementById('hero-terminal'); | |
| 306 | − var termBody = document.getElementById('term-body'); | |
| 307 | − if (term && termBody && !reduceMotion) { | |
| 308 | − var repos = (term.getAttribute('data-repos') || '').split(',').filter(Boolean); | |
| 309 | − var origin = term.getAttribute('data-origin') || ''; | |
| 310 | − var totalRepos = (function () { | |
| 311 | − var el = document.querySelector('[data-count]'); | |
| 312 | − return el ? el.getAttribute('data-count') : ''; | |
| 313 | − })(); | |
| 314 | − var idx = 0; | |
| 315 | − var line = function (cls, html) { | |
| 316 | − var div = document.createElement('div'); | |
| 317 | − div.className = 'term-line' + (cls ? ' ' + cls : ''); | |
| 318 | − div.innerHTML = html; | |
| 319 | − return div; | |
| 322 | + var load = function (path, li) { | |
| 323 | + var loading = doc.createElement('div'); | |
| 324 | + loading.className = 'tree-loading'; loading.textContent = 'Loading…'; | |
| 325 | + li.appendChild(loading); | |
| 326 | + return fetch('/api/v1/repos/' + repo + '/tree?ref=' + encodeURIComponent(ref) + '&path=' + encodeURIComponent(path)) | |
| 327 | + .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) | |
| 328 | + .then(function (data) { loading.remove(); li.appendChild(renderLevel(data.entries || [])); }) | |
| 329 | + .catch(function () { loading.textContent = 'Could not load this folder.'; }); | |
| 320 | 330 | }; |
| 321 | − var typeCommand = function (text, done) { | |
| 322 | − termBody.innerHTML = ''; | |
| 323 | − var row = line('', '<span class="term-prompt">$</span><span class="term-cmd"></span><span class="term-caret"></span>'); | |
| 324 | − termBody.appendChild(row); | |
| 325 | − var cmd = row.querySelector('.term-cmd'); | |
| 326 | − var i = 0; | |
| 327 | − var tick = function () { | |
| 328 | − cmd.textContent = text.slice(0, i); | |
| 329 | − i += 1; | |
| 330 | − if (i <= text.length) setTimeout(tick, 22 + Math.random() * 40); else setTimeout(done, 350); | |
| 331 | + tree.addEventListener('click', function (event) { | |
| 332 | + var btn = event.target.closest('button.tree-row'); | |
| 333 | + if (!btn) return; | |
| 334 | + var li = btn.parentElement; | |
| 335 | + var expanded = li.getAttribute('aria-expanded') === 'true'; | |
| 336 | + li.setAttribute('aria-expanded', expanded ? 'false' : 'true'); | |
| 337 | + var child = li.querySelector(':scope > ul'); | |
| 338 | + if (child) { child.hidden = expanded; return; } | |
| 339 | + if (!expanded) load(btn.getAttribute('data-path'), li); | |
| 340 | + }); | |
| 341 | + load('', tree).then(function () { | |
| 342 | + // auto-expand the folders leading to the current file | |
| 343 | + var parts = current.split('/').slice(0, -1); | |
| 344 | + var acc = ''; | |
| 345 | + var step = function (i) { | |
| 346 | + if (i >= parts.length) return; | |
| 347 | + acc = acc ? acc + '/' + parts[i] : parts[i]; | |
| 348 | + var btn = tree.querySelector('button.tree-row[data-path="' + acc.replace(/"/g, '\\"') + '"]'); | |
| 349 | + if (!btn) return; | |
| 350 | + var li = btn.parentElement; | |
| 351 | + li.setAttribute('aria-expanded', 'true'); | |
| 352 | + load(acc, li).then(function () { step(i + 1); }); | |
| 331 | 353 | }; |
| 332 | − tick(); | |
| 333 | − }; | |
| 334 | − var cycle = function () { | |
| 335 | − if (!repos.length) return; | |
| 336 | − var repo = repos[idx % repos.length]; | |
| 337 | − idx += 1; | |
| 338 | − typeCommand('git clone ' + origin + '/' + repo + '.git', function () { | |
| 339 | − var caret = termBody.querySelector('.term-caret'); | |
| 340 | − if (caret) caret.remove(); | |
| 341 | − var outs = [ | |
| 342 | − ["term-out", "Cloning into '" + repo + "'…"], | |
| 343 | − ['term-out', 'remote: SPB Git · ' + (totalRepos ? fmt(parseInt(totalRepos, 10)) + ' public repositories' : 'public forge')], | |
| 344 | − ['term-out', 'Receiving objects: 100% — done.'], | |
| 345 | − ['term-out term-ok', 'Resolving deltas: 100% — done.'], | |
| 346 | − ]; | |
| 347 | − var k = 0; | |
| 348 | − var emit = function () { | |
| 349 | − if (k < outs.length) { | |
| 350 | − termBody.appendChild(line(outs[k][0], outs[k][1])); | |
| 351 | − k += 1; | |
| 352 | − setTimeout(emit, 260 + Math.random() * 240); | |
| 353 | − } else { | |
| 354 | − termBody.appendChild(line('', '<span class="term-prompt">$</span><span class="term-caret"></span>')); | |
| 355 | − setTimeout(cycle, 3400); | |
| 356 | − } | |
| 357 | − }; | |
| 358 | − setTimeout(emit, 260); | |
| 359 | − }); | |
| 360 | − }; | |
| 361 | − setTimeout(cycle, 1800); | |
| 354 | + step(0); | |
| 355 | + }); | |
| 356 | + var treeOpen = $('#tree-open'); | |
| 357 | + var treeSheet = $('#tree-sheet'); | |
| 358 | + if (treeOpen && treeSheet) treeOpen.addEventListener('click', function () { openDialog(treeSheet); }); | |
| 362 | 359 | } |
| 363 | 360 | |
| 364 | 361 | /* ── README table of contents ─────────────── */ |
| 365 | − var readme = document.getElementById('readme'); | |
| 366 | − var toc = document.getElementById('readme-toc'); | |
| 367 | − var tocList = document.getElementById('readme-toc-list'); | |
| 368 | − var readmeLayout = document.getElementById('readme-layout'); | |
| 362 | + var readme = $('#readme'); | |
| 363 | + var toc = $('#readme-toc'); | |
| 364 | + var tocList = $('#readme-toc-list'); | |
| 365 | + var readmeLayout = $('#readme-layout'); | |
| 369 | 366 | if (readme && toc && tocList && readmeLayout) { |
| 370 | − var heads = Array.prototype.slice.call(readme.querySelectorAll('h2[id], h3[id]')); | |
| 367 | + var heads = $$('h2[id], h3[id]', readme); | |
| 371 | 368 | if (heads.length >= 3) { |
| 372 | 369 | var links = []; |
| 373 | 370 | heads.forEach(function (h) { |
| 374 | − var li = document.createElement('li'); | |
| 371 | + var li = doc.createElement('li'); | |
| 375 | 372 | li.className = 'depth-' + h.tagName.slice(1); |
| 376 | − var a = document.createElement('a'); | |
| 373 | + var a = doc.createElement('a'); | |
| 377 | 374 | a.href = '#' + h.id; |
| 378 | 375 | a.textContent = h.textContent.replace(/^#\s*/, '').trim(); |
| 379 | − li.appendChild(a); | |
| 380 | − tocList.appendChild(li); | |
| 381 | − links.push(a); | |
| 376 | + li.appendChild(a); tocList.appendChild(li); links.push(a); | |
| 382 | 377 | }); |
| 383 | 378 | toc.hidden = false; |
| 384 | 379 | readmeLayout.classList.add('has-toc'); |
@@ -387,9 +382,8 @@ | ||
| 387 | 382 | var spy = new IntersectionObserver(function (entries) { |
| 388 | 383 | entries.forEach(function (entry) { |
| 389 | 384 | if (!entry.isIntersecting) return; |
| 390 | − var id = entry.target.id; | |
| 391 | 385 | if (active) active.classList.remove('active'); |
| 392 | − active = links.find(function (l) { return l.getAttribute('href') === '#' + id; }) || null; | |
| 386 | + active = links.find(function (l) { return l.getAttribute('href') === '#' + entry.target.id; }) || null; | |
| 393 | 387 | if (active) active.classList.add('active'); |
| 394 | 388 | }); |
| 395 | 389 | }, { rootMargin: '-90px 0px -70% 0px', threshold: 0 }); |
@@ -398,201 +392,171 @@ | ||
| 398 | 392 | } |
| 399 | 393 | } |
| 400 | 394 | |
| 401 | − /* ── ⌘K command palette ───────────────────── */ | |
| 402 | − var cmdk = document.getElementById('cmdk'); | |
| 395 | + /* ── ⌘K search & command palette ─────────── */ | |
| 396 | + var cmdk = $('#cmdk'); | |
| 403 | 397 | if (cmdk) { |
| 404 | − var cmdkInput = document.getElementById('cmdk-input'); | |
| 405 | − var cmdkList = document.getElementById('cmdk-list'); | |
| 406 | − var cmdkRepos = null; | |
| 398 | + var cmdkInput = $('#cmdk-input'); | |
| 399 | + var cmdkList = $('#cmdk-list'); | |
| 407 | 400 | var cmdkItems = []; |
| 408 | 401 | var cmdkIndex = 0; |
| 409 | − | |
| 402 | + var scope = 'all'; | |
| 403 | + var seq = 0; | |
| 404 | + var debounce = null; | |
| 405 | + var repoCtx = cmdk.getAttribute('data-repo') || ''; | |
| 406 | + var refCtx = cmdk.getAttribute('data-ref') || ''; | |
| 407 | + var RECENT_KEY = 'spbgit-recent-queries'; | |
| 408 | + var recent = []; | |
| 409 | + try { recent = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]'); } catch (e) { recent = []; } | |
| 410 | + var remember = function (q) { | |
| 411 | + q = q.trim(); if (!q) return; | |
| 412 | + recent = [q].concat(recent.filter(function (x) { return x !== q; })).slice(0, 6); | |
| 413 | + try { localStorage.setItem(RECENT_KEY, JSON.stringify(recent)); } catch (e) { /* ignore */ } | |
| 414 | + }; | |
| 410 | 415 | var commands = [ |
| 411 | 416 | { name: 'Home', desc: 'All repositories', href: '/', icon: '⌂', keys: 'home index' }, |
| 412 | 417 | { name: 'Activity', desc: 'Latest pushes and tags', href: '/#activity', icon: '◉', keys: 'feed pushes' }, |
| 413 | 418 | { name: 'Commit calendar', desc: 'The last 52 weeks', href: '/#heatmap', icon: '▦', keys: 'heatmap streak' }, |
| 414 | − { name: 'Toggle theme', desc: 'Dark ↔ light', action: flipTheme, icon: '◐', keys: 'dark light mode' }, | |
| 419 | + { name: 'Switch theme', desc: 'System → light → dark → midnight', action: cycleTheme, icon: '◐', keys: 'dark light midnight mode theme' }, | |
| 415 | 420 | { name: 'JSON API', desc: '/api/v1/repos', href: '/api/v1/repos', icon: '{}', keys: 'api json' }, |
| 416 | 421 | { name: 'Atom feed', desc: '/feed.atom', href: '/feed.atom', icon: '≋', keys: 'rss atom feed' }, |
| 417 | − { name: 'spboucher.ai', desc: 'Main site', href: 'https://spboucher.ai', icon: '↗', keys: 'site owner' }, | |
| 422 | + { name: 'Metric definitions', desc: 'How commits and streaks are counted', href: '/about/metrics', icon: 'ⓘ', keys: 'metrics definitions stats' }, | |
| 423 | + { name: 'spboucher.ai', desc: 'Main site', href: 'https://spboucher.ai', icon: '↗', keys: 'site owner' } | |
| 418 | 424 | ]; |
| 419 | − | |
| 420 | − var fuzzyScore = function (query, text) { | |
| 425 | + if (repoCtx) { | |
| 426 | + commands.unshift( | |
| 427 | + { name: 'Go to code', desc: repoCtx, href: '/' + repoCtx, icon: '‹›', keys: 'code files tree' }, | |
| 428 | + { name: 'Go to commits', desc: repoCtx, href: '/' + repoCtx + '/commits/' + refCtx, icon: '●', keys: 'history log' }, | |
| 429 | + { name: 'Go to branches', desc: repoCtx, href: '/' + repoCtx + '/branches', icon: 'ᛘ', keys: 'refs' }, | |
| 430 | + { name: 'Go to releases', desc: repoCtx, href: '/' + repoCtx + '/tags', icon: '⌂', keys: 'tags releases' } | |
| 431 | + ); | |
| 432 | + } | |
| 433 | + var fuzzy = function (query, text) { | |
| 421 | 434 | if (!query) return 0; |
| 422 | − var q = query.toLowerCase(); | |
| 423 | − var t = text.toLowerCase(); | |
| 435 | + var q = query.toLowerCase(); var t = text.toLowerCase(); | |
| 424 | 436 | var direct = t.indexOf(q); |
| 425 | 437 | if (direct !== -1) return direct; |
| 426 | − var ti = 0; | |
| 427 | − var gaps = 0; | |
| 428 | − for (var qi = 0; qi < q.length; qi += 1) { | |
| 429 | − var found = t.indexOf(q[qi], ti); | |
| 430 | − if (found === -1) return -1; | |
| 431 | − gaps += found - ti; | |
| 432 | − ti = found + 1; | |
| 433 | − } | |
| 438 | + var ti = 0; var gaps = 0; | |
| 439 | + for (var qi = 0; qi < q.length; qi += 1) { var f = t.indexOf(q[qi], ti); if (f === -1) return -1; gaps += f - ti; ti = f + 1; } | |
| 434 | 440 | return 100 + gaps; |
| 435 | 441 | }; |
| 436 | − | |
| 437 | − var addGroup = function (label) { | |
| 438 | − var li = document.createElement('li'); | |
| 439 | − li.className = 'cmdk-group'; | |
| 440 | − li.textContent = label; | |
| 441 | − cmdkList.appendChild(li); | |
| 442 | − }; | |
| 442 | + var addGroup = function (label) { var li = doc.createElement('li'); li.className = 'cmdk-group'; li.setAttribute('role', 'presentation'); li.textContent = label; cmdkList.appendChild(li); }; | |
| 443 | 443 | var addItem = function (entry) { |
| 444 | − var li = document.createElement('li'); | |
| 445 | − li.className = 'cmdk-item'; | |
| 446 | − li.setAttribute('role', 'option'); | |
| 447 | − var icon = document.createElement('span'); | |
| 448 | − icon.className = 'cmdk-item-icon'; | |
| 449 | − icon.textContent = entry.icon || '›'; | |
| 450 | − li.appendChild(icon); | |
| 451 | − var name = document.createElement('span'); | |
| 452 | − name.className = 'cmdk-item-name'; | |
| 453 | − name.textContent = entry.name; | |
| 454 | − li.appendChild(name); | |
| 455 | − if (entry.desc) { | |
| 456 | − var desc = document.createElement('span'); | |
| 457 | − desc.className = 'cmdk-item-desc'; | |
| 458 | − desc.textContent = entry.desc; | |
| 459 | − li.appendChild(desc); | |
| 460 | − } | |
| 461 | − if (entry.hint) { | |
| 462 | − var hint = document.createElement('span'); | |
| 463 | − hint.className = 'cmdk-item-hint'; | |
| 464 | − hint.textContent = entry.hint; | |
| 465 | − li.appendChild(hint); | |
| 466 | − } | |
| 444 | + var li = doc.createElement('li'); | |
| 445 | + li.className = 'cmdk-item'; li.setAttribute('role', 'option'); li.id = 'cmdk-opt-' + cmdkItems.length; | |
| 446 | + var icon = doc.createElement('span'); icon.className = 'cmdk-item-icon'; icon.textContent = entry.icon || '›'; li.appendChild(icon); | |
| 447 | + var main = doc.createElement('span'); main.className = 'cmdk-item-main'; | |
| 448 | + var name = doc.createElement('span'); name.className = 'cmdk-item-name'; name.textContent = entry.name; main.appendChild(name); | |
| 449 | + if (entry.desc) { var desc = doc.createElement('span'); desc.className = 'cmdk-item-desc'; desc.textContent = entry.desc; main.appendChild(desc); } | |
| 450 | + li.appendChild(main); | |
| 451 | + if (entry.hint) { var hint = doc.createElement('span'); hint.className = 'cmdk-item-hint'; hint.textContent = entry.hint; li.appendChild(hint); } | |
| 467 | 452 | li.__run = function () { |
| 453 | + remember(cmdkInput.value); | |
| 468 | 454 | if (entry.action) { entry.action(); cmdkClose(); } else if (entry.href) { location.href = entry.href; } |
| 469 | 455 | }; |
| 470 | 456 | li.addEventListener('click', li.__run); |
| 471 | 457 | li.addEventListener('pointerenter', function () { cmdkSelect(cmdkItems.indexOf(li)); }); |
| 472 | − cmdkList.appendChild(li); | |
| 473 | − cmdkItems.push(li); | |
| 458 | + cmdkList.appendChild(li); cmdkItems.push(li); | |
| 474 | 459 | }; |
| 475 | − | |
| 476 | − var cmdkRender = function () { | |
| 477 | − var query = cmdkInput.value.trim(); | |
| 478 | − cmdkList.innerHTML = ''; | |
| 479 | − cmdkItems = []; | |
| 480 | − if (cmdkRepos === null) { | |
| 481 | − cmdkList.innerHTML = '<li class="cmdk-empty">Loading repositories…</li>'; | |
| 482 | − return; | |
| 483 | − } | |
| 484 | − var scored = []; | |
| 485 | − cmdkRepos.forEach(function (repo) { | |
| 486 | − var score = fuzzyScore(query, repo.name + ' ' + (repo.description || '') + ' ' + (repo.topics || []).join(' ')); | |
| 487 | − if (score !== -1) scored.push({ repo: repo, score: score }); | |
| 488 | − }); | |
| 489 | − scored.sort(function (a, b) { return a.score - b.score || a.repo.name.localeCompare(b.repo.name); }); | |
| 490 | − scored = scored.slice(0, query ? 9 : 7); | |
| 491 | − var cmds = commands.filter(function (c) { return fuzzyScore(query, c.name + ' ' + c.keys) !== -1; }).slice(0, query ? 4 : 3); | |
| 492 | − | |
| 493 | − if (scored.length) { | |
| 460 | + var status = function (text) { var li = doc.createElement('li'); li.className = 'cmdk-status'; li.textContent = text; cmdkList.appendChild(li); }; | |
| 461 | + var renderResults = function (query, data) { | |
| 462 | + cmdkList.innerHTML = ''; cmdkItems = []; | |
| 463 | + var r = data || {}; | |
| 464 | + if (r.repos && r.repos.length) { | |
| 494 | 465 | addGroup(query ? 'Repositories' : 'Recently pushed'); |
| 495 | − scored.forEach(function (entry) { | |
| 496 | − addItem({ name: entry.repo.name, desc: entry.repo.description, hint: entry.repo.topLanguage || '', href: '/' + entry.repo.name, icon: '◇' }); | |
| 497 | − }); | |
| 466 | + r.repos.forEach(function (x) { addItem({ name: x.name, desc: x.description, hint: x.topLanguage || '', href: '/' + x.name, icon: '◇' }); }); | |
| 498 | 467 | } |
| 499 | − if (cmds.length) { | |
| 500 | − addGroup('Commands'); | |
| 501 | − cmds.forEach(addItem); | |
| 468 | + if (r.files && r.files.length) { | |
| 469 | + addGroup('Files'); | |
| 470 | + r.files.forEach(function (x) { addItem({ name: x.path.split('/').pop(), desc: x.repo + ' · ' + x.path, href: '/' + x.repo + '/blob/' + x.ref + '/' + x.path.split('/').map(encodeURIComponent).join('/'), icon: '▤' }); }); | |
| 502 | 471 | } |
| 503 | − if (query) { | |
| 504 | − addGroup('Search'); | |
| 505 | − addItem({ name: 'Search “' + query + '”', desc: 'Names, topics and README content', href: '/search?q=' + encodeURIComponent(query), icon: '⌕', hint: '↵' }); | |
| 472 | + if (r.commits && r.commits.length) { | |
| 473 | + addGroup('Commits'); | |
| 474 | + r.commits.forEach(function (x) { addItem({ name: x.subject, desc: x.repo + ' · ' + x.shortSha + ' · ' + x.when, href: '/' + x.repo + '/commit/' + x.sha, icon: '●' }); }); | |
| 475 | + } | |
| 476 | + if (r.branches && r.branches.length) { | |
| 477 | + addGroup('Branches & tags'); | |
| 478 | + r.branches.forEach(function (x) { addItem({ name: x.name, desc: x.repo + ' · ' + x.kind, href: '/' + x.repo + '/tree/' + x.name, icon: x.kind === 'tag' ? '⌂' : 'ᛘ' }); }); | |
| 479 | + } | |
| 480 | + if (scope === 'all') { | |
| 481 | + var cmds = commands.filter(function (c) { return fuzzy(query, c.name + ' ' + c.keys) !== -1; }).slice(0, query ? 4 : 4); | |
| 482 | + if (cmds.length) { addGroup('Commands'); cmds.forEach(addItem); } | |
| 483 | + } | |
| 484 | + if (!query && recent.length) { | |
| 485 | + addGroup('Recent searches'); | |
| 486 | + recent.forEach(function (q) { addItem({ name: q, desc: 'Search again', icon: '↻', action: function () { cmdkInput.value = q; cmdkQuery(); } }); }); | |
| 506 | 487 | } |
| 507 | − if (!cmdkItems.length) { | |
| 508 | − var empty = document.createElement('li'); | |
| 509 | − empty.className = 'cmdk-empty'; | |
| 510 | − empty.textContent = 'Nothing matches “' + query + '”'; | |
| 511 | − cmdkList.appendChild(empty); | |
| 512 | − return; | |
| 488 | + if (query) { | |
| 489 | + addGroup('Full search'); | |
| 490 | + addItem({ name: 'Search “' + query + '”', desc: 'Names, topics, READMEs, files and commits', href: '/search?q=' + encodeURIComponent(query), icon: '⌕', hint: '↵' }); | |
| 513 | 491 | } |
| 492 | + if (!cmdkItems.length) { status(query ? 'Nothing matches “' + query + '”' : 'Type to search'); return; } | |
| 514 | 493 | cmdkSelect(0); |
| 515 | 494 | }; |
| 516 | − | |
| 495 | + var cmdkQuery = function () { | |
| 496 | + var query = cmdkInput.value.trim(); | |
| 497 | + var mine = ++seq; | |
| 498 | + var url = '/api/v1/search?q=' + encodeURIComponent(query) + '&scope=' + scope + (repoCtx ? '&repo=' + encodeURIComponent(repoCtx) : '') + '&limit=8'; | |
| 499 | + if (!cmdkItems.length) { cmdkList.innerHTML = ''; status('Searching…'); } | |
| 500 | + fetch(url).then(function (res) { return res.json(); }) | |
| 501 | + .then(function (data) { if (mine === seq) renderResults(query, data); }) | |
| 502 | + .catch(function () { if (mine === seq) { cmdkList.innerHTML = ''; status('Search is unavailable right now.'); } }); | |
| 503 | + }; | |
| 517 | 504 | var cmdkSelect = function (index) { |
| 518 | 505 | cmdkIndex = Math.max(0, Math.min(index, cmdkItems.length - 1)); |
| 519 | 506 | cmdkItems.forEach(function (item, i) { item.setAttribute('aria-selected', i === cmdkIndex ? 'true' : 'false'); }); |
| 520 | − var current = cmdkItems[cmdkIndex]; | |
| 521 | − if (current) current.scrollIntoView({ block: 'nearest' }); | |
| 507 | + var cur = cmdkItems[cmdkIndex]; | |
| 508 | + if (cur) { cur.scrollIntoView({ block: 'nearest' }); cmdkInput.setAttribute('aria-activedescendant', cur.id); } | |
| 522 | 509 | }; |
| 523 | − | |
| 524 | 510 | var cmdkOpen = function () { |
| 525 | − cmdk.hidden = false; | |
| 526 | − document.body.classList.add('cmdk-open'); | |
| 511 | + openDialog(cmdk); | |
| 527 | 512 | cmdkInput.value = ''; |
| 528 | − cmdkRender(); | |
| 529 | − cmdkInput.focus(); | |
| 530 | − if (cmdkRepos === null) { | |
| 531 | − fetch('/api/v1/repos') | |
| 532 | − .then(function (res) { return res.json(); }) | |
| 533 | − .then(function (data) { cmdkRepos = data.repos || []; cmdkRender(); }) | |
| 534 | − .catch(function () { cmdkRepos = []; cmdkRender(); }); | |
| 535 | − } | |
| 536 | − }; | |
| 537 | − var cmdkClose = function () { | |
| 538 | − cmdk.hidden = true; | |
| 539 | − document.body.classList.remove('cmdk-open'); | |
| 513 | + cmdkItems = []; | |
| 514 | + cmdkQuery(); | |
| 515 | + setTimeout(function () { cmdkInput.focus(); }, 30); | |
| 540 | 516 | }; |
| 541 | − | |
| 542 | − ['cmdk-open', 'cmdk-open-alt'].forEach(function (id) { | |
| 543 | − var opener = document.getElementById(id); | |
| 544 | − if (opener) opener.addEventListener('click', cmdkOpen); | |
| 545 | − }); | |
| 546 | − cmdk.addEventListener('click', function (event) { | |
| 547 | − if (event.target.closest('[data-cmdk-close]')) cmdkClose(); | |
| 517 | + var cmdkClose = function () { closeDialog(cmdk); }; | |
| 518 | + ['cmdk-open', 'cmdk-open-alt'].forEach(function (id) { var o = doc.getElementById(id); if (o) o.addEventListener('click', cmdkOpen); }); | |
| 519 | + cmdkInput.addEventListener('input', function () { clearTimeout(debounce); debounce = setTimeout(cmdkQuery, 140); }); | |
| 520 | + $$('#cmdk-scopes [data-scope]').forEach(function (b) { | |
| 521 | + b.addEventListener('click', function () { | |
| 522 | + scope = b.getAttribute('data-scope'); | |
| 523 | + $$('#cmdk-scopes [data-scope]').forEach(function (x) { x.setAttribute('aria-selected', x === b ? 'true' : 'false'); }); | |
| 524 | + cmdkQuery(); cmdkInput.focus(); | |
| 525 | + }); | |
| 548 | 526 | }); |
| 549 | − cmdkInput.addEventListener('input', cmdkRender); | |
| 550 | − document.addEventListener('keydown', function (event) { | |
| 527 | + doc.addEventListener('keydown', function (event) { | |
| 551 | 528 | var target = event.target; |
| 552 | 529 | var typing = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable); |
| 553 | − if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { | |
| 554 | − event.preventDefault(); | |
| 555 | − if (cmdk.hidden) cmdkOpen(); else cmdkClose(); | |
| 556 | − return; | |
| 557 | − } | |
| 558 | − if (event.key === '/' && !typing && !event.metaKey && !event.ctrlKey && !event.altKey && cmdk.hidden) { | |
| 559 | − event.preventDefault(); | |
| 560 | − cmdkOpen(); | |
| 561 | − return; | |
| 562 | − } | |
| 563 | − if (cmdk.hidden) return; | |
| 564 | − if (event.key === 'Escape') { event.preventDefault(); cmdkClose(); } | |
| 565 | − else if (event.key === 'ArrowDown') { event.preventDefault(); cmdkSelect(cmdkIndex + 1); } | |
| 530 | + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); if (cmdk.open) cmdkClose(); else cmdkOpen(); return; } | |
| 531 | + if (event.key === '/' && !typing && !event.metaKey && !event.ctrlKey && !event.altKey && !cmdk.open) { event.preventDefault(); cmdkOpen(); return; } | |
| 532 | + if (!cmdk.open) return; | |
| 533 | + if (event.key === 'ArrowDown') { event.preventDefault(); cmdkSelect(cmdkIndex + 1); } | |
| 566 | 534 | else if (event.key === 'ArrowUp') { event.preventDefault(); cmdkSelect(cmdkIndex - 1); } |
| 567 | − else if (event.key === 'Enter') { | |
| 568 | − var current = cmdkItems[cmdkIndex]; | |
| 569 | − if (current && current.__run) { event.preventDefault(); current.__run(); } | |
| 570 | − } | |
| 535 | + else if (event.key === 'Tab' && cmdkItems.length) { event.preventDefault(); cmdkSelect(cmdkIndex + (event.shiftKey ? -1 : 1)); } | |
| 536 | + else if (event.key === 'Enter') { var cur = cmdkItems[cmdkIndex]; if (cur && cur.__run) { event.preventDefault(); cur.__run(); } } | |
| 571 | 537 | }); |
| 572 | 538 | } |
| 573 | 539 | |
| 574 | 540 | /* ── Mermaid: lazy-load only when present ─── */ |
| 575 | − var mermaidBlocks = document.querySelectorAll('.mermaid-block'); | |
| 541 | + var mermaidBlocks = $$('.mermaid-block'); | |
| 576 | 542 | if (mermaidBlocks.length > 0) { |
| 577 | − var script = document.createElement('script'); | |
| 543 | + var script = doc.createElement('script'); | |
| 578 | 544 | script.src = '/assets/vendor/mermaid.min.js'; |
| 579 | 545 | script.onload = function () { |
| 580 | 546 | if (!window.mermaid) return; |
| 581 | − var isDark = document.documentElement.getAttribute('data-theme') === 'dark'; | |
| 547 | + var isDark = root.getAttribute('data-theme') !== 'light'; | |
| 582 | 548 | window.mermaid.initialize({ startOnLoad: false, theme: isDark ? 'dark' : 'default', securityLevel: 'strict' }); |
| 583 | 549 | mermaidBlocks.forEach(function (block, index) { |
| 584 | 550 | var src = block.querySelector('.mermaid-src'); |
| 585 | 551 | var target = block.querySelector('.mermaid-target'); |
| 586 | 552 | if (!src || !target) return; |
| 587 | − window.mermaid | |
| 588 | − .render('mermaid-svg-' + index, src.textContent) | |
| 589 | − .then(function (result) { | |
| 590 | − target.innerHTML = result.svg; // mermaid output, securityLevel strict | |
| 591 | − block.setAttribute('data-rendered', '1'); | |
| 592 | − }) | |
| 553 | + window.mermaid.render('mermaid-svg-' + index, src.textContent) | |
| 554 | + .then(function (result) { target.innerHTML = result.svg; block.setAttribute('data-rendered', '1'); }) | |
| 593 | 555 | .catch(function () { block.setAttribute('data-rendered', '0'); }); |
| 594 | 556 | }); |
| 595 | 557 | }; |
| 596 | − document.body.appendChild(script); | |
| 558 | + doc.body.appendChild(script); | |
| 597 | 559 | } |
| 560 | + | |
| 561 | + if (reduceMotion) root.classList.add('reduce-motion'); | |
| 598 | 562 | })(); |
modified
src/web/assets/js/theme-boot.js
+11 −8
@@ -5,19 +5,22 @@ | ||
| 5 | 5 | * Author : Simon-Pierre Boucher |
| 6 | 6 | * Contact : contact@spboucher.ai |
| 7 | 7 | * File : src/web/assets/js/theme-boot.js |
| 8 | − * Purpose : Apply the persisted theme before first paint (no FOUC) | |
| 8 | + * Purpose : Resolve the theme (auto · light · dark · midnight) before first paint — no FOUC | |
| 9 | 9 | * License : MIT © Simon-Pierre Boucher |
| 10 | 10 | * ───────────────────────────────────────────── |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | (function () { |
| 14 | + var THEMES = ['light', 'dark', 'midnight']; | |
| 14 | 15 | var stored = null; |
| 15 | − try { | |
| 16 | − stored = localStorage.getItem('spbgit-theme'); | |
| 17 | − } catch (e) { /* private mode */ } | |
| 18 | − var theme = stored; | |
| 19 | − if (theme !== 'dark' && theme !== 'light') { | |
| 20 | − theme = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; | |
| 16 | + try { stored = localStorage.getItem('spbgit-theme'); } catch (e) { /* private mode */ } | |
| 17 | + var pref = THEMES.indexOf(stored) !== -1 ? stored : 'auto'; | |
| 18 | + var resolved = pref; | |
| 19 | + if (pref === 'auto') { | |
| 20 | + resolved = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; | |
| 21 | 21 | } |
| 22 | − document.documentElement.setAttribute('data-theme', theme); | |
| 22 | + var root = document.documentElement; | |
| 23 | + root.setAttribute('data-theme', resolved); | |
| 24 | + root.setAttribute('data-theme-pref', pref); | |
| 25 | + root.classList.remove('no-js'); | |
| 23 | 26 | })(); |
modified
src/web/routes.mjs
+108 −47
@@ -5,7 +5,7 @@ | ||
| 5 | 5 | * Author : Simon-Pierre Boucher |
| 6 | 6 | * Contact : contact@spboucher.ai |
| 7 | 7 | * File : src/web/routes.mjs |
| 8 | − * Purpose : Server-rendered web UI — every HTML page of the forge | |
| 8 | + * Purpose : Server-rendered web UI v2.0 — every HTML page of the forge (streaming raw files, cached diffs, metrics page) | |
| 9 | 9 | * License : MIT © Simon-Pierre Boucher |
| 10 | 10 | * ───────────────────────────────────────────── |
| 11 | 11 | */ |
@@ -28,6 +28,8 @@ import { | ||
| 28 | 28 | } from '../lib/util.mjs'; |
| 29 | 29 | |
| 30 | 30 | const BLOB_RENDER_LIMIT = 1024 * 1024; // 1 MB — beyond this, offer raw only |
| 31 | +/** Diffs with more lines than this start collapsed (expand on demand). */ | |
| 32 | +const DIFF_COLLAPSE_LINES = 1500; | |
| 31 | 33 | const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.avif']); |
| 32 | 34 | const RAW_MIME = { |
| 33 | 35 | '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', |
@@ -56,6 +58,7 @@ export function setupViews(config) { | ||
| 56 | 58 | env.addGlobal('owner', config.owner); |
| 57 | 59 | env.addGlobal('publicUrl', config.publicUrl); |
| 58 | 60 | env.addGlobal('assetVersion', packageVersion()); |
| 61 | + env.addGlobal('features', { ai: Boolean(config.ai?.enabled) }); | |
| 59 | 62 | return env; |
| 60 | 63 | } |
| 61 | 64 | |
@@ -68,12 +71,11 @@ function packageVersion() { | ||
| 68 | 71 | } |
| 69 | 72 | } |
| 70 | 73 | |
| 71 | −/** Group a commit page by calendar day (UTC) for the timeline view. */ | |
| 72 | −function groupByDay(commits) { | |
| 74 | +/** Group a commit page by the author's calendar day for the timeline view. */ | |
| 75 | +export function groupByDay(commits) { | |
| 73 | 76 | const days = []; |
| 74 | 77 | let current = null; |
| 75 | 78 | for (const commit of commits) { |
| 76 | − // Group on the author's own calendar day (the ISO prefix keeps the commit's offset). | |
| 77 | 79 | const iso = String(commit.date).slice(0, 10); |
| 78 | 80 | if (!current || current.iso !== iso) { |
| 79 | 81 | const d = new Date(`${iso}T00:00:00Z`); |
@@ -88,6 +90,33 @@ function groupByDay(commits) { | ||
| 88 | 90 | return days; |
| 89 | 91 | } |
| 90 | 92 | |
| 93 | +/** | |
| 94 | + * Pair deletions with additions for a side-by-side view. | |
| 95 | + * @param {Array<{type: string}>} lines unified hunk lines | |
| 96 | + * @returns {Array<{left: object|null, right: object|null}>} | |
| 97 | + */ | |
| 98 | +export function splitRows(lines) { | |
| 99 | + const rows = []; | |
| 100 | + let dels = []; | |
| 101 | + let adds = []; | |
| 102 | + const flush = () => { | |
| 103 | + const n = Math.max(dels.length, adds.length); | |
| 104 | + for (let i = 0; i < n; i += 1) rows.push({ left: dels[i] ?? null, right: adds[i] ?? null }); | |
| 105 | + dels = []; | |
| 106 | + adds = []; | |
| 107 | + }; | |
| 108 | + for (const line of lines) { | |
| 109 | + if (line.type === 'del') dels.push(line); | |
| 110 | + else if (line.type === 'add') adds.push(line); | |
| 111 | + else { | |
| 112 | + flush(); | |
| 113 | + rows.push({ left: line, right: line.type === 'ctx' ? line : null }); | |
| 114 | + } | |
| 115 | + } | |
| 116 | + flush(); | |
| 117 | + return rows; | |
| 118 | +} | |
| 119 | + | |
| 91 | 120 | /** Render a view with the standard page envelope. */ |
| 92 | 121 | function makeRender(env, config) { |
| 93 | 122 | return function render(reply, template, data) { |
@@ -141,10 +170,11 @@ async function highlightHunk(lines, lang) { | ||
| 141 | 170 | } |
| 142 | 171 | } |
| 143 | 172 | |
| 144 | −/** Prepare a parsed diff for the template (adds highlighted line HTML). */ | |
| 145 | −async function prepareDiff(files) { | |
| 173 | +/** Prepare a parsed diff for the template (adds highlighted line HTML, sizes). */ | |
| 174 | +export async function prepareDiff(files) { | |
| 146 | 175 | for (const file of files) { |
| 147 | 176 | const lang = langForPath(file.newPath || file.oldPath); |
| 177 | + let lineCount = 0; | |
| 148 | 178 | for (const hunk of file.hunks) { |
| 149 | 179 | const content = hunk.lines.filter((l) => l.type !== 'meta'); |
| 150 | 180 | const highlighted = await highlightHunk(content, lang); |
@@ -152,7 +182,10 @@ async function prepareDiff(files) { | ||
| 152 | 182 | for (const line of hunk.lines) { |
| 153 | 183 | line.html = line.type === 'meta' ? escapeHtml(line.text) : highlighted[i++]; |
| 154 | 184 | } |
| 185 | + lineCount += hunk.lines.length; | |
| 155 | 186 | } |
| 187 | + file.lineCount = lineCount; | |
| 188 | + file.large = lineCount > DIFF_COLLAPSE_LINES; | |
| 156 | 189 | } |
| 157 | 190 | return files; |
| 158 | 191 | } |
@@ -212,11 +245,8 @@ export async function registerWeb(app, ctx) { | ||
| 212 | 245 | |
| 213 | 246 | // ───────────────────────── home ───────────────────────── |
| 214 | 247 | app.get('/', async (request, reply) => { |
| 215 | − const [overviews, stats, heatmap] = await Promise.all([ | |
| 216 | − allOverviews(ctx), | |
| 217 | − siteStats(ctx), | |
| 218 | − contributionCalendar(ctx), | |
| 219 | − ]); | |
| 248 | + const overviews = await allOverviews(ctx); | |
| 249 | + const [stats, heatmap] = await Promise.all([siteStats(ctx, overviews), contributionCalendar(ctx)]); | |
| 220 | 250 | const pinned = overviews.filter((o) => o.pinned).slice(0, 6); |
| 221 | 251 | const activity = ctx.activity.recent(18); |
| 222 | 252 | const topics = [...new Set(overviews.flatMap((o) => o.topics))].sort(); |
@@ -227,6 +257,7 @@ export async function registerWeb(app, ctx) { | ||
| 227 | 257 | return render(reply, 'home.njk', { |
| 228 | 258 | title: 'SPB Git — Simon-Pierre Boucher', |
| 229 | 259 | description: 'Personal git platform of Simon-Pierre Boucher — every repository, public and clonable.', |
| 260 | + nav: 'repos', | |
| 230 | 261 | overviews, |
| 231 | 262 | pinned, |
| 232 | 263 | stats, |
@@ -240,16 +271,27 @@ export async function registerWeb(app, ctx) { | ||
| 240 | 271 | }); |
| 241 | 272 | }); |
| 242 | 273 | |
| 274 | + app.get('/about/metrics', async (request, reply) => { | |
| 275 | + const stats = await siteStats(ctx); | |
| 276 | + return render(reply, 'metrics.njk', { | |
| 277 | + title: 'Metric definitions · SPB Git', | |
| 278 | + description: 'How commits, streaks, languages and activity are counted on SPB Git.', | |
| 279 | + stats, | |
| 280 | + canonicalPath: '/about/metrics', | |
| 281 | + }); | |
| 282 | + }); | |
| 283 | + | |
| 243 | 284 | // ───────────────────────── search ───────────────────────── |
| 244 | 285 | app.get('/search', async (request, reply) => { |
| 245 | 286 | const q = String(request.query.q ?? '').slice(0, 120); |
| 246 | − const results = q ? await search(ctx, q) : { repos: [], readmes: [] }; | |
| 287 | + const results = q ? await search(ctx, q, { scope: 'all', limit: 20 }) : null; | |
| 247 | 288 | return render(reply, 'search.njk', { |
| 248 | 289 | title: q ? `Search: ${q} · SPB Git` : 'Search · SPB Git', |
| 249 | − description: `Search results for ${q}`, | |
| 290 | + description: q ? `Search results for ${q}` : 'Search repositories, files, commits and branches.', | |
| 250 | 291 | q, |
| 251 | − results, | |
| 252 | − canonicalPath: `/search`, | |
| 292 | + results: results ?? { repos: [], readmes: [], files: [], commits: [], branches: [] }, | |
| 293 | + indexStatus: results?.index ?? null, | |
| 294 | + canonicalPath: '/search', | |
| 253 | 295 | }); |
| 254 | 296 | }); |
| 255 | 297 | |
@@ -261,6 +303,7 @@ export async function registerWeb(app, ctx) { | ||
| 261 | 303 | 'Allow: /', |
| 262 | 304 | 'Disallow: /archive/', |
| 263 | 305 | 'Disallow: /internal/', |
| 306 | + 'Disallow: /api/v1/search', | |
| 264 | 307 | `Sitemap: ${config.publicUrl}/sitemap.xml`, |
| 265 | 308 | '', |
| 266 | 309 | ].join('\n'); |
@@ -270,6 +313,7 @@ export async function registerWeb(app, ctx) { | ||
| 270 | 313 | const overviews = await allOverviews(ctx); |
| 271 | 314 | const urls = [ |
| 272 | 315 | { loc: `${config.publicUrl}/`, priority: '1.0' }, |
| 316 | + { loc: `${config.publicUrl}/about/metrics`, priority: '0.3' }, | |
| 273 | 317 | ...overviews.map((o) => ({ |
| 274 | 318 | loc: `${config.publicUrl}/${o.name}`, |
| 275 | 319 | lastmod: o.lastPush ? new Date(o.lastPush).toISOString() : undefined, |
@@ -316,7 +360,7 @@ export async function registerWeb(app, ctx) { | ||
| 316 | 360 | return [ |
| 317 | 361 | '<?xml version="1.0" encoding="utf-8"?>', |
| 318 | 362 | '<feed xmlns="http://www.w3.org/2005/Atom">', |
| 319 | − ` <title>SPB Git — activity</title>`, | |
| 363 | + ' <title>SPB Git — activity</title>', | |
| 320 | 364 | ` <link href="${config.publicUrl}/feed.atom" rel="self"/>`, |
| 321 | 365 | ` <link href="${config.publicUrl}/"/>`, |
| 322 | 366 | ` <id>${config.publicUrl}/feed.atom</id>`, |
@@ -366,31 +410,33 @@ export async function registerWeb(app, ctx) { | ||
| 366 | 410 | return reply.send(buf); |
| 367 | 411 | }); |
| 368 | 412 | |
| 369 | − // ───────────────────────── raw files ───────────────────────── | |
| 413 | + // ───────────────────────── raw files (streamed, never buffered) ───────────────────────── | |
| 370 | 414 | app.get('/raw/:repo/*', async (request, reply) => { |
| 371 | 415 | const name = repoParam(request, reply); |
| 372 | 416 | if (!name) return reply; |
| 373 | 417 | const resolved = await ctx.repos.resolveRefAndPath(name, request.params['*']); |
| 374 | 418 | if (!resolved || resolved.path === '') return notFound(request, reply); |
| 375 | − const blob = await ctx.repos.blob(name, resolved.sha, resolved.path); | |
| 376 | − if (!blob) return notFound(request, reply); | |
| 419 | + const info = await ctx.repos.blobInfo(name, resolved.sha, resolved.path); | |
| 420 | + if (!info) return notFound(request, reply); | |
| 377 | 421 | const ext = posix.extname(resolved.path).toLowerCase(); |
| 378 | 422 | let mime = RAW_MIME[ext]; |
| 379 | − if (!mime) mime = blob.binary ? 'application/octet-stream' : 'text/plain; charset=utf-8'; | |
| 423 | + if (!mime) mime = info.binary ? 'application/octet-stream' : 'text/plain; charset=utf-8'; | |
| 380 | 424 | // Stored-XSS guard: never serve repo HTML as HTML. |
| 381 | 425 | if (['.html', '.htm', '.xhtml'].includes(ext)) mime = 'text/plain; charset=utf-8'; |
| 382 | − const immutable = /^[0-9a-f]{40}$/.test(resolved.ref) || /^[0-9a-f]{7,40}$/.test(resolved.ref); | |
| 426 | + const immutable = /^[0-9a-f]{7,40}$/.test(resolved.ref); | |
| 383 | 427 | // PDFs must not be CSP-sandboxed or the built-in browser viewer refuses to render. |
| 384 | 428 | const csp = ext === '.pdf' |
| 385 | 429 | ? "default-src 'none'; object-src 'self'; style-src 'unsafe-inline'" |
| 386 | 430 | : "default-src 'none'; style-src 'unsafe-inline'; sandbox"; |
| 387 | 431 | reply |
| 388 | 432 | .type(mime) |
| 433 | + .header('Content-Length', info.size) | |
| 389 | 434 | .header('X-Content-Type-Options', 'nosniff') |
| 390 | 435 | .header('Content-Security-Policy', csp) |
| 391 | 436 | .header('Cache-Control', immutable ? 'public, max-age=31536000, immutable' : 'public, max-age=60'); |
| 392 | 437 | if (ext === '.pdf') reply.header('Content-Disposition', 'inline'); |
| 393 | − return reply.send(blob.content); | |
| 438 | + if (info.size === 0) return reply.send(''); | |
| 439 | + return reply.send(await ctx.repos.blobStream(name, resolved.sha, resolved.path)); | |
| 394 | 440 | }); |
| 395 | 441 | |
| 396 | 442 | // ───────────────────────── archives ───────────────────────── |
@@ -449,6 +495,7 @@ export async function registerWeb(app, ctx) { | ||
| 449 | 495 | ...shell, |
| 450 | 496 | tab: 'code', |
| 451 | 497 | currentRef: overview.defaultBranch, |
| 498 | + path: '', | |
| 452 | 499 | entries, |
| 453 | 500 | lastCommits, |
| 454 | 501 | readme, |
@@ -496,40 +543,38 @@ export async function registerWeb(app, ctx) { | ||
| 496 | 543 | if (type === 'tree') { |
| 497 | 544 | return reply.redirect(`/${name}/tree/${resolved.ref}/${resolved.path}`); |
| 498 | 545 | } |
| 499 | − const blob = await ctx.repos.blob(name, resolved.sha, resolved.path); | |
| 500 | − if (!blob) return notFound(request, reply); | |
| 546 | + const info = await ctx.repos.blobInfo(name, resolved.sha, resolved.path); | |
| 547 | + if (!info) return notFound(request, reply); | |
| 501 | 548 | const shell = await repoShell(ctx, name); |
| 502 | 549 | const ext = posix.extname(resolved.path).toLowerCase(); |
| 503 | 550 | const rawUrl = `/raw/${name}/${resolved.ref}/${resolved.path}`; |
| 504 | 551 | |
| 505 | − const view = { | |
| 506 | − kind: 'code', | |
| 507 | − html: null, | |
| 508 | − lines: 0, | |
| 509 | − lang: null, | |
| 510 | − tooLarge: false, | |
| 511 | − isImage: false, | |
| 512 | − isMarkdown: false, | |
| 513 | − notebookNote: false, | |
| 514 | − }; | |
| 552 | + const view = { kind: 'code', html: null, lines: 0, lang: null, tooLarge: false, isImage: false, isMarkdown: false, notebookNote: false }; | |
| 515 | 553 | |
| 516 | 554 | if (ext === '.pdf') { |
| 517 | 555 | view.kind = 'pdf'; |
| 518 | − } else if (blob.binary || IMAGE_EXT.has(ext)) { | |
| 556 | + } else if (info.binary || IMAGE_EXT.has(ext)) { | |
| 519 | 557 | view.kind = 'binary'; |
| 520 | 558 | view.isImage = IMAGE_EXT.has(ext); |
| 521 | − } else if (blob.size > BLOB_RENDER_LIMIT) { | |
| 559 | + } else if (info.size > BLOB_RENDER_LIMIT) { | |
| 522 | 560 | view.kind = 'toolarge'; |
| 523 | 561 | view.tooLarge = true; |
| 524 | 562 | } else { |
| 563 | + // Only text files small enough to render are ever read into memory. | |
| 564 | + const blob = await ctx.repos.blob(name, resolved.sha, resolved.path, { maxBytes: BLOB_RENDER_LIMIT }); | |
| 565 | + if (!blob || !blob.content) return notFound(request, reply); | |
| 525 | 566 | const text = blob.content.toString('utf8'); |
| 526 | 567 | if (['.md', '.markdown'].includes(ext) && request.query.plain !== '1') { |
| 527 | 568 | view.kind = 'markdown'; |
| 528 | 569 | view.isMarkdown = true; |
| 529 | 570 | const basePath = resolved.path.includes('/') ? resolved.path.slice(0, resolved.path.lastIndexOf('/')) : '.'; |
| 530 | − view.html = await renderMarkdown(text, { | |
| 531 | − repo: name, ref: resolved.ref, basePath, publicUrl: config.publicUrl, | |
| 532 | − }); | |
| 571 | + const cachePath = ctx.cache.repoPath(name, resolved.sha, `md-${resolved.path.replaceAll('/', '_')}.html`); | |
| 572 | + const cached = ctx.cache.getBuffer(cachePath); | |
| 573 | + if (cached) view.html = cached.toString('utf8'); | |
| 574 | + else { | |
| 575 | + view.html = await renderMarkdown(text, { repo: name, ref: resolved.ref, basePath, publicUrl: config.publicUrl }); | |
| 576 | + ctx.cache.set(cachePath, view.html); | |
| 577 | + } | |
| 533 | 578 | } else { |
| 534 | 579 | let source = text; |
| 535 | 580 | if (ext === '.ipynb') { |
@@ -540,7 +585,8 @@ export async function registerWeb(app, ctx) { | ||
| 540 | 585 | source = text; |
| 541 | 586 | } |
| 542 | 587 | } |
| 543 | − const result = await highlightFile(source, resolved.path); | |
| 588 | + const cachePath = ctx.cache.repoPath(name, resolved.sha, `hl-${resolved.path.replaceAll('/', '_')}.json`); | |
| 589 | + const result = await ctx.cache.remember(cachePath, () => highlightFile(source, resolved.path)); | |
| 544 | 590 | view.html = result.html; |
| 545 | 591 | view.lines = result.lines; |
| 546 | 592 | view.lang = result.lang; |
@@ -557,8 +603,8 @@ export async function registerWeb(app, ctx) { | ||
| 557 | 603 | refSha: resolved.sha, |
| 558 | 604 | path: resolved.path, |
| 559 | 605 | fileName: resolved.path.split('/').pop(), |
| 560 | − crumbs: breadcrumbs(name, 'blob', resolved.ref, resolved.path), | |
| 561 | − blobSize: blob.size, | |
| 606 | + crumbs: breadcrumbs(name, 'blob', resolved.ref, resolved.path).map((c, i, arr) => (i < arr.length - 1 ? { ...c, href: c.href.replace('/blob/', '/tree/') } : c)), | |
| 607 | + blobSize: info.size, | |
| 562 | 608 | rawUrl, |
| 563 | 609 | view, |
| 564 | 610 | canonicalPath: `/${name}/blob/${resolved.ref}/${resolved.path}`, |
@@ -595,15 +641,25 @@ export async function registerWeb(app, ctx) { | ||
| 595 | 641 | app.get('/:repo/commits', commitsHandler); |
| 596 | 642 | app.get('/:repo/commits/*', commitsHandler); |
| 597 | 643 | |
| 598 | − // ───────────────────────── single commit ───────────────────────── | |
| 644 | + // ───────────────────────── single commit (prepared diff cached per sha) ───────────────────────── | |
| 599 | 645 | app.get('/:repo/commit/:sha', async (request, reply) => { |
| 600 | 646 | const name = repoParam(request, reply); |
| 601 | 647 | if (!name) return reply; |
| 602 | 648 | const shaParam = String(request.params.sha ?? ''); |
| 603 | 649 | if (!/^[0-9a-f]{4,40}$/i.test(shaParam)) return notFound(request, reply); |
| 604 | − const commit = await ctx.repos.commit(name, shaParam); | |
| 650 | + const full = await ctx.repos.resolveRef(name, shaParam); | |
| 651 | + if (!full) return notFound(request, reply); | |
| 652 | + const commit = await ctx.cache.remember(ctx.cache.repoPath(name, full, 'commit-v2.json'), async () => { | |
| 653 | + const c = await ctx.repos.commit(name, full); | |
| 654 | + if (!c) return null; | |
| 655 | + await prepareDiff(c.files); | |
| 656 | + return c; | |
| 657 | + }); | |
| 605 | 658 | if (!commit) return notFound(request, reply); |
| 606 | − await prepareDiff(commit.files); | |
| 659 | + const view = request.query.view === 'split' ? 'split' : 'unified'; | |
| 660 | + if (view === 'split') { | |
| 661 | + for (const file of commit.files) for (const hunk of file.hunks) hunk.rows = splitRows(hunk.lines); | |
| 662 | + } | |
| 607 | 663 | const shell = await repoShell(ctx, name); |
| 608 | 664 | return render(reply, 'commit.njk', { |
| 609 | 665 | title: `${commit.subject} · ${commit.shortSha} · ${name} · SPB Git`, |
@@ -612,6 +668,7 @@ export async function registerWeb(app, ctx) { | ||
| 612 | 668 | tab: 'commits', |
| 613 | 669 | currentRef: shell.overview.defaultBranch, |
| 614 | 670 | commit, |
| 671 | + view, | |
| 615 | 672 | canonicalPath: `/${name}/commit/${commit.sha}`, |
| 616 | 673 | ogImagePath: `/og/${name}.png`, |
| 617 | 674 | }); |
@@ -623,7 +680,11 @@ export async function registerWeb(app, ctx) { | ||
| 623 | 680 | if (!name) return reply; |
| 624 | 681 | const resolved = await ctx.repos.resolveRefAndPath(name, request.params['*']); |
| 625 | 682 | if (!resolved || resolved.path === '') return notFound(request, reply); |
| 626 | − const blame = await ctx.repos.blame(name, resolved.sha, resolved.path); | |
| 683 | + const info = await ctx.repos.blobInfo(name, resolved.sha, resolved.path); | |
| 684 | + if (!info || info.binary || info.size > BLOB_RENDER_LIMIT) return notFound(request, reply); | |
| 685 | + const blame = await ctx.cache.remember(ctx.cache.repoPath(name, resolved.sha, `blame-${resolved.path.replaceAll('/', '_')}.json`), () => | |
| 686 | + ctx.repos.blame(name, resolved.sha, resolved.path), | |
| 687 | + ); | |
| 627 | 688 | if (!blame) return notFound(request, reply); |
| 628 | 689 | const shell = await repoShell(ctx, name); |
| 629 | 690 | // Age-scale each hunk 0..9 (newer = brighter accent). |
@@ -641,7 +702,7 @@ export async function registerWeb(app, ctx) { | ||
| 641 | 702 | tab: 'code', |
| 642 | 703 | currentRef: resolved.ref, |
| 643 | 704 | path: resolved.path, |
| 644 | − crumbs: breadcrumbs(name, 'blame', resolved.ref, resolved.path), | |
| 705 | + crumbs: breadcrumbs(name, 'tree', resolved.ref, resolved.path), | |
| 645 | 706 | blame, |
| 646 | 707 | canonicalPath: `/${name}/blame/${resolved.ref}/${resolved.path}`, |
| 647 | 708 | ogImagePath: `/og/${name}.png`, |
modified
src/web/views/blame.njk
+15 −15
@@ -5,28 +5,27 @@ | ||
| 5 | 5 | Author : Simon-Pierre Boucher |
| 6 | 6 | Contact : contact@spboucher.ai |
| 7 | 7 | File : src/web/views/blame.njk |
| 8 | − Purpose : Blame view — hunks grouped per commit, color-aged | |
| 8 | + Purpose : Blame view v2.0 — hunks grouped per commit, age-coloured, horizontally scrollable on phones | |
| 9 | 9 | License : MIT © Simon-Pierre Boucher |
| 10 | 10 | ───────────────────────────────────────────── |
| 11 | 11 | #}{% extends "layout.njk" %} |
| 12 | +{% import "components/ui.njk" as ui %} | |
| 12 | 13 | {% block content %} |
| 13 | 14 | {% include "partials/repo-header.njk" %} |
| 14 | 15 | |
| 15 | −<div class="blob-card"> | |
| 16 | +<div class="card blob-card"> | |
| 16 | 17 | <div class="blob-header"> |
| 17 | − <nav class="path-breadcrumb" aria-label="Path"> | |
| 18 | − <a href="/{{ overview.name }}">{{ overview.name }}</a> | |
| 19 | − {% for crumb in crumbs %} | |
| 20 | − <span class="crumb-sep">/</span>{% if loop.last %}<strong>{{ crumb.name }}</strong>{% else %}<span>{{ crumb.name }}</span>{% endif %} | |
| 21 | − {% endfor %} | |
| 22 | − <span class="chip">blame</span> | |
| 23 | − </nav> | |
| 24 | − <div class="blob-actions"> | |
| 25 | − <a class="btn btn-sm" href="/{{ overview.name }}/blob/{{ currentRef }}/{{ path }}" rel="nofollow">Normal view</a> | |
| 18 | + <div class="blob-header-row"> | |
| 19 | + {{ ui.breadcrumbs(overview.name, '/' + overview.name, crumbs) }} | |
| 20 | + {{ ui.chip('blame', 'info') }} | |
| 21 | + </div> | |
| 22 | + <div class="blob-actions" role="toolbar" aria-label="Blame actions"> | |
| 23 | + <a class="btn btn-sm" href="/{{ overview.name }}/blob/{{ currentRef }}/{{ path }}" rel="nofollow">{{ ui.icon('code', 14) }} Normal view</a> | |
| 26 | 24 | <a class="btn btn-sm" href="/raw/{{ overview.name }}/{{ currentRef }}/{{ path }}" rel="nofollow">Raw</a> |
| 25 | + <a class="btn btn-sm" href="/{{ overview.name }}/commits/{{ currentRef }}?path={{ path | urlencode }}" rel="nofollow">{{ ui.icon('history', 14) }} History</a> | |
| 27 | 26 | </div> |
| 28 | 27 | </div> |
| 29 | − | |
| 28 | + <div class="blame-scroll"> | |
| 30 | 29 | <table class="blame-table"> |
| 31 | 30 | <tbody> |
| 32 | 31 | {% for hunk in blame.hunks %} |
@@ -35,9 +34,9 @@ | ||
| 35 | 34 | {% if loop.first %} |
| 36 | 35 | <td class="blame-commit" rowspan="{{ hunk.lines.length }}"> |
| 37 | 36 | <div class="blame-commit-inner"> |
| 38 | − <span class="commit-avatar">{{ hunk.authorEmail | identicon(20) }}</span> | |
| 39 | − <a href="/{{ overview.name }}/commit/{{ hunk.sha }}" title="{{ hunk.subject }}">{{ hunk.subject | truncate(48) }}</a> | |
| 40 | − <span class="muted blame-date">{{ hunk.date | reltime }}</span> | |
| 37 | + <span class="commit-avatar">{{ hunk.authorEmail | identicon(18) }}</span> | |
| 38 | + <a href="/{{ overview.name }}/commit/{{ hunk.sha }}" title="{{ hunk.subject }}">{{ hunk.subject | truncate(44) }}</a> | |
| 39 | + <time class="muted blame-date" datetime="{{ hunk.date }}">{{ hunk.date | reltime }}</time> | |
| 41 | 40 | </div> |
| 42 | 41 | </td> |
| 43 | 42 | {% endif %} |
@@ -48,5 +47,6 @@ | ||
| 48 | 47 | {% endfor %} |
| 49 | 48 | </tbody> |
| 50 | 49 | </table> |
| 50 | + </div> | |
| 51 | 51 | </div> |
| 52 | 52 | {% endblock %} |
modified
src/web/views/blob.njk
+32 −32
@@ -5,46 +5,45 @@ | ||
| 5 | 5 | Author : Simon-Pierre Boucher |
| 6 | 6 | Contact : contact@spboucher.ai |
| 7 | 7 | File : src/web/views/blob.njk |
| 8 | − Purpose : File view v4 — glass sticky header, wrap toggle, shiki code, line anchors | |
| 8 | + Purpose : File view v2.0 — sticky toolbar, wrap/copy/raw/blame/history/permalink, code with line anchors, markdown, images, PDF, binaries | |
| 9 | 9 | License : MIT © Simon-Pierre Boucher |
| 10 | 10 | ───────────────────────────────────────────── |
| 11 | 11 | #}{% extends "layout.njk" %} |
| 12 | +{% import "components/ui.njk" as ui %} | |
| 12 | 13 | {% block content %} |
| 13 | 14 | {% include "partials/repo-header.njk" %} |
| 14 | 15 | |
| 15 | −<div class="blob-card"> | |
| 16 | +<div class="repo-toolbar"> | |
| 17 | + <div class="repo-toolbar-left"> | |
| 18 | + {{ ui.refSelect(overview.name, branches, tags, currentRef, 'blob', path) }} | |
| 19 | + <button type="button" class="btn btn-sm only-mobile" id="tree-open">{{ ui.icon('folder', 14) }} Files</button> | |
| 20 | + </div> | |
| 21 | +</div> | |
| 22 | + | |
| 23 | +<div class="card blob-card"> | |
| 16 | 24 | <div class="blob-header"> |
| 17 | − <nav class="path-breadcrumb" aria-label="Path"> | |
| 18 | − <a href="/{{ overview.name }}">{{ overview.name }}</a> | |
| 19 | − {% for crumb in crumbs %} | |
| 20 | − <span class="crumb-sep">/</span>{% if loop.last %}<strong>{{ crumb.name }}</strong>{% else %}<a href="{{ crumb.href | replace('/blame/', '/tree/') | replace('/blob/', '/tree/') }}">{{ crumb.name }}</a>{% endif %} | |
| 21 | − {% endfor %} | |
| 22 | − </nav> | |
| 23 | − <div class="blob-meta"> | |
| 24 | − <span class="muted">{{ blobSize | bytes }}</span> | |
| 25 | − {% if view.lines %}<span class="muted">· {{ view.lines | num }} lines</span>{% endif %} | |
| 26 | − {% if view.lang %}<span class="chip">{{ view.lang }}</span>{% endif %} | |
| 25 | + <div class="blob-header-row"> | |
| 26 | + {{ ui.breadcrumbs(overview.name, '/' + overview.name, crumbs) }} | |
| 27 | + <div class="blob-meta"> | |
| 28 | + <span class="num">{{ blobSize | bytes }}</span> | |
| 29 | + {% if view.lines %}<span class="num">· {{ view.lines | num }} lines</span>{% endif %} | |
| 30 | + {% if view.lang %}{{ ui.chip(view.lang, 'neutral') }}{% endif %} | |
| 31 | + </div> | |
| 27 | 32 | </div> |
| 28 | − <div class="blob-actions"> | |
| 33 | + <div class="blob-actions" role="toolbar" aria-label="File actions"> | |
| 29 | 34 | {% if view.isMarkdown %} |
| 30 | − {% if view.kind == 'markdown' %} | |
| 31 | − <a class="btn btn-sm" href="?plain=1" rel="nofollow">View source</a> | |
| 32 | − {% else %} | |
| 33 | − <a class="btn btn-sm" href="/{{ overview.name }}/blob/{{ currentRef }}/{{ path }}" rel="nofollow">Rendered</a> | |
| 34 | − {% endif %} | |
| 35 | + {% if view.kind == 'markdown' %}<a class="btn btn-sm" href="?plain=1" rel="nofollow">{{ ui.icon('code', 14) }} Source</a> | |
| 36 | + {% else %}<a class="btn btn-sm" href="/{{ overview.name }}/blob/{{ currentRef }}/{{ path }}" rel="nofollow">{{ ui.icon('book', 14) }} Rendered</a>{% endif %} | |
| 35 | 37 | {% endif %} |
| 36 | 38 | {% if view.kind == 'code' %} |
| 37 | − <button class="btn btn-sm" type="button" id="wrap-toggle" aria-pressed="false" title="Toggle soft wrap">Wrap</button> | |
| 38 | − <button class="btn btn-sm code-copy" type="button" data-code-copy data-code-target="#code-view" title="Copy file contents"> | |
| 39 | − <span class="code-copy-label">Copy file</span> | |
| 40 | − </button> | |
| 39 | + <button class="btn btn-sm" type="button" id="wrap-toggle" aria-pressed="false" title="Toggle soft wrap">{{ ui.icon('wrap', 14) }} Wrap</button> | |
| 40 | + <button class="btn btn-sm code-copy" type="button" data-code-copy data-code-target="#code-view" title="Copy file contents">{{ ui.icon('copy', 14) }}<span class="code-copy-label">Copy</span></button> | |
| 41 | 41 | {% endif %} |
| 42 | 42 | <a class="btn btn-sm" href="{{ rawUrl }}" rel="nofollow">Raw</a> |
| 43 | − <a class="btn btn-sm" href="/{{ overview.name }}/blame/{{ currentRef }}/{{ path }}" rel="nofollow">Blame</a> | |
| 44 | − <a class="btn btn-sm" href="/{{ overview.name }}/commits/{{ currentRef }}?path={{ path | urlencode }}" rel="nofollow">History</a> | |
| 45 | − <button class="btn btn-sm copy-btn" type="button" | |
| 46 | − data-copy="{{ publicUrl }}/{{ overview.name }}/blob/{{ refSha }}/{{ path }}" | |
| 47 | − title="Copy permalink (pinned to {{ refSha | truncate(7, true, '') }})">Permalink</button> | |
| 43 | + {% if view.kind == 'code' or view.kind == 'markdown' %}<a class="btn btn-sm" href="/{{ overview.name }}/blame/{{ currentRef }}/{{ path }}" rel="nofollow">Blame</a>{% endif %} | |
| 44 | + <a class="btn btn-sm" href="/{{ overview.name }}/commits/{{ currentRef }}?path={{ path | urlencode }}" rel="nofollow">{{ ui.icon('history', 14) }} History</a> | |
| 45 | + {{ ui.copyButton(publicUrl + '/' + overview.name + '/blob/' + refSha + '/' + path, 'Permalink', 'Copy permalink pinned to ' + (refSha | truncate(7, true, ''))) }} | |
| 46 | + {% if view.kind == 'binary' or view.kind == 'pdf' or view.kind == 'toolarge' %}<a class="btn btn-sm" href="{{ rawUrl }}" download>{{ ui.icon('download', 14) }} Download</a>{% endif %} | |
| 48 | 47 | </div> |
| 49 | 48 | </div> |
| 50 | 49 | |
@@ -63,22 +62,23 @@ | ||
| 63 | 62 | </div> |
| 64 | 63 | {% elif view.kind == 'binary' %} |
| 65 | 64 | {% if view.isImage %} |
| 66 | − <div class="blob-image"><img src="{{ rawUrl }}" alt="{{ fileName }}"></div> | |
| 65 | + <div class="blob-image"><img src="{{ rawUrl }}" alt="{{ fileName }}" loading="lazy"></div> | |
| 67 | 66 | {% else %} |
| 68 | 67 | <div class="blob-binary"> |
| 69 | − <p>Binary file — {{ blobSize | bytes }}.</p> | |
| 70 | − <a class="btn btn-primary" href="{{ rawUrl }}" download>Download</a> | |
| 68 | + <p>Binary file · {{ blobSize | bytes }}</p> | |
| 69 | + <a class="btn btn-primary" href="{{ rawUrl }}" download>{{ ui.icon('download', 14) }} Download {{ fileName }}</a> | |
| 71 | 70 | </div> |
| 72 | 71 | {% endif %} |
| 73 | 72 | {% elif view.kind == 'toolarge' %} |
| 74 | 73 | <div class="blob-binary"> |
| 75 | − <p>This file is {{ blobSize | bytes }} — too large to display.</p> | |
| 74 | + <p>This file is {{ blobSize | bytes }} — too large to display inline.</p> | |
| 76 | 75 | <a class="btn btn-primary" href="{{ rawUrl }}">View raw</a> |
| 77 | 76 | </div> |
| 78 | 77 | {% elif view.kind == 'markdown' %} |
| 79 | 78 | <article class="markdown-body blob-markdown">{{ view.html | safe }}</article> |
| 80 | 79 | {% else %} |
| 81 | − <div class="code-view" id="code-view">{{ view.html | safe }}</div> | |
| 80 | + <div class="code-view" id="code-view" tabindex="0">{{ view.html | safe }}</div> | |
| 82 | 81 | {% endif %} |
| 83 | 82 | </div> |
| 83 | +{% include "partials/tree-sheet.njk" %} | |
| 84 | 84 | {% endblock %} |
modified
src/web/views/branches.njk
+18 −27
@@ -5,37 +5,28 @@ | ||
| 5 | 5 | Author : Simon-Pierre Boucher |
| 6 | 6 | Contact : contact@spboucher.ai |
| 7 | 7 | File : src/web/views/branches.njk |
| 8 | − Purpose : Branch list with ahead/behind vs default | |
| 8 | + Purpose : Branch list v2.0 — ahead/behind vs default, responsive rows | |
| 9 | 9 | License : MIT © Simon-Pierre Boucher |
| 10 | 10 | ───────────────────────────────────────────── |
| 11 | 11 | #}{% extends "layout.njk" %} |
| 12 | +{% import "components/ui.njk" as ui %} | |
| 12 | 13 | {% block content %} |
| 13 | 14 | {% include "partials/repo-header.njk" %} |
| 14 | 15 | |
| 15 | −<table class="ref-table"> | |
| 16 | − <thead><tr><th>Branch</th><th>Last commit</th><th>Ahead / behind {{ overview.defaultBranch }}</th></tr></thead> | |
| 17 | − <tbody> | |
| 18 | − {% for branch in branchesDetailed %} | |
| 19 | − <tr> | |
| 20 | − <td> | |
| 21 | − <a class="ref-name" href="/{{ overview.name }}/tree/{{ branch.name }}">{{ branch.name }}</a> | |
| 22 | − {% if branch.isDefault %}<span class="chip chip-default">default</span>{% endif %} | |
| 23 | − </td> | |
| 24 | − <td class="muted">{{ branch.subject | truncate(60) }} · {{ branch.date | reltime }}</td> | |
| 25 | − <td> | |
| 26 | − {% if branch.isDefault %} | |
| 27 | − <span class="muted">—</span> | |
| 28 | − {% else %} | |
| 29 | − <span class="ahead-behind"> | |
| 30 | − <span class="diff-add" title="{{ branch.ahead }} ahead">↑{{ branch.ahead }}</span> | |
| 31 | − <span class="diff-del" title="{{ branch.behind }} behind">↓{{ branch.behind }}</span> | |
| 32 | − </span> | |
| 33 | − {% endif %} | |
| 34 | − </td> | |
| 35 | − </tr> | |
| 36 | − {% else %} | |
| 37 | − <tr><td colspan="3" class="muted">No branches yet.</td></tr> | |
| 38 | − {% endfor %} | |
| 39 | − </tbody> | |
| 40 | −</table> | |
| 16 | +{% if branchesDetailed.length %} | |
| 17 | +<ul class="ref-list card"> | |
| 18 | + {% for branch in branchesDetailed %} | |
| 19 | + <li> | |
| 20 | + <span class="ref-name"><a href="/{{ overview.name }}/tree/{{ branch.name }}">{{ branch.name }}</a>{% if branch.isDefault %}{{ ui.chip('default', 'ok') }}{% endif %}</span> | |
| 21 | + <span class="ref-sub">{{ branch.subject | truncate(70) }} · <time datetime="{{ branch.date }}">{{ branch.date | reltime }}</time></span> | |
| 22 | + <span class="ahead-behind"> | |
| 23 | + {% if branch.isDefault %}<span class="muted">—</span> | |
| 24 | + {% else %}<span class="diff-add" title="{{ branch.ahead }} ahead of {{ overview.defaultBranch }}">↑{{ branch.ahead }}</span><span class="diff-del" title="{{ branch.behind }} behind {{ overview.defaultBranch }}">↓{{ branch.behind }}</span>{% endif %} | |
| 25 | + </span> | |
| 26 | + </li> | |
| 27 | + {% endfor %} | |
| 28 | +</ul> | |
| 29 | +{% else %} | |
| 30 | +{{ ui.emptyState('No branches yet', '', '', 'branch') }} | |
| 31 | +{% endif %} | |
| 41 | 32 | {% endblock %} |
modified
src/web/views/commit.njk
+57 −27
@@ -5,25 +5,25 @@ | ||
| 5 | 5 | Author : Simon-Pierre Boucher |
| 6 | 6 | Contact : contact@spboucher.ai |
| 7 | 7 | File : src/web/views/commit.njk |
| 8 | − Purpose : Single commit v4 — header with diff bar, file jump list, collapsible highlighted diffs | |
| 8 | + Purpose : Single commit v2.0 — header, changed-file list, unified/split diffs, wrap toggle, collapsed large files | |
| 9 | 9 | License : MIT © Simon-Pierre Boucher |
| 10 | 10 | ───────────────────────────────────────────── |
| 11 | 11 | #}{% extends "layout.njk" %} |
| 12 | +{% import "components/ui.njk" as ui %} | |
| 12 | 13 | {% block content %} |
| 13 | 14 | {% include "partials/repo-header.njk" %} |
| 14 | 15 | |
| 15 | 16 | <article class="commit-detail"> |
| 16 | − <header class="commit-detail-header"> | |
| 17 | + <header class="card commit-detail-header"> | |
| 17 | 18 | <h2 class="commit-detail-subject">{{ commit.subject }}</h2> |
| 18 | − {% if commit.body %}<pre class="commit-body">{{ commit.body }}</pre>{% endif %} | |
| 19 | + {% if commit.body %}<pre class="commit-body" style="margin-bottom:12px">{{ commit.body }}</pre>{% endif %} | |
| 19 | 20 | <div class="commit-detail-meta"> |
| 20 | − <span class="commit-avatar">{{ commit.authorEmail | identicon(24) }}</span> | |
| 21 | + <span class="commit-avatar">{{ commit.authorEmail | identicon(22) }}</span> | |
| 21 | 22 | <strong>{{ commit.authorName }}</strong> |
| 22 | − <span class="muted">committed {{ commit.date | reltime }} ({{ commit.date | datefull }})</span> | |
| 23 | − <button class="sha-chip copy-btn" type="button" data-copy="{{ commit.sha }}" title="Copy full SHA">{{ commit.shortSha }}</button> | |
| 24 | − {% for parent in commit.parents %} | |
| 25 | − <a class="muted mono" style="font-size:12px" href="/{{ overview.name }}/commit/{{ parent }}">parent {{ parent | truncate(7, true, '') }}</a> | |
| 26 | − {% endfor %} | |
| 23 | + <span class="muted">committed <time datetime="{{ commit.date }}">{{ commit.date | reltime }}</time> ({{ commit.date | datefull }})</span> | |
| 24 | + <button class="sha-chip copy-btn" type="button" data-copy="{{ commit.sha }}" title="Copy full SHA" aria-label="Copy SHA {{ commit.shortSha }}">{{ commit.shortSha }}</button> | |
| 25 | + {% for parent in commit.parents %}<a class="muted mono text-xs" href="/{{ overview.name }}/commit/{{ parent }}">parent {{ parent | truncate(7, true, '') }}</a>{% endfor %} | |
| 26 | + <a class="btn btn-sm btn-ghost" href="/{{ overview.name }}/tree/{{ commit.sha }}">{{ ui.icon('code', 14) }} Browse files</a> | |
| 27 | 27 | </div> |
| 28 | 28 | {% set total = (commit.additions + commit.deletions) or 1 %} |
| 29 | 29 | <p class="commit-stats"> |
@@ -34,43 +34,73 @@ | ||
| 34 | 34 | </p> |
| 35 | 35 | </header> |
| 36 | 36 | |
| 37 | − {% if commit.files.length > 1 %} | |
| 38 | − <nav class="diff-nav" aria-label="Changed files"> | |
| 39 | − {% for file in commit.files %} | |
| 40 | − <a href="#diff-{{ loop.index }}"><span>{{ file.newPath or file.oldPath }}</span><span class="diff-add">+{{ file.additions }}</span><span class="diff-del">−{{ file.deletions }}</span></a> | |
| 41 | − {% endfor %} | |
| 42 | − </nav> | |
| 43 | − {% endif %} | |
| 37 | + <div class="diff-toolbar"> | |
| 38 | + {% if commit.files.length > 1 %} | |
| 39 | + <details class="card diff-nav"> | |
| 40 | + <summary>{{ ui.icon('chevron-down', 14) }} {{ commit.files.length }} changed files</summary> | |
| 41 | + <ul> | |
| 42 | + {% for file in commit.files %} | |
| 43 | + <li><a href="#diff-{{ loop.index }}"><span>{{ file.newPath or file.oldPath }}</span><span class="diff-add">+{{ file.additions }}</span><span class="diff-del">−{{ file.deletions }}</span></a></li> | |
| 44 | + {% endfor %} | |
| 45 | + </ul> | |
| 46 | + </details> | |
| 47 | + {% endif %} | |
| 48 | + <div class="btn-row" style="margin-left:auto"> | |
| 49 | + <button class="btn btn-sm" type="button" id="diff-wrap" aria-pressed="false" title="Wrap long lines">{{ ui.icon('wrap', 14) }} Wrap</button> | |
| 50 | + <span class="hide-mobile btn-row"> | |
| 51 | + <a class="btn btn-sm {{ 'is-active' if view != 'split' }}" href="?view=unified" aria-current="{{ 'true' if view != 'split' else 'false' }}">Unified</a> | |
| 52 | + <a class="btn btn-sm {{ 'is-active' if view == 'split' }}" href="?view=split" aria-current="{{ 'true' if view == 'split' else 'false' }}">Split</a> | |
| 53 | + </span> | |
| 54 | + </div> | |
| 55 | + </div> | |
| 44 | 56 | |
| 57 | + <div id="diffs" class="{{ 'diff-split' if view == 'split' }}"> | |
| 45 | 58 | {% for file in commit.files %} |
| 46 | − <details class="diff-file" id="diff-{{ loop.index }}" open> | |
| 59 | + <details class="card diff-file" id="diff-{{ loop.index }}" {{ "open" if loop.index <= 12 }}> | |
| 47 | 60 | <summary class="diff-file-header"> |
| 61 | + {{ ui.icon('chevron-down', 14, 'caret') }} | |
| 48 | 62 | <span class="diff-status diff-status-{{ file.status }}">{{ file.status }}</span> |
| 49 | 63 | <code class="diff-path">{% if file.status == 'renamed' %}{{ file.oldPath }} → {% endif %}{{ file.newPath or file.oldPath }}</code> |
| 50 | 64 | <span class="diff-counts"><span class="diff-add">+{{ file.additions }}</span> <span class="diff-del">−{{ file.deletions }}</span></span> |
| 51 | 65 | </summary> |
| 52 | 66 | {% if file.binary %} |
| 53 | − <p class="diff-binary muted">Binary file not shown.</p> | |
| 67 | + <p class="diff-binary">Binary file not shown.</p> | |
| 68 | + {% else %} | |
| 69 | + {% if file.large and not showAll %} | |
| 70 | + <div class="diff-collapsed"><span>Large diff ({{ file.lineCount | num }} lines) is not loaded by default to keep the page fast.</span><a class="btn btn-sm" href="?view={{ view }}&all=1#diff-{{ loop.index }}" rel="nofollow">Load diff</a></div> | |
| 54 | 71 | {% else %} |
| 55 | 72 | <div class="diff-table-wrap"> |
| 56 | 73 | <table class="diff-table"> |
| 57 | 74 | <tbody> |
| 58 | 75 | {% for hunk in file.hunks %} |
| 59 | − <tr class="diff-hunk-header"><td class="diff-gutter" colspan="2"></td><td><code>{{ hunk.header }}</code></td></tr> | |
| 60 | − {% for line in hunk.lines %} | |
| 61 | − <tr class="diff-line diff-line-{{ line.type }}"> | |
| 62 | − <td class="diff-gutter">{{ line.old if line.old }}</td> | |
| 63 | − <td class="diff-gutter">{{ line.new if line.new }}</td> | |
| 64 | − <td class="diff-code"><span class="diff-sign">{{ '+' if line.type == 'add' else ('−' if line.type == 'del' else ' ') }}</span><span class="diff-content">{{ line.html | safe }}</span></td> | |
| 65 | − </tr> | |
| 66 | − {% endfor %} | |
| 76 | + <tr class="diff-hunk-header"><td class="diff-gutter" colspan="{{ 4 if view == 'split' else 2 }}"></td><td colspan="{{ 2 if view == 'split' else 1 }}"><code>{{ hunk.header }}</code></td></tr> | |
| 77 | + {% if view == 'split' %} | |
| 78 | + {% for row in hunk.rows %} | |
| 79 | + <tr class="diff-line"> | |
| 80 | + {% if row.left %}<td class="diff-gutter diff-line-{{ row.left.type }}">{{ row.left.old if row.left.old }}</td><td class="diff-code diff-line-{{ row.left.type }}"><span class="diff-sign">{{ '−' if row.left.type == 'del' else ' ' }}</span><span class="diff-content">{{ row.left.html | safe }}</span></td> | |
| 81 | + {% else %}<td class="diff-gutter diff-cell-empty"></td><td class="diff-code diff-cell-empty"></td>{% endif %} | |
| 82 | + {% if row.right %}<td class="diff-gutter diff-line-{{ row.right.type }}">{{ row.right.new if row.right.new }}</td><td class="diff-code diff-line-{{ row.right.type }}"><span class="diff-sign">{{ '+' if row.right.type == 'add' else ' ' }}</span><span class="diff-content">{{ row.right.html | safe }}</span></td> | |
| 83 | + {% else %}<td class="diff-gutter diff-cell-empty"></td><td class="diff-code diff-cell-empty"></td>{% endif %} | |
| 84 | + </tr> | |
| 85 | + {% endfor %} | |
| 86 | + {% else %} | |
| 87 | + {% for line in hunk.lines %} | |
| 88 | + <tr class="diff-line diff-line-{{ line.type }}"> | |
| 89 | + <td class="diff-gutter">{{ line.old if line.old }}</td> | |
| 90 | + <td class="diff-gutter">{{ line.new if line.new }}</td> | |
| 91 | + <td class="diff-code"><span class="diff-sign">{{ '+' if line.type == 'add' else ('−' if line.type == 'del' else ' ') }}</span><span class="diff-content">{{ line.html | safe }}</span></td> | |
| 92 | + </tr> | |
| 93 | + {% endfor %} | |
| 94 | + {% endif %} | |
| 67 | 95 | {% endfor %} |
| 68 | 96 | </tbody> |
| 69 | 97 | </table> |
| 70 | 98 | </div> |
| 71 | − {% if file.truncated %}<p class="muted" style="padding:10px 16px">Diff truncated — file too large.</p>{% endif %} | |
| 99 | + {% endif %} | |
| 100 | + {% if file.truncated %}<p class="diff-truncated">Diff truncated — file too large.</p>{% endif %} | |
| 72 | 101 | {% endif %} |
| 73 | 102 | </details> |
| 74 | 103 | {% endfor %} |
| 104 | + </div> | |
| 75 | 105 | </article> |
| 76 | 106 | {% endblock %} |
modified
src/web/views/commits.njk
+19 −32
@@ -5,56 +5,47 @@ | ||
| 5 | 5 | Author : Simon-Pierre Boucher |
| 6 | 6 | Contact : contact@spboucher.ai |
| 7 | 7 | File : src/web/views/commits.njk |
| 8 | − Purpose : Paginated commit history v4 — grouped by day on a timeline | |
| 8 | + Purpose : Commit history v2.0 — timeline grouped by day, stats, pagination, path filter | |
| 9 | 9 | License : MIT © Simon-Pierre Boucher |
| 10 | 10 | ───────────────────────────────────────────── |
| 11 | 11 | #}{% extends "layout.njk" %} |
| 12 | +{% import "components/ui.njk" as ui %} | |
| 12 | 13 | {% block content %} |
| 13 | 14 | {% include "partials/repo-header.njk" %} |
| 14 | 15 | |
| 15 | 16 | <div class="repo-toolbar"> |
| 16 | − <div class="ref-select-wrap"> | |
| 17 | − <label class="sr-only" for="ref-select">Branch</label> | |
| 18 | − <select id="ref-select" class="ref-select" data-repo="{{ overview.name }}" data-kind="commits"> | |
| 19 | − {% for branch in branches %} | |
| 20 | − <option value="{{ branch.name }}" {{ 'selected' if branch.name == currentRef }}>{{ branch.name }}{{ ' (default)' if branch.isDefault }}</option> | |
| 21 | − {% endfor %} | |
| 22 | − {% for tag in tags %} | |
| 23 | − <option value="{{ tag.name }}" {{ 'selected' if tag.name == currentRef }}>tag: {{ tag.name }}</option> | |
| 24 | − {% endfor %} | |
| 25 | − </select> | |
| 17 | + <div class="repo-toolbar-left"> | |
| 18 | + {{ ui.refSelect(overview.name, branches, tags, currentRef, 'commits', '') }} | |
| 19 | + {% if filterPath %} | |
| 20 | + <span class="chip chip-info">History of <code>{{ filterPath }}</code></span> | |
| 21 | + <a class="btn btn-sm btn-ghost" href="/{{ overview.name }}/commits/{{ currentRef }}">Clear filter</a> | |
| 22 | + {% endif %} | |
| 26 | 23 | </div> |
| 27 | − {% if filterPath %} | |
| 28 | − <p class="muted" style="margin:0">History of <code>{{ filterPath }}</code> · <a href="/{{ overview.name }}/commits/{{ currentRef }}">clear filter</a></p> | |
| 29 | − {% else %} | |
| 30 | − <p class="muted mono" style="margin:0;font-size:13px">page {{ page }}</p> | |
| 31 | − {% endif %} | |
| 24 | + <div class="repo-toolbar-right"><span class="muted mono text-sm">page {{ page }}</span></div> | |
| 32 | 25 | </div> |
| 33 | 26 | |
| 34 | 27 | {% if days.length %} |
| 35 | 28 | <ol class="commit-days"> |
| 36 | 29 | {% for day in days %} |
| 37 | 30 | <li class="commit-day"> |
| 38 | − <h3 class="commit-day-label">{{ day.label }} <span class="count">{{ day.commits.length }}</span></h3> | |
| 39 | − <ol class="commit-list"> | |
| 31 | + <h2 class="commit-day-label">{{ day.label }} <span class="count">{{ day.commits.length }}</span></h2> | |
| 32 | + <ol class="commit-list card"> | |
| 40 | 33 | {% for commit in day.commits %} |
| 41 | 34 | <li class="commit-row"> |
| 42 | − <span class="commit-avatar" title="{{ commit.authorName }}">{{ commit.authorEmail | identicon(28) }}</span> | |
| 35 | + <span class="commit-avatar" title="{{ commit.authorName }}">{{ commit.authorEmail | identicon(26) }}</span> | |
| 43 | 36 | <div class="commit-main"> |
| 44 | 37 | <div class="commit-subject"> |
| 45 | 38 | <a href="/{{ overview.name }}/commit/{{ commit.sha }}">{{ commit.subject }}</a> |
| 46 | − {% if commit.body %} | |
| 47 | − <details class="commit-body-toggle"><summary aria-label="Show commit body">…</summary><pre class="commit-body">{{ commit.body }}</pre></details> | |
| 48 | − {% endif %} | |
| 39 | + {% if commit.body %}<details class="commit-body-toggle"><summary aria-label="Show commit body">…</summary><pre class="commit-body">{{ commit.body }}</pre></details>{% endif %} | |
| 49 | 40 | </div> |
| 50 | − <div class="commit-meta muted"> | |
| 51 | − <strong style="color:var(--text)">{{ commit.authorName }}</strong> committed {{ commit.date | reltime }} | |
| 41 | + <div class="commit-meta"> | |
| 42 | + <strong>{{ commit.authorName }}</strong> <time datetime="{{ commit.date }}" title="{{ commit.date }}">{{ commit.date | reltime }}</time> | |
| 52 | 43 | · {{ commit.filesChanged | num }} file{{ 's' if commit.filesChanged != 1 }} |
| 53 | 44 | {% if commit.additions %}<span class="diff-add">+{{ commit.additions | num }}</span>{% endif %} |
| 54 | 45 | {% if commit.deletions %}<span class="diff-del">−{{ commit.deletions | num }}</span>{% endif %} |
| 55 | 46 | </div> |
| 56 | 47 | </div> |
| 57 | − <button class="sha-chip copy-btn" type="button" data-copy="{{ commit.sha }}" title="Copy full SHA">{{ commit.shortSha }}</button> | |
| 48 | + <button class="sha-chip copy-btn" type="button" data-copy="{{ commit.sha }}" title="Copy full SHA" aria-label="Copy SHA {{ commit.shortSha }}">{{ commit.shortSha }}</button> | |
| 58 | 49 | </li> |
| 59 | 50 | {% endfor %} |
| 60 | 51 | </ol> |
@@ -62,15 +53,11 @@ | ||
| 62 | 53 | {% endfor %} |
| 63 | 54 | </ol> |
| 64 | 55 | {% else %} |
| 65 | −<div class="empty-state"><p>No commits yet.</p></div> | |
| 56 | +{{ ui.emptyState('No commits yet', 'This ref has no history to show.', '', 'commit') }} | |
| 66 | 57 | {% endif %} |
| 67 | 58 | |
| 68 | 59 | <nav class="pagination" aria-label="Commit pages"> |
| 69 | − {% if page > 1 %} | |
| 70 | − <a class="btn" href="?page={{ page - 1 }}{% if filterPath %}&path={{ filterPath | urlencode }}{% endif %}">← Newer</a> | |
| 71 | − {% endif %} | |
| 72 | − {% if hasNext %} | |
| 73 | − <a class="btn" href="?page={{ page + 1 }}{% if filterPath %}&path={{ filterPath | urlencode }}{% endif %}">Older →</a> | |
| 74 | − {% endif %} | |
| 60 | + {% if page > 1 %}<a class="btn" href="?page={{ page - 1 }}{% if filterPath %}&path={{ filterPath | urlencode }}{% endif %}">{{ ui.icon('arrow-left', 14) }} Newer</a>{% endif %} | |
| 61 | + {% if hasNext %}<a class="btn" href="?page={{ page + 1 }}{% if filterPath %}&path={{ filterPath | urlencode }}{% endif %}">Older {{ ui.icon('arrow-right', 14) }}</a>{% endif %} | |
| 75 | 62 | </nav> |
| 76 | 63 | {% endblock %} |
added
src/web/views/components/ui.njk
+154 −0
@@ -0,0 +1,154 @@ | ||
| 1 | +{# | |
| 2 | + ───────────────────────────────────────────── | |
| 3 | + SPB Git — Personal Git Platform | |
| 4 | + ───────────────────────────────────────────── | |
| 5 | + Author : Simon-Pierre Boucher | |
| 6 | + Contact : contact@spboucher.ai | |
| 7 | + File : src/web/views/components/ui.njk | |
| 8 | + Purpose : Component library v2.0 — icons, chips, stat strip, tabs, breadcrumbs, ref picker, empty/error/skeleton states | |
| 9 | + License : MIT © Simon-Pierre Boucher | |
| 10 | + ───────────────────────────────────────────── | |
| 11 | +#} | |
| 12 | +{#- Inline icon set (16×16 viewBox, currentColor). Names mirror Octicons. -#} | |
| 13 | +{% macro icon(name, size=16, cls='') -%} | |
| 14 | +<svg class="ic {{ cls }}" width="{{ size }}" height="{{ size }}" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" focusable="false"> | |
| 15 | +{%- if name == 'search' -%}<path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/> | |
| 16 | +{%- elif name == 'menu' -%}<path d="M1 2.75A.75.75 0 0 1 1.75 2h12.5a.75.75 0 0 1 0 1.5H1.75A.75.75 0 0 1 1 2.75Zm0 5A.75.75 0 0 1 1.75 7h12.5a.75.75 0 0 1 0 1.5H1.75A.75.75 0 0 1 1 7.75ZM1.75 12h12.5a.75.75 0 0 1 0 1.5H1.75a.75.75 0 0 1 0-1.5Z"/> | |
| 17 | +{%- elif name == 'close' -%}<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"/> | |
| 18 | +{%- elif name == 'sun' -%}<path d="M8 12a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm0-1.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Zm5.657-8.157a.75.75 0 0 1 0 1.061l-1.061 1.06a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l1.06-1.06a.75.75 0 0 1 1.06 0Zm-9.193 9.193a.75.75 0 0 1 0 1.06l-1.06 1.061a.75.75 0 1 1-1.061-1.06l1.06-1.061a.75.75 0 0 1 1.061 0ZM8 0a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0V.75A.75.75 0 0 1 8 0ZM3 8a.75.75 0 0 1-.75.75H.75a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 3 8Zm13 0a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 16 8Zm-8 5a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 8 13Zm3.536-1.464a.75.75 0 0 1 1.06 0l1.061 1.06a.75.75 0 0 1-1.06 1.061l-1.061-1.06a.75.75 0 0 1 0-1.061ZM2.343 2.343a.75.75 0 0 1 1.061 0l1.06 1.061a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-1.06-1.06a.75.75 0 0 1 0-1.06Z"/> | |
| 19 | +{%- elif name == 'moon' -%}<path d="M9.598 1.591a.749.749 0 0 1 .785-.175 7.001 7.001 0 1 1-8.967 8.967.75.75 0 0 1 .961-.96 5.5 5.5 0 0 0 7.046-7.046.75.75 0 0 1 .175-.786Zm1.616 1.945a7 7 0 0 1-7.678 7.678 5.499 5.499 0 1 0 7.678-7.678Z"/> | |
| 20 | +{%- elif name == 'midnight' -%}<path d="M9.598 1.591a.749.749 0 0 1 .785-.175 7.001 7.001 0 1 1-8.967 8.967.75.75 0 0 1 .961-.96 5.5 5.5 0 0 0 7.046-7.046.75.75 0 0 1 .175-.786Zm1.616 1.945a7 7 0 0 1-7.678 7.678 5.499 5.499 0 1 0 7.678-7.678Z"/><path d="M12.5 1.5a.5.5 0 0 1 .5.5v1h1a.5.5 0 0 1 0 1h-1v1a.5.5 0 0 1-1 0V4h-1a.5.5 0 0 1 0-1h1V2a.5.5 0 0 1 .5-.5Z"/> | |
| 21 | +{%- elif name == 'auto' -%}<path d="M1 3.75C1 2.784 1.784 2 2.75 2h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 13H2.75A1.75 1.75 0 0 1 1 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Zm2.5 10.5h5.5a.75.75 0 0 1 0 1.5h-5.5a.75.75 0 0 1 0-1.5Z"/> | |
| 22 | +{%- elif name == 'code' -%}<path d="m11.28 3.22 4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L13.94 8l-3.72-3.72a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215Zm-6.56 0a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L2.06 8l3.72 3.72a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L.47 8.53a.75.75 0 0 1 0-1.06Z"/> | |
| 23 | +{%- elif name == 'commit' -%}<path d="M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/> | |
| 24 | +{%- elif name == 'branch' -%}<path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/> | |
| 25 | +{%- elif name == 'tag' -%}<path d="M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775Zm1.5 0c0 .066.026.13.073.177l6.25 6.25a.25.25 0 0 0 .354 0l5.025-5.025a.25.25 0 0 0 0-.354l-6.25-6.25a.25.25 0 0 0-.177-.073H2.75a.25.25 0 0 0-.25.25ZM6 5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z"/> | |
| 26 | +{%- elif name == 'folder' -%}<path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2A1.75 1.75 0 0 0 5 1z"/> | |
| 27 | +{%- elif name == 'file' -%}<path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914z"/> | |
| 28 | +{%- elif name == 'copy' -%}<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"/><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/> | |
| 29 | +{%- elif name == 'check' -%}<path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/> | |
| 30 | +{%- elif name == 'download' -%}<path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z"/><path d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06l1.97 1.969Z"/> | |
| 31 | +{%- elif name == 'external' -%}<path d="M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z"/> | |
| 32 | +{%- elif name == 'chevron-right' -%}<path d="M6.22 3.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L9.94 8 6.22 4.28a.75.75 0 0 1 0-1.06Z"/> | |
| 33 | +{%- elif name == 'chevron-down' -%}<path d="M12.78 5.22a.749.749 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.06 0L3.22 6.28a.749.749 0 1 1 1.06-1.06L8 8.939l3.72-3.719a.749.749 0 0 1 1.06 0Z"/> | |
| 34 | +{%- elif name == 'rss' -%}<path d="M2.002 2.725a.75.75 0 0 1 .797-.699C8.79 2.42 13.58 7.21 13.974 13.201a.75.75 0 0 1-1.497.098 10.502 10.502 0 0 0-9.776-9.776.747.747 0 0 1-.7-.798ZM2.84 7.05h-.002a7.002 7.002 0 0 1 6.113 6.111.75.75 0 0 1-1.49.178 5.503 5.503 0 0 0-4.8-4.8.75.75 0 0 1 .179-1.489ZM2 13a1 1 0 1 1 2 0 1 1 0 0 1-2 0Z"/> | |
| 35 | +{%- elif name == 'terminal' -%}<path d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25Zm1.75-.25a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25ZM7.25 8a.749.749 0 0 1-.22.53l-2.25 2.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L5.44 8 3.72 6.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.25 2.25c.141.14.22.331.22.53Zm1.5 1.5h3a.75.75 0 0 1 0 1.5h-3a.75.75 0 0 1 0-1.5Z"/> | |
| 36 | +{%- elif name == 'book' -%}<path d="M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.743 3.743 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75Zm7.251 10.324.004-5.073-.002-2.253A2.25 2.25 0 0 0 5.003 2.5H1.5v9h3.757a3.75 3.75 0 0 1 1.994.574ZM8.755 4.75l-.004 7.322a3.752 3.752 0 0 1 1.992-.572H14.5v-9h-3.495a2.25 2.25 0 0 0-2.25 2.25Z"/> | |
| 37 | +{%- elif name == 'beaker' -%}<path d="M5 5.782V2.5h-.25a.75.75 0 0 1 0-1.5h6.5a.75.75 0 0 1 0 1.5H11v3.282l3.666 5.76C15.619 13.04 14.543 15 12.767 15H3.233c-1.776 0-2.852-1.96-1.899-3.458Zm-2.4 6.565a.75.75 0 0 0 .633 1.153h9.534a.75.75 0 0 0 .633-1.153L12.225 11H3.775ZM9.5 2.5h-3V6a.75.75 0 0 1-.117.403L4.73 9h6.54L9.617 6.403A.75.75 0 0 1 9.5 6Z"/> | |
| 38 | +{%- elif name == 'sparkle' -%}<path d="M7.53 1.282a.5.5 0 0 1 .94 0l.478 1.306a7.492 7.492 0 0 0 4.464 4.464l1.305.478a.5.5 0 0 1 0 .94l-1.305.478a7.492 7.492 0 0 0-4.464 4.464l-.478 1.305a.5.5 0 0 1-.94 0l-.478-1.305a7.492 7.492 0 0 0-4.464-4.464L1.282 8.47a.5.5 0 0 1 0-.94l1.306-.478a7.492 7.492 0 0 0 4.464-4.464Z"/> | |
| 39 | +{%- elif name == 'history' -%}<path d="m.427 1.927 1.215 1.215a8.002 8.002 0 1 1-1.6 5.685.75.75 0 1 1 1.493-.154 6.5 6.5 0 1 0 1.18-4.458l1.358 1.358A.25.25 0 0 1 3.896 6H.25A.25.25 0 0 1 0 5.75V2.104a.25.25 0 0 1 .427-.177ZM7.75 4a.75.75 0 0 1 .75.75v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5A.75.75 0 0 1 7.75 4Z"/> | |
| 40 | +{%- elif name == 'graph' -%}<path d="M1.5 1.75V13.5h13.75a.75.75 0 0 1 0 1.5H.75a.75.75 0 0 1-.75-.75V1.75a.75.75 0 0 1 1.5 0Zm14.28 2.53-5.25 5.25a.75.75 0 0 1-1.06 0L7 7.06 4.28 9.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.25-3.25a.75.75 0 0 1 1.06 0L10 7.94l4.72-4.72a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z"/> | |
| 41 | +{%- elif name == 'home' -%}<path d="M6.906.664a1.749 1.749 0 0 1 2.187 0l5.25 4.2c.415.332.657.835.657 1.367v7.019A1.75 1.75 0 0 1 13.25 15h-3.5a.75.75 0 0 1-.75-.75V9H7v5.25a.75.75 0 0 1-.75.75h-3.5A1.75 1.75 0 0 1 1 13.25V6.23c0-.531.242-1.034.657-1.366l5.25-4.2Zm1.25 1.171a.25.25 0 0 0-.312 0l-5.25 4.2a.25.25 0 0 0-.094.196v7.019c0 .138.112.25.25.25H5.5V8.25a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 .75.75v5.25h2.75a.25.25 0 0 0 .25-.25V6.23a.25.25 0 0 0-.094-.195Z"/> | |
| 42 | +{%- elif name == 'pin' -%}<path d="m11.294.984 3.722 3.722a1.75 1.75 0 0 1-.504 2.826l-1.327.613a3.089 3.089 0 0 0-1.707 2.084l-.584 2.454c-.317 1.332-1.972 1.8-2.94.832L5.75 11.311 1.78 15.28a.749.749 0 1 1-1.06-1.06l3.969-3.97-2.204-2.204c-.968-.968-.5-2.623.832-2.94l2.454-.584a3.08 3.08 0 0 0 2.084-1.707l.613-1.327a1.75 1.75 0 0 1 2.826-.504Z"/> | |
| 43 | +{%- elif name == 'info' -%}<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"/> | |
| 44 | +{%- elif name == 'image' -%}<path d="M16 13.25A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25V2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75ZM1.75 2.5a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h.94l.03-.03 6.077-6.078a1.75 1.75 0 0 1 2.412-.06L14.5 10.31V2.75a.25.25 0 0 0-.25-.25Zm12.5 11a.25.25 0 0 0 .25-.25v-.917l-4.298-3.889a.25.25 0 0 0-.344.009L4.81 13.5ZM7 6a2 2 0 1 1-3.999.001A2 2 0 0 1 7 6ZM5.5 6a.5.5 0 1 0-1 0 .5.5 0 0 0 1 0Z"/> | |
| 45 | +{%- elif name == 'wrap' -%}<path d="M1 3.75A.75.75 0 0 1 1.75 3h12.5a.75.75 0 0 1 0 1.5H1.75A.75.75 0 0 1 1 3.75Zm0 4A.75.75 0 0 1 1.75 7h10.5a2.75 2.75 0 0 1 0 5.5H9.56l.72.72a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215l-2-2a.75.75 0 0 1 0-1.06l2-2a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L9.56 11h2.69a1.25 1.25 0 0 0 0-2.5H1.75A.75.75 0 0 1 1 7.75Zm0 4a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 0 1.5h-3a.75.75 0 0 1-.75-.75Z"/> | |
| 46 | +{%- elif name == 'arrow-left' -%}<path d="M7.78 12.53a.75.75 0 0 1-1.06 0L2.47 8.28a.75.75 0 0 1 0-1.06l4.25-4.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L4.81 7h7.44a.75.75 0 0 1 0 1.5H4.81l2.97 2.97a.75.75 0 0 1 0 1.06Z"/> | |
| 47 | +{%- elif name == 'arrow-right' -%}<path d="M8.22 2.97a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l2.97-2.97H3.75a.75.75 0 0 1 0-1.5h7.44L8.22 4.03a.75.75 0 0 1 0-1.06Z"/> | |
| 48 | +{%- elif name == 'mail' -%}<path d="M1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25v-8.5C0 2.784.784 2 1.75 2ZM1.5 12.251c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V5.809L8.38 9.397a.75.75 0 0 1-.76 0L1.5 5.809v6.442Zm13-8.181v-.32a.25.25 0 0 0-.25-.25H1.75a.25.25 0 0 0-.25.25v.32L8 7.88Z"/> | |
| 49 | +{%- else -%}<circle cx="8" cy="8" r="3"/> | |
| 50 | +{%- endif -%} | |
| 51 | +</svg> | |
| 52 | +{%- endmacro %} | |
| 53 | + | |
| 54 | +{#- Status / topic chip. kind ∈ '', 'topic', 'ok', 'warn', 'danger', 'info', 'neutral', 'ember' -#} | |
| 55 | +{% macro chip(label, kind='', href=null, title='') -%} | |
| 56 | +{%- if href -%}<a class="chip chip-{{ kind or 'neutral' }}" href="{{ href }}"{% if title %} title="{{ title }}"{% endif %}>{{ label }}</a> | |
| 57 | +{%- else -%}<span class="chip chip-{{ kind or 'neutral' }}"{% if title %} title="{{ title }}"{% endif %}>{{ label }}</span>{%- endif -%} | |
| 58 | +{%- endmacro %} | |
| 59 | + | |
| 60 | +{#- Compact statistic strip. items: [{value, label, href, tone, title, sub}] -#} | |
| 61 | +{% macro statStrip(items, cls='') -%} | |
| 62 | +<dl class="stat-strip {{ cls }}"> | |
| 63 | + {%- for s in items -%} | |
| 64 | + {%- if s.href -%} | |
| 65 | + <a class="stat-item {{ 'tone-' + s.tone if s.tone }}" href="{{ s.href }}"{% if s.title %} title="{{ s.title }}"{% endif %}> | |
| 66 | + <dd class="stat-value">{{ s.value }}</dd><dt class="stat-label">{{ s.label }}{% if s.sub %} <span class="stat-sub">{{ s.sub }}</span>{% endif %}</dt> | |
| 67 | + </a> | |
| 68 | + {%- else -%} | |
| 69 | + <div class="stat-item {{ 'tone-' + s.tone if s.tone }}"{% if s.title %} title="{{ s.title }}"{% endif %}> | |
| 70 | + <dd class="stat-value">{{ s.value }}</dd><dt class="stat-label">{{ s.label }}{% if s.sub %} <span class="stat-sub">{{ s.sub }}</span>{% endif %}</dt> | |
| 71 | + </div> | |
| 72 | + {%- endif -%} | |
| 73 | + {%- endfor -%} | |
| 74 | +</dl> | |
| 75 | +{%- endmacro %} | |
| 76 | + | |
| 77 | +{#- Horizontal tab strip. items: [{key, label, href, count, icon}] -#} | |
| 78 | +{% macro tabs(items, active, label='Sections') -%} | |
| 79 | +<nav class="tabs" aria-label="{{ label }}"> | |
| 80 | + <div class="tabs-track"> | |
| 81 | + {%- for t in items -%} | |
| 82 | + <a class="tab {{ 'is-active' if t.key == active }}" href="{{ t.href }}"{% if t.key == active %} aria-current="page"{% endif %}> | |
| 83 | + {%- if t.icon %}{{ icon(t.icon, 15) }}{% endif -%} | |
| 84 | + <span>{{ t.label }}</span>{% if t.count is defined and t.count !== null %}<span class="count">{{ t.count }}</span>{% endif %} | |
| 85 | + </a> | |
| 86 | + {%- endfor -%} | |
| 87 | + </div> | |
| 88 | +</nav> | |
| 89 | +{%- endmacro %} | |
| 90 | + | |
| 91 | +{#- Path breadcrumbs. crumbs: [{name, href}] (last = current) -#} | |
| 92 | +{% macro breadcrumbs(rootLabel, rootHref, crumbs, label='Path') -%} | |
| 93 | +<nav class="crumbs" aria-label="{{ label }}"> | |
| 94 | + <ol> | |
| 95 | + <li><a href="{{ rootHref }}">{{ rootLabel }}</a></li> | |
| 96 | + {%- for c in crumbs -%} | |
| 97 | + <li>{% if loop.last %}<span aria-current="page">{{ c.name }}</span>{% elif c.href %}<a href="{{ c.href }}">{{ c.name }}</a>{% else %}<span>{{ c.name }}</span>{% endif %}</li> | |
| 98 | + {%- endfor -%} | |
| 99 | + </ol> | |
| 100 | +</nav> | |
| 101 | +{%- endmacro %} | |
| 102 | + | |
| 103 | +{#- Branch / tag picker (native select for reliability on iOS) -#} | |
| 104 | +{% macro refSelect(repo, branches, tags, currentRef, kind='tree', path='') -%} | |
| 105 | +<label class="ref-select-wrap"> | |
| 106 | + <span class="sr-only">Branch or tag</span> | |
| 107 | + {{ icon('branch', 14, 'ref-select-icon') }} | |
| 108 | + <select id="ref-select" class="ref-select" data-repo="{{ repo }}" data-kind="{{ kind }}" data-path="{{ path }}" aria-label="Switch branch or tag"> | |
| 109 | + <optgroup label="Branches"> | |
| 110 | + {%- for b in branches -%} | |
| 111 | + <option value="{{ b.name }}" {{ 'selected' if b.name == currentRef }}>{{ b.name }}{{ ' (default)' if b.isDefault }}</option> | |
| 112 | + {%- endfor -%} | |
| 113 | + </optgroup> | |
| 114 | + {%- if tags.length -%} | |
| 115 | + <optgroup label="Tags"> | |
| 116 | + {%- for t in tags -%} | |
| 117 | + <option value="{{ t.name }}" {{ 'selected' if t.name == currentRef }}>{{ t.name }}</option> | |
| 118 | + {%- endfor -%} | |
| 119 | + </optgroup> | |
| 120 | + {%- endif -%} | |
| 121 | + </select> | |
| 122 | + {{ icon('chevron-down', 14, 'ref-select-caret') }} | |
| 123 | +</label> | |
| 124 | +{%- endmacro %} | |
| 125 | + | |
| 126 | +{% macro emptyState(title, hint='', snippet='', iconName='folder') -%} | |
| 127 | +<div class="empty-state"> | |
| 128 | + <div class="empty-icon">{{ icon(iconName, 22) }}</div> | |
| 129 | + <p class="empty-title">{{ title }}</p> | |
| 130 | + {% if hint %}<p class="empty-hint">{{ hint }}</p>{% endif %} | |
| 131 | + {% if snippet %}<pre class="snippet">{{ snippet }}</pre>{% endif %} | |
| 132 | + {{ caller() if caller }} | |
| 133 | +</div> | |
| 134 | +{%- endmacro %} | |
| 135 | + | |
| 136 | +{% macro errorState(title, message='') -%} | |
| 137 | +<div class="error-state" role="alert"> | |
| 138 | + <div class="empty-icon tone-danger">{{ icon('info', 22) }}</div> | |
| 139 | + <p class="empty-title">{{ title }}</p> | |
| 140 | + {% if message %}<p class="empty-hint">{{ message }}</p>{% endif %} | |
| 141 | +</div> | |
| 142 | +{%- endmacro %} | |
| 143 | + | |
| 144 | +{#- Loading skeleton (used by client-side panels: palette, search, AI) -#} | |
| 145 | +{% macro skeleton(lines=3, cls='') -%} | |
| 146 | +<div class="skeleton {{ cls }}" aria-hidden="true"> | |
| 147 | + {%- for i in range(0, lines) -%}<span class="skeleton-line" style="width:{{ [92, 68, 80, 55, 74][i % 5] }}%"></span>{%- endfor -%} | |
| 148 | +</div> | |
| 149 | +{%- endmacro %} | |
| 150 | + | |
| 151 | +{#- Copy-to-clipboard button (44px target on touch) -#} | |
| 152 | +{% macro copyButton(text, label='Copy', title='Copy to clipboard', cls='') -%} | |
| 153 | +<button type="button" class="btn btn-sm copy-btn {{ cls }}" data-copy="{{ text }}" title="{{ title }}" aria-label="{{ title }}">{{ icon('copy', 14) }}<span class="copy-label">{{ label }}</span></button> | |
| 154 | +{%- endmacro %} | |
modified
src/web/views/error.njk
+8 −7
@@ -5,19 +5,20 @@ | ||
| 5 | 5 | Author : Simon-Pierre Boucher |
| 6 | 6 | Contact : contact@spboucher.ai |
| 7 | 7 | File : src/web/views/error.njk |
| 8 | − Purpose : Custom 404 / 500 pages v4 — display numerals, serif message | |
| 8 | + Purpose : Custom 404 / 500 pages v2.0 | |
| 9 | 9 | License : MIT © Simon-Pierre Boucher |
| 10 | 10 | ───────────────────────────────────────────── |
| 11 | 11 | #}{% extends "layout.njk" %} |
| 12 | +{% import "components/ui.njk" as ui %} | |
| 12 | 13 | {% block content %} |
| 13 | 14 | <div class="error-page"> |
| 14 | − <div class="error-code">{{ status }}</div> | |
| 15 | − <p class="error-message">{{ message }}</p> | |
| 16 | − <pre class="error-art"> git checkout --orphan reality | |
| 15 | + <div class="error-code" aria-hidden="true">{{ status }}</div> | |
| 16 | + <h1 class="error-message">{{ message }}</h1> | |
| 17 | + <pre class="snippet error-art"> git checkout --orphan reality | |
| 17 | 18 | fatal: reference is not a tree</pre> |
| 18 | − <div style="display:flex;gap:10px;justify-content:center;flex-wrap:wrap"> | |
| 19 | − <a class="btn btn-primary" href="/">Back to the repositories</a> | |
| 20 | − <button class="btn" type="button" id="cmdk-open-alt">Search with ⌘K</button> | |
| 19 | + <div class="btn-row" style="justify-content:center"> | |
| 20 | + <a class="btn btn-primary" href="/">{{ ui.icon('home', 14) }} Back to the repositories</a> | |
| 21 | + <button class="btn" type="button" id="cmdk-open-alt">{{ ui.icon('search', 14) }} Search</button> | |
| 21 | 22 | </div> |
| 22 | 23 | </div> |
| 23 | 24 | {% endblock %} |
modified
src/web/views/home.njk
+63 −76
@@ -5,115 +5,106 @@ | ||
| 5 | 5 | Author : Simon-Pierre Boucher |
| 6 | 6 | Contact : contact@spboucher.ai |
| 7 | 7 | File : src/web/views/home.njk |
| 8 | − Purpose : Home v4 — cinematic hero + terminal, stats, bento pinned, repo grid, activity, stack ring, heatmap | |
| 8 | + Purpose : Home v2.0 — compact hero, stat strip, pinned, searchable catalog, activity, stack, heatmap | |
| 9 | 9 | License : MIT © Simon-Pierre Boucher |
| 10 | 10 | ───────────────────────────────────────────── |
| 11 | 11 | #}{% extends "layout.njk" %} |
| 12 | +{% import "components/ui.njk" as ui %} | |
| 12 | 13 | {% block content %} |
| 13 | 14 | <section class="hero" aria-labelledby="hero-name"> |
| 14 | − <canvas class="hero-canvas" id="hero-canvas" aria-hidden="true"></canvas> | |
| 15 | − <div class="hero-grid"> | |
| 16 | − <div class="hero-copy"> | |
| 17 | − <p class="eyebrow"><span class="eyebrow-dot" aria-hidden="true"></span>git.spboucher.ai<span class="eyebrow-more"> · personal software forge</span></p> | |
| 18 | − <h1 class="hero-name" id="hero-name">{{ owner.name }}</h1> | |
| 19 | − <p class="hero-tagline">{{ owner.tagline }}</p> | |
| 20 | − <div class="hero-actions"> | |
| 21 | − <a class="btn btn-primary" href="#repos">Browse {{ stats.repos | num }} repositories</a> | |
| 22 | − <a class="btn" href="/feed.atom"> | |
| 23 | − <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 2a1 1 0 0 1 1-1c6.627 0 12 5.373 12 12a1 1 0 1 1-2 0C13 7.477 8.523 3 3 3a1 1 0 0 1-1-1Zm0 5a1 1 0 0 1 1-1 7 7 0 0 1 7 7 1 1 0 1 1-2 0 5 5 0 0 0-5-5 1 1 0 0 1-1-1Zm1.5 8a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Z"/></svg> | |
| 24 | − Follow activity | |
| 25 | − </a> | |
| 26 | − </div> | |
| 27 | − <p class="hero-contact"><a href="mailto:{{ owner.email }}">{{ owner.email }}</a> · <a href="https://spboucher.ai" rel="noopener">spboucher.ai</a></p> | |
| 15 | + <div class="hero-main"> | |
| 16 | + <p class="eyebrow"><span class="eyebrow-dot" aria-hidden="true"></span>git.spboucher.ai<span class="hide-mobile"> · personal software forge</span></p> | |
| 17 | + <h1 class="hero-title" id="hero-name">{{ owner.name }}</h1> | |
| 18 | + <p class="hero-lede">{{ owner.tagline }}</p> | |
| 19 | + <div class="hero-actions"> | |
| 20 | + <a class="btn btn-primary" href="#repos">Browse {{ stats.repos | num }} repositories</a> | |
| 21 | + <a class="btn" href="/feed.atom">{{ ui.icon('rss', 14) }} Follow activity</a> | |
| 28 | 22 | </div> |
| 29 | − <div class="hero-terminal" id="hero-terminal" data-repos="{{ terminalRepos | join(',') }}" data-origin="{{ publicUrl }}" aria-hidden="true"> | |
| 23 | + <p class="hero-contact"><a href="mailto:{{ owner.email }}">{{ owner.email }}</a><a href="https://spboucher.ai" rel="noopener">spboucher.ai</a></p> | |
| 24 | + </div> | |
| 25 | + <aside class="hero-aside" aria-hidden="true"> | |
| 26 | + <div class="terminal"> | |
| 30 | 27 | <div class="term-bar"><span class="term-dot"></span><span class="term-dot"></span><span class="term-dot"></span><span class="term-title">zsh — spb@forge</span></div> |
| 31 | − <div class="term-body" id="term-body"> | |
| 28 | + <div class="term-body"> | |
| 32 | 29 | <div class="term-line"><span class="term-prompt">$</span><span class="term-cmd">git clone {{ publicUrl }}/{{ terminalRepos[0] }}.git</span></div> |
| 33 | 30 | <div class="term-line term-out">Cloning into '{{ terminalRepos[0] }}'…</div> |
| 34 | 31 | <div class="term-line term-out">remote: SPB Git · {{ stats.repos | num }} public repositories</div> |
| 35 | − <div class="term-line term-out term-ok">done.</div> | |
| 32 | + <div class="term-line term-out term-ok">Resolving deltas: 100% — done.</div> | |
| 36 | 33 | <div class="term-line"><span class="term-prompt">$</span><span class="term-caret"></span></div> |
| 37 | 34 | </div> |
| 38 | 35 | </div> |
| 39 | − </div> | |
| 40 | − <div class="hero-stats"> | |
| 41 | − <div class="stat spot"><span class="stat-value" data-count="{{ stats.repos }}">{{ stats.repos | num }}</span><span class="stat-label">public repositories</span></div> | |
| 42 | − <div class="stat spot"><span class="stat-value" data-count="{{ stats.commits }}">{{ stats.commits | num }}</span><span class="stat-label">commits, all time</span></div> | |
| 43 | − <div class="stat spot"><span class="stat-value" data-count="{{ stats.languages }}">{{ stats.languages | num }}</span><span class="stat-label">languages</span></div> | |
| 44 | − <div class="stat stat-ember spot"><span class="stat-value" data-count="{{ heatmap.total }}">{{ heatmap.total | num }}</span><span class="stat-label">commits, last 52 weeks</span></div> | |
| 45 | − <div class="stat stat-ember spot"><span class="stat-value" data-count="{{ heatmap.streak }}">{{ heatmap.streak | num }}</span><span class="stat-label">day streak</span><span class="stat-sub">longest {{ heatmap.longest | num }}</span></div> | |
| 46 | − </div> | |
| 36 | + </aside> | |
| 47 | 37 | </section> |
| 48 | 38 | |
| 39 | +{{ ui.statStrip([ | |
| 40 | + { value: stats.repos | num, label: 'public repositories', href: '#repos' }, | |
| 41 | + { value: stats.commits | num, label: 'commits, all time', title: 'Commits reachable from each default branch, summed over all repositories' }, | |
| 42 | + { value: heatmap.total | num, label: 'commits, last 52 weeks', tone: 'ember', href: '#heatmap', title: 'Same commits, limited to the last 52 weeks (UTC days)' }, | |
| 43 | + { value: heatmap.streak | num, label: 'day streak', tone: 'ember', sub: 'longest ' + (heatmap.longest | num) }, | |
| 44 | + { value: stats.languages | num, label: 'languages' } | |
| 45 | +], 'stat-strip-hero') }} | |
| 46 | + | |
| 49 | 47 | {% if pinned.length %} |
| 50 | −<section class="section reveal" aria-labelledby="pinned-h"> | |
| 48 | +<section class="section" aria-labelledby="pinned-h" style="margin-top:var(--space-7)"> | |
| 51 | 49 | <header class="section-head"> |
| 52 | 50 | <div> |
| 53 | 51 | <h2 id="pinned-h" class="section-title">Pinned</h2> |
| 54 | 52 | <p class="section-sub">Hand-picked — the work worth opening first.</p> |
| 55 | 53 | </div> |
| 56 | 54 | </header> |
| 57 | − <div class="bento"> | |
| 55 | + <div class="repo-grid bento"> | |
| 58 | 56 | {% for repo in pinned %}{% include "partials/repo-card.njk" %}{% endfor %} |
| 59 | 57 | </div> |
| 60 | 58 | </section> |
| 61 | 59 | {% endif %} |
| 62 | 60 | |
| 63 | 61 | <div class="home-columns"> |
| 64 | − <section class="home-main section" id="repos" aria-labelledby="repos-h"> | |
| 62 | + <section class="section" id="repos" aria-labelledby="repos-h"> | |
| 65 | 63 | <header class="section-head"> |
| 66 | 64 | <div> |
| 67 | 65 | <h2 id="repos-h" class="section-title">Repositories <span class="count" id="repo-count">{{ overviews.length | num }}</span></h2> |
| 68 | 66 | <p class="section-sub">Everything is public and clonable over HTTPS.</p> |
| 69 | 67 | </div> |
| 70 | − <div class="repo-filters"> | |
| 71 | − <input type="search" id="repo-filter" placeholder="Filter by name or description…" aria-label="Filter repositories"> | |
| 72 | − <select id="repo-sort" aria-label="Sort repositories"> | |
| 68 | + <div class="catalog-filters"> | |
| 69 | + <input class="input" type="search" id="repo-filter" placeholder="Filter by name or description…" aria-label="Filter repositories" autocomplete="off"> | |
| 70 | + <span class="select-wrap"><select class="select" id="repo-sort" aria-label="Sort repositories"> | |
| 73 | 71 | <option value="pushed">Recently pushed</option> |
| 74 | 72 | <option value="name">Name</option> |
| 75 | 73 | <option value="created">Created</option> |
| 76 | 74 | <option value="commits">Most commits</option> |
| 77 | − </select> | |
| 78 | − <select id="repo-lang" aria-label="Filter by language"> | |
| 75 | + </select>{{ ui.icon('chevron-down', 14) }}</span> | |
| 76 | + <span class="select-wrap"><select class="select" id="repo-lang" aria-label="Filter by language"> | |
| 79 | 77 | <option value="">All languages</option> |
| 80 | 78 | {% for lang in languages %}<option value="{{ lang }}">{{ lang }}</option>{% endfor %} |
| 81 | − </select> | |
| 82 | − <select id="repo-topic" aria-label="Filter by topic"> | |
| 79 | + </select>{{ ui.icon('chevron-down', 14) }}</span> | |
| 80 | + <span class="select-wrap"><select class="select" id="repo-topic" aria-label="Filter by topic"> | |
| 83 | 81 | <option value="">All topics</option> |
| 84 | 82 | {% for topic in topics %}<option value="{{ topic }}">{{ topic }}</option>{% endfor %} |
| 85 | − </select> | |
| 83 | + </select>{{ ui.icon('chevron-down', 14) }}</span> | |
| 86 | 84 | </div> |
| 87 | 85 | </header> |
| 88 | 86 | <div class="repo-grid" id="repo-list"> |
| 89 | 87 | {% for repo in overviews %}{% include "partials/repo-card.njk" %}{% endfor %} |
| 90 | − {% if not overviews.length %} | |
| 91 | − <div class="empty-state"> | |
| 92 | − <p>No repositories yet. Create the first one:</p> | |
| 93 | − <pre class="clone-snippet">spbgit create hello-world --push</pre> | |
| 94 | − </div> | |
| 95 | − {% endif %} | |
| 96 | 88 | </div> |
| 97 | − <p class="muted" id="repo-list-empty" hidden>No repository matches the current filters.</p> | |
| 89 | + {% if not overviews.length %} | |
| 90 | + {{ ui.emptyState('No repositories yet', 'Create the first one from the terminal:', 'spbgit create hello-world --push') }} | |
| 91 | + {% endif %} | |
| 92 | + <p class="muted" id="repo-list-empty" hidden style="margin-top:12px">No repository matches the current filters.</p> | |
| 98 | 93 | </section> |
| 99 | 94 | |
| 100 | 95 | <aside class="home-side"> |
| 101 | − <section class="side-card spot" id="activity" aria-labelledby="activity-h"> | |
| 102 | − <h2 id="activity-h">Activity <span class="count">live</span></h2> | |
| 103 | − <ul class="activity-feed"> | |
| 96 | + <section class="card card-pad side-card" id="activity" aria-labelledby="activity-h"> | |
| 97 | + <h2 id="activity-h">{{ ui.icon('history', 15) }} Activity <span class="count" style="margin-left:auto">live</span></h2> | |
| 98 | + <ul class="activity-feed" style="margin-top:12px"> | |
| 104 | 99 | {% for event in activity %} |
| 105 | 100 | <li class="{{ 'is-del' if event.deleted else ('is-tag' if event.refType == 'tag') }}"> |
| 106 | 101 | <span class="activity-dot" aria-hidden="true"></span> |
| 107 | 102 | <div> |
| 108 | − {% if event.deleted %} | |
| 109 | − deleted {{ event.refType }} <code>{{ event.ref }}</code> in <a href="/{{ event.repo }}">{{ event.repo }}</a> | |
| 110 | − {% elif event.refType == 'tag' %} | |
| 111 | − tagged <code>{{ event.ref }}</code> on <a href="/{{ event.repo }}">{{ event.repo }}</a> | |
| 112 | − {% else %} | |
| 113 | − pushed {{ event.commits | num }} commit{{ 's' if event.commits != 1 }} to <a href="/{{ event.repo }}">{{ event.repo }}</a> | |
| 114 | − {% if event.ref != 'main' and event.ref != 'master' %}<code class="ref-inline">{{ event.ref }}</code>{% endif %} | |
| 103 | + {% if event.deleted %}deleted {{ event.refType }} <code>{{ event.ref }}</code> in <a href="/{{ event.repo }}">{{ event.repo }}</a> | |
| 104 | + {% elif event.refType == 'tag' %}tagged <code>{{ event.ref }}</code> on <a href="/{{ event.repo }}">{{ event.repo }}</a> | |
| 105 | + {% else %}pushed {{ event.commits | num }} commit{{ 's' if event.commits != 1 }} to <a href="/{{ event.repo }}">{{ event.repo }}</a>{% if event.ref != 'main' and event.ref != 'master' %} <code class="ref-inline">{{ event.ref }}</code>{% endif %} | |
| 115 | 106 | {% endif %} |
| 116 | − <span class="muted">· {{ event.at | reltime }}</span> | |
| 107 | + <time class="activity-time" datetime="{{ event.at }}" title="{{ event.at }}">· {{ event.at | reltime }}</time> | |
| 117 | 108 | </div> |
| 118 | 109 | </li> |
| 119 | 110 | {% else %} |
@@ -123,34 +114,33 @@ | ||
| 123 | 114 | </section> |
| 124 | 115 | |
| 125 | 116 | {% if stack.list.length %} |
| 126 | − <section class="side-card spot" aria-labelledby="stack-h"> | |
| 127 | − <h2 id="stack-h">Stack <span class="count">{{ stats.languages }} languages</span></h2> | |
| 128 | − <div class="stack"> | |
| 129 | − <div class="ring" style="--ring-stops: {{ stack.stops }}" role="img" aria-label="Language share across all repositories"> | |
| 117 | + <section class="card card-pad side-card" aria-labelledby="stack-h"> | |
| 118 | + <h2 id="stack-h">{{ ui.icon('graph', 15) }} Stack <span class="count" style="margin-left:auto">{{ stats.languages }} languages</span></h2> | |
| 119 | + <div class="stack" style="margin-top:12px"> | |
| 120 | + <div class="ring" style="--ring-stops: {{ stack.stops }}" role="img" aria-label="Language share across all repositories, equal weight per repository"> | |
| 130 | 121 | <div class="ring-center">{{ stack.list[0].percent }}%<small>{{ stack.list[0].name | truncate(10, true, '') }}</small></div> |
| 131 | 122 | </div> |
| 132 | 123 | <ul class="stack-list"> |
| 133 | − {% for lang in stack.list %} | |
| 134 | − <li><span class="lang-dot" style="background:{{ lang.color }}"></span>{{ lang.name }}<span class="pct">{{ lang.percent }}%</span></li> | |
| 135 | − {% endfor %} | |
| 124 | + {% for lang in stack.list %}<li><span class="lang-dot" style="background:{{ lang.color }}"></span>{{ lang.name }}<span class="pct">{{ lang.percent }}%</span></li>{% endfor %} | |
| 136 | 125 | </ul> |
| 137 | 126 | </div> |
| 127 | + <p class="metric-note">Each repository weighs the same. <a href="/about/metrics">How metrics are defined</a></p> | |
| 138 | 128 | </section> |
| 139 | 129 | {% endif %} |
| 140 | 130 | |
| 141 | − <section class="side-card spot" aria-labelledby="cli-h"> | |
| 142 | − <h2 id="cli-h">From the terminal</h2> | |
| 143 | − <pre class="footer-cmd"><span class="term-prompt">$</span>spbgit clone <repo> | |
| 144 | −<span class="term-prompt">$</span>spbgit list --sort pushed</pre> | |
| 145 | − <p class="muted" style="margin:10px 0 0;font-size:13px">The <a href="/spbgit">spbgit</a> CLI manages every repository from one command line.</p> | |
| 131 | + <section class="card card-pad side-card" aria-labelledby="cli-h"> | |
| 132 | + <h2 id="cli-h">{{ ui.icon('terminal', 15) }} From the terminal</h2> | |
| 133 | + <pre class="snippet" style="margin-top:12px"><span class="prompt">$</span>spbgit clone <repo> | |
| 134 | +<span class="prompt">$</span>spbgit list --sort pushed</pre> | |
| 135 | + <p class="muted text-sm" style="margin-top:10px">The <a href="/spbgit">spbgit</a> CLI manages every repository from one command line.</p> | |
| 146 | 136 | </section> |
| 147 | 137 | </aside> |
| 148 | 138 | </div> |
| 149 | 139 | |
| 150 | −<section id="heatmap" class="heatmap-section section reveal" aria-labelledby="heatmap-h"> | |
| 151 | − <div class="heatmap-card card spot"> | |
| 140 | +<section id="heatmap" class="section" aria-labelledby="heatmap-h"> | |
| 141 | + <div class="card card-pad"> | |
| 152 | 142 | <div class="heatmap-head"> |
| 153 | − <h2 id="heatmap-h" class="section-title">{{ heatmap.total | num }} commits in the last year</h2> | |
| 143 | + <h2 id="heatmap-h" class="section-title">{{ heatmap.total | num }} commits in the last 52 weeks</h2> | |
| 154 | 144 | <div class="heatmap-stats"> |
| 155 | 145 | <span><strong class="ember">{{ heatmap.streak | num }}</strong>day streak</span> |
| 156 | 146 | <span><strong>{{ heatmap.longest | num }}</strong>longest streak</span> |
@@ -158,16 +148,13 @@ | ||
| 158 | 148 | {% if heatmap.busiest %}<span><strong>{{ heatmap.busiest.count | num }}</strong>busiest day · {{ heatmap.busiest.date }}</span>{% endif %} |
| 159 | 149 | </div> |
| 160 | 150 | </div> |
| 161 | − <div class="heatmap-scroll">{{ heatmap.svg | safe }}</div> | |
| 151 | + <div class="heatmap-scroll" tabindex="0" aria-label="Commit calendar, scroll horizontally">{{ heatmap.svg | safe }}</div> | |
| 162 | 152 | <div class="heatmap-legend" aria-hidden="true"> |
| 163 | 153 | <span>Less</span> |
| 164 | − <span class="heatmap-cell-demo" data-level="0"></span> | |
| 165 | − <span class="heatmap-cell-demo" data-level="1"></span> | |
| 166 | − <span class="heatmap-cell-demo" data-level="2"></span> | |
| 167 | − <span class="heatmap-cell-demo" data-level="3"></span> | |
| 168 | − <span class="heatmap-cell-demo" data-level="4"></span> | |
| 154 | + <span class="heatmap-cell-demo" data-level="0"></span><span class="heatmap-cell-demo" data-level="1"></span><span class="heatmap-cell-demo" data-level="2"></span><span class="heatmap-cell-demo" data-level="3"></span><span class="heatmap-cell-demo" data-level="4"></span> | |
| 169 | 155 | <span>More</span> |
| 170 | 156 | </div> |
| 157 | + <p class="metric-note">Commits on each repository's default branch, by committer date (UTC). Streaks count consecutive active days. <a href="/about/metrics">Definitions</a></p> | |
| 171 | 158 | </div> |
| 172 | 159 | </section> |
| 173 | 160 | {% endblock %} |
modified
src/web/views/layout.njk
+100 −74
@@ -5,11 +5,11 @@ | ||
| 5 | 5 | Author : Simon-Pierre Boucher |
| 6 | 6 | Contact : contact@spboucher.ai |
| 7 | 7 | File : src/web/views/layout.njk |
| 8 | − Purpose : Base layout v4 — background stack, glass nav, ⌘K, footer grid, meta/OG | |
| 8 | + Purpose : AppShell v2.0 — compact sticky header, mobile navigation sheet, theme menu, ⌘K palette, footer, meta/OG | |
| 9 | 9 | License : MIT © Simon-Pierre Boucher |
| 10 | 10 | ───────────────────────────────────────────── |
| 11 | −#}<!DOCTYPE html> | |
| 12 | −<html lang="en" data-theme="dark" class="no-js"> | |
| 11 | +#}{% import "components/ui.njk" as ui %}<!DOCTYPE html> | |
| 12 | +<html lang="en" data-theme="light" data-theme-pref="auto" class="no-js"> | |
| 13 | 13 | <head> |
| 14 | 14 | <meta charset="utf-8"> |
| 15 | 15 | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> |
@@ -17,8 +17,11 @@ | ||
| 17 | 17 | <meta name="description" content="{{ description | default('SPB Git — the personal git platform of Simon-Pierre Boucher.') }}"> |
| 18 | 18 | <link rel="canonical" href="{{ canonical }}"> |
| 19 | 19 | <meta name="author" content="{{ owner.name }}"> |
| 20 | − <meta name="theme-color" content="#04060b" media="(prefers-color-scheme: dark)"> | |
| 21 | − <meta name="theme-color" content="#f3f5fb" media="(prefers-color-scheme: light)"> | |
| 20 | + <meta name="color-scheme" content="light dark"> | |
| 21 | + <meta name="theme-color" content="#f4f6fb" media="(prefers-color-scheme: light)"> | |
| 22 | + <meta name="theme-color" content="#15181f" media="(prefers-color-scheme: dark)"> | |
| 23 | + <meta name="apple-mobile-web-app-capable" content="yes"> | |
| 24 | + <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> | |
| 22 | 25 | <meta property="og:site_name" content="SPB Git"> |
| 23 | 26 | <meta property="og:type" content="website"> |
| 24 | 27 | <meta property="og:title" content="{{ title | default('SPB Git') }}"> |
@@ -36,106 +39,84 @@ | ||
| 36 | 39 | <link rel="preload" href="/assets/fonts/inter-400.woff2" as="font" type="font/woff2" crossorigin> |
| 37 | 40 | <link rel="preload" href="/assets/fonts/inter-600.woff2" as="font" type="font/woff2" crossorigin> |
| 38 | 41 | <link rel="preload" href="/assets/fonts/bricolage-800.woff2" as="font" type="font/woff2" crossorigin> |
| 39 | − <link rel="preload" href="/assets/fonts/jetbrains-mono-400.woff2" as="font" type="font/woff2" crossorigin> | |
| 40 | 42 | <link rel="stylesheet" href="/assets/css/tokens.css?v={{ assetVersion }}"> |
| 41 | 43 | <link rel="stylesheet" href="/assets/css/app.css?v={{ assetVersion }}"> |
| 42 | 44 | <link rel="stylesheet" href="/assets/css/markdown.css?v={{ assetVersion }}"> |
| 43 | 45 | <script src="/assets/js/theme-boot.js?v={{ assetVersion }}"></script> |
| 46 | + {% block head %}{% endblock %} | |
| 44 | 47 | </head> |
| 45 | −<body> | |
| 46 | − <div class="bg" aria-hidden="true"><div class="bg-mesh"></div><div class="bg-grid"></div><div class="bg-noise"></div></div> | |
| 47 | − <svg class="spark-defs" aria-hidden="true" focusable="false"> | |
| 48 | − <defs> | |
| 49 | − <linearGradient id="spark-fill" x1="0" y1="0" x2="0" y2="1"> | |
| 50 | − <stop offset="0" stop-color="#86a4ff" stop-opacity="0.35"/> | |
| 51 | − <stop offset="1" stop-color="#86a4ff" stop-opacity="0"/> | |
| 52 | − </linearGradient> | |
| 53 | − </defs> | |
| 54 | − </svg> | |
| 48 | +<body class="{{ bodyClass | default('') }}"> | |
| 49 | + <div class="bg" aria-hidden="true"><div class="bg-mesh"></div><div class="bg-grid"></div></div> | |
| 55 | 50 | <a class="skip-link" href="#main">Skip to content</a> |
| 56 | − <header class="topnav"> | |
| 57 | − <div class="container topnav-inner"> | |
| 58 | − <a class="brand" href="/" aria-label="SPB Git home"> | |
| 59 | − <svg class="brand-mark" width="30" height="30" viewBox="0 0 28 28" aria-hidden="true"> | |
| 51 | + | |
| 52 | + <header class="app-header" id="app-header"> | |
| 53 | + <div class="container app-header-inner"> | |
| 54 | + <a class="brand" href="/" aria-label="SPB Git — home"> | |
| 55 | + <svg class="brand-mark" width="28" height="28" viewBox="0 0 28 28" aria-hidden="true"> | |
| 60 | 56 | <defs> |
| 61 | 57 | <linearGradient id="spbg-nav" x1="0" y1="0" x2="28" y2="28" gradientUnits="userSpaceOnUse"> |
| 62 | − <stop offset="0" stop-color="#c996ff"/><stop offset="0.35" stop-color="#86a4ff"/> | |
| 63 | − <stop offset="0.7" stop-color="#46d6ff"/><stop offset="1" stop-color="#3ff0bd"/> | |
| 64 | − </linearGradient> | |
| 65 | − <linearGradient id="spbg-nav-gloss" x1="0" y1="0" x2="0" y2="28" gradientUnits="userSpaceOnUse"> | |
| 66 | − <stop offset="0" stop-color="#fff" stop-opacity="0.3"/><stop offset="0.55" stop-color="#fff" stop-opacity="0"/> | |
| 58 | + <stop offset="0" stop-color="#c996ff"/><stop offset="0.35" stop-color="#6f9bff"/> | |
| 59 | + <stop offset="0.7" stop-color="#46d6ff"/><stop offset="1" stop-color="#35e0c0"/> | |
| 67 | 60 | </linearGradient> |
| 68 | 61 | </defs> |
| 69 | 62 | <rect width="28" height="28" rx="7" fill="url(#spbg-nav)"/> |
| 70 | − <rect width="28" height="28" rx="7" fill="url(#spbg-nav-gloss)"/> | |
| 71 | − <path d="M 18.6 8.3 C 17.6 6.9 15.9 6.1 13.9 6.1 C 11.1 6.1 9.2 7.7 9.2 9.8 C 9.2 11.8 10.7 12.9 13.5 13.55 L 14.5 13.78 C 17.3 14.45 18.8 15.6 18.8 17.7 C 18.8 20 16.9 21.6 14.1 21.6 C 11.9 21.6 10.2 20.8 9.2 19.3" | |
| 72 | − fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/> | |
| 73 | − <circle cx="18.6" cy="8.3" r="2.05" fill="#fff"/> | |
| 74 | − <circle cx="9.2" cy="19.3" r="2.05" fill="#fff"/> | |
| 63 | + <path d="M 18.6 8.3 C 17.6 6.9 15.9 6.1 13.9 6.1 C 11.1 6.1 9.2 7.7 9.2 9.8 C 9.2 11.8 10.7 12.9 13.5 13.55 L 14.5 13.78 C 17.3 14.45 18.8 15.6 18.8 17.7 C 18.8 20 16.9 21.6 14.1 21.6 C 11.9 21.6 10.2 20.8 9.2 19.3" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/> | |
| 64 | + <circle cx="18.6" cy="8.3" r="2.05" fill="#fff"/><circle cx="9.2" cy="19.3" r="2.05" fill="#fff"/> | |
| 75 | 65 | </svg> |
| 76 | 66 | <span class="brand-name">SPB Git</span> |
| 77 | − <span class="brand-tag">forge</span> | |
| 78 | 67 | </a> |
| 79 | − <nav class="topnav-primary" aria-label="Primary"> | |
| 80 | − <a href="/#repos">Repositories</a> | |
| 68 | + | |
| 69 | + <nav class="nav-primary" aria-label="Primary"> | |
| 70 | + <a href="/#repos" {{ 'aria-current="page"' | safe if nav == 'repos' }}>Repositories</a> | |
| 81 | 71 | <a href="/#activity">Activity</a> |
| 82 | 72 | <a href="/api/v1/repos">API</a> |
| 83 | 73 | </nav> |
| 84 | − <button type="button" class="search-trigger" id="cmdk-open" aria-label="Search repositories (⌘K)"> | |
| 85 | − <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><circle cx="7" cy="7" r="5"/><path d="m14.5 14.5-3.8-3.8" stroke-linecap="round"/></svg> | |
| 86 | − <span>Search repositories…</span> | |
| 87 | − <kbd>⌘K</kbd> | |
| 74 | + | |
| 75 | + <button type="button" class="search-trigger" id="cmdk-open" aria-label="Search (⌘K)" aria-keyshortcuts="Meta+K Control+K /"> | |
| 76 | + {{ ui.icon('search', 16) }} | |
| 77 | + <span class="search-trigger-label">Search repositories, files, commits…</span> | |
| 78 | + <kbd class="kbd" aria-hidden="true">⌘K</kbd> | |
| 88 | 79 | </button> |
| 89 | − <div class="topnav-links"> | |
| 90 | − <button id="theme-toggle" class="icon-btn" type="button" aria-label="Toggle color theme" title="Toggle theme"> | |
| 91 | − <svg class="icon-sun" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-1 0v-1A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-1 0v-1A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1 0-1h1a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1 0-1h1A.5.5 0 0 1 3 8zm10.657-5.657a.5.5 0 0 1 0 .707l-.707.708a.5.5 0 0 1-.708-.708l.707-.707a.5.5 0 0 1 .708 0zm-9.9 9.9a.5.5 0 0 1 0 .707l-.707.707a.5.5 0 0 1-.707-.707l.707-.707a.5.5 0 0 1 .707 0zm9.9 1.414a.5.5 0 0 1-.707 0l-.708-.707a.5.5 0 0 1 .708-.707l.707.707a.5.5 0 0 1 0 .707zm-9.9-9.9a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l.708.707a.5.5 0 0 1 0 .707z"/></svg> | |
| 92 | − <svg class="icon-moon" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M6 .278a.77.77 0 0 1 .08.858 7.2 7.2 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.79.79 0 0 1 .81.316.73.73 0 0 1-.031.893A8.35 8.35 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.75.75 0 0 1 6 .278z"/></svg> | |
| 80 | + | |
| 81 | + <div class="header-actions"> | |
| 82 | + <button type="button" class="icon-btn" id="theme-open" aria-label="Theme" aria-haspopup="dialog" title="Theme"> | |
| 83 | + <span class="theme-ic theme-ic-light">{{ ui.icon('sun', 16) }}</span> | |
| 84 | + <span class="theme-ic theme-ic-dark">{{ ui.icon('moon', 16) }}</span> | |
| 85 | + <span class="theme-ic theme-ic-midnight">{{ ui.icon('midnight', 16) }}</span> | |
| 93 | 86 | </button> |
| 94 | − <a class="topnav-ext" href="https://spboucher.ai" rel="noopener">spboucher.ai ↗</a> | |
| 87 | + <a class="header-ext" href="https://spboucher.ai" rel="noopener">spboucher.ai {{ ui.icon('external', 13) }}</a> | |
| 88 | + <button type="button" class="icon-btn only-mobile" id="nav-open" aria-label="Menu" aria-haspopup="dialog">{{ ui.icon('menu', 18) }}</button> | |
| 95 | 89 | </div> |
| 96 | 90 | </div> |
| 97 | 91 | </header> |
| 98 | − <main id="main" class="container"> | |
| 92 | + | |
| 93 | + <main id="main" class="container {{ mainClass | default('') }}"> | |
| 99 | 94 | {% block content %}{% endblock %} |
| 100 | 95 | </main> |
| 96 | + | |
| 101 | 97 | <footer class="footer"> |
| 102 | 98 | <div class="container footer-grid"> |
| 103 | 99 | <div class="footer-col footer-about"> |
| 104 | − <span class="footer-brand"> | |
| 105 | − <svg width="20" height="20" viewBox="0 0 28 28" aria-hidden="true"> | |
| 106 | − <defs> | |
| 107 | − <linearGradient id="spbg-ft" x1="0" y1="0" x2="28" y2="28" gradientUnits="userSpaceOnUse"> | |
| 108 | − <stop offset="0" stop-color="#c996ff"/><stop offset="0.35" stop-color="#86a4ff"/> | |
| 109 | − <stop offset="0.7" stop-color="#46d6ff"/><stop offset="1" stop-color="#3ff0bd"/> | |
| 110 | − </linearGradient> | |
| 111 | − </defs> | |
| 112 | − <rect width="28" height="28" rx="7" fill="url(#spbg-ft)"/> | |
| 113 | − <path d="M 18.6 8.3 C 17.6 6.9 15.9 6.1 13.9 6.1 C 11.1 6.1 9.2 7.7 9.2 9.8 C 9.2 11.8 10.7 12.9 13.5 13.55 L 14.5 13.78 C 17.3 14.45 18.8 15.6 18.8 17.7 C 18.8 20 16.9 21.6 14.1 21.6 C 11.9 21.6 10.2 20.8 9.2 19.3" | |
| 114 | − fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/> | |
| 115 | − <circle cx="18.6" cy="8.3" r="2.05" fill="#fff"/> | |
| 116 | − <circle cx="9.2" cy="19.3" r="2.05" fill="#fff"/> | |
| 117 | − </svg> | |
| 118 | − SPB Git | |
| 119 | − </span> | |
| 100 | + <span class="footer-brand">SPB Git</span> | |
| 120 | 101 | <p>The personal software forge of {{ owner.name }}. Every repository is public and clonable — one origin, no middlemen.</p> |
| 121 | − <pre class="footer-cmd"><span class="term-prompt">$</span>git clone {{ publicUrl }}/<repo>.git</pre> | |
| 102 | + <pre class="snippet footer-cmd"><span class="prompt">$</span>git clone {{ publicUrl }}/<repo>.git</pre> | |
| 122 | 103 | </div> |
| 123 | 104 | <div class="footer-col"> |
| 124 | − <h4>Explore</h4> | |
| 105 | + <h2>Explore</h2> | |
| 125 | 106 | <a href="/#repos">Repositories</a> |
| 126 | 107 | <a href="/#activity">Activity</a> |
| 127 | 108 | <a href="/search">Search</a> |
| 128 | 109 | <a href="/#heatmap">Commit calendar</a> |
| 129 | 110 | </div> |
| 130 | 111 | <div class="footer-col"> |
| 131 | − <h4>Machine</h4> | |
| 112 | + <h2>Machine</h2> | |
| 132 | 113 | <a href="/api/v1/repos">JSON API</a> |
| 133 | 114 | <a href="/feed.atom">Atom feed</a> |
| 134 | 115 | <a href="/sitemap.xml">Sitemap</a> |
| 135 | 116 | <a href="/healthz">Health</a> |
| 136 | 117 | </div> |
| 137 | 118 | <div class="footer-col"> |
| 138 | − <h4>Owner</h4> | |
| 119 | + <h2>Owner</h2> | |
| 139 | 120 | <a href="https://spboucher.ai" rel="noopener">spboucher.ai</a> |
| 140 | 121 | <a href="mailto:{{ owner.email }}">{{ owner.email }}</a> |
| 141 | 122 | </div> |
@@ -145,26 +126,71 @@ | ||
| 145 | 126 | <span class="footer-status"><i class="status-dot" aria-hidden="true"></i>self-hosted · MacLustr</span> |
| 146 | 127 | </div> |
| 147 | 128 | </footer> |
| 148 | − <div class="cmdk" id="cmdk" hidden> | |
| 149 | − <div class="cmdk-backdrop" data-cmdk-close></div> | |
| 150 | − <div class="cmdk-panel" role="dialog" aria-modal="true" aria-label="Command palette"> | |
| 129 | + | |
| 130 | + {# ── Mobile navigation sheet ── #} | |
| 131 | + <dialog class="sheet" id="nav-sheet" aria-label="Menu"> | |
| 132 | + <div class="sheet-handle" aria-hidden="true"></div> | |
| 133 | + <div class="sheet-head"><strong>Menu</strong><button type="button" class="icon-btn" data-close aria-label="Close menu">{{ ui.icon('close', 16) }}</button></div> | |
| 134 | + <nav class="sheet-nav" aria-label="Site"> | |
| 135 | + <a href="/">{{ ui.icon('home') }} Home</a> | |
| 136 | + <a href="/#repos">{{ ui.icon('folder') }} Repositories</a> | |
| 137 | + <a href="/#activity">{{ ui.icon('history') }} Activity</a> | |
| 138 | + <a href="/#heatmap">{{ ui.icon('graph') }} Commit calendar</a> | |
| 139 | + <a href="/search">{{ ui.icon('search') }} Search</a> | |
| 140 | + <a href="/api/v1/repos">{{ ui.icon('code') }} JSON API</a> | |
| 141 | + <a href="/feed.atom">{{ ui.icon('rss') }} Atom feed</a> | |
| 142 | + <a href="https://spboucher.ai" rel="noopener">{{ ui.icon('external') }} spboucher.ai</a> | |
| 143 | + <a href="mailto:{{ owner.email }}">{{ ui.icon('mail') }} {{ owner.email }}</a> | |
| 144 | + </nav> | |
| 145 | + <div class="sheet-section"> | |
| 146 | + <h2>Theme</h2> | |
| 147 | + <div class="theme-choices" role="radiogroup" aria-label="Theme"> | |
| 148 | + <button type="button" role="radio" data-theme-choice="auto" aria-checked="false">{{ ui.icon('auto') }} System</button> | |
| 149 | + <button type="button" role="radio" data-theme-choice="light" aria-checked="false">{{ ui.icon('sun') }} Light</button> | |
| 150 | + <button type="button" role="radio" data-theme-choice="dark" aria-checked="false">{{ ui.icon('moon') }} Dark</button> | |
| 151 | + <button type="button" role="radio" data-theme-choice="midnight" aria-checked="false">{{ ui.icon('midnight') }} Midnight</button> | |
| 152 | + </div> | |
| 153 | + </div> | |
| 154 | + </dialog> | |
| 155 | + | |
| 156 | + {# ── Theme popover (desktop) ── #} | |
| 157 | + <dialog class="popover" id="theme-menu" aria-label="Theme"> | |
| 158 | + <div class="theme-choices" role="radiogroup" aria-label="Theme"> | |
| 159 | + <button type="button" role="radio" data-theme-choice="auto" aria-checked="false">{{ ui.icon('auto') }} System</button> | |
| 160 | + <button type="button" role="radio" data-theme-choice="light" aria-checked="false">{{ ui.icon('sun') }} Light</button> | |
| 161 | + <button type="button" role="radio" data-theme-choice="dark" aria-checked="false">{{ ui.icon('moon') }} Dark</button> | |
| 162 | + <button type="button" role="radio" data-theme-choice="midnight" aria-checked="false">{{ ui.icon('midnight') }} Midnight</button> | |
| 163 | + </div> | |
| 164 | + </dialog> | |
| 165 | + | |
| 166 | + {# ── Command palette / global search ── #} | |
| 167 | + <dialog class="cmdk" id="cmdk" aria-label="Search and commands" data-repo="{{ overview.name if overview else '' }}" data-ref="{{ currentRef or '' }}"> | |
| 168 | + <div class="cmdk-panel"> | |
| 151 | 169 | <div class="cmdk-input-row"> |
| 152 | − <svg width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"> | |
| 153 | − <circle cx="7" cy="7" r="5"/><path d="m14.5 14.5-3.8-3.8" stroke-linecap="round"/> | |
| 154 | − </svg> | |
| 155 | − <input id="cmdk-input" type="text" placeholder="Jump to a repository, or type a command…" autocomplete="off" | |
| 156 | − role="combobox" aria-expanded="true" aria-controls="cmdk-list" aria-label="Jump to a repository"> | |
| 157 | − <kbd class="kbd">esc</kbd> | |
| 170 | + {{ ui.icon('search', 18) }} | |
| 171 | + <input id="cmdk-input" type="search" placeholder="Search repositories, files, commits, branches…" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" enterkeyhint="go" | |
| 172 | + role="combobox" aria-expanded="true" aria-controls="cmdk-list" aria-autocomplete="list" aria-label="Search"> | |
| 173 | + <button type="button" class="icon-btn cmdk-close" data-close aria-label="Close search">{{ ui.icon('close', 16) }}</button> | |
| 174 | + </div> | |
| 175 | + <div class="cmdk-scopes" id="cmdk-scopes" role="tablist" aria-label="Search scope"> | |
| 176 | + <button type="button" role="tab" data-scope="all" aria-selected="true">All</button> | |
| 177 | + <button type="button" role="tab" data-scope="repos" aria-selected="false">Repos</button> | |
| 178 | + <button type="button" role="tab" data-scope="files" aria-selected="false">Files</button> | |
| 179 | + <button type="button" role="tab" data-scope="commits" aria-selected="false">Commits</button> | |
| 180 | + <button type="button" role="tab" data-scope="branches" aria-selected="false">Branches</button> | |
| 158 | 181 | </div> |
| 159 | 182 | <ul class="cmdk-list" id="cmdk-list" role="listbox" aria-label="Results"></ul> |
| 160 | 183 | <div class="cmdk-footer"> |
| 161 | 184 | <span><kbd>↑</kbd><kbd>↓</kbd> navigate</span> |
| 162 | 185 | <span><kbd>↵</kbd> open</span> |
| 163 | 186 | <span><kbd>esc</kbd> close</span> |
| 164 | − <span style="margin-left:auto"><kbd>/</kbd> or <kbd>⌘K</kbd> anywhere</span> | |
| 187 | + <span class="cmdk-footer-right"><kbd>/</kbd> or <kbd>⌘K</kbd> anywhere</span> | |
| 165 | 188 | </div> |
| 166 | 189 | </div> |
| 167 | − </div> | |
| 190 | + </dialog> | |
| 191 | + | |
| 192 | + <div class="toasts" id="toasts" aria-live="polite" aria-atomic="false"></div> | |
| 168 | 193 | <script src="/assets/js/app.js?v={{ assetVersion }}" defer></script> |
| 194 | + {% block scripts %}{% endblock %} | |
| 169 | 195 | </body> |
| 170 | 196 | </html> |
added
src/web/views/metrics.njk
+52 −0
@@ -0,0 +1,52 @@ | ||
| 1 | +{# | |
| 2 | + ───────────────────────────────────────────── | |
| 3 | + SPB Git — Personal Git Platform | |
| 4 | + ───────────────────────────────────────────── | |
| 5 | + Author : Simon-Pierre Boucher | |
| 6 | + Contact : contact@spboucher.ai | |
| 7 | + File : src/web/views/metrics.njk | |
| 8 | + Purpose : /about/metrics — precise definitions of every statistic shown on the site | |
| 9 | + License : MIT © Simon-Pierre Boucher | |
| 10 | + ───────────────────────────────────────────── | |
| 11 | +#}{% extends "layout.njk" %} | |
| 12 | +{% import "components/ui.njk" as ui %} | |
| 13 | +{% block content %} | |
| 14 | +<section class="search-hero"> | |
| 15 | + <p class="eyebrow"><span class="eyebrow-dot" aria-hidden="true"></span>about · metrics</p> | |
| 16 | + <h1 class="section-title" style="font-size:28px;margin-top:10px">How the numbers are counted</h1> | |
| 17 | + <p class="muted" style="margin-top:8px;max-width:70ch">Every statistic on SPB Git is computed from the bare repositories on disk. Nothing is estimated or sampled. This page states each definition so that two numbers that look comparable really are.</p> | |
| 18 | +</section> | |
| 19 | +<div class="card" style="margin-bottom:var(--space-6)"> | |
| 20 | + <article class="markdown-body" style="padding:var(--space-5)"> | |
| 21 | + <h2 id="commits">Commits</h2> | |
| 22 | + <ul> | |
| 23 | + <li><strong>Commits (per repository)</strong> — <code>git rev-list --count HEAD</code>: every commit reachable from the default branch, including merges. Commits that only exist on other branches are not counted.</li> | |
| 24 | + <li><strong>Commits, all time</strong> — the sum of the per-repository counts above across all {{ stats.repos | num }} repositories.</li> | |
| 25 | + <li><strong>Commits, last 52 weeks</strong> — the same commits (default branch, every repository), kept when their <em>committer date</em> falls inside the 52-week calendar grid ending today (UTC days, weeks start on Sunday). Because it is a filter of the all-time set, it can never exceed the all-time total.</li> | |
| 26 | + <li>Both figures come from one pass over the repositories and are recomputed together whenever a push changes any default branch; a repository pushed while the server was unavailable is detected on the next request by comparing the cached HEAD with the live one.</li> | |
| 27 | + <li>Identity rule: a commit is counted once per repository where it is reachable. Mirrors or forks of the same history therefore count their commits again — this is deliberate (the catalog counts repositories, not unique commits).</li> | |
| 28 | + </ul> | |
| 29 | + <h2 id="calendar">Calendar, streaks, active days</h2> | |
| 30 | + <ul> | |
| 31 | + <li><strong>Calendar cell</strong> — commits whose committer date (UTC) falls on that day. Colour levels are quartiles of the busiest day in the window.</li> | |
| 32 | + <li><strong>Active days</strong> — days with at least one commit in the window.</li> | |
| 33 | + <li><strong>Day streak</strong> — consecutive active days ending today; today may still be empty without breaking the streak.</li> | |
| 34 | + <li><strong>Longest streak</strong> — the longest run of consecutive active days inside the window.</li> | |
| 35 | + <li><strong>Sparkline</strong> (repository cards) — weekly commit counts over the last 26 weeks, same source.</li> | |
| 36 | + </ul> | |
| 37 | + <h2 id="languages">Languages</h2> | |
| 38 | + <ul> | |
| 39 | + <li><strong>Repository language bar</strong> — bytes per language at HEAD, Linguist-style: only programming and markup languages, vendored paths and lock files excluded.</li> | |
| 40 | + <li><strong>Languages (site)</strong> — number of distinct languages appearing in at least one repository bar.</li> | |
| 41 | + <li><strong>Stack ring</strong> — each repository contributes its percentage split with <em>equal weight</em>, so a single large repository cannot dominate.</li> | |
| 42 | + </ul> | |
| 43 | + <h2 id="activity">Activity, clones, size</h2> | |
| 44 | + <ul> | |
| 45 | + <li><strong>Last push</strong> — the most recent committer date across the repository's branches.</li> | |
| 46 | + <li><strong>Activity feed</strong> — events recorded by the post-receive hook (pushes, tags, deletions).</li> | |
| 47 | + <li><strong>Clone count</strong> — number of <code>git-upload-pack</code> requests (clones and fetches, including CI and crawlers). Shown only when non-zero.</li> | |
| 48 | + <li><strong>Size on disk</strong> — loose plus packed object size reported by <code>git count-objects</code>.</li> | |
| 49 | + </ul> | |
| 50 | + </article> | |
| 51 | +</div> | |
| 52 | +{% endblock %} | |
added
src/web/views/partials/clone-panel.njk
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +{# | |
| 2 | + ───────────────────────────────────────────── | |
| 3 | + SPB Git — Personal Git Platform | |
| 4 | + ───────────────────────────────────────────── | |
| 5 | + Author : Simon-Pierre Boucher | |
| 6 | + Contact : contact@spboucher.ai | |
| 7 | + File : src/web/views/partials/clone-panel.njk | |
| 8 | + Purpose : Clone panel v2.0 — HTTPS URL (selectable), copy, spbgit CLI, source archives; sheet on phones | |
| 9 | + License : MIT © Simon-Pierre Boucher | |
| 10 | + ───────────────────────────────────────────── | |
| 11 | +#}{% import "components/ui.njk" as ui %} | |
| 12 | +<dialog class="sheet clone-panel" id="clone-panel" aria-label="Clone {{ overview.name }}"> | |
| 13 | + <div class="sheet-handle" aria-hidden="true"></div> | |
| 14 | + <div class="sheet-head"><h2>{{ ui.icon('terminal', 16) }} Clone {{ overview.name }}</h2><button type="button" class="icon-btn" data-close aria-label="Close">{{ ui.icon('close', 16) }}</button></div> | |
| 15 | + <label class="text-xs muted" for="clone-https">HTTPS</label> | |
| 16 | + <div class="clone-row"> | |
| 17 | + <input id="clone-https" class="clone-url" type="text" readonly value="{{ overview.cloneUrl }}" aria-label="HTTPS clone URL" spellcheck="false"> | |
| 18 | + {{ ui.copyButton(overview.cloneUrl, 'Copy', 'Copy HTTPS URL') }} | |
| 19 | + </div> | |
| 20 | + <p class="clone-hint">Anonymous clone and fetch. Pushing requires the owner's personal access token.</p> | |
| 21 | + <label class="text-xs muted" for="clone-cli">spbgit CLI</label> | |
| 22 | + <div class="clone-row"> | |
| 23 | + <input id="clone-cli" class="clone-url" type="text" readonly value="spbgit clone {{ overview.name }}" aria-label="spbgit CLI command" spellcheck="false"> | |
| 24 | + {{ ui.copyButton('spbgit clone ' + overview.name, 'Copy', 'Copy spbgit command') }} | |
| 25 | + </div> | |
| 26 | + {% if not overview.empty %} | |
| 27 | + <div class="btn-row"> | |
| 28 | + <a class="btn btn-sm" href="/archive/{{ overview.name }}/{{ currentRef }}.zip" rel="nofollow">{{ ui.icon('download', 14) }} ZIP</a> | |
| 29 | + <a class="btn btn-sm" href="/archive/{{ overview.name }}/{{ currentRef }}.tar.gz" rel="nofollow">{{ ui.icon('download', 14) }} tar.gz</a> | |
| 30 | + <span class="muted text-xs">snapshot of <code>{{ currentRef }}</code></span> | |
| 31 | + </div> | |
| 32 | + {% endif %} | |
| 33 | +</dialog> | |
modified
src/web/views/partials/file-table.njk
+14 −13
@@ -5,36 +5,37 @@ | ||
| 5 | 5 | Author : Simon-Pierre Boucher |
| 6 | 6 | Contact : contact@spboucher.ai |
| 7 | 7 | File : src/web/views/partials/file-table.njk |
| 8 | − Purpose : Tree file table — icon, name, last commit, relative date | |
| 8 | + Purpose : Tree file table v2.0 — icon, name (ellipsis + full title), last commit, relative date | |
| 9 | 9 | License : MIT © Simon-Pierre Boucher |
| 10 | 10 | ───────────────────────────────────────────── |
| 11 | −#}<table class="file-table"> | |
| 12 | − <thead class="sr-only"><tr><th>Name</th><th>Last commit</th><th>Updated</th></tr></thead> | |
| 11 | +#}{% import "components/ui.njk" as ui %} | |
| 12 | +<div class="card file-card"> | |
| 13 | +<table class="file-table"> | |
| 14 | + <thead class="sr-only"><tr><th scope="col">Name</th><th scope="col">Last commit</th><th scope="col">Updated</th></tr></thead> | |
| 13 | 15 | <tbody> |
| 14 | 16 | {% if path %} |
| 15 | 17 | <tr class="file-row"> |
| 16 | 18 | <td class="file-name" colspan="3"> |
| 17 | − <a href="{{ ('/' + overview.name + '/tree/' + currentRef + '/' + parentPath) if parentPath else ('/' + overview.name) }}" rel="nofollow">…</a> | |
| 19 | + <a href="{{ ('/' + overview.name + '/tree/' + currentRef + '/' + parentPath) if parentPath else ('/' + overview.name + '/tree/' + currentRef) }}" rel="nofollow" aria-label="Parent directory">{{ ui.icon('arrow-left', 14, 'file-icon') }}<span>..</span></a> | |
| 18 | 20 | </td> |
| 19 | 21 | </tr> |
| 20 | 22 | {% endif %} |
| 21 | 23 | {% for entry in entries %} |
| 24 | + {% set lc = lastCommits[entry.name] %} | |
| 22 | 25 | <tr class="file-row"> |
| 23 | 26 | <td class="file-name"> |
| 24 | 27 | {% if entry.type == 'tree' %} |
| 25 | − <svg class="file-icon icon-dir" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2A1.75 1.75 0 0 0 5 1z"/></svg> | |
| 26 | − <a href="/{{ overview.name }}/tree/{{ currentRef }}/{{ entry.path }}">{{ entry.name }}</a> | |
| 28 | + <a href="/{{ overview.name }}/tree/{{ currentRef }}/{{ entry.path }}" title="{{ entry.name }}">{{ ui.icon('folder', 16, 'file-icon icon-dir') }}<span>{{ entry.name }}</span></a> | |
| 27 | 29 | {% else %} |
| 28 | − <svg class="file-icon" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914z"/></svg> | |
| 29 | − <a href="/{{ overview.name }}/blob/{{ currentRef }}/{{ entry.path }}">{{ entry.name }}</a> | |
| 30 | + <a href="/{{ overview.name }}/blob/{{ currentRef }}/{{ entry.path }}" title="{{ entry.name }}">{{ ui.icon('file', 16, 'file-icon') }}<span>{{ entry.name }}</span></a> | |
| 30 | 31 | {% endif %} |
| 31 | 32 | </td> |
| 32 | − <td class="file-commit"> | |
| 33 | − {% set lc = lastCommits[entry.name] %} | |
| 34 | − {% if lc %}<a href="/{{ overview.name }}/commit/{{ lc.sha }}" class="muted">{{ lc.subject | truncate(70) }}</a>{% endif %} | |
| 35 | − </td> | |
| 36 | − <td class="file-date muted">{% if lc %}{{ lc.date | reltime }}{% endif %}</td> | |
| 33 | + <td class="file-commit">{% if lc %}<a href="/{{ overview.name }}/commit/{{ lc.sha }}" title="{{ lc.subject }}">{{ lc.subject | truncate(80) }}</a>{% endif %}</td> | |
| 34 | + <td class="file-date">{% if lc %}<time datetime="{{ lc.date }}" title="{{ lc.date }}">{{ lc.date | reltime }}</time>{% endif %}</td> | |
| 37 | 35 | </tr> |
| 36 | + {% else %} | |
| 37 | + <tr><td colspan="3" class="muted" style="padding:16px">This directory is empty.</td></tr> | |
| 38 | 38 | {% endfor %} |
| 39 | 39 | </tbody> |
| 40 | 40 | </table> |
| 41 | +</div> | |
modified
src/web/views/partials/repo-card.njk
+9 −12
@@ -5,10 +5,11 @@ | ||
| 5 | 5 | Author : Simon-Pierre Boucher |
| 6 | 6 | Contact : contact@spboucher.ai |
| 7 | 7 | File : src/web/views/partials/repo-card.njk |
| 8 | − Purpose : Repo card v4 — name, 26-week sparkline, description, topics, meta | |
| 8 | + Purpose : RepositoryCard v2.0 — name, 26-week sparkline, description, topics, meta | |
| 9 | 9 | License : MIT © Simon-Pierre Boucher |
| 10 | 10 | ───────────────────────────────────────────── |
| 11 | −#}<article class="repo-card spot" data-name="{{ repo.name }}" | |
| 11 | +#}{% import "components/ui.njk" as ui %} | |
| 12 | +<article class="repo-card card" data-name="{{ repo.name }}" | |
| 12 | 13 | data-description="{{ repo.description }}" |
| 13 | 14 | data-language="{{ repo.topLanguage or '' }}" |
| 14 | 15 | data-topics="{{ repo.topics | join(',') }}" |
@@ -18,7 +19,7 @@ | ||
| 18 | 19 | <div class="repo-card-top"> |
| 19 | 20 | <h3 class="repo-card-name"> |
| 20 | 21 | <a href="/{{ repo.name }}">{{ repo.name }}</a> |
| 21 | − {% if repo.pinned %}<span class="pin" title="Pinned" aria-label="Pinned">★</span>{% endif %} | |
| 22 | + {% if repo.pinned %}<span class="pin" title="Pinned" aria-label="Pinned">{{ ui.icon('pin', 13) }}</span>{% endif %} | |
| 22 | 23 | </h3> |
| 23 | 24 | {% if repo.spark %} |
| 24 | 25 | <svg class="spark {{ 'spark-quiet' if repo.spark.total == 0 }}" viewBox="0 0 120 34" preserveAspectRatio="none" role="img" |
@@ -31,16 +32,12 @@ | ||
| 31 | 32 | </div> |
| 32 | 33 | {% if repo.description %}<p class="repo-card-desc">{{ repo.description }}</p>{% endif %} |
| 33 | 34 | {% if repo.topics.length %} |
| 34 | − <div class="chip-row"> | |
| 35 | − {% for topic in repo.topics %}<a class="chip chip-topic" href="/search?q={{ topic }}">{{ topic }}</a>{% endfor %} | |
| 36 | − </div> | |
| 35 | + <div class="chip-row">{% for topic in repo.topics %}{{ ui.chip(topic, 'topic', '/search?q=' + topic) }}{% endfor %}</div> | |
| 37 | 36 | {% endif %} |
| 38 | 37 | <div class="repo-card-meta"> |
| 39 | − {% if repo.topLanguage %} | |
| 40 | − <span class="lang-dot-label"><span class="lang-dot" style="background:{{ repo.topLanguage | langcolor }}"></span>{{ repo.topLanguage }}</span> | |
| 41 | − {% endif %} | |
| 42 | − {% if repo.commitCount %}<span title="Commits" class="mono">{{ repo.commitCount | num }} commits</span>{% endif %} | |
| 43 | − {% if repo.cloneCount %}<span title="Clone / fetch count">⇩ {{ repo.cloneCount | num }}</span>{% endif %} | |
| 44 | − {% if repo.lastPush %}<span class="pushed">pushed {{ repo.lastPush | reltime }}</span>{% else %}<span class="muted pushed">empty</span>{% endif %} | |
| 38 | + {% if repo.topLanguage %}<span class="lang-dot-label"><span class="lang-dot" style="background:{{ repo.topLanguage | langcolor }}"></span>{{ repo.topLanguage }}</span>{% endif %} | |
| 39 | + {% if repo.commitCount %}<span class="num" title="Commits on {{ repo.defaultBranch }}">{{ repo.commitCount | num }} commits</span>{% endif %} | |
| 40 | + {% if repo.cloneCount %}<span class="num" title="Clone and fetch requests">⇩ {{ repo.cloneCount | num }}</span>{% endif %} | |
| 41 | + {% if repo.lastPush %}<time class="pushed" datetime="{{ repo.lastPush }}" title="{{ repo.lastPush }}">pushed {{ repo.lastPush | reltime }}</time>{% else %}<span class="muted pushed">empty</span>{% endif %} | |
| 45 | 42 | </div> |
| 46 | 43 | </article> |
modified
src/web/views/partials/repo-header.njk
+35 −49
@@ -5,76 +5,62 @@ | ||
| 5 | 5 | Author : Simon-Pierre Boucher |
| 6 | 6 | Contact : contact@spboucher.ai |
| 7 | 7 | File : src/web/views/partials/repo-header.njk |
| 8 | − Purpose : Repo page header v4 — display title, badges, stat tiles, language bar, tabs | |
| 8 | + Purpose : Repo page header v2.0 — title, chips, description, compact stat strip, language bar, tabs | |
| 9 | 9 | License : MIT © Simon-Pierre Boucher |
| 10 | 10 | ───────────────────────────────────────────── |
| 11 | −#}<header class="repo-header"> | |
| 11 | +#}{% import "components/ui.njk" as ui %} | |
| 12 | +<header class="repo-header"> | |
| 12 | 13 | <div class="repo-title-row"> |
| 13 | 14 | <h1 class="repo-title"> |
| 14 | 15 | <a href="/" class="repo-owner">spb</a><span class="repo-sep">/</span><a href="/{{ overview.name }}" class="repo-name">{{ overview.name }}</a> |
| 15 | 16 | </h1> |
| 16 | 17 | <div class="repo-badges"> |
| 17 | − <span class="chip chip-public">Public</span> | |
| 18 | − {% if overview.license %}<span class="chip chip-license" title="License detected from {{ overview.licensePath }}">{{ overview.license }}</span>{% endif %} | |
| 19 | − {% if overview.pinned %}<span class="chip" style="color:var(--ember-2);background:var(--ember-soft)">★ Pinned</span>{% endif %} | |
| 18 | + {{ ui.chip('Public', 'neutral') }} | |
| 19 | + {% if overview.license %}{{ ui.chip(overview.license, 'ok', null, 'License detected from ' + overview.licensePath) }}{% endif %} | |
| 20 | + {% if overview.pinned %}{{ ui.chip('Pinned', 'ember') }}{% endif %} | |
| 21 | + {% if overview.research %}{{ ui.chip('Research', 'info', '/' + overview.name + '/research') }}{% endif %} | |
| 20 | 22 | </div> |
| 21 | 23 | </div> |
| 22 | 24 | {% if overview.description %}<p class="repo-desc">{{ overview.description }}</p>{% endif %} |
| 23 | 25 | {% if overview.homepage or overview.topics.length %} |
| 24 | 26 | <div class="repo-meta-row"> |
| 25 | 27 | {% if overview.homepage %} |
| 26 | − <a class="repo-homepage" href="{{ overview.homepage }}" rel="noopener noreferrer"> | |
| 27 | − <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M7.775 3.275a.75.75 0 0 0 1.06 1.06l1.25-1.25a2 2 0 1 1 2.83 2.83l-2.5 2.5a2 2 0 0 1-2.83 0 .75.75 0 0 0-1.06 1.06 3.5 3.5 0 0 0 4.95 0l2.5-2.5a3.5 3.5 0 0 0-4.95-4.95l-1.25 1.25Zm-4.69 9.64a2 2 0 0 1 0-2.83l2.5-2.5a2 2 0 0 1 2.83 0 .75.75 0 0 0 1.06-1.06 3.5 3.5 0 0 0-4.95 0l-2.5 2.5a3.5 3.5 0 0 0 4.95 4.95l1.25-1.25a.75.75 0 0 0-1.06-1.06l-1.25 1.25a2 2 0 0 1-2.83 0Z"/></svg> | |
| 28 | − {{ overview.homepage | replace('https://', '') | replace('http://', '') }} | |
| 29 | − </a> | |
| 28 | + <a class="repo-homepage" href="{{ overview.homepage }}" rel="noopener noreferrer">{{ ui.icon('external', 14) }}{{ overview.homepage | replace('https://', '') | replace('http://', '') }}</a> | |
| 30 | 29 | {% endif %} |
| 31 | 30 | {% if overview.topics.length %} |
| 32 | − <div class="chip-row"> | |
| 33 | − {% for topic in overview.topics %}<a class="chip chip-topic" href="/search?q={{ topic }}">{{ topic }}</a>{% endfor %} | |
| 34 | − </div> | |
| 31 | + <div class="chip-row">{% for topic in overview.topics %}{{ ui.chip(topic, 'topic', '/search?q=' + topic) }}{% endfor %}</div> | |
| 35 | 32 | {% endif %} |
| 36 | 33 | </div> |
| 37 | 34 | {% endif %} |
| 38 | 35 | |
| 39 | − <div class="repo-stats"> | |
| 40 | − <a class="rstat spot" href="/{{ overview.name }}/commits/{{ overview.defaultBranch }}"><span class="rstat-v">{{ overview.commitCount | num }}</span><span class="rstat-l">commits</span></a> | |
| 41 | − <a class="rstat spot" href="/{{ overview.name }}/branches"><span class="rstat-v">{{ overview.branchCount | num }}</span><span class="rstat-l">branches</span></a> | |
| 42 | − <a class="rstat spot" href="/{{ overview.name }}/tags"><span class="rstat-v">{{ overview.tagCount | num }}</span><span class="rstat-l">releases</span></a> | |
| 43 | − <div class="rstat"><span class="rstat-v">{{ overview.sizeBytes | bytes }}</span><span class="rstat-l">size</span></div> | |
| 44 | − <div class="rstat"><span class="rstat-v mono" style="font-size:16px;font-family:var(--font-mono)">{{ overview.defaultBranch }}</span><span class="rstat-l">default branch</span></div> | |
| 45 | − <div class="rstat"><span class="rstat-v" title="{{ overview.lastPush }}">{{ (overview.lastPush | reltime) if overview.lastPush else '—' }}</span><span class="rstat-l">last push</span></div> | |
| 46 | − </div> | |
| 36 | + {{ ui.statStrip([ | |
| 37 | + { value: overview.commitCount | num, label: 'commits', href: '/' + overview.name + '/commits/' + overview.defaultBranch, title: 'Commits reachable from ' + overview.defaultBranch }, | |
| 38 | + { value: overview.branchCount | num, label: 'branches', href: '/' + overview.name + '/branches' }, | |
| 39 | + { value: overview.tagCount | num, label: 'releases', href: '/' + overview.name + '/tags' }, | |
| 40 | + { value: overview.sizeBytes | bytes, label: 'on disk' }, | |
| 41 | + { value: overview.defaultBranch, label: 'default branch' }, | |
| 42 | + { value: (overview.lastPush | reltime) if overview.lastPush else '—', label: 'last push', title: overview.lastPush or '' } | |
| 43 | + ]) }} | |
| 47 | 44 | |
| 48 | 45 | {% if overview.languages.length %} |
| 49 | − <div class="lang-bar" role="img" aria-label="Language composition"> | |
| 50 | − {% for lang in overview.languages %} | |
| 51 | − <span class="lang-bar-seg" style="width:{{ lang.percent }}%;background:{{ lang.color }}" title="{{ lang.name }} {{ lang.percent }}%"></span> | |
| 52 | − {% endfor %} | |
| 53 | − </div> | |
| 54 | − <div class="lang-legend"> | |
| 55 | − {% for lang in overview.languages %} | |
| 56 | − {% if lang.percent >= 0.5 %} | |
| 57 | − <span class="lang-dot-label"><span class="lang-dot" style="background:{{ lang.color }}"></span>{{ lang.name }} <span class="muted">{{ lang.percent }}%</span></span> | |
| 58 | − {% endif %} | |
| 59 | − {% endfor %} | |
| 46 | + <div class="lang-block"> | |
| 47 | + <div class="lang-bar" role="img" aria-label="Language composition: {% for lang in overview.languages %}{{ lang.name }} {{ lang.percent }}%{{ ', ' if not loop.last }}{% endfor %}"> | |
| 48 | + {% for lang in overview.languages %}<span class="lang-bar-seg" style="width:{{ lang.percent }}%;background:{{ lang.color }}" title="{{ lang.name }} {{ lang.percent }}%"></span>{% endfor %} | |
| 49 | + </div> | |
| 50 | + <div class="lang-legend" aria-hidden="true"> | |
| 51 | + {% for lang in overview.languages %}{% if lang.percent >= 0.5 and loop.index <= 6 %} | |
| 52 | + <span class="lang-dot-label"><span class="lang-dot" style="background:{{ lang.color }}"></span>{{ lang.name }} <span class="muted num">{{ lang.percent }}%</span></span> | |
| 53 | + {% endif %}{% endfor %} | |
| 54 | + </div> | |
| 60 | 55 | </div> |
| 61 | 56 | {% endif %} |
| 62 | − <nav class="repo-tabs" aria-label="Repository sections"> | |
| 63 | − <a class="repo-tab {{ 'active' if tab == 'code' }}" href="/{{ overview.name }}"> | |
| 64 | − <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="m11.28 3.22 4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L13.94 8l-3.72-3.72a.75.75 0 1 1 1.06-1.06Zm-6.56 0a.75.75 0 1 1 1.06 1.06L2.06 8l3.72 3.72a.75.75 0 1 1-1.06 1.06L.47 8.53a.75.75 0 0 1 0-1.06Z"/></svg> | |
| 65 | − Code | |
| 66 | − </a> | |
| 67 | − <a class="repo-tab {{ 'active' if tab == 'commits' }}" href="/{{ overview.name }}/commits/{{ currentRef }}"> | |
| 68 | − <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/></svg> | |
| 69 | − Commits <span class="count">{{ overview.commitCount | num }}</span> | |
| 70 | − </a> | |
| 71 | − <a class="repo-tab {{ 'active' if tab == 'branches' }}" href="/{{ overview.name }}/branches"> | |
| 72 | − <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/></svg> | |
| 73 | − Branches <span class="count">{{ overview.branchCount }}</span> | |
| 74 | − </a> | |
| 75 | − <a class="repo-tab {{ 'active' if tab == 'tags' }}" href="/{{ overview.name }}/tags"> | |
| 76 | − <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775Zm1.5 0c0 .066.026.13.073.177l6.25 6.25a.25.25 0 0 0 .354 0l5.025-5.025a.25.25 0 0 0 0-.354l-6.25-6.25a.25.25 0 0 0-.177-.073H2.75a.25.25 0 0 0-.25.25ZM6 5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z"/></svg> | |
| 77 | − Releases <span class="count">{{ overview.tagCount }}</span> | |
| 78 | − </a> | |
| 79 | − </nav> | |
| 57 | + | |
| 58 | + {% set repoTabs = [ | |
| 59 | + { key: 'code', label: 'Code', href: '/' + overview.name, icon: 'code' }, | |
| 60 | + { key: 'commits', label: 'Commits', href: '/' + overview.name + '/commits/' + currentRef, icon: 'commit', count: overview.commitCount | num }, | |
| 61 | + { key: 'branches', label: 'Branches', href: '/' + overview.name + '/branches', icon: 'branch', count: overview.branchCount }, | |
| 62 | + { key: 'tags', label: 'Releases', href: '/' + overview.name + '/tags', icon: 'tag', count: overview.tagCount } | |
| 63 | + ] %} | |
| 64 | + {% if overview.research %}{% set repoTabs = (repoTabs.push({ key: 'research', label: 'Research', href: '/' + overview.name + '/research', icon: 'beaker' }), repoTabs) %}{% endif %} | |
| 65 | + {{ ui.tabs(repoTabs, tab, 'Repository sections') }} | |
| 80 | 66 | </header> |
added
src/web/views/partials/tree-sheet.njk
+16 −0
@@ -0,0 +1,16 @@ | ||
| 1 | +{# | |
| 2 | + ───────────────────────────────────────────── | |
| 3 | + SPB Git — Personal Git Platform | |
| 4 | + ───────────────────────────────────────────── | |
| 5 | + Author : Simon-Pierre Boucher | |
| 6 | + Contact : contact@spboucher.ai | |
| 7 | + File : src/web/views/partials/tree-sheet.njk | |
| 8 | + Purpose : FileTree v2.0 — lazy expandable tree (desktop sidebar panel + phone sheet share one markup) | |
| 9 | + License : MIT © Simon-Pierre Boucher | |
| 10 | + ───────────────────────────────────────────── | |
| 11 | +#}{% import "components/ui.njk" as ui %} | |
| 12 | +<dialog class="sheet" id="tree-sheet" aria-label="Files"> | |
| 13 | + <div class="sheet-handle" aria-hidden="true"></div> | |
| 14 | + <div class="sheet-head"><strong>Files · <code>{{ currentRef }}</code></strong><button type="button" class="icon-btn" data-close aria-label="Close">{{ ui.icon('close', 16) }}</button></div> | |
| 15 | + <nav class="file-tree" id="file-tree" data-repo="{{ overview.name }}" data-ref="{{ currentRef }}" data-current="{{ path or '' }}" aria-label="Repository files"></nav> | |
| 16 | +</dialog> | |
modified
src/web/views/repo.njk
+14 −27
@@ -5,51 +5,35 @@ | ||
| 5 | 5 | Author : Simon-Pierre Boucher |
| 6 | 6 | Contact : contact@spboucher.ai |
| 7 | 7 | File : src/web/views/repo.njk |
| 8 | − Purpose : Repo home v4 — terminal clone box, file table, README with sticky table of contents | |
| 8 | + Purpose : Repo home v2.0 — toolbar (ref picker, files, clone), file table, README with table of contents | |
| 9 | 9 | License : MIT © Simon-Pierre Boucher |
| 10 | 10 | ───────────────────────────────────────────── |
| 11 | 11 | #}{% extends "layout.njk" %} |
| 12 | +{% import "components/ui.njk" as ui %} | |
| 12 | 13 | {% block content %} |
| 13 | 14 | {% include "partials/repo-header.njk" %} |
| 14 | 15 | |
| 15 | 16 | {% if overview.empty %} |
| 16 | −<div class="empty-repo card spot" style="border-radius:var(--radius-lg)"> | |
| 17 | − <h2 class="section-title" style="justify-content:center">This repository is empty</h2> | |
| 18 | − <p class="muted">Push something to bring it to life:</p> | |
| 19 | − <pre class="clone-snippet">git remote add origin {{ overview.cloneUrl }} | |
| 20 | −git push -u origin {{ overview.defaultBranch }}</pre> | |
| 21 | − <p class="muted">or, from anywhere: <code>spbgit clone {{ overview.name }}</code></p> | |
| 22 | −</div> | |
| 17 | +{{ ui.emptyState('This repository is empty', 'Push something to bring it to life:', 'git remote add origin ' + overview.cloneUrl + '\ngit push -u origin ' + overview.defaultBranch) }} | |
| 23 | 18 | {% else %} |
| 24 | 19 | |
| 25 | 20 | <div class="repo-toolbar"> |
| 26 | − <div class="ref-select-wrap"> | |
| 27 | − <label class="sr-only" for="ref-select">Branch</label> | |
| 28 | − <select id="ref-select" class="ref-select" data-repo="{{ overview.name }}" data-kind="tree"> | |
| 29 | − {% for branch in branches %} | |
| 30 | − <option value="{{ branch.name }}" {{ 'selected' if branch.name == currentRef }}>{{ branch.name }}{{ ' (default)' if branch.isDefault }}</option> | |
| 31 | − {% endfor %} | |
| 32 | − {% for tag in tags %} | |
| 33 | − <option value="{{ tag.name }}" {{ 'selected' if tag.name == currentRef }}>tag: {{ tag.name }}</option> | |
| 34 | − {% endfor %} | |
| 35 | − </select> | |
| 21 | + <div class="repo-toolbar-left"> | |
| 22 | + {{ ui.refSelect(overview.name, branches, tags, currentRef, 'tree', '') }} | |
| 23 | + <a class="btn btn-sm" href="/{{ overview.name }}/commits/{{ currentRef }}">{{ ui.icon('history', 14) }} History</a> | |
| 36 | 24 | </div> |
| 37 | − <div class="clone-box"> | |
| 38 | − <span class="clone-prompt" aria-hidden="true">$</span> | |
| 39 | − <input class="clone-url" type="text" readonly value="git clone {{ overview.cloneUrl }}" aria-label="Clone command"> | |
| 40 | − <button class="btn btn-sm copy-btn" type="button" data-copy="git clone {{ overview.cloneUrl }}" title="Copy clone command">Copy</button> | |
| 41 | − <button class="btn btn-sm copy-btn" type="button" data-copy="spbgit clone {{ overview.name }}" title="Copy spbgit command">spbgit</button> | |
| 42 | − <a class="btn btn-sm" href="/archive/{{ overview.name }}/{{ currentRef }}.zip" rel="nofollow">ZIP</a> | |
| 43 | − <a class="btn btn-sm" href="/archive/{{ overview.name }}/{{ currentRef }}.tar.gz" rel="nofollow">tar.gz</a> | |
| 25 | + <div class="repo-toolbar-right"> | |
| 26 | + <button type="button" class="btn btn-sm only-mobile" id="tree-open">{{ ui.icon('folder', 14) }} Files</button> | |
| 27 | + <button type="button" class="btn btn-sm btn-primary" id="clone-open">{{ ui.icon('terminal', 14) }} Clone</button> | |
| 44 | 28 | </div> |
| 45 | 29 | </div> |
| 46 | 30 | |
| 47 | 31 | {% include "partials/file-table.njk" %} |
| 48 | 32 | |
| 49 | 33 | {% if readme %} |
| 50 | −<section class="readme-section" aria-label="README"> | |
| 34 | +<section class="card readme-section" aria-label="README"> | |
| 51 | 35 | <div class="readme-header"> |
| 52 | − <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.743 3.743 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75Zm7.251 10.324.004-5.073-.002-2.253A2.25 2.25 0 0 0 5.003 2.5H1.5v9h3.757a3.75 3.75 0 0 1 1.994.574M8.755 4.75l-.004 7.322a3.752 3.752 0 0 1 1.992-.572H14.5v-9h-3.495a2.25 2.25 0 0 0-2.25 2.25"/></svg> | |
| 36 | + {{ ui.icon('book', 15) }} | |
| 53 | 37 | <code>{{ readme.path }}</code> |
| 54 | 38 | <a class="count" href="/{{ overview.name }}/blob/{{ currentRef }}/{{ readme.path }}?plain=1" rel="nofollow" title="View source">source</a> |
| 55 | 39 | </div> |
@@ -62,5 +46,8 @@ git push -u origin {{ overview.defaultBranch }}</pre> | ||
| 62 | 46 | </div> |
| 63 | 47 | </section> |
| 64 | 48 | {% endif %} |
| 49 | + | |
| 50 | +{% include "partials/clone-panel.njk" %} | |
| 51 | +{% include "partials/tree-sheet.njk" %} | |
| 65 | 52 | {% endif %} |
| 66 | 53 | {% endblock %} |
modified
src/web/views/search.njk
+42 −18
@@ -5,18 +5,20 @@ | ||
| 5 | 5 | Author : Simon-Pierre Boucher |
| 6 | 6 | Contact : contact@spboucher.ai |
| 7 | 7 | File : src/web/views/search.njk |
| 8 | − Purpose : Global search v4 — hero query bar, grouped repo + README results | |
| 8 | + Purpose : Global search v2.0 — repos, files, commits, branches and README content, grouped | |
| 9 | 9 | License : MIT © Simon-Pierre Boucher |
| 10 | 10 | ───────────────────────────────────────────── |
| 11 | 11 | #}{% extends "layout.njk" %} |
| 12 | +{% import "components/ui.njk" as ui %} | |
| 12 | 13 | {% block content %} |
| 13 | 14 | <section class="search-hero"> |
| 14 | − <p class="eyebrow"><span class="eyebrow-dot" aria-hidden="true"></span>search · names, descriptions, topics, READMEs</p> | |
| 15 | − <h1 class="section-title" style="font-size:34px">{% if q %}Results for “{{ q }}”{% else %}Search the forge{% endif %}</h1> | |
| 16 | − <form action="/search" method="get" class="search-page-form" role="search"> | |
| 17 | − <input type="search" name="q" value="{{ q }}" placeholder="Search repositories, topics, READMEs…" aria-label="Search query" autofocus> | |
| 18 | − <button class="btn btn-primary" type="submit">Search</button> | |
| 15 | + <p class="eyebrow"><span class="eyebrow-dot" aria-hidden="true"></span>search · repositories, files, commits, branches, READMEs</p> | |
| 16 | + <h1 class="section-title" style="font-size:28px;margin-top:10px">{% if q %}Results for “{{ q }}”{% else %}Search the forge{% endif %}</h1> | |
| 17 | + <form action="/search" method="get" class="search-form" role="search"> | |
| 18 | + <input class="input" type="search" name="q" value="{{ q }}" placeholder="Search repositories, files, commits…" aria-label="Search query" autofocus enterkeyhint="search"> | |
| 19 | + <button class="btn btn-primary" type="submit">{{ ui.icon('search', 14) }} Search</button> | |
| 19 | 20 | </form> |
| 21 | + {% if indexStatus %}<p class="metric-note">Index built {{ indexStatus.builtAt | reltime }} · {{ indexStatus.repos | num }} repositories · {{ indexStatus.files | num }} paths</p>{% endif %} | |
| 20 | 22 | </section> |
| 21 | 23 | |
| 22 | 24 | {% if q %} |
@@ -25,19 +27,43 @@ | ||
| 25 | 27 | {% if results.repos.length %} |
| 26 | 28 | <ul class="search-results"> |
| 27 | 29 | {% for hit in results.repos %} |
| 28 | − <li class="spot"> | |
| 30 | + <li class="card"> | |
| 29 | 31 | <a class="search-hit-name" href="/{{ hit.name }}">{{ hit.name }}</a> |
| 30 | − {% if hit.topLanguage %} <span class="lang-dot-label muted" style="font-size:13px;margin-left:10px"><span class="lang-dot" style="background:{{ hit.topLanguage | langcolor }}"></span>{{ hit.topLanguage }}</span>{% endif %} | |
| 32 | + {% if hit.topLanguage %} <span class="lang-dot-label muted text-sm" style="margin-left:10px"><span class="lang-dot" style="background:{{ hit.topLanguage | langcolor }}"></span>{{ hit.topLanguage }}</span>{% endif %} | |
| 31 | 33 | {% if hit.description %}<p class="search-snippet">{{ hit.description }}</p>{% endif %} |
| 32 | − {% if hit.topics.length %} | |
| 33 | − <div class="chip-row">{% for topic in hit.topics %}<a class="chip chip-topic" href="/search?q={{ topic }}">{{ topic }}</a>{% endfor %}</div> | |
| 34 | − {% endif %} | |
| 34 | + {% if hit.topics.length %}<div class="chip-row">{% for topic in hit.topics %}{{ ui.chip(topic, 'topic', '/search?q=' + topic) }}{% endfor %}</div>{% endif %} | |
| 35 | 35 | </li> |
| 36 | 36 | {% endfor %} |
| 37 | 37 | </ul> |
| 38 | − {% else %} | |
| 39 | − <p class="muted">No repository matches “{{ q }}”.</p> | |
| 40 | − {% endif %} | |
| 38 | + {% else %}<p class="muted">No repository matches “{{ q }}”.</p>{% endif %} | |
| 39 | +</section> | |
| 40 | + | |
| 41 | +<section class="section" aria-labelledby="file-results-h"> | |
| 42 | + <header class="section-head"><h2 id="file-results-h" class="section-title">Files <span class="count">{{ results.files.length }}</span></h2></header> | |
| 43 | + {% if results.files.length %} | |
| 44 | + <ul class="search-results"> | |
| 45 | + {% for hit in results.files %} | |
| 46 | + <li class="card"> | |
| 47 | + <a class="search-hit-path" href="/{{ hit.repo }}/blob/{{ hit.ref }}/{{ hit.path }}">{{ hit.path }}</a> | |
| 48 | + <p class="search-snippet">{{ hit.repo }} · {{ hit.ref }}</p> | |
| 49 | + </li> | |
| 50 | + {% endfor %} | |
| 51 | + </ul> | |
| 52 | + {% else %}<p class="muted">No file path matches “{{ q }}”.</p>{% endif %} | |
| 53 | +</section> | |
| 54 | + | |
| 55 | +<section class="section" aria-labelledby="commit-results-h"> | |
| 56 | + <header class="section-head"><h2 id="commit-results-h" class="section-title">Commits <span class="count">{{ results.commits.length }}</span></h2></header> | |
| 57 | + {% if results.commits.length %} | |
| 58 | + <ul class="search-results"> | |
| 59 | + {% for hit in results.commits %} | |
| 60 | + <li class="card"> | |
| 61 | + <a class="search-hit-name" href="/{{ hit.repo }}/commit/{{ hit.sha }}">{{ hit.subject }}</a> | |
| 62 | + <p class="search-snippet">{{ hit.repo }} · <code>{{ hit.shortSha }}</code> · {{ hit.authorName }} · {{ hit.date | reltime }}</p> | |
| 63 | + </li> | |
| 64 | + {% endfor %} | |
| 65 | + </ul> | |
| 66 | + {% else %}<p class="muted">No commit message matches “{{ q }}”.</p>{% endif %} | |
| 41 | 67 | </section> |
| 42 | 68 | |
| 43 | 69 | <section class="section" aria-labelledby="readme-results-h"> |
@@ -45,15 +71,13 @@ | ||
| 45 | 71 | {% if results.readmes.length %} |
| 46 | 72 | <ul class="search-results"> |
| 47 | 73 | {% for hit in results.readmes %} |
| 48 | − <li class="spot"> | |
| 74 | + <li class="card"> | |
| 49 | 75 | <a class="search-hit-name" href="/{{ hit.name }}">{{ hit.name }}</a> |
| 50 | 76 | <p class="search-snippet">{{ hit.snippet }}</p> |
| 51 | 77 | </li> |
| 52 | 78 | {% endfor %} |
| 53 | 79 | </ul> |
| 54 | − {% else %} | |
| 55 | − <p class="muted">No README mentions “{{ q }}”.</p> | |
| 56 | − {% endif %} | |
| 80 | + {% else %}<p class="muted">No README mentions “{{ q }}”.</p>{% endif %} | |
| 57 | 81 | </section> |
| 58 | 82 | {% endif %} |
| 59 | 83 | {% endblock %} |
modified
src/web/views/tags.njk
+13 −18
@@ -5,50 +5,45 @@ | ||
| 5 | 5 | Author : Simon-Pierre Boucher |
| 6 | 6 | Contact : contact@spboucher.ai |
| 7 | 7 | File : src/web/views/tags.njk |
| 8 | − Purpose : Releases v4 — tags with "latest" marker, binary assets, source archives | |
| 8 | + Purpose : Releases v2.0 — tags with "latest" marker, binary assets with sha256, source archives | |
| 9 | 9 | License : MIT © Simon-Pierre Boucher |
| 10 | 10 | ───────────────────────────────────────────── |
| 11 | 11 | #}{% extends "layout.njk" %} |
| 12 | +{% import "components/ui.njk" as ui %} | |
| 12 | 13 | {% block content %} |
| 13 | 14 | {% include "partials/repo-header.njk" %} |
| 14 | 15 | |
| 15 | 16 | {% if tagsWithAssets.length %} |
| 16 | 17 | <div class="release-list"> |
| 17 | 18 | {% for tag in tagsWithAssets %} |
| 18 | − <article class="release-card spot"> | |
| 19 | + <article class="card release-card"> | |
| 19 | 20 | <header class="release-header"> |
| 20 | 21 | <h2 class="release-tag"><a href="/{{ overview.name }}/tree/{{ tag.name }}">{{ tag.name }}</a></h2> |
| 21 | − {% if loop.first %}<span class="release-latest">latest</span>{% endif %} | |
| 22 | + {% if loop.first %}{{ ui.chip('latest', 'ember') }}{% endif %} | |
| 22 | 23 | {% if tag.subject %}<span class="release-subject">{{ tag.subject }}</span>{% endif %} |
| 23 | − <span class="muted">· {{ tag.date | reltime }}</span> | |
| 24 | + <time class="muted" datetime="{{ tag.date }}">· {{ tag.date | reltime }}</time> | |
| 24 | 25 | </header> |
| 25 | 26 | {% if tag.assets.length %} |
| 26 | 27 | <ul class="release-assets"> |
| 27 | 28 | {% for asset in tag.assets %} |
| 28 | 29 | <li> |
| 29 | − <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8.75 1.75V6h2.44a.75.75 0 0 1 .53 1.28l-3.19 3.19a.75.75 0 0 1-1.06 0L4.28 7.28A.75.75 0 0 1 4.81 6h2.44V1.75a.75.75 0 0 1 1.5 0ZM2.5 12.25a.75.75 0 0 0 0 1.5h11a.75.75 0 0 0 0-1.5Z"/></svg> | |
| 30 | + {{ ui.icon('download', 16) }} | |
| 30 | 31 | <a href="/releases/{{ overview.name }}/{{ tag.key }}/{{ asset.name | urlencode }}" rel="nofollow">{{ asset.name }}</a> |
| 31 | − <span class="muted">{{ asset.size | bytes }}</span> | |
| 32 | − {% if asset.sha256 %} | |
| 33 | − <button class="sha-chip copy-btn" type="button" data-copy="{{ asset.sha256 }}" title="Copy sha256" style="margin-left:auto">sha256</button> | |
| 34 | − {% endif %} | |
| 32 | + <span class="muted num">{{ asset.size | bytes }}</span> | |
| 33 | + {% if asset.sha256 %}<button class="sha-chip copy-btn" type="button" data-copy="{{ asset.sha256 }}" title="Copy sha256" style="margin-left:auto">sha256</button>{% endif %} | |
| 35 | 34 | </li> |
| 36 | 35 | {% endfor %} |
| 37 | 36 | </ul> |
| 38 | 37 | {% endif %} |
| 39 | − <div class="archive-links"> | |
| 40 | − <a class="btn btn-sm" href="/archive/{{ overview.name }}/{{ tag.name }}.zip" rel="nofollow">Source (zip)</a> | |
| 41 | − <a class="btn btn-sm" href="/archive/{{ overview.name }}/{{ tag.name }}.tar.gz" rel="nofollow">Source (tar.gz)</a> | |
| 42 | − <a class="btn btn-sm btn-ghost" href="/{{ overview.name }}/commits/{{ tag.name }}" rel="nofollow">History →</a> | |
| 38 | + <div class="btn-row"> | |
| 39 | + <a class="btn btn-sm" href="/archive/{{ overview.name }}/{{ tag.name }}.zip" rel="nofollow">{{ ui.icon('download', 14) }} Source (zip)</a> | |
| 40 | + <a class="btn btn-sm" href="/archive/{{ overview.name }}/{{ tag.name }}.tar.gz" rel="nofollow">{{ ui.icon('download', 14) }} Source (tar.gz)</a> | |
| 41 | + <a class="btn btn-sm btn-ghost" href="/{{ overview.name }}/commits/{{ tag.name }}" rel="nofollow">History {{ ui.icon('arrow-right', 14) }}</a> | |
| 43 | 42 | </div> |
| 44 | 43 | </article> |
| 45 | 44 | {% endfor %} |
| 46 | 45 | </div> |
| 47 | 46 | {% else %} |
| 48 | −<div class="empty-state spot" style="border-radius:var(--radius-lg)"> | |
| 49 | − <p>No releases yet. Tag a version, push it, then attach artifacts:</p> | |
| 50 | − <pre class="clone-snippet">git tag v1.0.0 && git push --tags | |
| 51 | −spbgit release upload {{ overview.name }} v1.0.0 MyApp.dmg</pre> | |
| 52 | −</div> | |
| 47 | +{{ ui.emptyState('No releases yet', 'Tag a version, push it, then attach artifacts:', 'git tag v1.0.0 && git push --tags\nspbgit release upload ' + overview.name + ' v1.0.0 MyApp.dmg', 'tag') }} | |
| 53 | 48 | {% endif %} |
| 54 | 49 | {% endblock %} |
modified
src/web/views/tree.njk
+12 −17
@@ -5,32 +5,27 @@ | ||
| 5 | 5 | Author : Simon-Pierre Boucher |
| 6 | 6 | Contact : contact@spboucher.ai |
| 7 | 7 | File : src/web/views/tree.njk |
| 8 | − Purpose : Directory browsing — breadcrumb + file table | |
| 8 | + Purpose : Directory browsing v2.0 — ref picker, breadcrumbs, file table, lazy file tree | |
| 9 | 9 | License : MIT © Simon-Pierre Boucher |
| 10 | 10 | ───────────────────────────────────────────── |
| 11 | 11 | #}{% extends "layout.njk" %} |
| 12 | +{% import "components/ui.njk" as ui %} | |
| 12 | 13 | {% block content %} |
| 13 | 14 | {% include "partials/repo-header.njk" %} |
| 14 | 15 | |
| 15 | 16 | <div class="repo-toolbar"> |
| 16 | − <div class="ref-select-wrap"> | |
| 17 | − <label class="sr-only" for="ref-select">Branch</label> | |
| 18 | − <select id="ref-select" class="ref-select" data-repo="{{ overview.name }}" data-kind="tree" data-path="{{ path }}"> | |
| 19 | − {% for branch in branches %} | |
| 20 | − <option value="{{ branch.name }}" {{ 'selected' if branch.name == currentRef }}>{{ branch.name }}{{ ' (default)' if branch.isDefault }}</option> | |
| 21 | − {% endfor %} | |
| 22 | − {% for tag in tags %} | |
| 23 | − <option value="{{ tag.name }}" {{ 'selected' if tag.name == currentRef }}>tag: {{ tag.name }}</option> | |
| 24 | − {% endfor %} | |
| 25 | − </select> | |
| 17 | + <div class="repo-toolbar-left"> | |
| 18 | + {{ ui.refSelect(overview.name, branches, tags, currentRef, 'tree', path) }} | |
| 19 | + {{ ui.breadcrumbs(overview.name, '/' + overview.name, crumbs) }} | |
| 20 | + </div> | |
| 21 | + <div class="repo-toolbar-right"> | |
| 22 | + <button type="button" class="btn btn-sm only-mobile" id="tree-open">{{ ui.icon('folder', 14) }} Files</button> | |
| 23 | + <a class="btn btn-sm" href="/{{ overview.name }}/commits/{{ currentRef }}?path={{ path | urlencode }}">{{ ui.icon('history', 14) }} History</a> | |
| 24 | + <button type="button" class="btn btn-sm btn-primary" id="clone-open">{{ ui.icon('terminal', 14) }} Clone</button> | |
| 26 | 25 | </div> |
| 27 | − <nav class="path-breadcrumb" aria-label="Path"> | |
| 28 | − <a href="/{{ overview.name }}">{{ overview.name }}</a> | |
| 29 | − {% for crumb in crumbs %} | |
| 30 | − <span class="crumb-sep">/</span>{% if loop.last %}<strong>{{ crumb.name }}</strong>{% else %}<a href="{{ crumb.href }}">{{ crumb.name }}</a>{% endif %} | |
| 31 | − {% endfor %} | |
| 32 | − </nav> | |
| 33 | 26 | </div> |
| 34 | 27 | |
| 35 | 28 | {% include "partials/file-table.njk" %} |
| 29 | +{% include "partials/clone-panel.njk" %} | |
| 30 | +{% include "partials/tree-sheet.njk" %} | |
| 36 | 31 | {% endblock %} |
modified
test/e2e/roundtrip.test.mjs
+104 −1
@@ -11,7 +11,7 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 14 | −import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; | |
| 14 | +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs'; | |
| 15 | 15 | import { tmpdir } from 'node:os'; |
| 16 | 16 | import { join } from 'node:path'; |
| 17 | 17 | import { execFile } from 'node:child_process'; |
@@ -259,6 +259,109 @@ describe('full round trip', () => { | ||
| 259 | 259 | expect((await fetch(`${base}/releases/e2e-demo/v1.0.0/MyApp.dmg`)).status).toBe(404); |
| 260 | 260 | }, 60000); |
| 261 | 261 | |
| 262 | + | |
| 263 | + it('search API finds files, commits and branches (scoped, limited)', async () => { | |
| 264 | + const files = await (await fetch(`${base}/api/v1/search?q=main.go&scope=files`)).json(); | |
| 265 | + expect(files.files.map((f) => f.path)).toContain('main.go'); | |
| 266 | + expect(files.commits).toEqual([]); | |
| 267 | + const commits = await (await fetch(`${base}/api/v1/search?q=e2e%20commit&scope=commits&repo=e2e-demo`)).json(); | |
| 268 | + expect(commits.commits[0].subject).toBe('feat: e2e commit'); | |
| 269 | + const refs = await (await fetch(`${base}/api/v1/search?q=main&scope=branches`)).json(); | |
| 270 | + expect(refs.branches.some((b) => b.repo === 'e2e-demo' && b.name === 'main' && b.kind === 'branch')).toBe(true); | |
| 271 | + const all = await (await fetch(`${base}/api/v1/search?q=e2e&limit=3`)).json(); | |
| 272 | + expect(all.repos[0].name).toBe('e2e-demo'); | |
| 273 | + expect(all.index.repos).toBeGreaterThanOrEqual(1); | |
| 274 | + // No README text or path lists leak through the repo projection. | |
| 275 | + expect(all.repos[0].paths).toBeUndefined(); | |
| 276 | + expect(all.repos[0].readmeText).toBeUndefined(); | |
| 277 | + const page = await (await fetch(`${base}/search?q=main.go`)).text(); | |
| 278 | + expect(page).toContain('main.go'); | |
| 279 | + }); | |
| 280 | + | |
| 281 | + it('tree API lists a level and rejects traversal', async () => { | |
| 282 | + const root = await (await fetch(`${base}/api/v1/repos/e2e-demo/tree`)).json(); | |
| 283 | + expect(root.entries.map((e) => e.name)).toEqual(expect.arrayContaining(['README.md', 'main.go'])); | |
| 284 | + expect((await fetch(`${base}/api/v1/repos/e2e-demo/tree?path=../..`)).status).toBe(400); | |
| 285 | + expect((await fetch(`${base}/api/v1/repos/e2e-demo/tree?path=nope`)).status).toBe(404); | |
| 286 | + expect((await fetch(`${base}/api/v1/repos/e2e-demo/tree?ref=nope`)).status).toBe(404); | |
| 287 | + }); | |
| 288 | + | |
| 289 | + it('large binary files stream from /raw and never buffer in the blob view', async () => { | |
| 290 | + const work = join(root, 'work'); | |
| 291 | + const big = Buffer.alloc(3 * 1024 * 1024); | |
| 292 | + for (let i = 0; i < big.length; i += 4096) big[i] = i % 251; // sparse non-zero, starts with NUL → binary | |
| 293 | + writeFileSync(join(work, 'data.bin'), big); | |
| 294 | + await execFileAsync('git', ['add', '-A'], { cwd: work, env: GIT_ENV }); | |
| 295 | + await execFileAsync('git', ['commit', '-qm', 'chore: add binary blob'], { cwd: work, env: GIT_ENV }); | |
| 296 | + const url = new URL(`${base}/e2e-demo.git`); | |
| 297 | + url.username = 'spb'; | |
| 298 | + url.password = token; | |
| 299 | + await execFileAsync('git', ['push', '-q', url.href, 'main'], { cwd: work, env: GIT_ENV }); | |
| 300 | + | |
| 301 | + const raw = await fetch(`${base}/raw/e2e-demo/main/data.bin`); | |
| 302 | + expect(raw.status).toBe(200); | |
| 303 | + expect(raw.headers.get('content-type')).toBe('application/octet-stream'); | |
| 304 | + expect(Number(raw.headers.get('content-length'))).toBe(big.length); | |
| 305 | + const body = Buffer.from(await raw.arrayBuffer()); | |
| 306 | + expect(body.equals(big)).toBe(true); | |
| 307 | + const head = await fetch(`${base}/raw/e2e-demo/main/data.bin`, { method: 'HEAD' }); | |
| 308 | + expect(head.status).toBe(200); | |
| 309 | + | |
| 310 | + const html = await (await fetch(`${base}/e2e-demo/blob/main/data.bin`)).text(); | |
| 311 | + expect(html).toContain('Binary file'); | |
| 312 | + expect(html).toContain('Download'); | |
| 313 | + }, 60000); | |
| 314 | + | |
| 315 | + it('commit totals are coherent and a push missed by the hook is still detected', async () => { | |
| 316 | + const before = await (await fetch(`${base}/api/v1/repos/e2e-demo`)).json(); | |
| 317 | + expect(before.commitCount).toBe(2); | |
| 318 | + const statsBefore = await (await fetch(`${base}/api/v1/stats`)).json(); | |
| 319 | + expect(statsBefore.commits).toBeGreaterThanOrEqual(statsBefore.commitsLast52Weeks); | |
| 320 | + | |
| 321 | + // Disable the post-receive hook, push a third commit: no cache bust happens. | |
| 322 | + const hook = join(root, 'git', 'e2e-demo.git', 'hooks', 'post-receive'); | |
| 323 | + const original = readFileSync(hook, 'utf8'); | |
| 324 | + writeFileSync(hook, '#!/bin/sh\nexit 0\n'); | |
| 325 | + const work = join(root, 'work'); | |
| 326 | + writeFileSync(join(work, 'third.txt'), 'three\n'); | |
| 327 | + await execFileAsync('git', ['add', '-A'], { cwd: work, env: GIT_ENV }); | |
| 328 | + await execFileAsync('git', ['commit', '-qm', 'feat: third commit (hook disabled)'], { cwd: work, env: GIT_ENV }); | |
| 329 | + const url = new URL(`${base}/e2e-demo.git`); | |
| 330 | + url.username = 'spb'; | |
| 331 | + url.password = token; | |
| 332 | + await execFileAsync('git', ['push', '-q', url.href, 'main'], { cwd: work, env: GIT_ENV }); | |
| 333 | + writeFileSync(hook, original); | |
| 334 | + | |
| 335 | + const after = await (await fetch(`${base}/api/v1/repos/e2e-demo`)).json(); | |
| 336 | + expect(after.commitCount).toBe(3); | |
| 337 | + const statsAfter = await (await fetch(`${base}/api/v1/stats`)).json(); | |
| 338 | + expect(statsAfter.commits).toBe(statsBefore.commits + 1); | |
| 339 | + expect(statsAfter.commitsLast52Weeks).toBe(statsBefore.commitsLast52Weeks + 1); | |
| 340 | + expect(statsAfter.commits).toBeGreaterThanOrEqual(statsAfter.commitsLast52Weeks); | |
| 341 | + const home = await (await fetch(`${base}/`)).text(); | |
| 342 | + expect(home).toContain('commits, all time'); | |
| 343 | + expect((await fetch(`${base}/about/metrics`)).status).toBe(200); | |
| 344 | + }, 60000); | |
| 345 | + | |
| 346 | + it('split diff view, history filter and blame render', async () => { | |
| 347 | + const { commits } = await (await fetch(`${base}/api/v1/repos/e2e-demo/commits`)).json(); | |
| 348 | + const split = await (await fetch(`${base}/e2e-demo/commit/${commits[0].sha}?view=split`)).text(); | |
| 349 | + expect(split).toContain('diff-split'); | |
| 350 | + expect(split).toContain('diff-cell-empty'); | |
| 351 | + const history = await (await fetch(`${base}/e2e-demo/commits/main?path=main.go`)).text(); | |
| 352 | + expect(history).toContain('History of'); | |
| 353 | + const blame = await (await fetch(`${base}/e2e-demo/blame/main/main.go`)).text(); | |
| 354 | + expect(blame).toContain('blame-table'); | |
| 355 | + expect((await fetch(`${base}/e2e-demo/blame/main/data.bin`)).status).toBe(404); | |
| 356 | + }); | |
| 357 | + | |
| 358 | + it('healthz exposes feature flags and git concurrency stats', async () => { | |
| 359 | + const h = await (await fetch(`${base}/healthz`)).json(); | |
| 360 | + expect(h.features.ai).toBe(false); | |
| 361 | + expect(h.git.limit).toBeGreaterThan(0); | |
| 362 | + expect(typeof h.memory.rssMb).toBe('number'); | |
| 363 | + }); | |
| 364 | + | |
| 262 | 365 | it('PATCH updates metadata and DELETE soft-deletes', async () => { |
| 263 | 366 | const patch = await fetch(`${base}/api/v1/repos/e2e-demo`, { |
| 264 | 367 | method: 'PATCH', |
added
test/unit/routes-helpers.test.mjs
+87 −0
@@ -0,0 +1,87 @@ | ||
| 1 | +/** | |
| 2 | + * ───────────────────────────────────────────── | |
| 3 | + * SPB Git — Personal Git Platform | |
| 4 | + * ───────────────────────────────────────────── | |
| 5 | + * Author : Simon-Pierre Boucher | |
| 6 | + * Contact : contact@spboucher.ai | |
| 7 | + * File : test/unit/routes-helpers.test.mjs | |
| 8 | + * Purpose : Unit tests — split-diff row pairing, day grouping, research detection, search terms | |
| 9 | + * License : MIT © Simon-Pierre Boucher | |
| 10 | + * ───────────────────────────────────────────── | |
| 11 | + */ | |
| 12 | + | |
| 13 | +import { describe, it, expect } from 'vitest'; | |
| 14 | +import { splitRows, groupByDay } from '../../src/web/routes.mjs'; | |
| 15 | +import { detectResearchSignals, sparkline } from '../../src/lib/overview.mjs'; | |
| 16 | +import { queryTerms } from '../../src/lib/search.mjs'; | |
| 17 | +import { sameStamps } from '../../src/stats/activity.mjs'; | |
| 18 | + | |
| 19 | +describe('splitRows', () => { | |
| 20 | + it('pairs deletions with additions and keeps context on both sides', () => { | |
| 21 | + const lines = [ | |
| 22 | + { type: 'ctx', text: 'a' }, | |
| 23 | + { type: 'del', text: 'b' }, | |
| 24 | + { type: 'del', text: 'c' }, | |
| 25 | + { type: 'add', text: 'B' }, | |
| 26 | + { type: 'ctx', text: 'd' }, | |
| 27 | + { type: 'add', text: 'E' }, | |
| 28 | + { type: 'meta', text: '\\ No newline' }, | |
| 29 | + ]; | |
| 30 | + const rows = splitRows(lines); | |
| 31 | + expect(rows).toHaveLength(6); | |
| 32 | + expect(rows[0]).toEqual({ left: lines[0], right: lines[0] }); | |
| 33 | + expect(rows[1]).toEqual({ left: lines[1], right: lines[3] }); | |
| 34 | + expect(rows[2]).toEqual({ left: lines[2], right: null }); | |
| 35 | + expect(rows[3]).toEqual({ left: lines[4], right: lines[4] }); | |
| 36 | + expect(rows[4]).toEqual({ left: null, right: lines[5] }); | |
| 37 | + expect(rows[5]).toEqual({ left: lines[6], right: null }); | |
| 38 | + }); | |
| 39 | +}); | |
| 40 | + | |
| 41 | +describe('groupByDay', () => { | |
| 42 | + it('groups on the author calendar day, preserving order', () => { | |
| 43 | + const days = groupByDay([ | |
| 44 | + { date: '2026-11-01T23:10:00-04:00', sha: '1' }, | |
| 45 | + { date: '2026-11-01T08:00:00-04:00', sha: '2' }, | |
| 46 | + { date: '2026-10-31T23:59:00-04:00', sha: '3' }, | |
| 47 | + ]); | |
| 48 | + expect(days.map((d) => d.iso)).toEqual(['2026-11-01', '2026-10-31']); | |
| 49 | + expect(days[0].commits).toHaveLength(2); | |
| 50 | + expect(days[0].label).toMatch(/Nov 1, 2026/); | |
| 51 | + }); | |
| 52 | +}); | |
| 53 | + | |
| 54 | +describe('detectResearchSignals', () => { | |
| 55 | + it('flags a LaTeX paper with bibliography and figures', () => { | |
| 56 | + const r = detectResearchSignals(['paper/main.tex', 'paper/refs.bib', 'paper/main.pdf', 'figures/fig1.png', 'src/model.py']); | |
| 57 | + expect(r.likely).toBe(true); | |
| 58 | + expect(r.figures).toBe(1); | |
| 59 | + }); | |
| 60 | + it('does not flag an ordinary web app', () => { | |
| 61 | + expect(detectResearchSignals(['src/index.ts', 'README.md', 'docs/guide.pdf']).likely).toBe(false); | |
| 62 | + }); | |
| 63 | +}); | |
| 64 | + | |
| 65 | +describe('sparkline', () => { | |
| 66 | + it('buckets the last 26 weeks and ignores older commits', () => { | |
| 67 | + const now = 1_700_000_000; | |
| 68 | + const s = sparkline([now - 100, now - 8 * 86400, now - 200 * 86400], now); | |
| 69 | + expect(s.total).toBe(2); | |
| 70 | + expect(s.counts[25]).toBe(1); | |
| 71 | + expect(s.counts[24]).toBe(1); | |
| 72 | + expect(s.path.startsWith('M')).toBe(true); | |
| 73 | + }); | |
| 74 | +}); | |
| 75 | + | |
| 76 | +describe('queryTerms / sameStamps', () => { | |
| 77 | + it('normalizes queries', () => { | |
| 78 | + expect(queryTerms(' Git Forge a x ')).toEqual(['git', 'forge']); | |
| 79 | + expect(queryTerms('')).toEqual([]); | |
| 80 | + }); | |
| 81 | + it('compares stamp maps', () => { | |
| 82 | + expect(sameStamps({ a: '1' }, { a: '1' })).toBe(true); | |
| 83 | + expect(sameStamps({ a: '1' }, { a: '2' })).toBe(false); | |
| 84 | + expect(sameStamps({ a: '1' }, { a: '1', b: '2' })).toBe(false); | |
| 85 | + expect(sameStamps(null, {})).toBe(false); | |
| 86 | + }); | |
| 87 | +}); | |
added
test/unit/semaphore.test.mjs
+45 −0
@@ -0,0 +1,45 @@ | ||
| 1 | +/** | |
| 2 | + * ───────────────────────────────────────────── | |
| 3 | + * SPB Git — Personal Git Platform | |
| 4 | + * ───────────────────────────────────────────── | |
| 5 | + * Author : Simon-Pierre Boucher | |
| 6 | + * Contact : contact@spboucher.ai | |
| 7 | + * File : test/unit/semaphore.test.mjs | |
| 8 | + * Purpose : Unit tests — bounded concurrency semaphore | |
| 9 | + * License : MIT © Simon-Pierre Boucher | |
| 10 | + * ───────────────────────────────────────────── | |
| 11 | + */ | |
| 12 | + | |
| 13 | +import { describe, it, expect } from 'vitest'; | |
| 14 | +import { Semaphore } from '../../src/lib/semaphore.mjs'; | |
| 15 | + | |
| 16 | +describe('Semaphore', () => { | |
| 17 | + it('never exceeds its limit and releases in FIFO order', async () => { | |
| 18 | + const sem = new Semaphore(3); | |
| 19 | + let active = 0; | |
| 20 | + let peak = 0; | |
| 21 | + const order = []; | |
| 22 | + const jobs = Array.from({ length: 10 }, (_, i) => sem.run(async () => { | |
| 23 | + active += 1; | |
| 24 | + peak = Math.max(peak, active); | |
| 25 | + order.push(i); | |
| 26 | + await new Promise((r) => setTimeout(r, 5)); | |
| 27 | + active -= 1; | |
| 28 | + return i; | |
| 29 | + })); | |
| 30 | + const results = await Promise.all(jobs); | |
| 31 | + expect(peak).toBe(3); | |
| 32 | + expect(results).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); | |
| 33 | + expect(order).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); | |
| 34 | + expect(sem.stats()).toEqual({ active: 0, waiting: 0, limit: 3 }); | |
| 35 | + }); | |
| 36 | + | |
| 37 | + it('releases the slot when the job throws and ignores double release', async () => { | |
| 38 | + const sem = new Semaphore(1); | |
| 39 | + await expect(sem.run(async () => { throw new Error('boom'); })).rejects.toThrow('boom'); | |
| 40 | + const release = await sem.acquire(); | |
| 41 | + release(); | |
| 42 | + release(); | |
| 43 | + expect(sem.stats().active).toBe(0); | |
| 44 | + }); | |
| 45 | +}); | |
| 46 | ||