Differ clean for scrims routes; cookbook and manifest updates

This commit is contained in:
Kalle
2026-08-17 19:09:28 +03:00
parent 2d4592ee79
commit e3c33de056
29 changed files with 231 additions and 103 deletions

View File

@@ -67,26 +67,77 @@ Auth: `requireUser()` / `getUser()` / `actorId()` from
`#lib/features/auth/user.server.ts` read `event.locals.user`, resolved once per
request in `hooks.server.ts`. No AsyncLocalStorage.
## Write path (action → command), first shape
## Write path (action → command), the golden pattern
The full write-path pattern lands with the `/scrims` slice; `/leaderboards`
established the command shape for fixed-field mutations:
From the `/scrims` slice (`scrims.remote.ts`):
- Each `_action` branch of a React action union becomes its own `command()`
with its own valibot schema (the `_action` discriminator disappears).
- **Server-driven refresh is the default**: the handler calls
`getX(args).refresh()` / `.set(result)` for exactly the queries it
invalidated.
- When the server genuinely can't know which query instance the client holds
(filter/pagination args — the leaderboards case), the **client** rides the
refresh on the mutation round trip instead:
- **Single-flight refresh**: the client rides the refresh on the mutation round
trip by passing the query *function* — `acceptScrimRequest(args)
.updates(getScrimPosts)` — and the handler fulfils it with
`await requested(getScrimPosts, 5).refreshAll()`. Kit resolves the client's
active instances of that query (whatever args they hold), re-runs them
server-side, and the fresh data rides back on the command response.
- Mutations that live queries observe skip `.updates()` entirely: the handler
publishes to the event bus (`Events.publish(Events.scrimChannel(id))`) and
every subscribed stream — including the actor's own tab — re-yields.
- React's `errorToast`/`errorToastIfFalsy`/`notFoundIfNullish` port as
`#lib/utils/respond.server.ts` (they `error(400/404, message)` the command).
Handlers that used `{ fieldErrors }` returns keep them — `SendouForm` shows
them under the fields.
- `requirePermission(obj, permission)` is ported in
`#lib/modules/permissions/guards.server.ts`.
```ts
skipTeam({ season, identifier }).updates(getLeaderboards(queryArgs));
```
## SendouForm (schema-driven forms)
Never leave a mutation without one of the two — the refresh-everything default
must not ship.
`~/form/` ports as `#lib/form/`: `SendouForm.svelte`, `FormField.svelte`
(dispatch by schema metadata), `fields.ts` (valibot builders + a WeakMap
registry replacing zod's `formRegistry`), per-type field components in
`fields/`. The React data flow is kept — values in `$state`, client-side
valibot validation with translated `forms:*` message keys, submit sends the
raw values — but the transport is a remote **command whose arg schema is the
form schema** (server-side validation for free). Kit's remote `form()` stays
for simple fixed-field forms (the sidenav pattern); the complex fields (Date
values, tuple/union shapes) don't fit its FormData-backed field model, and
these forms were JS-dependent in React too.
- Page wiring: `onSubmit={async (values) => { const r = await
createScrimPost(values as v.InferInput<typeof schema>); if (r?.fieldErrors)
return r; await goto(...); }}` plus `onSuccess` for closing dialogs.
- Cross-field `superRefine` → `v.pipe(v.object(...), v.transform(...),
v.forward(v.check(...), [path]), ...)`; `.overwrite` → `v.transform` in the
same pipe.
- Fields render through context (`form-context.ts`); custom fields are
`<FormField name="x">{#snippet children(props)}...{/snippet}</FormField>`
with the same `CustomFieldRenderProps` contract as React.
- The unsaved-changes guard is `#lib/form/UnsavedChangesGuard.svelte` in the
root layout + a `registerDirtyChecker` registry (`beforeNavigate` replaces
`useBlocker`; a `willUnload` cancel triggers the native prompt).
- `DatePicker.svelte` replaces react-aria's DatePicker: contenteditable
`spinbutton` segments named `"{segment}, {label}"` (the e2e contract), built
from `Intl.DateTimeFormat.formatToParts`. Segment quirks that pixel parity
depends on: no `white-space: pre` (flex whitespace collapsing is what glues
react-aria's literals), no explicit font-size (inherits like react-aria),
month/day/hour unpadded, minute 2-digit.
## Live queries (`query.live`) + the event bus
- `#lib/server/events.ts` is the in-process bus: mutations `publish(channel)`,
live-query generators `for await (const _ of Events.subscribe(channel))`
re-yield a fresh snapshot per (coalesced) wake-up. Always snapshots, never
event logs. A built-in heartbeat keeps proxies from idle-closing streams.
- Live queries stream over GET + SSE — they never interfere with the e2e
helpers' POST waits, and `fetch()` resolving on headers keeps the
hydration-indicator busy counter honest.
- SSR awaits the stream's first value; generators must yield before blocking
on the bus.
- Consumers: `getNotifications` (bell, per-user channel), `getChatRoom`/
`getChatRooms` (chat), `getScrim` (both teams' scrim pages update live —
replaces skalop's revalidation broadcasts).
Never leave a mutation without a refresh story — the refresh-everything
default must not ship.
## Search params
@@ -193,6 +244,23 @@ zod codec.
`IS_E2E_TEST_RUN` only; it counts in-flight remote fetches for the busy
state.
## Popovers, menus, selects (mount semantics)
react-aria unmounts popover/menu content when closed; the handwritten
`Popover`/`Menu` components now do the same (`{#if open}` around children).
This is what keeps strict-mode e2e selectors (and ARIA snapshots) from seeing
N hidden copies of testids/labels. `Select` keeps its *items* mounted (the
trigger text comes from item registration) but mounts the search field only
while open; its listbox is labelled only while open so a closed select exposes
exactly one element under its label. Search-item locators in the e2e helpers
filter `{ visible: true }` for the same reason.
Icon-only buttons: React applies the size classes to the icon element itself
(an 18px icon inside the 20px slot ends up 20×18); the Svelte `Button` wraps
icons in a span whose `svg { width: 100%; height: auto }` wins on specificity.
Where parity matters, out-specify it with a tripled class
(`:global(.x.x.x) { height: 18px; }`) — see `ScrimTeamMembersPopover.svelte`.
## One-offs ledger (discoveries)
- Kit 3 moved `Handle` to `@sveltejs/kit/hooks` and `getRequestEvent` to
@@ -222,3 +290,34 @@ zod codec.
back to `.avif` while the oracle baked `.png` from `apps/web-react/.env`
(broken sidebar event logos). One shared `.env` is the source of truth until
the cutover; tooling overrides passed via `process.env` still win over it.
- Kit 3 param matchers are Standard Schemas in a single `src/params.ts`
exporting `params = defineParams({ integer: v.pipe(v.string(),
v.regex(/^\d+$/)) })` — the `src/params/<name>.ts` convention is gone.
- Files named `*.remote.server.ts` are treated as remote-function modules by
Kit (everything exported must be a remote function) — hence
`#lib/utils/respond.server.ts`, not `remote.server.ts`.
- adapter-node 6 bakes `ORIGIN` (from `paths.origin`) at build time, and a
bare request guesses `https://`. One e2e/differ build serves many worker
ports, so the tooling spawns `apps/web/scripts/e2e-server.mjs` — a shim that
injects `x-forwarded-proto: http` with `PROTOCOL_HEADER` set — and
remote-function CSRF (which ignores `csrf.trustedOrigins`) sees the right
per-port origin.
- Context in async components must be set *before* the first `await`
(`set_context_after_init` is fatal in prod). Pattern: `setUserCardContext({
userCards: () => data.userCards })` above `const data = $derived(await …)` —
the getter only runs after the await resolves.
- Mutating `$state` synchronously inside a `$derived`-triggered fetch is a
`state_unsafe_mutation` — the e2e fetch-counter patch defers its counter
writes a microtask (`HydrationTestIndicator.svelte`).
- Routes without their own meta render `<DefaultMetaTags />` — the React root
supplies default title/description/og tags to meta-less routes, and the SSR
head diff flags their absence.
- The census walk must use an index entry's own `path` (from `prefix()`);
`/scrims` was silently missing from the census before.
- `RENDER_GIT_COMMIT` comes from `apps/web-react/.env` via `loadEnv` in
vite.config (plain `process.env` misses it and the footer loses its Version
line — a differ pixel diff).
- `notify`/`resolveNotifications` port to
`#lib/features/notifications/core/`, publishing to per-user bus channels
instead of skalop pings; web push is still unported (`xxx:` in
`notify.server.ts`).

View File

@@ -372,6 +372,8 @@
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"chat.sidebar.inactive": "",
"chat.room.scrim": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -372,6 +372,8 @@
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"chat.sidebar.inactive": "",
"chat.room.scrim": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -372,6 +372,8 @@
"chat.newMessages": "Nuevos mensajes",
"chat.sidebar.title": "Chat",
"chat.sidebar.noActiveChats": "No hay chats activos",
"chat.sidebar.inactive": "",
"chat.room.scrim": "",
"fc.title": "Clave de amigo",
"fc.whereToFind": "Encuentra tu clave de amigo en tu Nintendo Switch (2)",
"fc.onceSetStaffOnly": "Una vez guardada, solo un miembro del staff puede modificarla.",

View File

@@ -372,6 +372,8 @@
"chat.newMessages": "Nuevo mensajes",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"chat.sidebar.inactive": "",
"chat.room.scrim": "",
"fc.title": "Clave de amigo",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -372,6 +372,8 @@
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"chat.sidebar.inactive": "",
"chat.room.scrim": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -372,6 +372,8 @@
"chat.newMessages": "Nouveau message",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"chat.sidebar.inactive": "",
"chat.room.scrim": "",
"fc.title": "Code ami ",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -372,6 +372,8 @@
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"chat.sidebar.inactive": "",
"chat.room.scrim": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -372,6 +372,8 @@
"chat.newMessages": "Nuovi messaggi",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"chat.sidebar.inactive": "",
"chat.room.scrim": "",
"fc.title": "Codice amico",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -372,6 +372,8 @@
"chat.newMessages": "新着メッセージ",
"chat.sidebar.title": "チャット",
"chat.sidebar.noActiveChats": "使用中のチャットはありません",
"chat.sidebar.inactive": "",
"chat.room.scrim": "",
"fc.title": "フレンドコード",
"fc.whereToFind": "フレンドコードは Nintendo Switch から見ることができます",
"fc.onceSetStaffOnly": "フレンドコードは一度設定すると、変更できるのはスタッフのみです。",

View File

@@ -372,6 +372,8 @@
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"chat.sidebar.inactive": "",
"chat.room.scrim": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -372,6 +372,8 @@
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"chat.sidebar.inactive": "",
"chat.room.scrim": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -372,6 +372,8 @@
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"chat.sidebar.inactive": "",
"chat.room.scrim": "",
"fc.title": "",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -372,6 +372,8 @@
"chat.newMessages": "",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"chat.sidebar.inactive": "",
"chat.room.scrim": "",
"fc.title": "Código de amigo",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -372,6 +372,8 @@
"chat.newMessages": "Новые сообщения",
"chat.sidebar.title": "",
"chat.sidebar.noActiveChats": "",
"chat.sidebar.inactive": "",
"chat.room.scrim": "",
"fc.title": "Код друга",
"fc.whereToFind": "",
"fc.onceSetStaffOnly": "",

View File

@@ -372,6 +372,8 @@
"chat.newMessages": "新消息",
"chat.sidebar.title": "聊天",
"chat.sidebar.noActiveChats": "暂无活跃聊天",
"chat.sidebar.inactive": "",
"chat.room.scrim": "",
"fc.title": "好友编号",
"fc.whereToFind": "在您的 Nintendo Switch (2) 上找到好友编号",
"fc.onceSetStaffOnly": "设置完成后,只有工作人员能修改",

View File

@@ -92,9 +92,11 @@ async function buildCensus(): Promise<CensusRow[]> {
const walk = (entries: RouteEntry[], parentPath: string) => {
for (const entry of entries) {
const fullPath = entry.index
? parentPath
: joinPaths(parentPath, entry.path);
// an index entry inside `prefix()` carries the prefix as its own path
const fullPath =
entry.path !== undefined
? joinPaths(parentPath, entry.path)
: parentPath;
if (entry.children) {
walk(entry.children, fullPath);

View File

@@ -5,7 +5,11 @@
// from `x-forwarded-proto` + Host keeps remote-function CSRF checks passing.
// Run with PROTOCOL_HEADER=x-forwarded-proto (set by the tooling).
import { createServer } from "node:http";
import { handler } from "../build/handler.js";
// the build output only exists after `pnpm run build`; the indirection keeps
// svelte-check from following (and type-checking) the generated chunks
const buildDir = "../build";
const { handler } = await import(`${buildDir}/handler.js`);
const port = Number(process.env.PORT ?? 3000);

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { Calendar } from "@lucide/svelte";
import { getLocale } from "#lib/paraglide/runtime.js";
import FormMessage from "./FormMessage.svelte";
import Label from "./Label.svelte";
@@ -184,10 +185,9 @@ function segmentValue(type: EditableSegmentType): number | string | null {
function segmentText(type: EditableSegmentType): string {
const segValue = segmentValue(type);
if (segValue === null) return PLACEHOLDERS[type];
if (type === "dayPeriod") return String(segValue);
if (type === "hour") return String(segValue);
if (type === "minute") return String(segValue).padStart(2, "0");
if (type === "year") return String(segValue).padStart(4, "0");
return String(segValue).padStart(2, "0");
return String(segValue);
}
function setSegmentValue(type: EditableSegmentType, next: number | string) {
@@ -535,30 +535,16 @@ const popoverId = `${uid}-calendar-popover`;
aria-label="Open calendar"
onclick={openCalendar}
>
<svg
class="icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M8 2v4" />
<path d="M16 2v4" />
<rect width="18" height="18" x="3" y="4" rx="2" />
<path d="M3 10h18" />
</svg>
<Calendar class="icon" />
</button>
</div>
{#if errorText}
<FormMessage type="error" spaced={false} id={errorId}>
<FormMessage type="error" id={errorId}>
{errorText}
</FormMessage>
{/if}
{#if bottomText}
<FormMessage type="info" spaced={false}>{bottomText}</FormMessage>
<FormMessage type="info">{bottomText}</FormMessage>
{/if}
<div
bind:this={calendarPopover}
@@ -648,7 +634,6 @@ const popoverId = `${uid}-calendar-popover`;
.root {
display: flex;
flex-direction: column;
gap: var(--s-1-5);
width: 100%;
&[data-open] .group {
@@ -674,11 +659,9 @@ const popoverId = `${uid}-calendar-popover`;
.dateInput {
display: flex;
font-size: var(--font-sm);
}
.segment {
white-space: pre;
caret-color: transparent;
&:not([data-type="literal"]) {
@@ -704,11 +687,9 @@ const popoverId = `${uid}-calendar-popover`;
background-color: transparent;
border: none;
outline: none;
cursor: pointer;
display: flex;
}
.icon {
.button :global(.icon) {
color: var(--color-text-high);
width: var(--field-size-icon);
height: var(--field-size-icon);

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import MetaTags from "./MetaTags.svelte";
/** The site-wide fallback meta for routes without their own (the React app's root defaults). */
</script>
<MetaTags
title="sendou.ink"
ogTitle="sendou.ink - Competitive Splatoon Hub"
description="Sendou.ink is the home of competitive Splatoon featuring daily tournaments and a seasonal ladder. Variety of tools and the largest collection of builds by top players allow you to level up your skill in Splatoon 3."
/>

View File

@@ -197,6 +197,11 @@ const composedAriaLabel = $derived(
white-space: nowrap;
}
.root :global(button:disabled .selectValue) {
color: var(--color-text-high);
font-style: italic;
}
.itemTextsContainer {
line-height: 1.1;
font-size: var(--font-sm);

View File

@@ -186,6 +186,11 @@ const composedAriaLabel = $derived(
white-space: nowrap;
}
.root :global(button:disabled .selectValue) {
color: var(--color-text-high);
font-style: italic;
}
.itemTextsContainer {
line-height: 1.1;
font-size: var(--font-sm);

View File

@@ -117,14 +117,13 @@ const timePopoverFooterText = $derived(
{#if post.isPrivate}
<Popover>
{#snippet trigger(triggerProps)}
<button
type="button"
class="iconButton"
data-testid="limited-visibility-popover"
<Button
variant="minimal"
testId="limited-visibility-popover"
{...triggerProps}
>
<EyeOff class="usersIcon" />
</button>
{#snippet icon()}<EyeOff class="scrimUsersIcon" />{/snippet}
</Button>
{/snippet}
{m.scrims_limitedVisibility()}
</Popover>
@@ -209,10 +208,10 @@ const timePopoverFooterText = $derived(
{@const tournament = post.mapsTournament}
<Popover>
{#snippet trigger(triggerProps)}
<button
type="button"
class="iconButton tournamentPopoverTrigger"
data-testid="tournament-popover-trigger"
<Button
variant="minimal"
class="tournamentPopoverTrigger"
testId="tournament-popover-trigger"
{...triggerProps}
>
<Avatar
@@ -220,7 +219,7 @@ const timePopoverFooterText = $derived(
url={tournament.avatarUrl ?? undefined}
alt={tournament.name}
/>
</button>
</Button>
{/snippet}
<div class="stack sm text-center">
<a
@@ -341,25 +340,9 @@ const timePopoverFooterText = $derived(
{/snippet}
<style>
.iconButton {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
padding: 0;
color: var(--color-text);
border-radius: var(--radius-field);
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
}
.iconButton :global(.usersIcon) {
width: 18px;
/* the React app sizes the icon itself (18px) inside the 20px button icon
slot; the tripled class out-specifies Button's own svg sizing */
:global(.scrimUsersIcon.scrimUsersIcon.scrimUsersIcon) {
height: 18px;
}
@@ -392,7 +375,7 @@ const timePopoverFooterText = $derived(
font-size: var(--font-xs);
}
.tournamentPopoverTrigger {
:global(.tournamentPopoverTrigger) {
height: auto;
}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Users } from "@lucide/svelte";
import { Popover } from "@sendou/components";
import { Button, Popover } from "@sendou/components";
import Avatar from "#lib/components/Avatar.svelte";
import NoteAvatar from "#lib/components/NoteAvatar.svelte";
import UserCard from "#lib/features/user-page/components/UserCard.svelte";
@@ -18,9 +18,9 @@ const cards = getUserCardContext();
<Popover>
{#snippet trigger(triggerProps)}
<button type="button" class="minimalButton" {...triggerProps}>
<Users class="usersIcon" />
</button>
<Button variant="minimal" {...triggerProps}>
{#snippet icon()}<Users class="scrimUsersIcon" />{/snippet}
</Button>
{/snippet}
<div class="stack md">
{#each users as user (user.id)}
@@ -41,25 +41,9 @@ const cards = getUserCardContext();
</Popover>
<style>
.minimalButton {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
padding: 0;
color: var(--color-text);
border-radius: var(--radius-field);
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
}
.minimalButton :global(.usersIcon) {
width: 18px;
/* the React app sizes the icon itself (18px) inside the 20px button icon
slot; the tripled class out-specifies Button's own svg sizing */
:global(.scrimUsersIcon.scrimUsersIcon.scrimUsersIcon) {
height: 18px;
}
</style>

View File

@@ -186,6 +186,17 @@ const fieldId = $props.id();
{selectedKey}
onSelectionChange={handleSelectionChange}
>
{#snippet valueContent(key)}
{@const option = options.find((candidate) => candidate.key === key)}
{#if option}
<div class="option">
{option.label}
{#if option.users}
<span class="optionUsers">{option.users}</span>
{/if}
</div>
{/if}
{/snippet}
{#each options as option (option.key)}
<SelectItem id={option.key} textValue={option.textValue}>
<div class="option">

View File

@@ -1,10 +1,13 @@
<script lang="ts">
import DefaultMetaTags from "#lib/components/DefaultMetaTags.svelte";
import ScrimPage from "#lib/features/scrims/components/ScrimPage.svelte";
import { page } from "$app/state";
const scrimPostId = $derived(Number(page.params.id));
</script>
<DefaultMetaTags />
{#key scrimPostId}
<ScrimPage {scrimPostId} />
{/key}

View File

@@ -1,8 +1,11 @@
<script lang="ts">
import DefaultMetaTags from "#lib/components/DefaultMetaTags.svelte";
import Main from "#lib/components/Main.svelte";
import NewScrimForm from "#lib/features/scrims/components/NewScrimForm.svelte";
</script>
<DefaultMetaTags />
<Main>
<NewScrimForm />
</Main>

View File

@@ -1,7 +1,7 @@
import { paraglideVitePlugin } from "@inlang/paraglide-js";
import adapter from "@sveltejs/adapter-node";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
import { defineConfig, loadEnv } from "vite";
const IS_E2E_BUILD = process.env.VITE_E2E_TEST_RUN === "true";
@@ -10,7 +10,11 @@ export default defineConfig(({ mode }) => ({
// apps/web-react/.env is the single source of truth for them
envDir: "../web-react",
define: {
__GIT_COMMIT__: JSON.stringify(process.env.RENDER_GIT_COMMIT ?? ""),
__GIT_COMMIT__: JSON.stringify(
process.env.RENDER_GIT_COMMIT ??
loadEnv(mode, "../web-react", "").RENDER_GIT_COMMIT ??
"",
),
},
plugins: [
paraglideVitePlugin({

View File

@@ -46,7 +46,8 @@
"status": "pending"
},
"chat": {
"status": "pending"
"status": "migrated",
"notes": "rebuilt (not ported) on ChatRoom/ChatRoomMessage/ChatRoomRead + query.live per plan; scrim rooms are the first consumer. UI is a redesign so no differ parity gate; sendouq/tournament rooms + SplatNet QR links arrive with their features"
},
"comp-analyzer": {
"status": "pending"
@@ -105,7 +106,8 @@
"status": "pending"
},
"notifications": {
"status": "pending"
"status": "pending",
"notes": "bell peek/live stream/mark-seen/10s grace dot + notify/resolve server core shipped with the scrims slice (notifications e2e green against apps/web); /notifications page, web push and remaining surface still pending"
},
"object-damage-calculator": {
"status": "pending"
@@ -126,7 +128,8 @@
"status": "pending"
},
"scrims": {
"status": "pending"
"status": "verified",
"notes": "phase 3 vertical slice; scrims e2e (9 tests) green against apps/web and differ clean (pixel/head/resource) for /scrims, /scrims/new, /scrims/:id 2026-08-17"
},
"search": {
"status": "pending"