Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit c11cdfd3e51add5d181eec14995b22ae589003ae
parent e1f288c1fda2fd5f9d61c5aa846da6f1a6151a2a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon,  6 Jul 2026 10:51:36 -0400

Merge branch 'main' into worktree-feat+archive-downloads

Diffstat:
Mcommon/components/ThemeProvider.tsx | 34++++++++++++++++++++++++----------
Meditor/CHANGELOG.md | 1+
Aeditor/e2e/theme.spec.ts | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/CHANGELOG.md | 3+++
4 files changed, 85 insertions(+), 10 deletions(-)

diff --git a/common/components/ThemeProvider.tsx b/common/components/ThemeProvider.tsx @@ -4,6 +4,7 @@ import { createContext, useCallback, useContext, + useLayoutEffect, useEffect, useMemo, useState, @@ -15,13 +16,16 @@ import { type ThemeMode, } from "./themeConfig"; -// Runtime theme controller. The pre-paint <ThemeScript/> is the single source of -// truth for FIRST paint — it has already set the correct attributes on <html> -// from localStorage. This provider must therefore NEVER write to the DOM on -// mount (doing so would briefly fight the script whenever the persisted mode -// differs from `defaultMode`, causing a flash). It only writes to the DOM in -// response to explicit user changes and live OS-preference changes; on mount it -// merely adopts the persisted values into React state for the toggle UI. +// Runtime theme controller. The pre-paint <ThemeScript/> colors the FIRST paint +// by setting attributes on <html> from localStorage before hydration. But <html> +// is server-rendered with a static className that omits `.dark`, so that script +// mutation is lost across the hydration boundary and nothing restores it — the +// page would stay light until a user toggle. So on mount this provider RE-ASSERTS +// the *persisted, resolved* theme to the DOM (via useLayoutEffect, before paint). +// That is idempotent — it recomputes the exact same value the script already used +// from the same localStorage, so it never fights the script and never flashes. +// After mount it writes to the DOM only on explicit user changes and live +// OS-preference changes. type ThemeContextValue = { theme: ThemeFamily; @@ -68,8 +72,10 @@ export function ThemeProvider({ const [theme, setThemeState] = useState<ThemeFamily>(defaultTheme); const [mode, setModeState] = useState<ThemeMode>(defaultMode); - // Adopt persisted values into React state (NOT the DOM — the script owns that). - useEffect(() => { + // Adopt persisted values into React state AND re-assert them to the DOM before + // paint, so the theme survives the hydration boundary (see block comment above). + // useLayoutEffect runs synchronously before the browser paints the hydrated tree. + useLayoutEffect(() => { try { let t = localStorage.getItem(THEME_KEY) as ThemeFamily | "terminal" | null; const m = localStorage.getItem(MODE_KEY) as ThemeMode | null; @@ -85,10 +91,18 @@ export function ThemeProvider({ } if (t) setThemeState(t); if (m) setModeState(m); + // Re-apply the resolved persisted values (falling back to the defaults the + // server rendered) so <html> carries the correct `data-theme`/`.dark` again. + const resolvedTheme = t ?? defaultTheme; + const resolvedMode = m ?? defaultMode; + applyToDom(resolvedTheme, resolveDark(resolvedMode)); } catch { /* ignore */ } - }, []); + // Effectively runs once: the defaults are stable props (passed by the + // layout and never changed), so this re-adopts persisted state on mount and + // does not fight later user changes made through setTheme/setMode. + }, [defaultTheme, defaultMode]); // Track OS changes while in "system" mode (the only case the script can't // keep live after load). diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **Fixed: the editor (and every public site) always loaded in light mode until you toggled the theme.** Dark styling is driven purely by a `.dark` class on `<html>`; the pre-paint `<ThemeScript>` set it correctly before first paint, but `<html>` is server-rendered with a static class that omits `dark`, so that class was lost across React's hydration boundary and nothing put it back — the page fell to the light palette on every load until a manual toggle re-applied it directly (which is why toggling then "stuck"). `ThemeProvider` now re-asserts the persisted, resolved family + mode to `<html>` on mount via `useLayoutEffect` (before paint) — idempotent with the script, so there's no flash and no toggle needed. Explicit dark, `system` on a dark OS, and non-Base families all now survive a refresh. See `common/components/ThemeProvider.tsx` and `editor/e2e/theme.spec.ts`. - **Search results scroll smoothly again on large result sets.** The results list windows one card per matching video (only the on-screen cards are mounted), but each visible card and every one of its hit rows was re-rendering on *every* scroll frame — and each hit row re-ran its `<mark>` highlighting, so a single video with hundreds of hits meant hundreds of redundant highlight passes per frame while scrolling. The result cards and individual hit rows are now memoized so an unchanged card/row is skipped during scroll, and opening the modal on a hit only re-renders the two rows whose highlight state actually changes. No visible/behavioral change — same DOM, same results, just far less work per frame. See `common/components/TranscriptSearch.tsx` (`ResultCard`/`HitRow` memoization, `openWithMode` stabilized via `useCallback`). - **The monitor widget gains a needs-work channel list, more interaction buttons, and an in-place settings gear.** Three additions, all driveable from the widget builder. **(1) A "Needs work" list** (URL flag `act=1`) — a compact, per-channel worklist of videos to download (`↓ N`) or transcribe (`✎ N`), reusing the same `loadActionableSummary` that powers the `/actionable` page via a new `/api/widget/actionable` route; it polls on a 15s floor (the backlog changes on job completions, not seconds) and caps at 6 channels with a `+N more` line. **(2) More interactions** behind the existing `controls=1` switch: each needs-work row gains the same per-channel **Download missing** / **Transcribe pending** buttons as the actionable page (reusing `InlineActionButton`), and the controls row adds **Retry all failed** alongside Pause/Resume + Drain. **(3) An in-place settings gear** (on by default; URL flag `gear=0` to hide, or a **Show settings gear** builder checkbox) — clicking it opens the builder's own form *inside the widget window*, so a pinned widget can be reconfigured live without opening the builder page; edits apply immediately and mirror into the address bar via `history.replaceState`, so a reload preserves them and the link stays copyable. The builder form is extracted into a shared `WidgetConfigForm` used by both the builder and the overlay, and the widget's poller now fetches immediately on (re)subscribe instead of after one interval, so newly-enabled sections render at once. Existing links render unchanged (the two new flags default to their old behavior; the gear is the one new default-visible affordance and is read-only — it mutates no server state). See `editor/app/widget/lib/config.ts`, the new `editor/app/widget/components/WidgetConfigForm.tsx` and `editor/app/api/widget/actionable/route.ts`, `editor/app/widget/components/{MonitorWidget,WidgetControls}.tsx`, `editor/app/widget/builder/components/WidgetBuilder.tsx`, and `editor/e2e/widget.spec.ts`. - **Every site build now bundles downloadable per-channel transcript & live-chat archive zips.** The archive builders (per-channel `<slug>.zip` / `<slug>.live_chat.zip`) previously only ran as standalone actions that wrote to a non-served directory; now `compose-site` generates them for the site's own channels straight into the served `public/archives/` and writes a `manifest.json` (sizes + counts) that the site's new **Downloads** page reads. **`zip` is now the default archive format** everywhere (was `tar.gz`), and the Build page's format help text tracks the selected format. Generation is **on by default with three opt-out levels**: a global **Generate archive zips on build** toggle in Settings, a per-site **Generate archive zips** toggle (plus an optional **Archive size cap (MB)**) on the site's page, and a per-build **Skip archive zips** checkbox on the Build and Build & Deploy controls (`BUILD_ARCHIVES=0`). See `common/bin/compose-site.ts` (`composeArchives`), `common/controller/archive{Transcripts,LiveChat}.ts` (new `outDir` option), `common/lib/archiveOptions.ts` (default + manifest types), `common/lib/{site,settings}.ts` (opt-out flags), and `editor/app/{deploy/buildDeployCore.ts,build/buildAction.ts,deploy/components/Build{Export,Deploy}Button.tsx,sites/components/SiteForm.tsx,settings/components/SettingsForm.tsx}`. diff --git a/editor/e2e/theme.spec.ts b/editor/e2e/theme.spec.ts @@ -0,0 +1,57 @@ +import { test, expect } from "@playwright/test"; +import { resetData } from "./helpers"; + +// Regression: refreshing the editor must honor the persisted theme mode. The +// pre-paint <ThemeScript> sets `.dark` on <html>, but <html> is server-rendered +// with a static className that omits `dark`; the mutation was lost across the +// hydration boundary and nothing re-asserted it, so the page loaded light until +// a manual toggle. ThemeProvider now re-applies the persisted theme on mount. +const THEME_KEY = "ytdlp-tb:theme"; +const MODE_KEY = "ytdlp-tb:mode"; + +async function seed( + page: import("@playwright/test").Page, + values: Record<string, string>, +) { + // Need an origin before localStorage is writable. + await page.goto("/"); + await page.evaluate((vals) => { + for (const [k, v] of Object.entries(vals)) localStorage.setItem(k, v); + }, values); +} + +test.beforeEach(async () => { + await resetData("empty"); +}); + +test("explicit dark mode survives a reload without toggling", async ({ + page, +}) => { + await seed(page, { [MODE_KEY]: "dark" }); + await page.reload(); + await expect(page.locator("html")).toHaveClass(/(^|\s)dark(\s|$)/); +}); + +test("explicit light mode loads light", async ({ page }) => { + await seed(page, { [MODE_KEY]: "light" }); + await page.reload(); + await expect(page.locator("html")).not.toHaveClass(/(^|\s)dark(\s|$)/); +}); + +test.describe("system mode with OS dark", () => { + test.use({ colorScheme: "dark" }); + + test("system mode resolves to dark on reload", async ({ page }) => { + await seed(page, { [MODE_KEY]: "system" }); + await page.reload(); + await expect(page.locator("html")).toHaveClass(/(^|\s)dark(\s|$)/); + }); +}); + +test("non-base family + dark persist across reload", async ({ page }) => { + await seed(page, { [MODE_KEY]: "dark", [THEME_KEY]: "selenized" }); + await page.reload(); + const html = page.locator("html"); + await expect(html).toHaveClass(/(^|\s)dark(\s|$)/); + await expect(html).toHaveAttribute("data-theme", "selenized"); +}); diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## [Unreleased] +- **Fixed: sites always opened in light mode until you re-picked a theme.** If you'd chosen dark (or left it on "system" with a dark device), the page still loaded light on every visit and only switched after you opened the theme menu again. The saved theme is now re-applied before the page paints, so your choice sticks across reloads — no flash, no re-toggling. + ## [0.6.3] - 2026-07-04 - **Big archives are no longer dropped — they overflow to object storage.** Cloudflare Pages rejects any single asset over 25 MB, so oversize channel zips (a busy channel's live-chat log can be hundreds of MB) used to show as "too large to host." When an R2 overflow bucket is configured, those archives now upload there on deploy and the Downloads page links straight to them. Without a bucket configured, the old "unavailable" behavior stands. - **Combined "whole site" archives were removed** in favor of per-channel bundles — they duplicated the per-channel content and were always the first to blow past the size cap.