Commit Graph
96 Commits
Author SHA1 Message Date
jozamudi 3e5c7062ab login: per-source-IP failure budget (auth.py) — username rotation no longer dodges the lockout; sliding window, XFF trusted only behind the proxy (ANIWEB_TRUST_XFF); lockout logic now unit-testable (tests/test_auth.py) 2026-09-16 02:31:23 +00:00
jozamudi c5c4dff851 Pin dependencies (full env freeze): curl_cffi/curl-impersonate churn breaks impersonation silently 2026-09-16 02:06:56 +00:00
jozamudi 7f531fc309 CSRF header check now covers /login (exemption removed); login.html sends X-Aniweb 2026-09-16 02:02:09 +00:00
jozamudi b70ef403a9 Lockout -> server/auth.py + per-IP failure budget (XFF trusted only behind the proxy); unit tests 2026-09-16 01:31:40 +00:00
jozamudi 826ee9165d run.sh: PID-file restart; pkill fallback scoped to :8321 so test servers on :8399 are never hit 2026-09-16 00:56:48 +00:00
jozamudi 673aca827f login: sync def so the 0.5s brute-force sleep runs on the threadpool, not the event loop (+latency regression test) 2026-09-16 00:49:22 +00:00
jozamudi 9a4b8fb299 Docs: audit fix batch plan (sync login, scoped restart, per-IP budget, login CSRF, pinned deps) 2026-09-16 00:43:02 +00:00
jozamudi d90b1af8d6 Fix episode position logging/resume: NaN savePos, clobber-on-boot, unvalidated history writes
- app.js: pause/pagehide listeners passed the Event into savePos(floor),
  making pos NaN -> serialized as null -> every pause/unload clobbered the
  saved position. Wrap in arrows; pagehide now posts keepalive so the final
  save survives page unload
- app.js: play() re-fetches history on a miss (boot fetch swallows errors)
  and only bumps an existing row instead of posting pos:0 at start-of-play
- app.py: POST /api/history requires slug_id (was stored as "None"),
  validates/clamps pos; upsert keeps the old payload when the incoming
  pos is null so stale clients can't corrupt a row
- tests: +3 API tests (slug required, pos clamping, null-pos keeps saved),
  +1 end-to-end browser test (resume ~400s with a failed boot fetch);
  lockout test now uses a throwaway username so it can't lock out users
  the browser tests log in with; hardened the flaky autoplay test
2026-09-14 00:54:31 +00:00
jozamudi 7444e29592 Keep hianime alive across server rotation: try every listed server
hianime's HD-1 slot rotated to a megaplay chain whose CDN (norami.top)
is Cloudflare-blocked for us, which killed stream resolution (CDN probe
sat at 0 KB/s). The ZokoAnime server still serves the working
zokoanime chain. _master_m3u8 now walks every listed server of the
wanted type; _embed_payload handles both chains (window.__P blob and
megaplay data-id/getSourcesNew with re-roll). stream_meta returns {}
when the host is ours but no referer is needed, so the /stream
allowlist still holds.
2026-09-08 22:16:38 +00:00
jozamudi 65da3e848f Prefer the English caption track (label-based, not first-track)
hianime's subtitle 'lang' field is useless (everything says 'en'); the
real language is the label, and it is not always first — Chainsaw Man
E1 leads with Arabic. Pick label=='English', fall back to first track.
2026-09-07 03:01:38 +00:00
jozamudi 5b8170ece0 Subtitles: wire hianime's external .vtt into the player
- hianime.py: capture the first .vtt from the deobfuscated embed JSON
  (SUBS), expose subtitles(ep_id)
- upstream.py: subtitles() dispatcher (None for anidb, which bakes subs in)
- app.py: /api/episode/{id}/stream also returns a 'sub' stream token
- app.js: add a native <track> after hls.attachMedia (hls.js _cleanTracks
  disables tracks present at attach time — that silently killed earlier
  attempts) and set mode=showing so Chrome fetches the cues
2026-09-07 02:46:28 +00:00
jozamudi 834ca80a8c Provider layer + hianime source (primary)
- providers/{anidb,anikoto,hianime}.py: per-source modules behind the
  ANI_SOURCE fallback dispatch (upstream.py _call/_providers)
- hianime.py: pystardust/ani-cli#1897 flow — search/episode-list/
  servers API, HD-1 hash -> base64 embed url, window.__P blob
  (base64 + XOR 'otaku-embed-v1') -> master m3u8; registers CDN hosts
  and the required Referer via stream_meta()
- upstream.py: get_stream/fetch_parallel accept extra headers;
  default source order hianime,anidb,anikoto (anidb.app is 503)
- app.py: /stream allowlist accepts provider-registered hosts and
  sends their Referer; status probe follows the primary provider
  (hianime variant URIs are relative to the variant path)
- app.js: error-time position save floors at furthest point reached
  (hls.js resets currentTime to 0 before playerFail on fatal errors)
- tests: default-order + hianime import checks; live browser suite green
2026-09-07 01:38:53 +00:00
jozamudi ff8311c1a8 Status pill + git-SHA asset versioning
- _origin_probe(): 3-min check of anidb.app pages/API; OK/DOWN + reason
  in GET /api/cdn and server.log ([probe] ORIGIN ...)
- header pill: upstream-site dot (red = down) + CDN dot + label;
  per-dot hover tooltips, tap -> toast legend (mobile has no hover)
- ?v={{VERSION}} injected from 'git describe --always --dirty' at
  startup; JSV reads it from its own script URL - replaces hand-bumped
  numbers (JSV/?v drift) and gives play-probes the running commit
2026-09-03 16:33:51 +00:00
jozamudi 3f715434f0 PWA + test: manifest, icon, live-search test (were left uncommitted) 2026-09-03 16:33:51 +00:00
jozamudi 67274aed7d Docs: last two stale storage refs in SPEC (JSON -> SQLite) 2026-09-01 21:25:30 +00:00
jozamudi 14bdb04ef6 Docs: sync with reality
README: storage is SQLite (aniweb.db), not JSON files; CDN serves a
single quality (no ABR) — replaced the wrong 1080/720/360 claim;
document the 16-lane parallel fetch, intro-skip, episode names,
buffering UI, /api/cdn + background probe, the ?v= cache-busting rule,
and the client self-report diagnostics.
SPEC: same single-rendition correction (2 places).
PLAN: marked complete (all items shipped; deferred list was stale).
2026-09-01 21:25:13 +00:00
jozamudi 0315356f89 iOS root cause: start playback on BUFFER_APPENDED, not canplay
The live timeline caught it: iPhone buffered 513s, readyState stuck at
1, canplay NEVER fired -> play() never called -> eternal Loading.
Now: first BUFFER_APPENDED (hls.js's own 'data is in the pipe' signal)
triggers play, canplay still works, plus a 3s safety retry if still
paused with a non-empty buffer. play() calls are guarded + idempotent.
Desktop + mobile emu regression: playing. assets v=14.
2026-08-31 21:01:26 +00:00
jozamudi daac8d5256 JSV constant: 12 -> 13 (was left behind; stale-cache detection depends on it) 2026-08-31 20:56:08 +00:00
jozamudi aaaf8e9308 iPhone: full client timeline + self-diagnosing watchdog
Every step now beacons with millisecond offsets:
branch-decision -> new-hls-ok -> loadsource-called -> attach-ok ->
manifest-loaded -> level-loaded -> frag-loaded(1..2) -> buffer-appended
-> do-play -> play-resolved/play-rejected(name) + EVERY hls.js error
(fatal and silent retries). Plus a watchdog: if not playing within 15s,
it snapshots {readyState, networkState, paused, buffered, hls.state,
overlay text} every 15s (x6) — so a silent death anywhere produces a
state dump instead of nothing.

Verified on desktop: healthy timeline lands end-to-end, playback OK,
18/18 tests. assets v=13.
2026-08-31 20:53:18 +00:00
jozamudi aba266ee4b iPhone: step probes + full hls.js error stream to server
The play-probe now lands (iPhone: iOS 18.7 Safari, hls.js path, MSE ok),
but the /stream XHR never reaches the server on the failing attempts —
impossible under the known code path. Instrument each step
(new-hls-ok / loadsource-called / attach-ok) and log EVERY hls.js error
(fatal+non-fatal, with retry counter) so the client timeline is visible
server-side. Also fixed JSV constant drift (was stuck at 10).
events endpoint: accept hls-err, forward step/fatal/details/buffer/retry/http.
2026-08-31 20:48:02 +00:00
jozamudi 3df074a5fe hls.js: 10s -> 30s segment load cap (iPhone FragLoadTimeout)
The iPhone error 'Playback failed: FragLoadTimeout' + 74 all-200
segment requests = the phone's downlink can't deliver a ~700KB
segment within the 10s cap; hls.js measured the load client-side
while the server finished instantly. Raise maxLoadTimeMs to 30s
(first-byte 20s) so slow-but-alive phone links complete; retries
still bound the truly-dead case. assets v=11.
2026-08-31 20:32:40 +00:00
jozamudi 5268e77d00 events: accept client-fail + play-probe types (was 422)
The diagnostic beacons were rejected server-side (only 'error' was
whitelisted) — which is why the iPhone's fingerprint never arrived.
Now: error / client-fail / play-probe accepted, payload fields
forwarded; anything else still 422.
2026-08-31 20:22:53 +00:00
jozamudi 66a6ed5d93 play-probe: include JS version (detect stale-cache phones) 2026-08-31 20:12:50 +00:00
jozamudi c7f5ad4c7a play-probe: report branch + browser capabilities on every play attempt
iPhone stays on 'Loading…' with zero /stream requests and no error
event — impossible under any known code path, so instrument the
branch decision itself: which branch (hlsjs/native/none-throw),
hls.js presence, MSE support, native-HLS capability, full UA.
2026-08-31 20:05:34 +00:00
jozamudi be47ced521 Report client fingerprint on playback failure
showStreamError + playerFail now POST the failing browser's UA,
hls.js presence, MSE support and native-HLS capability to the
events table — a broken mobile browser becomes diagnosable from
the server side instead of by guessing.
2026-08-31 19:57:57 +00:00
jozamudi 8f6d4dcf77 Mobile audit fixes: iOS input auto-zoom + tap delay
Audited on iPhone SE (320px), iPhone 13, Pixel 5 emulations:
- FOUND: login/search inputs at 13.3px (< 16px) -> iOS Safari
  auto-zooms the page on focus. Fixed: 16px in style.css AND
  login.html (separate page with own CSS).
- FOUND: no touch-action anywhere -> double-tap-zoom tap delay on
  buttons. Fixed: touch-action: manipulation on interactive elements
  in both stylesheets.
- FOUND: .pl-spin missing from prefers-reduced-motion. Added.
- VERIFIED CLEAN: no horizontal overflow at 320/390/393px, controls
  row fits, chip+gauge in viewport, episode grid 66x31 ok,
  Tap-to-play gesture flow works on BOTH hls.js and native paths
  (simulated iOS autoplay block), toast fits, zero JS errors,
  desktop playback regression OK, 18/18 API tests.
assets v=7; login.html is no-store so updates without a bump.
2026-08-31 19:40:07 +00:00
jozamudi 9b90be3a10 Player: native-HLS fallback + visible stream errors
Root cause of 'eternal spinner': a browser without working hls.js
(no MSE, or hls.min.js blocked) threw 'HLS not supported' which the
old showStreamError hid UNDER the spinner overlay, and there was no
fallback — so the player sat on Loading… forever with no /stream
request ever leaving the browser (matched the prod log exactly).

- no-hls.js + canPlayType(mpegurl)  -> v.src = proxied m3u8, native
  <video> plays it (iOS < 17 path). Verified: readyState 4, playing,
  no hls.js at all.
- no-hls.js + no native HLS        -> VISIBLE 'Stream error: ...'
  overlay with Retry (was: hidden under the spinner)
- every play() catch routes through the visible overlay now

Desktop hls.js regression: still plays. assets v=6.
2026-08-31 19:26:53 +00:00
jozamudi 733fa673c2 Player: buffering visuals — spinner ring, buffer-ahead gauge, CDN chip
- Loading/Buffering overlays now show a rotating ring, not just text
- thin gauge under the video: how much is buffered ahead (0-30s scale),
  fills while loading, drains as it plays — you can see a stall building
- corner chip: live CDN KB/s from /api/cdn, green dot OK / amber degraded,
  refreshed on every stall — explains WHY it's buffering

Verified in-browser (CDN-independent): chip reflects live state (0 KB/s
degraded), gauge fills from video.buffered, stall overlay + ring,
playing clears. 18/18 API tests. assets v=5.
2026-08-31 15:37:06 +00:00
jozamudi a61e7dd0e7 GET /api/cdn: probe status endpoint (public, read-only)
Latest state + kbps + ~1h history (20 samples). curl it to check the
CDN without digging through server.log.
2026-08-31 15:23:36 +00:00
jozamudi eea538ab17 Background CDN probe: log throughput + state flips every 3 min
64KB Range fetch from a fixed episode (JJK S2 E25) on a daemon thread;
logs '[probe] CDN OK/DEGRADED (N KB/s)' on state change so server.log
shows exactly when a degraded window ends. 18/18 tests, verified live.
2026-08-31 15:17:20 +00:00
jozamudi 4c5dbab4b0 Proxy: 16 parallel lanes (was 4), like yt-dlp -N 16
Lane floor dropped 256KB -> 32KB so typical 0.35-2.2MB segments get the
full 16 lanes. Live check: 16 lanes used, 719KB in 0.55s, sha256
identical. Same fallback guarantees as before.
2026-08-31 15:00:21 +00:00
jozamudi ee2a888aca Proxy: fetch segments across 4 parallel byte-range lanes
The slow-segment logs show per-connection CDN throttling (15-40 KB/s
dribbles). yt-dlp's standard fix is -N 16 parallel fragment lanes; this
is the 4-lane version for the proxy: HEAD for content-length, then 4
concurrent Range GETs reassembled byte-exact (verified sha256-identical
to a direct CDN fetch). Small files (<512KB) still use a single GET.

ANY failure (no content-length, 416, lane error, timeout) falls back to
the existing single-stream path, so this is never worse than before.

Caveat (measured live): when the CDN caps SHARED egress rather than
per-connection, lanes just split the same pie — in that window both
1-lane and 4-lane time out. A different source is the only real fix
for those windows.

18/18 tests (new test pins lane stitch + fallback; also fixed a
pre-existing _get stub leak in the Jikan-shape test).
2026-08-31 14:51:55 +00:00
jozamudi 2576546b43 hls.js: drop segment load cap 30s -> 10s
Dribbling CDN segments (logged at 17.8s/59.7s) made the player stare
at 'Buffering…' for half a minute per attempt. Cap total load at 10s
so a retry kicks in sooner (timeoutRetry still 6x, backoff 1-20s);
fast segments (0.1-0.2s) are unaffected.
2026-08-31 14:10:11 +00:00
jozamudi 07554e4aa8 Playback page: show 'E{n} — episode title' line under the video
The name was only in the grid tooltip (hover-only, invisible on touch).
Add a persistent now-playing line between the player and controls:
'E1 — Ryomen Sukuna'. setNowEp() runs on every play() and refreshes
when the async Jikan name lands late. Bumped asset version to v=3.
2026-08-31 02:05:00 +00:00
jozamudi 8840a883c0 Cache-bust app.js/style.css from index.html (?v=2)
index.html is no-store (always fresh) but app.js/style.css are cached
max-age=300 in the browser — and possibly in the HTTPS proxy — so users
kept seeing the pre-fix UI (episode names in buttons) long after the
removal shipped. A versioned URL from the always-fresh index.html
bypasses both caches. Bump ?v= on every app.js/style.css edit.
2026-08-31 01:58:55 +00:00
jozamudi aeab72162e Episode grid: move names out of the button into the tooltip
The visible names made the grid uneven (long titles wrapped to 2-3
lines). Buttons now show only the E-number; the full name lives in
the title attribute (hover tooltip: 'E1 — Ryomen Sukuna'). Dropped
the now-unused .ep-name CSS.
2026-08-31 01:55:10 +00:00
jozamudi 343628a2e3 Episode names: render MAL titles on the episode grid
anidb's episodes API has no names (id/number/filler only), but the
Jikan call we already make for intro timings carries them (title +
title_japanese). _parse_intros now keeps one entry per episode with
title and optional start/length (entries are never null anymore).

Client: attachEpNames() reuses the introCache promise, stamps
epsList[i].name, re-renders; buttons show the name (3-line clamp) +
full tooltip. Jikan down => no names, no error (same graceful
degradation as the skip-intro button).

Verified headless: Jujutsu Kaisen S1 24/24 buttons named
('E1 | Ryomen Sukuna' ...); S2 (Jikan 504 at the time) rendered
gracefully without names. 17/17 API tests.
2026-08-31 01:52:38 +00:00
jozamudi d60524815f hls.js: put retry budget where 1.5.x actually reads it
maxNumRetry was set at the top of the load policy, which hls.js 1.5.x
ignores — it reads the budget from the errorRetry/timeoutRetry
sub-objects. Net effect before: a fragment-load *timeout* (a CDN that
starts sending then stalls) got only the default 1 retry before fatal
fragLoadTimeOut.

Now (verified resolved in-browser):
  maxTimeToFirstByteMs 15s   first byte
  maxLoadTimeMs        30s   total load (finite -> stalls time out)
  timeoutRetry.maxNumRetry 6  was 1
  errorRetry.maxNumRetry   8  our proxy's 502 path
2026-08-31 01:22:22 +00:00
jozamudi 83a2953a1c Proxy: log slow segment transfers (TTFB>3s or total>8s)
Distinguishes CDN-side stalls (server sees slow TTFB) from client-path
stalls (server fast, user still times out) when fragLoadTimeOut recurs.
2026-08-31 01:16:38 +00:00
jozamudi 19bc2afb32 Proxy: stream HLS segments through instead of buffering
fragLoadTimeOut root cause: /stream buffered the whole segment
(r.content) before sending, so the browser's time-to-first-byte = the
CDN's entire upload time. On slow CDN moments that trips hls.js's
first-byte timer even though the segment would have completed.

- upstream.get_stream(): opens the upstream response without reading
  the body (stream=True), same retry/timeout rules as _get
- segments now flow to the browser as the CDN emits them (256KB chunks);
  the 12s cap becomes a total-transfer cap; cache is populated on
  completion only (a truncated body never poisons the LRU)
- playlists keep the buffered path (body needed for URI rewriting)
- cache-hit fast path restored for both (stream() checks the LRU first)

Verified: segment byte-identical to direct CDN (sha256), sequential
keep-alive 3/3 OK, 5 concurrent fetches 200+identical, cache hit
0.148s miss -> 0.001s hit, 17/17 API tests green.

Skipped: raising hls.js retry counts — the first-byte race was the
binding constraint, not the retry budget.
2026-08-31 01:05:00 +00:00
jozamudi 27de918cb0 Intro skip: parse Jikan v4's actual response shape
Jikan /anime/{id}/episodes returns the episode list DIRECTLY under
'data' (flat array), not {'episodes': [...]}. The old code called
.get() on that list -> AttributeError, swallowed by the endpoint's
except -> [] -> the skip button could never appear, even with Jikan
up. Accept both shapes now.

Verified end-to-end with a mocked /api/intros response: the button is
wired into the player, visible exactly while currentTime is inside the
intro window (111-188s test), hidden outside it.

The live button still awaits Jikan/MAL recovery (504ing since this
feature landed); failures are not cached, so it activates on the first
successful fetch.

Skipped: MAL-direct fallback — official API needs a client key
(403 without), and the keyless HTML page carries no intro data.
2026-08-31 00:24:37 +00:00
jozamudi eb5082bd3b Player: defer play() to canplay; friendly tap-to-play fallback
Root cause of the spurious 'Browser blocked playback' on initial play:
play() was called right after hls.js attachMedia, before any data was
buffered. Chrome rejects such early play() calls with NotAllowedError —
indistinguishable from a real autoplay block — so the scary
'allow autoplay in site settings' overlay appeared on every start.

- wait for first data (canplay; hls.js's recommended pattern), then play
- a NotAllowedError at that point is a genuine policy block (common on
  phones): show a simple 'Tap to play' + Play button
- escalate to the 'allow autoplay' settings hint only if the tap is
  itself rejected
- dead-stream case is unaffected: hls.js fatal error still shows its
  own 'Playback failed + Retry' overlay
- also aligns with test_autoplay_blocked's expected 'Tap to play' text
2026-08-30 21:20:39 +00:00
jozamudi 0613cf7e27 Intro skip: MAL timings via keyless Jikan + player button
- upstream: _intros(mal_id) -> per-episode {start,length} (seconds),
  30d TTL cache; Jikan is down or show uncovered -> [] (no button)
- app: GET /api/intros/{mal_id}, never errors into the player
- player: 'skip intro' pill appears while currentTime is inside the
  intro window (+8s grace); click seeks past; reappears if rewound.
  Ep-1 intros are often missing in MAL data, so falls back to the
  show's first known timing (usually identical across eps)
- test: _parse_intros unit test (Jikan itself is 504ing right now,
  live path verified by hand: endpoint returns [] cleanly)
2026-08-30 21:01:24 +00:00
jozamudi c2461f8fba UI/UX polish: a11y, player controls, undo, stats ranges
- toasts: aria-live region + action button (used for history delete Undo)
- history cards: keyboard-reachable title link; delete = remove + Undo
  (re-POSTs the entry; no full list re-render)
- fail(): home/retry affordances instead of a dead-end error line
- per-view document.title + move focus to the new view on render
- player controls: episode jump (E#), auto-next toggle (persisted),
  picture-in-picture (when supported)
- auto-next off: 'Next: En' toast with Play instead of the countdown
- no-results search + empty home: recent-search chips (shared helper)
- stats: 30d/90d/1y period chips (api already supported days=N)
- search input: autocapitalize/spellcheck off; reduced-motion disables
  shimmer/toast animation; username in the menu on narrow screens
2026-08-30 20:53:55 +00:00
jozamudi 8f519b0251 hls.js: outlast CDN segment stalls (TTFT 5s->15s, 8 retries)
The upstream CDN intermittently serves 0 bytes on segments for 10s+
(fragLoadTimeOut errors 17:58-18:14 and 19:16-19:18 today). hls.js's
default 5s time-to-first-byte abort dropped the XHR before the proxy's
12s upstream attempt + 300ms retry could answer with its 502, so the
proxy-side retry never got a chance and stalls went straight to fatal.
fragLoadPolicy: maxTimeToFirstByteMs 15000 (> proxy's 12s timeout),
maxNumRetry 8 — slow-but-alive segments now finish, and stall tolerance
rises to several minutes.
2026-08-30 19:25:49 +00:00
jozamudi e361f0ba0e Autoplay: playsinline + non-fatal tap-to-play fallback
videoEl() lacked playsinline: on iOS Safari (and some Android browsers)
v.play() then rejects with NotAllowedError even on the first,
gesture-backed attempt.

A tap on the 'Tap to play' overlay that is STILL rejected (e.g. the user
has autoplay disallowed for the site) no longer goes to the fatal
'Playback failed: playback blocked by the browser' screen whose Retry
just re-ran play() into the same wall. It stays on the tappable overlay
with a hint to allow autoplay in the site settings.
2026-08-30 19:19:56 +00:00
jozamudi 71a4055e89 Save watch position on fatal player error; make failure test CDN-independent
playerFail() is the single choke point for every fatal error (hls.js fatal,
v.onerror, autoplay block) but never saved the position — periodic saves
(timeupdate) stop when hls is destroyed, so a fatal error at e.g. 40s lost
everything after the last 10s save. Call savePos() there.

test_stream_failure_retry redesigned around that: it no longer needs a
healthy CDN (which hangs on segment bytes intermittently). It waits only for
playlist metadata (duration comes from the VOD playlist, not segment bytes),
wipes the show's history, seeks to 6s (below the 10s periodic-save threshold,
above the 3s minimum — so only an error-time save could persist it), aborts
/stream to force a deterministic fatal, and asserts the position landed.
Retry-recovery now asserts a fresh token fetch instead of segment playback.
2026-08-30 18:48:52 +00:00
jozamudi 0fd06f036a Playback: retry flaky upstream, faster proxy fail, cache /stream in browser
- upstream._get: one 300ms retry on transient errors (CF 403/503, 429,
  5xx); 404 stays final. Fixes the 502 cluster that hls.js couldn't
  recover from (fragLoadTimeOut x3 in error log)
- /stream: upstream timeout 12s < hls.js fragLoadingTimeOut (20s), so a
  hung segment 502s first and hls.js retries instead of fatal-timing-out
- /stream responses: Cache-Control public max-age=86400 (VOD + token is
  the exact upstream URL => immutable); re-opens/re-seeks skip the proxy
- /api/stream: drop unused qualities (frontend never read it; saved a
  ~0.2s cold upstream round-trip per episode)
- app.js/style.css: max-age=300 instead of no-store
- test_history_page_and_delete: stale selector (home redesign removed
  the 'view all' link; use 'history ->')
2026-08-30 17:23:57 +00:00
jozamudi 36be7d0a02 Stats: fix month-label placement, back to orange
- labels now sit on the week CONTAINING the 1st of the month (old
  logic labelled the first column starting in that month, so a month
  beginning mid-week was labelled a whole column late — hovering the
  cells under 'Aug' showed Jul dates); skip labels that would crowd
  the previous one
- label + grid rows are inline-flex so flexbox shrink can't drift
  them apart at narrow widths
- cell scale back to the orange accent (color-mix of --acc)
2026-08-26 19:18:39 +00:00
jozamudi 0afd27d4d2 Stats: match GitHub's contribution-graph look
- GitHub's green scale (#0e4429 -> #39d353) instead of the orange
  accent-mix
- Mon/Wed/Fri row labels down the left (right-aligned, muted),
  month-label row offset to line up with the grid
- legend right-aligned under the grid
2026-08-26 19:14:00 +00:00