Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
44 changes: 18 additions & 26 deletions .claude/skills/code-style/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,34 +156,26 @@ implementing that algorithm — forcing list verbs onto textbook terms hides the

## Comments

Two comment jobs, two locations. A JSDoc block on a declaration carries the caller-facing contract:
what a reader needs to use the thing without opening its body — the guarantee, the invariants a
caller must uphold, the failure modes. A `//` at a statement carries the implementation note: why
that line does the non-obvious thing. A body's mechanism — how the algorithm walks, which step does
what — is never narrated from the top; it lives at the lines, or nowhere when the code already shows
it.

- Prefer the enforceable form. Before writing a comment, put the fact where a machine holds it:
encode an outcome set as a discriminated union, a bound as a named constant, a caller rule as a
type; protect a frozen wire or draw layout with a golden test. Comment only the residue neither a
type nor a test can hold, and where a test enforces an invariant, point at it rather than
restating the consequence.
- Comment the decision, not the code. A comment states an invariant, cross-file or runtime behavior,
or why a non-obvious choice was made. One that restates the name, the signature, or the next
line's mechanics is a defect — delete it.
- A long JSDoc block is a placement smell, not a prose exercise. When a declaration's comment runs
long because it narrates the body, relocate: the mechanism to `//` at the lines, the enforceable
parts into types or tests, leaving the block at the contract. A genuinely irreducible multi-point
contract stays — render it as structured prose (one point per paragraph, led by its topic
sentence; one fact per sentence; an outcome map or state-to-action table as a bullet list) and
load the `docs-writing` skill for its wording.
- JSDoc blocks are always multi-line (`/**` alone, one `*`-prefixed line per point, `*/` alone —
never single-line `/** … */`), attached directly to the declaration they describe.
Two lint rules own comment shape, and neither has an exception for authored code or a baseline
marker (generated output gets a per-file override in `.oxlintrc.json`): `zgeoff/no-jsdoc` bans every
`/** … */` block, and `zgeoff/max-consecutive-line-comments` bans a run of more than three
consecutive `//` lines. A fact a reader needs has a home that stays true: the code, a type, a named
constant, a test whose name states the rule, or the subsystem's doc under `docs/architecture/`. A
comment holds only the residue none of those can hold.

- Write a `//` comment for one thing: the reason the obvious alternative is wrong, when the cause
lives outside the file. A library quirk, a runtime or platform behavior, a parser or compiler
rule, a production incident. Place it at the line or declaration it explains, three lines at most.
- Never write what the code shows: what a function does, its parameters, its outcomes, an invariant
a test already states, which step does what. An agent derives those from the code, its tests, and
its references; a comment that caches them goes stale.
- A caller-facing contract is a test whose name states it, or a sentence in the subsystem doc. A
design decision is a sentence in the subsystem doc.
- Comments describe the code as it is now — no history ("previously", "now uses"), no project state
(issue numbers, phase labels, "not wired yet"); those live in the commit message.
- Comments don't name other declarations — renames strand the reference. State the contract instead:
"callers must pass edits sorted last-to-first", not "(buildEditsFromAST's contract)". A
declaration's own parameters and signature types are fine to name.
- Comments don't name other declarations — renames strand the reference. State the fact instead: "an
append from any other session is rejected", not "(see appendCheckpoints)". A declaration's own
parameters and signature types are fine to name.

## Type-only modules

Expand Down
4 changes: 2 additions & 2 deletions .claude/skills/docs-writing/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
name: docs-writing
description:
Prose rules for everything committed to the repo — docs/, READMEs, AGENTS.md, skills, and doc
comments. Use when writing, editing, or reviewing any repo prose.
Prose rules for everything committed to the repo — docs/, READMEs, AGENTS.md, and skills. Use when
writing, editing, or reviewing any repo prose.
---

# Docs writing
Expand Down
7 changes: 4 additions & 3 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -446,10 +446,11 @@
},
{
"files": ["**/schema.generated.ts"],
// kysely-codegen column overrides emit inline `import()` type annotations; the file is
// generated, so the style rule has nothing to teach it
// kysely-codegen emits inline `import()` type annotations and a JSDoc header; the file is
// generated, so the style rules have nothing to teach it
"rules": {
"typescript/consistent-type-imports": "off"
"typescript/consistent-type-imports": "off",
"zgeoff/no-jsdoc": "off"
}
},
{
Expand Down
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ read the architecture doc for the subsystem in full before reasoning from its co
| ---------------- | ---------------------------------------------------------------------------------------------------------- |
| `code-style` | writing, reviewing, or renaming any TypeScript — the function-verb taxonomy lives there |
| `testing` | designing, writing, or reviewing tests |
| `docs-writing` | writing or editing any committed prose: `docs/`, READMEs, this file, skills, doc comments |
| `docs-writing` | writing or editing any committed prose: `docs/`, READMEs, this file, skills |
Comment thread
zgeoff marked this conversation as resolved.
| `delivery-lead` | triage, refinement, milestone, board, or "what's next" work — the increment model and the roles live there |
| `game-lifecycle` | any work in the activity, replay, or idle packages — the assumptions to drop and the state machines |

Expand Down Expand Up @@ -341,6 +341,9 @@ the mechanics and provisioning.
`// oxlint-disable-next-line <rule> -- baseline(#236)`, never turned off in config. The
unused-directive check is the ratchet: fixing a baselined site strands its comment, and lint fails
until the comment is deleted.
- `zgeoff/no-jsdoc` and `zgeoff/max-consecutive-line-comments` are never baselined and never
disabled inline: a comment the rules reject is deleted or cut, and the fact it held moves to a
test name or the subsystem doc (the `code-style` skill owns the rule for what a comment holds).
- `typescript/prefer-readonly-parameter-types` is never baselined: a function's own
data/config/props/option types go `readonly` (React props `Readonly<Props>`), and framework
handles with no readonly form (a `Kysely`/`Elysia`/`RPCHandler`/`Request` handle, a `Date`, …) are
Expand Down
52 changes: 7 additions & 45 deletions apps/web-e2e/benchmarks/drag-pan.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,56 +2,19 @@ import { expect, test } from '../src/test';
import { waitForHoneypotWindow } from '../src/wait-for-honeypot-window';
import { waitForStableFrames } from '../src/wait-for-stable-frames';

/**
* How many drag legs to walk. Each leg drags 60% of the canvas width in the same direction, so
* travel accumulates leg over leg — enough total ground to cross many chunk boundaries without
* reading the client's internal chunk-size constants, which would couple this black-box benchmark
* to worldmap-client's geometry. Direction never alternates: a there-and-back oscillation would
* revisit the same chunks, and once chunk generation is cached the benchmark would measure cache
* replays instead of the generation cost it exists to track.
*/
// legs never alternate direction: a there-and-back drag revisits chunks whose generation is already
// cached, so the benchmark would measure cache replays instead of the generation cost it tracks
const DRAG_LEG_COUNT = 12;

/**
* Intermediate pointer-move events per leg. Camera-controls reads a drag as a series of pointermove
* deltas, not a single teleport — too few steps understates a real drag's incremental chunk
* crossings.
*/
const DRAG_STEPS_PER_LEG = 20;

/**
* A frame gap past this many milliseconds — more than one missed vsync at 60fps — counts as a
* dropped frame.
*/
const DROPPED_FRAME_THRESHOLD_MS = 32;

/**
* The frame-gap sampler's state, parked on the page's own `globalThis` so it survives across the
* two separate `page.evaluate` round trips that start and read it.
*/
interface DragPanWindow {
__dragPanFrameGaps: Array<number>;
__dragPanFrameLoopID: number;
}

/**
* Repeatable drag-pan performance probe for the explore map. Reports peak frame gap and
* dropped-frame count for a multi-leg one-way drag across the world map to `console.log` and a
* test annotation; it makes no pass/fail claim about specific numbers, since headless-GPU
* throughput varies by machine.
*
* Excluded from every default run: this config's `testDir` is `./benchmarks`, never scanned by
* `playwright.config.ts`'s `./specs`, so neither `bun run e2e` nor CI ever picks it up. The app
* server serves the prebuilt artifact, so build first, then run on demand:
*
* ```sh
* bun run build --filter=@vers/web
* bun run --cwd apps/web-e2e e2e:bench -- --headed
* ```
*
* `--headed` is recommended: headless Chromium's software WebGPU path measures compositor
* throughput this benchmark doesn't care about, not the app's own frame cost.
*/
// run with `--headed`: headless Chromium's software WebGPU path measures compositor throughput
// rather than the app's own frame cost
test('it drag-pans across the explore map and reports peak frame gap and dropped frames', async ({
page,
}) => {
Expand Down Expand Up @@ -87,10 +50,9 @@ test('it drag-pans across the explore map and reports peak frame gap and dropped
const leftX = box.x + box.width * 0.2;
const rightX = box.x + box.width * 0.8;

// a throwaway warm-up leg, run before the sampler is installed, so the measured legs below don't
// pay for whatever a drag itself first warms up (pointer-event handler JIT, first-drag camera-
// controls allocations) on top of the scene readiness waitForStableFrames already gated on. Ends
// at rightX, where the first measured leg (below) starts, so no extra jump sits between them.
// a throwaway warm-up leg, run before the sampler is installed, so the measured legs don't pay
// for what a first drag itself warms up (pointer-event handler JIT, first-drag camera-controls
// allocations). Ends at rightX, where the first measured leg starts.
await page.mouse.move(leftX, centerY);
await page.mouse.down();
await page.mouse.move(rightX, centerY, { steps: DRAG_STEPS_PER_LEG });
Expand Down
5 changes: 0 additions & 5 deletions apps/web-e2e/playwright.bench.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,6 @@ import type { E2EOptions } from './src/test';
// measuring the real WebGPU/R3F canvas, not the placeholder
const environment = loadE2EEnvironment({});

/**
* On-demand perf benchmarks: run manually against a real GPU, never picked up by `bun run e2e`'s
* default config. Its own `testDir` keeps every benchmark spec out of `playwright.config.ts`'s
* discovery entirely, so nothing here can ever land on the CI critical path.
*/
export default defineConfig<E2EOptions>({
expect: {
timeout: 10 * 1000,
Expand Down
6 changes: 0 additions & 6 deletions apps/web-e2e/playwright.stack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,6 @@ import type { E2EOptions } from './src/test';

const baseURL = process.env['STACK_BASE_URL'] ?? 'http://localhost:3200';

/**
* The full-stack suite: the whole converged spec set against the real service images the deploy
* pipeline is about to promote, booted by `docker-compose.stack.yml` before playwright runs — no
* webServer entries, the harness owns the stack lifecycle. Specs create their own unique accounts,
* so a retry never replays against state a failed attempt mutated.
*/
export default defineConfig<E2EOptions>({
expect: {
timeout: 10 * 1000,
Expand Down
5 changes: 0 additions & 5 deletions apps/web-e2e/specs/avatar-roster.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import { expect, test } from '../src/test';
import { waitForHoneypotWindow } from '../src/wait-for-honeypot-window';

/**
* The multi-avatar journey: create a second avatar from the roster (which auto-selects it),
* switch back to the first, and prove the choice survives a full reload — the selection is
* persisted server-side, not a client artifact.
*/
test('it creates a second avatar, switches back, and keeps the choice across a reload', async ({
page,
}) => {
Expand Down
5 changes: 0 additions & 5 deletions apps/web-e2e/specs/avatar-satellite.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import { expect, test } from '../src/test';
import { waitForHoneypotWindow } from '../src/wait-for-honeypot-window';

/**
* `/avatar` mounts its own satellite canvas alongside the persistent world canvas: two `<canvas>`
* elements are attached while the panel is up, and navigating away drops back to one as the
* satellite dies with the route (`keepAlive: false`) while the tagged world canvas survives.
*/
test('it mounts a second canvas for the avatar satellite and drops it on navigation away', async ({
page,
}) => {
Expand Down
5 changes: 0 additions & 5 deletions apps/web-e2e/specs/canvas-persistence.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import { expect, test } from '../src/test';
import { waitForHoneypotWindow } from '../src/wait-for-honeypot-window';

/**
* The `_game` layout mounts its canvas once and never remounts it across child-route navigation:
* a client-side nav to another game route must leave the same `<canvas>` element in the DOM,
* carrying whatever GPU state it already uploaded.
*/
test('it keeps the same canvas element across client-side game navigation', async ({ page }) => {
await page.setExtraHTTPHeaders({ 'x-forwarded-for': '127.0.0.1' });
await page.goto('/login');
Expand Down
5 changes: 0 additions & 5 deletions apps/web-e2e/specs/checkpoint-hash-parity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,6 @@ const CANONICAL_JSON = JSON.stringify([
'server-key',
]);

/**
* Every party on the checkpoint hash chain — service, verifier, browser client — must derive
* byte-identical digests. This asserts the Node-context contract call and a real browser's
* WebCrypto both derive the frozen digest from the same canonical bytes.
*/
test('it derives the same frozen digest from the contract call and from browser WebCrypto', async ({
page,
}) => {
Expand Down
7 changes: 0 additions & 7 deletions apps/web-e2e/specs/home-smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
import { expect, test } from '../src/test';
import { waitForHoneypotWindow } from '../src/wait-for-honeypot-window';

/**
* Exercises the home route against a live server, past what `bun test` can drive (it resolves
* package exports without the `react-server` condition, and there's no live request's
* `AsyncLocalStorage` context). The hero's calls to action come from route loader data, so this
* checks both the raw served HTML (200, `text/html`, hero copy and links already present) and the
* hydrated page.
*/
test('it serves the home page and renders the signed-out actions', async ({ page, request }) => {
const rawResponse = await request.get('/');

Expand Down
4 changes: 0 additions & 4 deletions apps/web-e2e/specs/production-boot-smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import { expect, test } from '../src/test';

/**
* Serving proof for the deployable artifact needing no signed-in state and no secrets: the health
* check answers and the anonymous home page renders.
*/
test('it serves the production build health check and anonymous home page', async ({ request }) => {
const health = await request.get('/health');

Expand Down
31 changes: 2 additions & 29 deletions apps/web-e2e/specs/signup-journey.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,6 @@ interface SignUpJourney extends E2EOptions {
readonly username: string;
}

/**
* Drives the whole account-creation journey — signup, emailed-code verification, onboarding, and
* avatar creation — and lands signed in at `/explore`. Every form submit paces past the artifact's
* real honeypot window first.
*/
async function runSignUpIntoGame(page: Page, journey: Readonly<SignUpJourney>): Promise<void> {
await page.setExtraHTTPHeaders({ 'x-forwarded-for': '127.0.0.1' });
await page.goto('/signup');
Expand Down Expand Up @@ -133,11 +128,8 @@ async function runSignUpIntoGame(page: Page, journey: Readonly<SignUpJourney>):
const nameField = page.getByLabel('Name', { exact: true });

// the create-avatar form's client mount replaces the server-rendered markup, so a name typed
// before the swap passes a value assertion yet submits as an empty form — and gating on the
// root hydration marker instead would serialize on the whole game shell, which can take far
// longer than the form needs. So the whole fill-and-submit cycle retries on the navigation
// outcome: an empty submit is rejected server-side without creating anything, which makes the
// retry safe, and the URL guard keeps a slow success from being submitted twice.
// before the swap passes a value assertion yet submits as an empty form. An empty submit is
// rejected server-side without creating anything, so the whole fill-and-submit cycle retries.
await expect(async () => {
if (new URL(page.url()).pathname !== '/explore') {
await nameField.fill(journey.avatarName);
Expand All @@ -151,11 +143,6 @@ async function runSignUpIntoGame(page: Page, journey: Readonly<SignUpJourney>):
}).toPass({ timeout: 20_000 });
}

/**
* Reads the onboarding code from whichever backend the journey signed up against: the mock
* verification service's e2e lookup, or the resend stub that captured the real service-email's
* welcome message.
*/
function waitForVerificationCode(
options: Readonly<E2EOptions & { email: string }>,
): Promise<string> {
Expand All @@ -166,11 +153,6 @@ function waitForVerificationCode(

const MockVerificationCodeSchema = z.object({ code: z.string() });

/**
* Polls the mock backend's test-only verification-code endpoint, which answers 404 until
* `createVerification` has stored a row for the email — the welcome-email send that carries the
* same code is a fire-and-forget queue drain behind the signup response.
*/
async function waitForMockVerificationCode(
email: string,
mockVerificationURL: string | undefined,
Expand Down Expand Up @@ -203,10 +185,6 @@ async function waitForMockVerificationCode(
const CapturedEmailSchema = z.object({ text: z.string() });
const CapturedEmailsSchema = z.object({ emails: z.array(CapturedEmailSchema) });

/**
* Polls the resend stub's capture endpoint for the welcome email the real service-email handed it
* and pulls the onboarding code out of the verification URL its `code` query param carries.
*/
async function waitForStackVerificationCode(
email: string,
resendStubURL: string | undefined,
Expand Down Expand Up @@ -237,11 +215,6 @@ async function waitForStackVerificationCode(
return code;
}

/**
* A globally-unique letters-only avatar name: `AvatarNameSchema` accepts letters only, and the
* real stack's database persists across runs and enforces the same uniqueness the mock backend
* does.
*/
function buildAvatarName(): string {
const alphabet = 'abcdefghijklmnopqrstuvwxyz';

Expand Down
12 changes: 1 addition & 11 deletions apps/web-e2e/specs/web-locks-fallback.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,7 @@ interface LockProbe {
readonly pendingCount: number;
}

/**
* Reads the origin's writer-lock state from a page. `navigator.locks.query()` sees locks held by
* the page's dedicated workers, so this is the observable proof of which tab's worker won the
* election.
*/
// `navigator.locks.query()` from the page also reports locks held by the page's dedicated workers
function readWriterLock(page: Page, lockName: string): Promise<LockProbe> {
return page.evaluate(async (name) => {
const state = await navigator.locks.query();
Expand All @@ -27,12 +23,6 @@ function readWriterLock(page: Page, lockName: string): Promise<LockProbe> {
}, lockName);
}

/**
* The fallback transport for browsers without SharedWorker: every tab spawns a dedicated worker,
* the workers race the writer lock, and closing the writer's tab promotes the next waiter. Real
* tab-death lock release is the one behaviour no in-process test can exercise — this spec is its
* only coverage.
*/
test('it elects one writer without SharedWorker and promotes a survivor when the writer tab closes', async ({
context,
page,
Expand Down
6 changes: 0 additions & 6 deletions apps/web-e2e/src/create-stack-seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,6 @@ import { createDB } from '@vers/db';
import { DEMO_ACCOUNTS } from '@vers/mock-services';
import invariant from 'tiny-invariant';

/**
* Seeds the full-stack postgres with the shared demo accounts so a spec's seeded login lands the
* same as it does against the mock backend. Passwords are argon2id-hashed to match the user
* service's own hasher; each account's avatar is a direct row insert, enough for the shell's
* active-avatar gate. Run once against the migrated stack database before playwright.
*/
async function createStackSeed(): Promise<void> {
const databaseURL = process.env['DATABASE_URL'];

Expand Down
Loading