diff --git a/MIGRATION.md b/MIGRATION.md
index 943d2353c..3b66183a4 100644
--- a/MIGRATION.md
+++ b/MIGRATION.md
@@ -193,3 +193,11 @@ zod codec.
a manual `Response` (Kit's `redirect()` only allows 3xx).
- The differ impersonates with `maxRedirects: 0` so the redirect target may be
a not-yet-migrated route.
+- Numeric `width`/`height` on `
` are set through the DOM property setters
+ on hydration, which truncate fractions (`27.76` → `27`, an 1px layout shift
+ vs React per image). Pass dimension attributes as strings when they can be
+ fractional — `Image.svelte` does this for all its images.
+- SvelteKit 3 + adapter-node derive the CSRF/remote-function self-origin from
+ proxy headers, guessing `https://` bare; e2e builds set
+ `paths.origin` + `csrf.trustedOrigins: ["*"]` (see `vite.config.ts`) or every
+ tooling/e2e form POST is rejected 403.
diff --git a/apps/web-react/app/db/seed/dev/misc.ts b/apps/web-react/app/db/seed/dev/misc.ts
index 1e2a71754..4cf017d38 100644
--- a/apps/web-react/app/db/seed/dev/misc.ts
+++ b/apps/web-react/app/db/seed/dev/misc.ts
@@ -41,9 +41,14 @@ export async function seedMisc({
await seedNotifications(users, tournaments);
await seedUserReports(users, sendouq);
- await LiveStreamFactory.replaceAll(
- users.showcaseIds.slice(0, STREAM_COUNT).map((userId) => ({ userId })),
- );
+ // the differ seeds a world where nobody is live on Twitch: live-stream chrome
+ // (sidebar streams, friend stream activity) depends on the tournament cluster,
+ // which migrates to apps/web in a later wave
+ if (process.env.SEED_QUIET_WORLD !== "true") {
+ await LiveStreamFactory.replaceAll(
+ users.showcaseIds.slice(0, STREAM_COUNT).map((userId) => ({ userId })),
+ );
+ }
await SplatoonRotationFactory.replaceAll();
}
diff --git a/apps/web-react/e2e/leaderboards.spec.ts b/apps/web-react/e2e/leaderboards.spec.ts
index 1491b748b..e29c85a79 100644
--- a/apps/web-react/e2e/leaderboards.spec.ts
+++ b/apps/web-react/e2e/leaderboards.spec.ts
@@ -10,6 +10,16 @@ test.describe("Leaderboards", () => {
page,
factories,
}) => {
+ // season 1 renders its first ten entries through the static top-ten
+ // showcase (names come from top-ten.json, not the database), so the
+ // asserted users must rank below ten fillers to appear as normal rows
+ for (let i = 0; i < 10; i++) {
+ const filler = await factories.UserFactory.create();
+ await factories.SkillFactory.create(
+ { userId: filler.id, mu: 40 - i },
+ { matchesCount: 10 },
+ );
+ }
const better = await factories.UserFactory.create({
discordName: "BetterPlayer",
});
@@ -17,7 +27,7 @@ test.describe("Leaderboards", () => {
discordName: "WorsePlayer",
});
await factories.SkillFactory.create(
- { userId: better.id, mu: 30 },
+ { userId: better.id, mu: 25 },
{ matchesCount: 10 },
);
await factories.SkillFactory.create(
@@ -52,7 +62,6 @@ test.describe("Leaderboards", () => {
weaponSplId: 40,
});
await factories.XRankPlacementFactory.create({
- playerUserId: zonesPlayer.id,
name: LOW_POWER_NAME,
playerSplId: `runner-up-${zonesPlayer.id}`,
mode: "SZ",
diff --git a/apps/web/package.json b/apps/web/package.json
index e9eaf511b..cafde1505 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -20,7 +20,7 @@
},
"dependencies": {
"@epic-web/cachified": "5.6.3",
- "@lucide/svelte": "0.544.0",
+ "@lucide/svelte": "1.28.0",
"@sendou/components": "workspace:*",
"@sendou/in-game-lists": "workspace:*",
"@sendou/utils": "workspace:*",
diff --git a/apps/web/src/lib/components/Image.svelte b/apps/web/src/lib/components/Image.svelte
index 7e944742e..d1e370a25 100644
--- a/apps/web/src/lib/components/Image.svelte
+++ b/apps/web/src/lib/components/Image.svelte
@@ -34,12 +34,14 @@ let {
+

{};
+
const breadcrumbs = $derived(
(page.data as { breadcrumbs?: Breadcrumb[] }).breadcrumbs ?? [],
);
@@ -422,6 +426,8 @@ function formatRelativeDate(timestamp: number) {
showSupport={Boolean(!user?.roles.includes("MINOR_SUPPORT"))}
showSearch={Boolean(user)}
isLoggedIn={Boolean(user)}
+ onChatToggle={user ? noopUntilChatRebuild : undefined}
+ onChatModalToggle={user ? noopUntilChatRebuild : undefined}
chatUnreadCount={0}
/>
diff --git a/apps/web/src/lib/components/layout/TopRightButtons.svelte b/apps/web/src/lib/components/layout/TopRightButtons.svelte
index 5d5ca4ac2..496695c72 100644
--- a/apps/web/src/lib/components/layout/TopRightButtons.svelte
+++ b/apps/web/src/lib/components/layout/TopRightButtons.svelte
@@ -254,6 +254,11 @@ const isMac = $derived(
font-size: var(--font-2xs);
background-color: var(--color-bg-higher);
font-family: inherit;
+
+ /** needs to go away so we have enough space even with both side panels open */
+ @media screen and (max-width: 1100px) {
+ display: none;
+ }
}
.chatButtonWrapperPersistent {
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 31a21840b..9d478f010 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -3,6 +3,8 @@ import adapter from "@sveltejs/adapter-node";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
+const IS_E2E_BUILD = process.env.VITE_E2E_TEST_RUN === "true";
+
export default defineConfig(({ mode }) => ({
define: {
__GIT_COMMIT__: JSON.stringify(process.env.RENDER_GIT_COMMIT ?? ""),
@@ -19,6 +21,15 @@ export default defineConfig(({ mode }) => ({
adapter: adapter(),
compilerOptions: { experimental: { async: true } },
experimental: { remoteFunctions: true },
+ // e2e/differ tooling POSTs (impersonate, theme) carry no Origin header,
+ // and the adapter guesses https:// when no proxy headers are present, so
+ // origin checks can never pass against the http test servers
+ ...(IS_E2E_BUILD
+ ? {
+ csrf: { trustedOrigins: ["*"] },
+ paths: { origin: process.env.VITE_SITE_DOMAIN },
+ }
+ : {}),
}),
],
test: {
diff --git a/migration-manifest.json b/migration-manifest.json
index 4932282cf..16d132683 100644
--- a/migration-manifest.json
+++ b/migration-manifest.json
@@ -76,8 +76,8 @@
"status": "pending"
},
"leaderboards": {
- "status": "migrated",
- "notes": "phase 2 vertical slice; verified pending differ + e2e run"
+ "status": "verified",
+ "notes": "phase 2 vertical slice; differ clean (pixel/head/resource/aria) and e2e green against both apps 2026-08-16"
},
"lfg": {
"status": "pending"
@@ -730,7 +730,7 @@
"feature": "top-search"
},
"features/leaderboards/routes/leaderboards.tsx": {
- "status": "migrated",
+ "status": "verified",
"path": "/leaderboards",
"feature": "leaderboards",
"notes": "ported by hand as the phase 2 golden slice; loader -> getLeaderboards query, action -> skipTeam/unskipTeam commands"
diff --git a/packages/components/src/Select.svelte b/packages/components/src/Select.svelte
index 368046ced..826203b21 100644
--- a/packages/components/src/Select.svelte
+++ b/packages/components/src/Select.svelte
@@ -246,6 +246,7 @@ const hasVisibleItems = $derived(items.size > 0);
const uid = $props.id();
const labelId = $derived(label ? `${uid}-select-label` : undefined);
+const valueId = `${uid}-select-value`;
setOpen(!open)}
onkeydown={onTriggerKeydown}
>
-
+
{#if currentKey !== null && valueContent}
{@render valueContent(currentKey)}
{:else if selectedText !== undefined}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 062a9b16b..a408c56b1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -23,8 +23,8 @@ importers:
specifier: 5.6.3
version: 5.6.3
'@lucide/svelte':
- specifier: 0.544.0
- version: 0.544.0(svelte@5.56.9)
+ specifier: 1.28.0
+ version: 1.28.0(svelte@5.56.9)
'@sendou/components':
specifier: workspace:*
version: link:../../packages/components
@@ -1232,8 +1232,8 @@ packages:
'@lix-js/sdk@0.12.2':
resolution: {integrity: sha512-sKGOPdVKdChS5q60F4VQE4ZTcf0B7ZJM3KGCVQFN2suVNKhHcllbsCiwRYuM5WL7Yu1ql7YexltJ2FCsDqKePg==}
- '@lucide/svelte@0.544.0':
- resolution: {integrity: sha512-9f9O6uxng2pLB01sxNySHduJN3HTl5p0HDu4H26VR51vhZfiMzyOMe9Mhof3XAk4l813eTtl+/DYRvGyoRR+yw==}
+ '@lucide/svelte@1.28.0':
+ resolution: {integrity: sha512-C1Ge84KW2z4q44/WDIEo/2KC2jby5u6mSlta7q+p6g7u0ld8sC/w1fMBiSswF+4bxAl9jlzzaVTpp79qqkiy5g==}
peerDependencies:
svelte: ^5
@@ -5933,7 +5933,7 @@ snapshots:
'@lix-js/sdk-linux-x64': 0.12.2
'@lix-js/sdk-win32-x64': 0.12.2
- '@lucide/svelte@0.544.0(svelte@5.56.9)':
+ '@lucide/svelte@1.28.0(svelte@5.56.9)':
dependencies:
svelte: 5.56.9
diff --git a/tooling/differ/README.md b/tooling/differ/README.md
index c7c2c8724..98ea7355f 100644
--- a/tooling/differ/README.md
+++ b/tooling/differ/README.md
@@ -51,6 +51,10 @@ The run writes `output//`:
serves (`VITE_E2E_TEST_RUN=true` baked in), reused when fresh.
2. Migrate + seed `db-differ-seed.sqlite3` with the clock frozen at `SEED_NOW`
(`scripts/seed.ts` installs `installSeedClock`), then copy it for each side.
+ The differ seeds with `SEED_QUIET_WORLD=true`: nobody is live on Twitch, so
+ the sidebar streams chrome (which depends on the not-yet-migrated tournament
+ cluster) renders identically empty on both sides. Drop the flag when the
+ tournament wave migrates.
3. `scripts/route-census.ts` — walks `app/routes.ts` and resolves every dynamic
param from the seeded database. Never hand-listed: a new route without a
resolver is a hard error; a resolver finding no seed data marks the row
diff --git a/tooling/differ/scripts/memwatch.sh b/tooling/differ/scripts/memwatch.sh
new file mode 100755
index 000000000..446cf3eb4
--- /dev/null
+++ b/tooling/differ/scripts/memwatch.sh
@@ -0,0 +1,64 @@
+#!/bin/bash
+# Memory watchdog: runs a command while sampling system-wide memory per second
+# and hard-capping the command's own process tree before macOS starts killing
+# the biggest process on the machine (usually the claude session).
+#
+# usage: memwatch.sh --
+#
+# writes to :
+# samples.tsv time \t free-pct \t top processes "rss_mb:pid:comm" (system-wide)
+# events.log watchdog kills and threshold crossings
+set -u
+
+LOG_DIR="$1"
+CAP_MB="$2"
+shift 2
+[ "$1" = "--" ] && shift
+
+mkdir -p "$LOG_DIR"
+SAMPLES="$LOG_DIR/samples.tsv"
+EVENTS="$LOG_DIR/events.log"
+: >"$SAMPLES"
+: >"$EVENTS"
+
+"$@" &
+ROOT_PID=$!
+
+tree_pids() {
+ # ROOT_PID plus all transitive children
+ local all="$ROOT_PID" frontier="$ROOT_PID" next
+ while [ -n "$frontier" ]; do
+ next=$(pgrep -P "$(echo "$frontier" | tr ' ' ',')" 2>/dev/null | tr '\n' ' ')
+ frontier=$(echo "$next" | xargs)
+ [ -n "$frontier" ] && all="$all $frontier"
+ done
+ echo "$all"
+}
+
+while kill -0 "$ROOT_PID" 2>/dev/null; do
+ ts=$(date +%H:%M:%S)
+ free_pct=$(memory_pressure -Q 2>/dev/null | grep -oE '[0-9]+%' | head -1 | tr -d '%')
+ top=$(ps -axo rss=,pid=,comm= | sort -rn | head -12 | awk '{printf "%d:%s:%s ", $1/1024, $2, $3}')
+ printf '%s\t%s%%\t%s\n' "$ts" "${free_pct:-?}" "$top" >>"$SAMPLES"
+
+ for pid in $(tree_pids); do
+ rss_kb=$(ps -o rss= -p "$pid" 2>/dev/null | tr -d ' ')
+ [ -z "$rss_kb" ] && continue
+ rss_mb=$((rss_kb / 1024))
+ if [ "$rss_mb" -gt "$CAP_MB" ]; then
+ cmd=$(ps -o command= -p "$pid" | cut -c1-200)
+ echo "$ts KILLED pid=$pid rss=${rss_mb}MB > cap=${CAP_MB}MB cmd=$cmd" >>"$EVENTS"
+ kill -9 "$pid"
+ fi
+ done
+
+ if [ -n "$free_pct" ] && [ "$free_pct" -lt 15 ]; then
+ echo "$ts PRESSURE free=${free_pct}% — top: $(echo "$top" | cut -c1-300)" >>"$EVENTS"
+ fi
+ sleep 1
+done
+
+wait "$ROOT_PID"
+exit_code=$?
+echo "$(date +%H:%M:%S) command exited with $exit_code" >>"$EVENTS"
+exit "$exit_code"
diff --git a/tooling/differ/src/differ.ts b/tooling/differ/src/differ.ts
index 4c2c19b23..d94983116 100644
--- a/tooling/differ/src/differ.ts
+++ b/tooling/differ/src/differ.ts
@@ -337,6 +337,19 @@ async function capturePage(
const page = await context.newPage();
const notes: string[] = [];
+ if (process.env.DIFFER_DEBUG === "true") {
+ page.on("pageerror", (e) =>
+ // biome-ignore lint/suspicious/noConsole: CLI script output
+ console.error(`[pageerror ${url}]`, String(e).slice(0, 300)),
+ );
+ page.on("requestfailed", (request) => {
+ const failure = request.failure()?.errorText ?? "";
+ if (failure.includes("REFUSED"))
+ // biome-ignore lint/suspicious/noConsole: CLI script output
+ console.error(`[refused] ${request.url().slice(0, 140)}`);
+ });
+ }
+
try {
await page.setViewportSize({
width: viewport.width,
@@ -351,12 +364,36 @@ async function capturePage(
.catch(() => {
notes.push("page never reported hydration");
});
+ // lazy post-hydration data (the notification bell) races the screenshot:
+ // give post-hydration effects a beat to launch their fetches, then wait
+ // for the app to report every in-flight fetch settled
+ await page.waitForTimeout(150);
+ await page
+ .locator('[data-testid="hydrated"][data-router-idle="true"]')
+ .waitFor({ state: "attached", timeout: HYDRATION_TIMEOUT_MS })
+ .catch(() => {
+ notes.push("router never went idle");
+ });
if ((await page.getByTestId("error-page").count()) > 0) {
notes.push("rendered the error page (seed data gap?)");
}
await page.addStyleTag({ content: KILL_ANIMATIONS_CSS });
+ // strip comment nodes and merge the text nodes around them: React SSR
+ // splits text at every interpolation (`2019-2026`), and glyphs
+ // after a text-node boundary rasterize at different subpixel offsets
+ // than the same text in one node, scattering one-shade-off pixels
+ await page.evaluate(() => {
+ const walker = document.createTreeWalker(
+ document.documentElement,
+ NodeFilter.SHOW_COMMENT,
+ );
+ const comments: Node[] = [];
+ while (walker.nextNode()) comments.push(walker.currentNode);
+ for (const comment of comments) comment.parentNode?.removeChild(comment);
+ document.documentElement.normalize();
+ });
await page.evaluate(() => document.fonts.ready);
const screenshot = await settleScreenshot(page, notes);
@@ -604,6 +641,8 @@ function normalizeHead(head: string, origins: string[]): string {
const tag = match[0]
.replace(/\s+/g, " ")
.replace(/\s*\/?>$/, ">")
+ // attribute names are case-insensitive and React emits camelCase (charSet)
+ .replace(/ ([A-Za-z-]+)=/g, (_, name) => ` ${name.toLowerCase()}=`)
.trim();
if (match[1].toLowerCase() === "link") {
diff --git a/tooling/differ/src/prepare.ts b/tooling/differ/src/prepare.ts
index 37ad04df1..84819f8db 100644
--- a/tooling/differ/src/prepare.ts
+++ b/tooling/differ/src/prepare.ts
@@ -39,6 +39,7 @@ export function prepare(config: RunConfig): Census {
run(config, "./node_modules/.bin/vite-node", ["./scripts/seed.ts"], {
DB_PATH: SEED_DB,
SEED_NOW: String(config.seedNow),
+ SEED_QUIET_WORLD: "true",
});
// one checkpointed file copies cleanly; the servers re-enable WAL themselves
diff --git a/tooling/differ/src/servers.ts b/tooling/differ/src/servers.ts
index b293c4769..17d6096ad 100644
--- a/tooling/differ/src/servers.ts
+++ b/tooling/differ/src/servers.ts
@@ -121,8 +121,11 @@ async function waitForServer(baseURL: string, timeout = 60000) {
function killPortListeners(...ports: number[]) {
try {
+ // -t must stay separate from -i: a clustered bare `-i` selects every
+ // internet socket on the machine and the kill below would slaughter
+ // unrelated apps (Discord, the terminal running this...)
const pids = execSync(
- `lsof -ti ${ports.map((p) => `-i :${p}`).join(" ")} || true`,
+ `lsof -t ${ports.map((p) => `-iTCP:${p}`).join(" ")} -sTCP:LISTEN || true`,
{
stdio: "pipe",
},