diff --git a/.claude/skills/ai-assistant-evals/SKILL.md b/.claude/skills/ai-assistant-evals/SKILL.md index dbfb02b0200..d70fc569b56 100644 --- a/.claude/skills/ai-assistant-evals/SKILL.md +++ b/.claude/skills/ai-assistant-evals/SKILL.md @@ -309,7 +309,7 @@ Check these before blaming the model. Status as of 2026-09-08. ## What the runner cannot tell you -- Whether the card looks right. Look at the screenshot. +- Whether the card looks right. Look at the capture. - Why the host froze, when it did. That needs the tab's console; the runner keeps the room and the trace. - Whether the card still works after the run. The snapshot holds the code and diff --git a/.claude/skills/screenshot-perf-diagnosis/SKILL.md b/.claude/skills/capture-perf-diagnosis/SKILL.md similarity index 69% rename from .claude/skills/screenshot-perf-diagnosis/SKILL.md rename to .claude/skills/capture-perf-diagnosis/SKILL.md index eae8b528e6c..781422a763d 100644 --- a/.claude/skills/screenshot-perf-diagnosis/SKILL.md +++ b/.claude/skills/capture-perf-diagnosis/SKILL.md @@ -1,38 +1,38 @@ --- -name: screenshot-perf-diagnosis -description: Diagnose why a screenshot capture took a given amount of time, from the per-capture stage telemetry the screenshot pipeline emits — one JSON log line per event on the `boxel:screenshot-perf` channel (the same `| json` convention as `boxel:client-perf`), plus the same `capture` breakdown persisted onto the capture's `media_cache_ledger.diagnostics` row so it is readable by SQL. Two event types cover the pipeline's processes — a `request` event from a serving surface (the GET `{realm}_screenshot/…` DSL route or `POST /_screenshot-card`) recording what the caller experienced (hit / rendered / congested / timeout / gated / error) and where the surface wall-clock went, and a `capture` event from the worker's `screenshot-card` task recording queue wait, the prerender stage breakdown, and the persist leg — joined on `jobId`, joined back to the realm-server's `realm:requests` lines by the `x-boxel-logging-correlation-id` `correlationId`, and joined to the prerender server/manager logs by the `prerenderRequestId`. Covers (1) a "this screenshot was slow" complaint given a served URL, a (realm + cardId + capture spec) triple, a matrix user/session, a time window, or a correlation id — locate the timing record(s) and break the wall-clock across the stages (queue-wait, page-acquire launch-vs-reuse, settle, image-paint wait, `page.screenshot`, persist upload-vs-dedupe-hit, serve) and name the dominant one; (2) separating a ledger hit (zero Chrome work, `outcome=hit`, no `capture` event) from an on-demand render (`outcome=rendered`); (3) separating a congested per-realm lane (a fail-fast `outcome=congested` 503 from the queue-depth pre-check, or a long `jobWaitMs`/`queueWaitMs` behind the serialized `screenshot:{realmURL}` lane) from a genuinely slow render (`renderMs`); (4) separating a cold-pool Chrome launch (`tabReused=false`, high `launchMs`/`tabStartupMs`) from a warm affinity reuse; (5) separating a real S3 upload (`persistOutcome=uploaded`) from a dedupe-on-write hit (`persistOutcome=deduped`); (6) separating a cache miss forced by a generation bump — the instance's index row was re-stamped (its own edit, a dependency change, or a realm-wide reindex), so a prior `media_cache_ledger` row exists for the same `(realm_url, source_url, capture_spec_hash)` at a lower `source_generation` — from a genuinely new capture spec (no prior row for that `capture_spec_hash`); and (7) recognizing an `outcome=timeout` (the sync wait expired with a 503 + Retry-After; the job keeps running, persists, and the caller's retry becomes a hit) and an `outcome=gated` (the realm's `allowArbitraryScreenshots` gate is closed; existing captures still serve); and (8) diagnosing a slow or failed **PDF** capture (`type=pdf`) — the output encoding is a `contentType` dimension (`application/pdf` vs the raster `image/*`) on both event types, the ledger row, and the dashboard's `contentType` template variable, so a "this PDF was slow" complaint runs the same hit-vs-render / stage-breakdown runbook scoped to `application/pdf`, with pagination cost landing in the render's capture stage and an over-bounds document (past the 20-page / 10 MB caps) surfacing as a `status=error` capture / `outcome=error` request rather than a slow success. Reads the "Screenshot Capture Performance" Grafana dashboard (grafanactl name `boxel-screenshot-perf`: capture latency by stage p50/p95, hit-vs-render and queue-vs-render splits, warm/cold page-acquire, persist outcome, sliced by realm, surface, and contentType encoding). For staging/prod this layers on `aws-access` (the AWS session + SSM port-forward to the read-only DB) and `tail-logs` (the Loki wrapper); hand off to `prerender-sizing` when the bottleneck is pool contention and to `indexing-diagnostics` for the prerender render internals (the `Timings`/`PoolMeta` the render itself records). Use when someone says a screenshot was slow, a capture URL took seconds, a `_screenshot/` request 503'd, or asks why a specific capture cost what it did. +name: capture-perf-diagnosis +description: Diagnose why a capture took a given amount of time, from the per-capture stage telemetry the capture pipeline emits — one JSON log line per event on the `boxel:capture-perf` channel (the same `| json` convention as `boxel:client-perf`), plus the same `capture` breakdown persisted onto the capture's `media_cache_ledger.diagnostics` row so it is readable by SQL. Two event types cover the pipeline's processes — a `request` event from a serving surface (the GET `{realm}_capture/…` DSL route or `POST /_capture-card`) recording what the caller experienced (hit / rendered / congested / timeout / gated / error) and where the surface wall-clock went, and a `capture` event from the worker's `capture-card` task recording queue wait, the prerender stage breakdown, and the persist leg — joined on `jobId`, joined back to the realm-server's `realm:requests` lines by the `x-boxel-logging-correlation-id` `correlationId`, and joined to the prerender server/manager logs by the `prerenderRequestId`. Covers (1) a "this capture was slow" complaint given a served URL, a (realm + cardId + capture spec) triple, a matrix user/session, a time window, or a correlation id — locate the timing record(s) and break the wall-clock across the stages (queue-wait, page-acquire launch-vs-reuse, settle, image-paint wait, `page.screenshot`, persist upload-vs-dedupe-hit, serve) and name the dominant one; (2) separating a ledger hit (zero Chrome work, `outcome=hit`, no `capture` event) from an on-demand render (`outcome=rendered`); (3) separating a congested per-realm lane (a fail-fast `outcome=congested` 503 from the queue-depth pre-check, or a long `jobWaitMs`/`queueWaitMs` behind the serialized `capture:{realmURL}` lane) from a genuinely slow render (`renderMs`); (4) separating a cold-pool Chrome launch (`tabReused=false`, high `launchMs`/`tabStartupMs`) from a warm affinity reuse; (5) separating a real S3 upload (`persistOutcome=uploaded`) from a dedupe-on-write hit (`persistOutcome=deduped`); (6) separating a cache miss forced by a generation bump — the instance's index row was re-stamped (its own edit, a dependency change, or a realm-wide reindex), so a prior `media_cache_ledger` row exists for the same `(realm_url, source_url, capture_spec_hash)` at a lower `source_generation` — from a genuinely new capture spec (no prior row for that `capture_spec_hash`); and (7) recognizing an `outcome=timeout` (the sync wait expired with a 503 + Retry-After; the job keeps running, persists, and the caller's retry becomes a hit) and an `outcome=gated` (the realm's `allowArbitraryCaptures` gate is closed; existing captures still serve); and (8) diagnosing a slow or failed **PDF** capture (`type=pdf`) — the output encoding is a `contentType` dimension (`application/pdf` vs the raster `image/*`) on both event types, the ledger row, and the dashboard's `contentType` template variable, so a "this PDF was slow" complaint runs the same hit-vs-render / stage-breakdown runbook scoped to `application/pdf`, with pagination cost landing in the render's capture stage and an over-bounds document (past the 20-page / 10 MB caps) surfacing as a `status=error` capture / `outcome=error` request rather than a slow success. Reads the "Capture Performance" Grafana dashboard (grafanactl name `boxel-screenshot-perf`: capture latency by stage p50/p95, hit-vs-render and queue-vs-render splits, warm/cold page-acquire, persist outcome, sliced by realm, surface, and contentType encoding). For staging/prod this layers on `aws-access` (the AWS session + SSM port-forward to the read-only DB) and `tail-logs` (the Loki wrapper); hand off to `prerender-sizing` when the bottleneck is pool contention and to `indexing-diagnostics` for the prerender render internals (the `Timings`/`PoolMeta` the render itself records). Use when someone says a capture was slow, a capture URL took seconds, a `_capture/` request 503'd, or asks why a specific capture cost what it did. allowed-tools: Read, Grep, Glob, Bash --- -# Screenshot capture performance diagnosis +# Capture performance diagnosis -The screenshot pipeline emits one structured per-capture timing record so a slow capture is explainable rather than a mystery. It is the screenshot-service sibling of `client-perf-diagnosis` (client telemetry) and `indexing-diagnostics` (the indexer's `diagnostics` columns), and it reuses both of their conventions: a `| json` Loki channel **and** a `diagnostics` JSONB blob persisted next to the artifact. +The capture pipeline emits one structured per-capture timing record so a slow capture is explainable rather than a mystery. It is the capture-service sibling of `client-perf-diagnosis` (client telemetry) and `indexing-diagnostics` (the indexer's `diagnostics` columns), and it reuses both of their conventions: a `| json` Loki channel **and** a `diagnostics` JSONB blob persisted next to the artifact. -This is the tool for turning "this screenshot was slow" into a stage-attributed breakdown that sums to the wall-clock, plus a plain-language root cause, cited to the exact log line / ledger row it came from. +This is the tool for turning "this capture was slow" into a stage-attributed breakdown that sums to the wall-clock, plus a plain-language root cause, cited to the exact log line / ledger row it came from. ## The pipeline and where each stage is measured ``` request on a surface ─────────────────────────────────────────────────► response - GET {realm}_screenshot/… (hit / rendered / - POST /_screenshot-card congested / timeout / + GET {realm}_capture/… (hit / rendered / + POST /_capture-card congested / timeout / │ gated / error) │ gen lookup · ledger lookup · gate(GET) · congestion precheck · enqueue │ - └── enqueue ──► [ screenshot:{realmURL} serialized lane ] ──► worker screenshot-card task + └── enqueue ──► [ capture:{realmURL} serialized lane ] ──► worker capture-card task queue wait │ ├─ permissions ├─ prerender call ──► prerender server │ page acquire (launch vs reuse) - │ render (nav · settle · imagePaint · screenshot) + │ render (nav · settle · imagePaint · capture) ├─ base64 decode └─ persist (putMedia: upload vs dedupe) ``` -Two event types, both on the **`boxel:screenshot-perf`** channel, defined in `packages/runtime-common/screenshot-perf.ts`: +Two event types, both on the **`boxel:capture-perf`** channel, defined in `packages/runtime-common/capture-perf.ts`: -- **`request`** — emitted by the serving surface when a capture-relevant request completes: the GET `_screenshot/` DSL route (`packages/runtime-common/realm.ts`) or `POST /_screenshot-card` (`packages/realm-server/handlers/handle-screenshot-card.ts`). It records what the caller experienced and how the **surface** wall-clock split. Plain uncaptured-miss 404s and request-shape 400s deliberately do **not** emit — absence of a `request` event for a URL means it never did capture work. -- **`capture`** — emitted by the worker's `screenshot-card` task (`packages/runtime-common/tasks/screenshot-card.ts`) when a job finishes: queue wait, the prerender stage breakdown, and the persist leg. The whole event (only the log-time `channel` is absent) is also written onto the capture's **`media_cache_ledger.diagnostics`** row for uploaded/deduped captures, so a completed capture's breakdown — envelope pivots included — is readable by SQL as well as by Loki. +- **`request`** — emitted by the serving surface when a capture-relevant request completes: the GET `_capture/` DSL route (`packages/runtime-common/realm.ts`) or `POST /_capture-card` (`packages/realm-server/handlers/handle-capture-card.ts`). It records what the caller experienced and how the **surface** wall-clock split. Plain uncaptured-miss 404s and request-shape 400s deliberately do **not** emit — absence of a `request` event for a URL means it never did capture work. +- **`capture`** — emitted by the worker's `capture-card` task (`packages/runtime-common/tasks/capture-card.ts`) when a job finishes: queue wait, the prerender stage breakdown, and the persist leg. The whole event (only the log-time `channel` is absent) is also written onto the capture's **`media_cache_ledger.diagnostics`** row for uploaded/deduped captures, so a completed capture's breakdown — envelope pivots included — is readable by SQL as well as by Loki. Every `*Ms` field is flat and top-level so LogQL can `unwrap` it directly. The fields NEST rather than sum as peers — attribute wall-clock with this containment in mind (remainders are unattributed overhead): @@ -41,7 +41,7 @@ queueWaitMs (outside totalMs — measured before the job claim) totalMs ≳ permissionsMs + prerenderMs + decodeMs + persistMs prerenderMs ⊇ launchMs + renderMs (+ transport to the prerenderer) launchMs ⊇ semaphoreMs + admissionMs + tabQueueMs + tabStartupMs + tabProbeMs - renderMs ⊇ navMs + settleMs + imagePaintMs + screenshotMs + renderMs ⊇ navMs + settleMs + imagePaintMs + cdpCaptureMs ``` `prerenderMs + launchMs + renderMs` double-counts: the latter two come out of the prerender response's own diagnostics and live inside the first. @@ -50,17 +50,17 @@ totalMs ≳ permissionsMs + prerenderMs + decodeMs + persistMs Envelope (shared): `eventType="request"`, `surface` (`get-dsl` | `post`), `outcome`, `realmURL`, `sourceURL`, `captureSpecHash`, `sourceGeneration`, `lane`, `correlationId`, `jobId`, `reservationId`, `contentType`, `totalMs`. `contentType` is the output **encoding** (`image/png`/`jpeg`/`webp` for a raster, `application/pdf` for a paged PDF); on a `request` event it is derived from the capture spec (`type=pdf` ⇒ `application/pdf`) or, on a hit, read off the served ledger row. Filter/split by it to isolate PDF traffic — see [PDF captures](#pdf-captures). -| Field | Stage | -| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `outcome` | `hit` (ledger hit, ~0 Chrome) · `rendered` (job finished inside the sync wait) · `congested` (GET only: fail-fast 503, no job enqueued — POST has no pre-check, so a backed-up lane shows there as a long `jobWaitMs` or `outcome=timeout`) · `timeout` (sync wait expired, 503 + Retry-After; job keeps running + persists) · `gated` (403, `allowArbitraryScreenshots` closed, GET only) · `error` | -| `hasTwin` | GET only (hard-coded null on POST): the congestion pre-check found a queued/in-flight twin to coalesce onto | -| `generationLookupMs` | narrow index read for the instance's live generation | -| `ledgerLookupMs` | `media_cache_ledger` lookup for the canonical identity (the hit fast-path) | -| `gateMs` | GET only: reading the realm's `allowArbitraryScreenshots` config | -| `precheckMs` | GET only: the fail-fast congestion pre-check query (`estimateScreenshotQueueWait`; POST runs that query only to size a timeout's `Retry-After`) | -| `enqueueMs` | publishing the job (incl. coalesce evaluation) | -| `jobWaitMs` | enqueue → job completion or sync-wait expiry — **the leg that holds queue-wait + render** | -| `serveMs` | GET: streaming the capture. POST: emitted only on the ledger-hit path — a `rendered` POST's base64 drain is unattributed | +| Field | Stage | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `outcome` | `hit` (ledger hit, ~0 Chrome) · `rendered` (job finished inside the sync wait) · `congested` (GET only: fail-fast 503, no job enqueued — POST has no pre-check, so a backed-up lane shows there as a long `jobWaitMs` or `outcome=timeout`) · `timeout` (sync wait expired, 503 + Retry-After; job keeps running + persists) · `gated` (403, `allowArbitraryCaptures` closed, GET only) · `error` | +| `hasTwin` | GET only (hard-coded null on POST): the congestion pre-check found a queued/in-flight twin to coalesce onto | +| `generationLookupMs` | narrow index read for the instance's live generation | +| `ledgerLookupMs` | `media_cache_ledger` lookup for the canonical identity (the hit fast-path) | +| `gateMs` | GET only: reading the realm's `allowArbitraryCaptures` config | +| `precheckMs` | GET only: the fail-fast congestion pre-check query (`estimateCaptureQueueWait`; POST runs that query only to size a timeout's `Retry-After`) | +| `enqueueMs` | publishing the job (incl. coalesce evaluation) | +| `jobWaitMs` | enqueue → job completion or sync-wait expiry — **the leg that holds queue-wait + render** | +| `serveMs` | GET: streaming the capture. POST: emitted only on the ledger-hit path — a `rendered` POST's base64 drain is unattributed | ### `capture` event fields @@ -68,12 +68,12 @@ Envelope (shared): `eventType="capture"`, `surface`, `status` (`ready` | `error` | Field | Stage | | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `queueWaitMs` | enqueue → reservation claim (from the queue's own clock) — **time behind the `screenshot:{realmURL}` lane** | +| `queueWaitMs` | enqueue → reservation claim (from the queue's own clock) — **time behind the `capture:{realmURL}` lane** | | `permissionsMs` | realm/user permission fetches before the render | | `prerenderMs` | the whole prerender call (incl. transport to the remote prerenderer) | | `launchMs` | page acquire inside the pool; components `semaphoreMs`, `admissionMs`, `tabQueueMs`, `tabStartupMs`, `tabProbeMs` | | `tabReused` | `true` = warm affinity hit; `false` = **cold-start tax** (a fresh Chrome tab) | -| `renderMs` | server render wall (navigation → capture bytes); components `navMs`, `settleMs`, `imagePaintMs`, `screenshotMs` | +| `renderMs` | server render wall (navigation → capture bytes); components `navMs`, `settleMs`, `imagePaintMs`, `cdpCaptureMs` | | `decodeMs` | base64 → bytes ahead of persist | | `persistMs` | `putMedia` (content hash + object write + ledger upsert) | | `persistOutcome` | `uploaded` (new object written) · `deduped` (identical bytes already stored, ledger row only) · `skipped` (no persist target / no store / capture failed) · `failed` | @@ -88,9 +88,9 @@ Envelope (shared): `eventType="capture"`, `surface`, `status` (`ready` | `error` Resolve the input to `(realmURL, sourceURL)` and, when you can, a `captureSpecHash` / `sourceGeneration`, then pull the record(s). -- **A served URL** `{realm}_screenshot/{path}?{params}` — `realmURL` = the realm root; `sourceURL` = `{realm}{path}` (the instance URL, drop the `_screenshot/` segment); the query params (`format`, `viewport`, `dsf`, `fullPage`, `clip`) are the capture spec. Find `request`/`capture` events for that `sourceURL`, or the `media_cache_ledger` rows for it. +- **A served URL** `{realm}_capture/{path}?{params}` — `realmURL` = the realm root; `sourceURL` = `{realm}{path}` (the instance URL, drop the `_capture/` segment); the query params (`format`, `viewport`, `dsf`, `fullPage`, `clip`) are the capture spec. Find `request`/`capture` events for that `sourceURL`, or the `media_cache_ledger` rows for it. - **A (realm + cardId + capture spec) triple** — `sourceURL` = `cardId` without a trailing `.json`. Same lookups. -- **A matrix user / session** — pivot user → `correlationId` via the realm-server request log (which has the authenticated user), then to `boxel:screenshot-perf`; that route covers both surfaces. `capture.runAs` is the job's render identity, not the requester: a POST job runs as its requester, but a GET job runs as the **realm owner** — so `| runAs="@user:…"` misses every ``-driven capture unless the complainer owns the realm. +- **A matrix user / session** — pivot user → `correlationId` via the realm-server request log (which has the authenticated user), then to `boxel:capture-perf`; that route covers both surfaces. `capture.runAs` is the job's render identity, not the requester: a POST job runs as its requester, but a GET job runs as the **realm owner** — so `| runAs="@user:…"` misses every ``-driven capture unless the complainer owns the realm. - **A time window** — `--since` / a Grafana range; scope with `realmURL`/`surface`. - **A correlation id** — filter both event types by `correlationId` to get the request + its capture in one shot. @@ -104,36 +104,36 @@ Everything below layers on **`aws-access`** for staging/prod (the AWS session, t ```logql # request events (surface wall-clock + outcome) -{service="realm-server", env="$env"} |= "boxel:screenshot-perf" | json +{service="realm-server", env="$env"} |= "boxel:capture-perf" | json | line_format "{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}" | json - | channel="boxel:screenshot-perf" | eventType="request" + | channel="boxel:capture-perf" | eventType="request" | realmURL="" | sourceURL="" # capture events (queue wait + prerender breakdown + persist) -{service=~"realm-server|worker", env="$env"} |= "boxel:screenshot-perf" | json +{service=~"realm-server|worker", env="$env"} |= "boxel:capture-perf" | json | line_format "{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}" | json - | channel="boxel:screenshot-perf" | eventType="capture" + | channel="boxel:capture-perf" | eventType="capture" | sourceURL="" # pin one capture by its correlation id (both events) -{service=~"realm-server|worker", env="$env"} |= "boxel:screenshot-perf" |= "" | json +{service=~"realm-server|worker", env="$env"} |= "boxel:capture-perf" |= "" | json | line_format "{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}" | json - | channel="boxel:screenshot-perf" + | channel="boxel:capture-perf" ``` -The **"Screenshot Capture Performance"** dashboard (grafanactl name `boxel-screenshot-perf`, under `boxel-status`) already renders these with `env` / `realm` / `surface` / `contentType` template vars: requests-by-outcome, request wall-clock, capture latency by stage (p95), the queue-wait-vs-render split, page-acquire by warm/cold tab, persist outcome, and a "slowest captures by card" table. Set `contentType=application/pdf` to scope the whole board to PDF captures. Start there for the shape, drill to raw lines for one capture. +The **"Capture Performance"** dashboard (grafanactl name `boxel-screenshot-perf`, under `boxel-status`) already renders these with `env` / `realm` / `surface` / `contentType` template vars: requests-by-outcome, request wall-clock, capture latency by stage (p95), the queue-wait-vs-render split, page-acquire by warm/cold tab, persist outcome, and a "slowest captures by card" table. Set `contentType=application/pdf` to scope the whole board to PDF captures. Start there for the shape, drill to raw lines for one capture. ### From a laptop via `tail-logs` (see the `tail-logs` skill) ```bash packages/observability/scripts/tail-logs.sh --env staging --service realm-server \ - --filter 'boxel:screenshot-perf' --since 1h --no-follow + --filter 'boxel:capture-perf' --since 1h --no-follow packages/observability/scripts/tail-logs.sh --env staging --service worker \ - --filter 'boxel:screenshot-perf' --since 1h --no-follow + --filter 'boxel:capture-perf' --since 1h --no-follow # one capture, both events — one regex (the script takes --filter OR --regex, # not both; the event stringifies channel ahead of correlationId) packages/observability/scripts/tail-logs.sh --env staging --service worker \ - --regex 'boxel:screenshot-perf.*' --since 6h --no-follow + --regex 'boxel:capture-perf.*' --since 6h --no-follow ``` ### Ledger diagnostics by SQL (the durable copy) @@ -158,7 +158,7 @@ SELECT source_generation, capture_spec_hash, lane, content_type, width, height, diagnostics->>'renderMs' AS render_ms, diagnostics->>'settleMs' AS settle_ms, diagnostics->>'imagePaintMs' AS image_paint_ms, - diagnostics->>'screenshotMs' AS screenshot_ms, + diagnostics->>'cdpCaptureMs' AS capture_ms, diagnostics->>'persistOutcome' AS persist_outcome, diagnostics->>'persistMs' AS persist_ms FROM media_cache_ledger @@ -173,11 +173,11 @@ Only on-demand renders write `diagnostics`; a pure ledger **hit** serves without Name the dominant stage, then place it in one of these: 1. **Ledger hit vs on-demand render.** `request.outcome=hit` with only `ledgerLookupMs` + `serveMs` and **no `capture` event** ⇒ zero Chrome work; the wall-clock is a store read. `outcome=rendered` ⇒ a real capture ran (join to the `capture` event by `jobId`). -2. **Congested lane vs slow render.** On GET, `outcome=congested` ⇒ the fail-fast queue-depth pre-check tripped (`precheckMs`, `hasTwin=false`); no job ran — the lane is backed up. POST has no pre-check: a backed-up lane there shows as a long `jobWaitMs` or `outcome=timeout`, never `congested`. Otherwise compare `capture.queueWaitMs` (time behind the serialized `screenshot:{realmURL}` lane) against `capture.renderMs`: a large `queueWaitMs` with a small `renderMs` is a **queue** problem (too much concurrent demand on one realm, or the pool draining slowly); a small `queueWaitMs` with a large `renderMs` is a **render** problem (this card is expensive to settle). Remember the join is many-to-one: for a request that coalesced onto an existing job, `capture.queueWaitMs` overstates what that caller waited — its own ceiling is `request.jobWaitMs`. +2. **Congested lane vs slow render.** On GET, `outcome=congested` ⇒ the fail-fast queue-depth pre-check tripped (`precheckMs`, `hasTwin=false`); no job ran — the lane is backed up. POST has no pre-check: a backed-up lane there shows as a long `jobWaitMs` or `outcome=timeout`, never `congested`. Otherwise compare `capture.queueWaitMs` (time behind the serialized `capture:{realmURL}` lane) against `capture.renderMs`: a large `queueWaitMs` with a small `renderMs` is a **queue** problem (too much concurrent demand on one realm, or the pool draining slowly); a small `queueWaitMs` with a large `renderMs` is a **render** problem (this card is expensive to settle). Remember the join is many-to-one: for a request that coalesced onto an existing job, `capture.queueWaitMs` overstates what that caller waited — its own ceiling is `request.jobWaitMs`. 3. **Cold launch vs warm reuse.** In the `capture` event, `tabReused=false` with a large `launchMs` (usually `tabStartupMs`-dominated) is the **cold-pool tax** — a fresh Chrome tab. `tabReused=true` with a small `launchMs` is a warm affinity hit. If cold launches dominate across many captures, the pool is under-warmed for the load → hand off to **`prerender-sizing`**. 4. **Real upload vs dedupe hit.** `persistOutcome=uploaded` moved bytes to the store (`persistMs` includes the object write); `deduped` means the content hash already existed — only a ledger row was written, so `persistMs` is small. A slow `uploaded` persist points at the object store, not the render. 5. **Re-indexed miss vs new spec.** A `rendered` outcome is a cache miss. Query `media_cache_ledger` for the same `(realm_url, source_url, capture_spec_hash)`. A **prior row at a lower `source_generation`** means the instance's index row was re-stamped — `source_generation` is the realm generation (`boxel_index.generation`), stamped on every row a batch writes, so ask "re-indexed alone, or realm-wide?": the card itself was edited; or something in its `deps` changed (a linked card, a module in its ancestry — invalidation fans out); or a **full reindex** re-stamped the whole realm, which invalidates every cached capture at once and shows up as a burst of `rendered` outcomes plus the lane congestion of item 2 — do not read that incident as "the cards were edited". **No prior row for that `capture_spec_hash`** means a genuinely new capture spec (first request for that format/geometry). -6. **Timeout / gated.** `outcome=timeout` — the sync wait expired (503 + Retry-After); the job keeps running and persists, so the caller's _retry_ is a `hit`. The user-perceived latency is the wait budget, not the render; the real render cost is in the (later) `capture` event with the same `jobId`. `outcome=gated` — the realm's `allowArbitraryScreenshots` gate is closed (GET only); no capture ran, but already-persisted captures still serve as hits. +6. **Timeout / gated.** `outcome=timeout` — the sync wait expired (503 + Retry-After); the job keeps running and persists, so the caller's _retry_ is a `hit`. The user-perceived latency is the wait budget, not the render; the real render cost is in the (later) `capture` event with the same `jobId`. `outcome=gated` — the realm's `allowArbitraryCaptures` gate is closed (GET only); no capture ran, but already-persisted captures still serve as hits. ## PDF captures @@ -185,7 +185,7 @@ A `type=pdf` capture is the **same pipeline** — enqueue, one serialized lane, - **Isolate PDF traffic by encoding.** Both event types carry `contentType`, and the dashboard has a matching **`contentType` template variable** — set it to `application/pdf` (or add `| contentType="application/pdf"` to the LogQL) to see only PDF captures. `request` events derive it from the spec (`?type=pdf`, or the `type:'pdf'` POST body) or the served ledger row, so it is populated whatever the outcome; `capture` events report what the render produced, which means **`null` on every non-`ready` status**. Scoping `capture` events to `contentType="application/pdf"` therefore shows you successful PDF captures and silently drops every failed one — including the over-bounds case below. For failures, lean on the `request` event, or query `capture` with `status="error"` and no encoding filter and pick the PDFs out by their `jobId`/`correlationId` join to a `request` event. A served URL ending `…?type=pdf` and a `media_cache_ledger` row with `content_type='application/pdf'` (its `width`/`height` NULL — a paged document has no single pixel extent) are the durable tells. - **Hit vs on-demand, same as raster.** A repeat fetch of a durable `?type=pdf` URL is a ledger `hit` (zero Chrome, `application/pdf` read off the row); the first miss — or the first fetch after the source card was edited (the generation bump of playbook item 5) — is an on-demand `rendered` PDF with a full `capture` event. -- **Where a slow PDF's time goes.** In the `capture` event the pagination (`page.pdf()`) is the capture-stage cost — the `screenshotMs` analog inside `renderMs` — and it grows with **page count**: a long multi-page document spends more there than a one-page one, on top of the same `settleMs` the raster path pays. `settleMs` is also where `media:'print'` shows up — a card whose print CSS (`@media print`, `@page`) reflows to a taller/heavier layout settles that layout before pagination. Split `renderMs` the usual way; a PDF that's slow because it's _long_ shows a large capture-stage share with an ordinary settle. +- **Where a slow PDF's time goes.** In the `capture` event the pagination (`page.pdf()`) is the capture-stage cost — the `cdpCaptureMs` analog inside `renderMs` — and it grows with **page count**: a long multi-page document spends more there than a one-page one, on top of the same `settleMs` the raster path pays. `settleMs` is also where `media:'print'` shows up — a card whose print CSS (`@media print`, `@page`) reflows to a taller/heavier layout settles that layout before pagination. Split `renderMs` the usual way; a PDF that's slow because it's _long_ shows a large capture-stage share with an ordinary settle. - **Over-bounds is an error, not a slow success.** The PDF leg caps output at **20 pages / 10 MB**, enforced post-render. Past either, the capture fails: `capture.status=error` and `request.outcome=error`. Two things to know about finding these. The `capture` event's `contentType` is **always `null` here** — the document was produced and then rejected, but the emit only fills `contentType` on a `ready` status — so an `application/pdf`-scoped board or query will not show it; find it via the `request` event (still `application/pdf`, `outcome=error`) or an unscoped `status="error"` capture query. And the cap message is **not in Loki at all** — do not go looking for it. The `capture` event has no message field, `realm:requests` logs only method / URL / status / duration / `corr=`, and neither error helper logs the text on its way out. It reaches the **caller** and nowhere else: a GET gets a 500 error doc titled _"PDF capture too large"_ whose message names the offending dimension, a POST gets its `status="error"` result body with that message in `error`. So from logs alone you can establish **that** a capture blew the caps (the `capture`/`request` pair, plus the 500 on the `realm:requests` line for the same `correlationId`) but not **which** cap — for that, re-issue the request and read the response. Read this as "this card is too big to be one PDF," not as a pipeline regression — the fix is the card's content or print CSS. An over-bounds render still burned the render time, so it can be both slow **and** failed. - **Document dimensions.** Page count and byte size are properties of the produced document, not perf stages: the byte size is the persisted object's size (the store / ledger object the `?type=pdf` URL serves). An over-bounds failure names only the **one** dimension that tripped, and byte size is checked first — so a document that is over on both reports the byte cap and never mentions its page count. For the render internals behind a slow pagination, hand off to `indexing-diagnostics` by `prerenderRequestId` as with any capture. @@ -193,14 +193,14 @@ A `type=pdf` capture is the **same pipeline** — enqueue, one serialized lane, Always: sum the stages to the wall-clock, name the dominant one, give a plain-language cause, and cite the `correlationId` + the field/row each number came from. -- **Queue-bound.** `request`: `outcome=rendered`, `totalMs=25000`, `jobWaitMs≈23800`. `capture` (same `jobId`): `queueWaitMs≈23000`, `renderMs≈1200` (`settleMs 700`, rest small), `tabReused=true`. Pre-check at the time showed 4 pending on `screenshot:{realm}`. +- **Queue-bound.** `request`: `outcome=rendered`, `totalMs=25000`, `jobWaitMs≈23800`. `capture` (same `jobId`): `queueWaitMs≈23000`, `renderMs≈1200` (`settleMs 700`, rest small), `tabReused=true`. Pre-check at the time showed 4 pending on `capture:{realm}`. → _"23s of the 25s was queue-wait behind 4 pending captures on that realm's serialized lane; the render itself was 1.2s. This is lane congestion, not a slow card. (correlationId `…`; `capture.queueWaitMs`, `capture.renderMs`.)"_ - **Cold launch + heavy settle.** `capture`: `tabReused=false`, `launchMs≈8000` (`tabStartupMs≈7600`), `renderMs≈6800` (`settleMs≈6000`) — both inside `prerenderMs≈15000` — plus `persistOutcome=uploaded`, `persistMs≈400`; `totalMs≈15600`, with `queueWaitMs≈200` paid before it (outside `totalMs`). - → _"Cold Chrome launch 8s + a 6s settle; the paint/screenshot itself was ~0.8s. A warm tab (steady state) would save the 8s launch. (correlationId `…`; `capture.tabReused=false`, `launchMs`, `settleMs`.)"_ — if cold launches recur, escalate to `prerender-sizing`. + → _"Cold Chrome launch 8s + a 6s settle; the paint/capture itself was ~0.8s. A warm tab (steady state) would save the 8s launch. (correlationId `…`; `capture.tabReused=false`, `launchMs`, `settleMs`.)"_ — if cold launches recur, escalate to `prerender-sizing`. ## Hand-offs - **`aws-access`** — prerequisite for any staging/prod query (AWS session, read-only DB via SSM, Loki auth). - **`tail-logs`** — the Loki wrapper used above. - **`prerender-sizing`** — when the bottleneck is pool contention (recurring cold launches, `tabQueueMs`/`admissionMs`/`semaphoreMs` dominating page-acquire): that skill decides the pool envelope and task size. -- **`indexing-diagnostics`** — for the render internals behind `prerenderRequestId` (the prerender `Timings`/`PoolMeta`, per-format render breakdown, stall classification); the screenshot render goes through the same prerender pipeline that skill already maps. +- **`indexing-diagnostics`** — for the render internals behind `prerenderRequestId` (the prerender `Timings`/`PoolMeta`, per-format render breakdown, stall classification); the capture render goes through the same prerender pipeline that skill already maps. diff --git a/.claude/skills/indexing-diagnostics/SKILL.md b/.claude/skills/indexing-diagnostics/SKILL.md index 30d734a158d..fdcf5288069 100644 --- a/.claude/skills/indexing-diagnostics/SKILL.md +++ b/.claude/skills/indexing-diagnostics/SKILL.md @@ -1,6 +1,6 @@ --- name: indexing-diagnostics -description: Investigate slow or failing indexing using the per-row diagnostics persisted split by visit — the index visit's breakdown on `boxel_index.diagnostics`, the prerender-html visit's render breakdown (launch/wait/render timings, per-format render timings) on `prerendered_html.diagnostics`, each mirrored onto its table's `error_doc.diagnostics` for error rows, joinable per row via url + the two request ids — plus the matching prerender-server / manager logs. Covers (1) a render inside indexing timed out — classify which part of the prerender pipeline stalled, (2) an incremental or full reindex was slow but didn't fail — attribute time across the invalidation fan-out and find the rows that cost the most, (3) enumerating cards with broken `linksTo` / `linksToMany` targets via `diagnostics.brokenLinks` (those cards index cleanly, so this is the only indexed signal), (4) verifying the module pre-warm phase populates the definition cache under a key the indexer / on-demand prerender reads actually hit — i.e. it isn't a silent no-op — via the `definition-cache-key` hit/miss log channel, and (5) attributing a slow in-render `_search` round-trip to the realm-server's own request→response stages (parse / SQL / loadLinks / serialize / queue) via the `realm:search-timing`, `realm:requests` (`dur=`), and `realm:health` log channels keyed by the `x-boxel-logging-correlation-id` correlation id, and (6) capturing full CPU profiles / CDP traces / heap-allocation profiles to the prerender S3 artifact bucket (`boxel-prerender-artifacts-`) when the summary signals name a hot function but you need the whole call tree, a JS-vs-GC-vs-layout breakdown, or a heap-growth story — the streaming trace is the only capture that survives a fully-wedged renderer; gated behind `PRERENDER_PROFILE_AFFINITY` + per-mode SSM flags and pulled with the `boxel-claude-readonly` S3 read grant, and (7) attributing a slow search-doc build to specific fields and link loads — the settle loop's per-target load timings (`searchDocSettleMs` / `searchDocLinkLoads`) vs the field walk's per-dotted-path evaluation timings (`searchDocMs` / `searchDocFieldsMs`), both on `boxel_index.diagnostics`, and (8) decomposing the between-visit / non-render slice of an index job's wall — the once-per-job phases (invalidation discovery, dependency ordering, module pre-warm, aggregate row writes, the final swap) on `jobs.result.phaseTimings` plus the per-row client overhead (file read / render round-trip transport / post-render bookkeeping) on `boxel_index.diagnostics.indexVisitClientMs` — the wall that runs serially between the server renders and is invisible in the per-row `totalElapsedMs`, and (9) decomposing a trivial card's fixed per-visit floor — the route machinery (the meta / icon / file-extract route transitions, instantiation, and per-visit request plumbing) around the search-doc work — into per-route wall-clock buckets on `boxel_index.diagnostics.indexRoutesMs` (the index-half sibling of the render channel's `renderFormatsMs`), so a floor that isn't the search doc reads as measured route steps rather than an inference from `renderElapsedMs`, and (10) recognizing a batch-level setup-phase failure of an incremental job — N error docs sharing one `error_doc.message` and `job_id`, carrying no visit diagnostics, from a rejected job whose whole batch failed before its visit loop — vs per-row render failures, and the re-push recovery those error docs enable, and (11) explaining a missing declared screenshot / thumbnail via `prerendered_html.diagnostics.screenshotErrors` (the row publishes normally — this is the only indexed signal, with per-slot `consecutiveFailures` reporting and the row-level `screenshotCaptureFailureRenders` counter the reconcile sweep's retry cap is enforced against) and attributing slow captures per slot via `screenshotTimingsMs`, the per-name decomposition of `renderFormatsMs.card.screenshots`, and (12) attributing a card-instance visit's dominant cost — the parent `render` route's model build, which runs inside whichever route step triggered the parent transition and so sits unbroken-down inside `indexRoutesMs.card.meta` — across `diagnostics.buildModelMs` (`fetchSource` / `deriveType` / `hydrate` / `storeSettle`) with per-module (`moduleEvaluationsMs`), per-field (`hydrateFieldsMs`) and per-load (`storeSettleWaits`) detail under the dominant stage, plus the explicitly measured plumbing residual `unattributedMs`, and (13) reconstructing the order a pass wrote its rows in — `diagnostics.writeSeq` (a per-batch 0-based write counter on both channels; `indexedAt` is millisecond-resolution and a buffered multi-row upsert stamps a whole flush identically, so it cannot order rows within a pass), which separates the URLs a write actually named from the dependents its fan-out discovered (an incremental index pass writes its targets first) and says how far into its plan a stalled job got, and (14) explaining why an index pass re-visited cards at commit because a peer pass of the same realm committed while it ran — the commit-time validation against the `realm_index_commits` ledger, read from `phaseTimings.validationMs` / `validationRounds` / `revisitCount` / `extendCount` / `followUpJobId` and `boxel_index.diagnostics.validationRound` — and finding the follow-up `incremental-index` job a commit enqueues when its peers kept leaving rows stale. Use when indexing fails with "Render timeout", when a user sees a 504, when a reindex took much longer than expected, when an `.gts` edit triggers a surprising amount of re-render work, when investigating prerender-saturation incidents, when a render stalls in `waiting-stability` on a `_search` whose SQL is fast but whose response is slow to come back, when a row's index visit is slow and you need to know which field or link load inside the search doc ate the time, when a trivial-search-doc card still costs far more per visit than its doc justifies and you need to attribute the per-visit floor to a route step, when a card-instance visit's `meta` route bucket dwarfs the search-doc work inside it and you need to know whether the module graph, the hydration, or the link loads carry it, when you need to know what order a pass indexed its rows in or which of them the triggering write actually named, when a pass re-visited cards at commit or an `incremental-index` job appeared that no write published, or when asked to list / count cards with broken links in a realm. For staging/prod investigations this skill layers on top of `aws-access`, which provides the AWS session and the SSM port-forward path into the in-VPC database (authenticated as `claude_readonly_user`) — read that skill first when the question is about a deployed environment. +description: Investigate slow or failing indexing using the per-row diagnostics persisted split by visit — the index visit's breakdown on `boxel_index.diagnostics`, the prerender-html visit's render breakdown (launch/wait/render timings, per-format render timings) on `prerendered_html.diagnostics`, each mirrored onto its table's `error_doc.diagnostics` for error rows, joinable per row via url + the two request ids — plus the matching prerender-server / manager logs. Covers (1) a render inside indexing timed out — classify which part of the prerender pipeline stalled, (2) an incremental or full reindex was slow but didn't fail — attribute time across the invalidation fan-out and find the rows that cost the most, (3) enumerating cards with broken `linksTo` / `linksToMany` targets via `diagnostics.brokenLinks` (those cards index cleanly, so this is the only indexed signal), (4) verifying the module pre-warm phase populates the definition cache under a key the indexer / on-demand prerender reads actually hit — i.e. it isn't a silent no-op — via the `definition-cache-key` hit/miss log channel, and (5) attributing a slow in-render `_search` round-trip to the realm-server's own request→response stages (parse / SQL / loadLinks / serialize / queue) via the `realm:search-timing`, `realm:requests` (`dur=`), and `realm:health` log channels keyed by the `x-boxel-logging-correlation-id` correlation id, and (6) capturing full CPU profiles / CDP traces / heap-allocation profiles to the prerender S3 artifact bucket (`boxel-prerender-artifacts-`) when the summary signals name a hot function but you need the whole call tree, a JS-vs-GC-vs-layout breakdown, or a heap-growth story — the streaming trace is the only capture that survives a fully-wedged renderer; gated behind `PRERENDER_PROFILE_AFFINITY` + per-mode SSM flags and pulled with the `boxel-claude-readonly` S3 read grant, and (7) attributing a slow search-doc build to specific fields and link loads — the settle loop's per-target load timings (`searchDocSettleMs` / `searchDocLinkLoads`) vs the field walk's per-dotted-path evaluation timings (`searchDocMs` / `searchDocFieldsMs`), both on `boxel_index.diagnostics`, and (8) decomposing the between-visit / non-render slice of an index job's wall — the once-per-job phases (invalidation discovery, dependency ordering, module pre-warm, aggregate row writes, the final swap) on `jobs.result.phaseTimings` plus the per-row client overhead (file read / render round-trip transport / post-render bookkeeping) on `boxel_index.diagnostics.indexVisitClientMs` — the wall that runs serially between the server renders and is invisible in the per-row `totalElapsedMs`, and (9) decomposing a trivial card's fixed per-visit floor — the route machinery (the meta / icon / file-extract route transitions, instantiation, and per-visit request plumbing) around the search-doc work — into per-route wall-clock buckets on `boxel_index.diagnostics.indexRoutesMs` (the index-half sibling of the render channel's `renderFormatsMs`), so a floor that isn't the search doc reads as measured route steps rather than an inference from `renderElapsedMs`, and (10) recognizing a batch-level setup-phase failure of an incremental job — N error docs sharing one `error_doc.message` and `job_id`, carrying no visit diagnostics, from a rejected job whose whole batch failed before its visit loop — vs per-row render failures, and the re-push recovery those error docs enable, and (11) explaining a missing declared capture / thumbnail via `prerendered_html.diagnostics.captureErrors` (the row publishes normally — this is the only indexed signal, with per-slot `consecutiveFailures` reporting and the row-level `captureFailureRenders` counter the reconcile sweep's retry cap is enforced against) and attributing slow captures per slot via `captureTimingsMs`, the per-name decomposition of `renderFormatsMs.card.captures`, and (12) attributing a card-instance visit's dominant cost — the parent `render` route's model build, which runs inside whichever route step triggered the parent transition and so sits unbroken-down inside `indexRoutesMs.card.meta` — across `diagnostics.buildModelMs` (`fetchSource` / `deriveType` / `hydrate` / `storeSettle`) with per-module (`moduleEvaluationsMs`), per-field (`hydrateFieldsMs`) and per-load (`storeSettleWaits`) detail under the dominant stage, plus the explicitly measured plumbing residual `unattributedMs`, and (13) reconstructing the order a pass wrote its rows in — `diagnostics.writeSeq` (a per-batch 0-based write counter on both channels; `indexedAt` is millisecond-resolution and a buffered multi-row upsert stamps a whole flush identically, so it cannot order rows within a pass), which separates the URLs a write actually named from the dependents its fan-out discovered (an incremental index pass writes its targets first) and says how far into its plan a stalled job got, and (14) explaining why an index pass re-visited cards at commit because a peer pass of the same realm committed while it ran — the commit-time validation against the `realm_index_commits` ledger, read from `phaseTimings.validationMs` / `validationRounds` / `revisitCount` / `extendCount` / `followUpJobId` and `boxel_index.diagnostics.validationRound` — and finding the follow-up `incremental-index` job a commit enqueues when its peers kept leaving rows stale. Use when indexing fails with "Render timeout", when a user sees a 504, when a reindex took much longer than expected, when an `.gts` edit triggers a surprising amount of re-render work, when investigating prerender-saturation incidents, when a render stalls in `waiting-stability` on a `_search` whose SQL is fast but whose response is slow to come back, when a row's index visit is slow and you need to know which field or link load inside the search doc ate the time, when a trivial-search-doc card still costs far more per visit than its doc justifies and you need to attribute the per-visit floor to a route step, when a card-instance visit's `meta` route bucket dwarfs the search-doc work inside it and you need to know whether the module graph, the hydration, or the link loads carry it, when you need to know what order a pass indexed its rows in or which of them the triggering write actually named, when a pass re-visited cards at commit or an `incremental-index` job appeared that no write published, or when asked to list / count cards with broken links in a realm. For staging/prod investigations this skill layers on top of `aws-access`, which provides the AWS session and the SSM port-forward path into the in-VPC database (authenticated as `claude_readonly_user`) — read that skill first when the question is about a deployed environment. allowed-tools: Read, Grep, Glob, Bash --- @@ -26,7 +26,7 @@ The first three read from the same `diagnostics` column; the difference is the q Eight places, all correlated: 1. **`boxel_index.diagnostics` (and `boxel_index_pending.diagnostics`, for rows a pass has staged but not yet committed)** — JSONB column, populated for **every** row the indexer writes, regardless of `has_error`. Source of truth for the **index visit** of a card/file: the `RenderTimeoutDiagnostics` server timings of that visit plus the host-side `PrerenderMetaDiagnostics` block (`serializeMs`, `searchDocMs`, `searchDocSettleMs`/`searchDocSettlePasses`, `searchDocFieldsMs`, `searchDocLinkLoads`, `computedCalls`/`computedCacheHits`), the per-route `indexRoutesMs` breakdown (the index-half sibling of the render channel's `renderFormatsMs` — the wall-clock of each index-visit route step, so the per-visit floor decomposes into `meta` / `icon` / `fileExtract` buckets; see [Mode L](#mode-l--the-index-visits-per-route-floor-meta--icon--file-extract)), and the write-side stamps: `invalidationId`, `passId` (the batch that wrote the row — the id its commit's `realm_index_commits.pass_id` carries, so a committed row joins to the commit that published it), `indexedAt`, `writeSeq` (this row's position in its pass's write order — the only field that orders two rows of the same pass; see [Reconstructing a pass's write order](#reconstructing-a-passs-write-order)), and `requestId`, plus `queueClaim` — how the queue claimed the job that wrote the row (see [Mode P](#mode-p--a-save-waited-in-the-queue)). It also carries an `indexVisitClientMs` block (`read` / `renderRpc` / `bookkeeping`) — the indexer's per-row client-side overhead _outside_ the server render, i.e. this row's slice of the between-visit wall (see [Mode K](#mode-k--the-index-jobs-between-visit-wall-non-render-overhead)) — and a `brokenLinks` array on any card row whose render found a broken `linksTo` / `linksToMany` target — see [Mode E](#mode-e--enumerate-cards-with-broken-links). Note `brokenLinks` is the one block that isn't about _timing_: a card with broken links still indexes as a clean `type='instance'` (the broken slot renders a placeholder), so it's the only indexed signal that the row has a broken reference. Rows written by a fused single-visit pass (the SQLite in-browser path) carry one **combined** blob covering both visits here instead. -2. **`prerendered_html.diagnostics` (and `prerendered_html_pending.diagnostics`)** — JSONB column, populated for every row the `prerender_html` job writes, success and render-error alike. Source of truth for the **prerender-html visit**: launch/wait timings, `renderElapsedMs`/`totalElapsedMs`, the per-format `renderFormatsMs` breakdown, and the visit's HTTP correlation id under `prerenderHtmlRequestId` (never `requestId` — that name always means an index visit). On instance rows it also carries the declared-screenshot channel: `screenshotTimingsMs` (per-slot capture wall-clock) and `screenshotErrors` (per-slot capture failures with their `consecutiveFailures` retry bookkeeping) — see [Mode M](#mode-m--declared-screenshot-capture-failures-and-per-slot-timings). It carries the same write-side stamps as the index channel — `invalidationId`, `passId`, `indexedAt`, `writeSeq`, and the `prerender_html` job's own `queueClaim` — on **every** live row, whether or not the render reported timings of its own, so a `prerender_html` job's fan-out groups and orders exactly like an index pass's. **The two channels' `invalidationId`s are different**: the id is scoped to a `Batch`, and an index pass and the `prerender_html` job it spawns are separate batches. Each id groups its own channel; join the channels on `url` (plus `generation`), never on `invalidationId`. See [Two visits, two tables](#two-visits-two-tables--which-timings-live-where). +2. **`prerendered_html.diagnostics` (and `prerendered_html_pending.diagnostics`)** — JSONB column, populated for every row the `prerender_html` job writes, success and render-error alike. Source of truth for the **prerender-html visit**: launch/wait timings, `renderElapsedMs`/`totalElapsedMs`, the per-format `renderFormatsMs` breakdown, and the visit's HTTP correlation id under `prerenderHtmlRequestId` (never `requestId` — that name always means an index visit). On instance rows it also carries the declared-capture channel: `captureTimingsMs` (per-slot capture wall-clock) and `captureErrors` (per-slot capture failures with their `consecutiveFailures` retry bookkeeping) — see [Mode M](#mode-m--declared-capture-capture-failures-and-per-slot-timings). It carries the same write-side stamps as the index channel — `invalidationId`, `passId`, `indexedAt`, `writeSeq`, and the `prerender_html` job's own `queueClaim` — on **every** live row, whether or not the render reported timings of its own, so a `prerender_html` job's fan-out groups and orders exactly like an index pass's. **The two channels' `invalidationId`s are different**: the id is scoped to a `Batch`, and an index pass and the `prerender_html` job it spawns are separate batches. Each id groups its own channel; join the channels on `url` (plus `generation`), never on `invalidationId`. See [Two visits, two tables](#two-visits-two-tables--which-timings-live-where). 3. **`modules.diagnostics`** — JSONB column, populated for every row `persistModuleCacheEntry` writes (success and error paths). Source of truth for **module** renders (`prerenderModule` → definition extraction). Same `RenderTimeoutDiagnostics` shape with `requestId` flattened in; none of the write-side stamps — no `invalidationId` and no `writeSeq` (module rows aren't written by a `Batch` at all). The row's existing `created_at` column is the wall-clock stamp for cross-table joins. See [Mode D](#mode-d--a-module-render-was-slow-or-hung) below. 4. **`error_doc.diagnostics`** — derived copy of the same table's `diagnostics`, written only for error rows: an index error's copy rides `boxel_index.error_doc`, a render error's rides `prerendered_html.error_doc` (an instance's effective error is the union of the two). Exists so the existing UI read path (`error_doc` → `CardErrorJSONAPI.meta.diagnostics` via `formattedError`) keeps working without a schema rename. Non-error rows have `error_doc = null`; go to `diagnostics` directly. 5. **Logs** — `prerender-server`, `manager`, and `remote-prerenderer` lines all carry `requestId=…`. `grep requestId=` collates one call across all three processes. The same id lands on `boxel_index.diagnostics->>'requestId'` and `modules.diagnostics->>'requestId'` — and, for the render channel, on `prerendered_html.diagnostics->>'prerenderHtmlRequestId'` — so a hung card render and the module renders it triggered (via `getDefinition`) can be joined back to one investigation. For saturation incidents there's also the periodic `prerender-queue-snapshot` line on each prerender server. @@ -52,7 +52,7 @@ A URL is produced by two prerender visits on two channels, and each visit's diag | where | visit | what's in it | | ------------------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `boxel_index.diagnostics` | index visit | that visit's server timings (`launchMs`, `waits`, `renderElapsedMs`, `totalElapsedMs`), the per-route floor split (`indexRoutesMs` — one number per index-visit route step, split into a `card` block `meta` / `icon` and a `file` block `fileExtract` / `icon`; see [Mode L](#mode-l--the-index-visits-per-route-floor-meta--icon--file-extract)), the search-doc build (`serializeMs`, `searchDocMs`, `searchDocSettleMs`/`searchDocSettlePasses`, the per-field `searchDocFieldsMs` and per-link-load `searchDocLinkLoads` detail, `computedCalls`/`computedCacheHits`), the indexer's own per-row client overhead outside the render (`indexVisitClientMs`: `read` / `renderRpc` / `bookkeeping` — see [Mode K](#mode-k--the-index-jobs-between-visit-wall-non-render-overhead)), `brokenLinks`, and the write-side stamps (`invalidationId`, `indexedAt`, `writeSeq`). HTTP id: `requestId`. | -| `prerendered_html.diagnostics` | prerender-html visit | that visit's server timings (`launchMs`, `waits`, `renderElapsedMs`, `totalElapsedMs`) plus `renderFormatsMs` — per-format wall-clock, split into a `card` and a `file` block with one number per html-route step (`isolated`, `head`, `atom`, `markdown`, `fitted`, `embedded`; the ancestor-driven `fitted`/`embedded` numbers each cover the whole ancestor chain; `card.screenshots` is the declared-screenshot capture step's aggregate, decomposed per slot by `screenshotTimingsMs`). Instance rows also carry `screenshotErrors` — per-slot declared-capture failures, the only indexed signal a declared screenshot is missing (see [Mode M](#mode-m--declared-screenshot-capture-failures-and-per-slot-timings)). Same write-side stamps as the index channel (`invalidationId`, `indexedAt`, `writeSeq`), scoped to the `prerender_html` job's own batch, plus `stampedFromIndexGeneration` — the live index row generation the row's own generation was read from (see [A prerender_html job's wait on its spawning passes](#a-prerender_html-jobs-wait-on-its-spawning-passes)). HTTP id: `prerenderHtmlRequestId`. | +| `prerendered_html.diagnostics` | prerender-html visit | that visit's server timings (`launchMs`, `waits`, `renderElapsedMs`, `totalElapsedMs`) plus `renderFormatsMs` — per-format wall-clock, split into a `card` and a `file` block with one number per html-route step (`isolated`, `head`, `atom`, `markdown`, `fitted`, `embedded`; the ancestor-driven `fitted`/`embedded` numbers each cover the whole ancestor chain; `card.captures` is the declared capture step's aggregate, decomposed per slot by `captureTimingsMs`). Instance rows also carry `captureErrors` — per-slot declared-capture failures, the only indexed signal a declared capture is missing (see [Mode M](#mode-m--declared-capture-capture-failures-and-per-slot-timings)). Same write-side stamps as the index channel (`invalidationId`, `indexedAt`, `writeSeq`), scoped to the `prerender_html` job's own batch, plus `stampedFromIndexGeneration` — the live index row generation the row's own generation was read from (see [A prerender_html job's wait on its spawning passes](#a-prerender_html-jobs-wait-on-its-spawning-passes)). HTTP id: `prerenderHtmlRequestId`. | So "why was indexing this card slow?" and "why was rendering this card slow?" are separately answerable per row: the index cost is `boxel_index.diagnostics`, the render cost is `prerendered_html.diagnostics`. Each side names the sub-step that dominated its channel: on the index side `indexRoutesMs` names the route (a slow `meta` build vs an `icon` render vs `fileExtract`), on the render side `renderFormatsMs` names the format (a slow `isolated` template vs a `fitted` render fanning out across many ancestors). @@ -1388,7 +1388,7 @@ The wait is keyed on the pass rather than the job because one job id can commit A reconcile repair has no spawning passes, and waits on the committed `generation` it was spawned from, which its first probe passes. A job enqueued by a worker predating `spawningIndexPasses` waits for `current_generation` to reach its `generation`. Spawned jobs carry the generation their pass anticipated in `generation` too, but only an older worker reads it. -Once released, the job reads each URL's live `boxel_index.generation` and stamps that onto the URL's `prerendered_html` row (and its declared-screenshot ledger rows' `source_generation`), recording it as `prerendered_html.diagnostics.stampedFromIndexGeneration`. A URL with no index row of that type takes the realm's committed generation and carries no such field. The job result's `generation` is that committed generation. +Once released, the job reads each URL's live `boxel_index.generation` and stamps that onto the URL's `prerendered_html` row (and its declared-capture ledger rows' `source_generation`), recording it as `prerendered_html.diagnostics.stampedFromIndexGeneration`. A URL with no index row of that type takes the realm's committed generation and carries no such field. The job result's `generation` is that committed generation. The job result records the wait: `spawningIndexJobIds` (the jobs of the passes it waited on, after any coalescing) and `phaseTimings.spawnGateMs`. The pass ids themselves are on the job's `args`. @@ -1506,26 +1506,26 @@ WHERE realm_url = 'https://localhost:4201/user/your-realm/' - **Inside `meta`, the doc build is only part of the story.** `card.meta` minus (`searchDocMs` + `searchDocSettleMs` + `serializeMs`) is mostly the **model build** the parent `render` route runs inside that transition — normally the dominant part of the bucket. [Mode N](#mode-n--the-model-build-inside-a-visit-source--type--hydrate--settle) breaks it into `fetchSource` / `deriveType` / `hydrate` / `storeSettle`, with per-module, per-field and per-load detail under each. What survives both is the meta route's own transition plus its types / displayNames / deps resolution; profiling inside that is Mode H territory. - **The fused (SQLite in-browser) path carries a combined blob.** A fused single-visit pass records the index routes on the same `boxel_index.diagnostics` as the render formats; the split-pipeline separation of index-vs-render doesn't apply there. -## Mode M — declared-screenshot capture failures and per-slot timings +## Mode M — declared capture failures and per-slot timings -**When to use this mode.** A card's declared screenshot (`static screenshots`) is missing, `screenshotURLs.` / `meta.screenshots.` comes back undefined, a thumbnail unexpectedly fell through its fallback chain, or the prerender-html visit's `renderFormatsMs.card.screenshots` bucket is large and you need to know which slot ate the time. +**When to use this mode.** A card's declared capture (`static captures`) is missing, `captureURLs.` / `meta.captures.` comes back undefined, a thumbnail unexpectedly fell through its fallback chain, or the prerender-html visit's `renderFormatsMs.card.captures` bucket is large and you need to know which slot ate the time. -**The failure model (the broken-links model).** A declared-capture failure never errors its row: the `prerendered_html` instance row publishes normally (no `error_doc`), the manifest (`prerendered_html.screenshots`) simply omits the failed name, and `diagnostics.screenshotErrors` records why — the only indexed signal that a declared screenshot is missing. The card stays fully served and searchable throughout; thumbnail consumers fall through their chain. +**The failure model (the broken-links model).** A declared-capture failure never errors its row: the `prerendered_html` instance row publishes normally (no `error_doc`), the manifest (`prerendered_html.captures`) simply omits the failed name, and `diagnostics.captureErrors` records why — the only indexed signal that a declared capture is missing. The card stays fully served and searchable throughout; thumbnail consumers fall through their chain. ```jsonc -"screenshotErrors": [ +"captureErrors": [ { "name": "hero", // slot name; '*' = the whole capture step failed (roster read error or step timeout) "message": "render never painted", "captureMs": 15310, // wall-clock of the failed render attempt; on '*' the whole step's elapsed; // absent when nothing rendered (capture-cap overflow, persist-step failure) "consecutiveFailures": 2 } // this slot's run of consecutive failing renders (a name change resets it) ], -"screenshotCaptureFailureRenders": 2 // consecutive renders recording ANY capture failure — the retry cap's term +"captureFailureRenders": 2 // consecutive renders recording ANY capture failure — the retry cap's term ``` -**The retry lane.** The `prerender-html-reconcile` sweep re-renders rows with recorded capture failures the same way it retries `visitRequestFailure` error rows: eligible while the row's `screenshotCaptureFailureRenders` AND some recorded slot's `consecutiveFailures` are below `DECLARED_SCREENSHOT_CAPTURE_RETRY_CAP` (3), and the row's `rendered_at` is at least `DECLARED_SCREENSHOT_CAPTURE_RETRY_MIN_AGE_MS` (45 min) old; a value missing from a legacy row reads as one. The row counter is the convergence bound — a per-slot run resets whenever the failing name changes (a `'*'` step failure alternating with a per-name one, format groups failing on alternating renders), so per-slot runs alone can sit at 1 forever on a struggling row — while the per-slot term stops retrying a stable failure set once every name in it is capped. Once capped, the absence is terminal until the URL's next invalidation. So: `screenshotCaptureFailureRenders` at the cap plus an old `rendered_at` means "the platform has given up on this row's captures — fix the card (or the underlying media) and edit/reindex"; below the cap means a retry is still coming. +**The retry lane.** The `prerender-html-reconcile` sweep re-renders rows with recorded capture failures the same way it retries `visitRequestFailure` error rows: eligible while the row's `captureFailureRenders` AND some recorded slot's `consecutiveFailures` are below `DECLARED_CAPTURE_RETRY_CAP` (3), and the row's `rendered_at` is at least `DECLARED_CAPTURE_RETRY_MIN_AGE_MS` (45 min) old; a value missing from a legacy row reads as one. The row counter is the convergence bound — a per-slot run resets whenever the failing name changes (a `'*'` step failure alternating with a per-name one, format groups failing on alternating renders), so per-slot runs alone can sit at 1 forever on a struggling row — while the per-slot term stops retrying a stable failure set once every name in it is capped. Once capped, the absence is terminal until the URL's next invalidation. So: `captureFailureRenders` at the cap plus an old `rendered_at` means "the platform has given up on this row's captures — fix the card (or the underlying media) and edit/reindex"; below the cap means a retry is still coming. -**Per-slot timings.** `diagnostics.screenshotTimingsMs` (`{name → ms}`) decomposes the `renderFormatsMs.card.screenshots` aggregate per slot, failed attempts included. Two caveats: slots captured from one shared format render (same `format`, no `render` component) each record that render's whole elapsed time, so the entries can sum past the aggregate; carry-forwards (`keyBy: 'file-content'`, unchanged source) record nothing — nothing rendered. +**Per-slot timings.** `diagnostics.captureTimingsMs` (`{name → ms}`) decomposes the `renderFormatsMs.card.captures` aggregate per slot, failed attempts included. Two caveats: slots captured from one shared format render (same `format`, no `render` component) each record that render's whole elapsed time, so the entries can sum past the aggregate; carry-forwards (`keyBy: 'file-content'`, unchanged source) record nothing — nothing rendered. **Enumerate cards with missing captures** (same shape as the Mode E brokenLinks queries; the jsonb operators are Postgres-only): @@ -1534,9 +1534,9 @@ SELECT p.url, se->>'name' AS slot, se->>'message' AS why, (se->>'consecutiveFailures')::int AS run, to_timestamp(p.rendered_at / 1000) AS rendered_at FROM prerendered_html p -CROSS JOIN LATERAL jsonb_array_elements(p.diagnostics->'screenshotErrors') AS se +CROSS JOIN LATERAL jsonb_array_elements(p.diagnostics->'captureErrors') AS se WHERE p.realm_url = '' - AND jsonb_typeof(p.diagnostics->'screenshotErrors') = 'array' + AND jsonb_typeof(p.diagnostics->'captureErrors') = 'array' ORDER BY run DESC, p.url; ``` @@ -1545,7 +1545,7 @@ ORDER BY run DESC, p.url; ```sql SELECT p.url, t.key AS slot, t.value::int AS capture_ms FROM prerendered_html p -CROSS JOIN LATERAL jsonb_each_text(p.diagnostics->'screenshotTimingsMs') AS t +CROSS JOIN LATERAL jsonb_each_text(p.diagnostics->'captureTimingsMs') AS t WHERE p.realm_url = '' ORDER BY t.value::int DESC LIMIT 20; @@ -1553,9 +1553,9 @@ LIMIT 20; ### What Mode M can't tell you -- **Why the render failed, beyond the message.** A capture failure records the engine's error text (`render never painted`, an image-paint timeout, a persist failure); for the render-level why, reproduce with the `_screenshot/` DSL on a dev realm or profile via Mode H. -- **Older rows predate the bookkeeping.** Rows written before `consecutiveFailures` / `screenshotCaptureFailureRenders` shipped carry neither; the sweep and the persist path both read a missing value as one, so those rows get (nearly) the full retry allowance again. -- **A slot that was never declared.** `screenshotErrors` covers declared slots whose capture failed; a name that was never in the card's merged `static screenshots` simply doesn't exist anywhere. Check the declaration (and its prototype-chain merge) first. +- **Why the render failed, beyond the message.** A capture failure records the engine's error text (`render never painted`, an image-paint timeout, a persist failure); for the render-level why, reproduce with the `_capture/` DSL on a dev realm or profile via Mode H. +- **Older rows predate the bookkeeping.** Rows written before `consecutiveFailures` / `captureFailureRenders` shipped carry neither; the sweep and the persist path both read a missing value as one, so those rows get (nearly) the full retry allowance again. +- **A slot that was never declared.** `captureErrors` covers declared slots whose capture failed; a name that was never in the card's merged `static captures` simply doesn't exist anywhere. Check the declaration (and its prototype-chain merge) first. ## Mode N — the model build inside a visit (source / type / hydrate / settle) @@ -1573,7 +1573,7 @@ The four stages, in the order they run: | Stage | What runs | The reading when it dominates | | ------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `fetchSource` | GET the card's source doc from the realm | The realm-server is slow to serve source — not a card problem. Correlate with `realm:requests` for the same correlation id (Mode G's join). | -| `deriveType` | Resolve `adoptsFrom`: **load** and **evaluate** the card's module graph, then the realm-meta and declared-screenshot lookups | The module graph — but split it first: `moduleEvaluationTotalMs` is the evaluate half, and the rest is the load half. See step 3. | +| `deriveType` | Resolve `adoptsFrom`: **load** and **evaluate** the card's module graph, then the realm-meta and declared-capture lookups | The module graph — but split it first: `moduleEvaluationTotalMs` is the evaluate half, and the rest is the load half. See step 3. | | `hydrate` | `store.add` — instantiate the card by deserializing its fields | Deserialization. `hydrateFieldsMs` names the fields; see step 4. | | `storeSettle` | `store.loaded()` — drain the loads hydration fired | Link loads / query fields. `storeSettleWaits` names the targets; see step 5. | @@ -1703,7 +1703,7 @@ Read the shape: - **A high `deriveType` with entries, on the first few rows of a job only** — a cold module graph. The tab warms and later cards of the same type pay nothing; the lever is pre-warm (Mode F), not the card. - **A high `deriveType` with entries on _every_ row** — the graph is being re-evaluated per visit. That means the loader keeps resetting: check `clearCache` retries and the loader-epoch reset (an instance-only pass shouldn't change the epoch), and read Mode F's cache-key hit/miss channel. -- **A high `deriveType` with _no_ entries** — check `moduleEvaluationCount` and the load/evaluate split above before concluding anything. The itemized list comes from diffing the Loader's slowest-N history, which is kept for the loader's whole life and **saturates**: one cold card can fill every slot, after which a later card's modest evaluations are dropped on insert and never appear in the diff. So an empty list on a row with a non-zero `moduleEvaluationCount` means the evaluations happened and lost the slowest-N race to an earlier card in the same job — read `moduleEvaluationTotalMs` for their summed cost and go find them on the job's first rows. Only `moduleEvaluationCount = 0` means the stage genuinely isn't evaluation, in which case it is the fetch/resolve half: many small modules loading over the wire (correlate with the realm-server's request log), or the realm-meta / declared-screenshot lookups that close the stage. +- **A high `deriveType` with _no_ entries** — check `moduleEvaluationCount` and the load/evaluate split above before concluding anything. The itemized list comes from diffing the Loader's slowest-N history, which is kept for the loader's whole life and **saturates**: one cold card can fill every slot, after which a later card's modest evaluations are dropped on insert and never appear in the diff. So an empty list on a row with a non-zero `moduleEvaluationCount` means the evaluations happened and lost the slowest-N race to an earlier card in the same job — read `moduleEvaluationTotalMs` for their summed cost and go find them on the job's first rows. Only `moduleEvaluationCount = 0` means the stage genuinely isn't evaluation, in which case it is the fetch/resolve half: many small modules loading over the wire (correlate with the realm-server's request log), or the realm-meta / declared-capture lookups that close the stage. - **One module dominating everywhere** — a heavy `.gts` in the realm's common ancestry. That single module is the whole realm's tax; it is also the same module a live-app card load pays for (see the cross-reference below). **Step 4 — `hydrate` dominant: which fields were expensive to deserialize.** @@ -2144,8 +2144,8 @@ The `NULL` writer row is the family as a whole. With writer lanes, a pass enqueu // plumbing between the steps: tab setup, the per-step // CDP round trips, terminal-error probes, response // assembly. Clamped at 0; absent on a visit that - // recorded no step buckets (a screenshot capture, - // whose components are the screenshot* fields). + // recorded no step buckets (a capture, + // whose components are the capture* fields). "buildModelMs": { // the parent `render` route's model build, which runs // inside whichever route step triggered the parent // transition — `indexRoutesMs.card.meta` on an index @@ -2156,7 +2156,7 @@ The `NULL` writer row is the family as a whole. With writer lanes, a pass enqueu // `renderStage` names the stage it was in). See Mode N. "fetchSource": 12, // GET the card's source doc from the realm "deriveType": 340, // resolve adoptsFrom: load + evaluate the module - // graph, then the realm-meta / declared-screenshot + // graph, then the realm-meta / declared-capture // lookups. Itemized by `moduleEvaluationsMs`. "hydrate": 88, // store.add — deserialize the card's fields. // Itemized by `hydrateFieldsMs`. diff --git a/.claude/skills/pr-privacy/SKILL.md b/.claude/skills/pr-privacy/SKILL.md index 192bbc05b4e..77fc8901344 100644 --- a/.claude/skills/pr-privacy/SKILL.md +++ b/.claude/skills/pr-privacy/SKILL.md @@ -19,7 +19,7 @@ Treat any of the following as user data that must be redacted before it touches - **User-authored content.** Card titles, descriptions, body text, field values quoted from a card document. - **Matrix handles, room IDs that map to user DMs, and email addresses.** - **Account IDs that map to a specific person**, including Matrix user IDs (`@handle:matrix.boxel.ai`). -- **Screenshots that incidentally show any of the above** in the address bar, breadcrumbs, sidebar, or card body. +- **Captures that incidentally show any of the above** in the address bar, breadcrumbs, sidebar, or card body. ## What is OK to keep verbatim @@ -31,11 +31,11 @@ Treat any of the following as user data that must be redacted before it touches ## How to redact -Preserve the *shape* of the data so the reviewer can still reason about it, drop the identifying parts: +Preserve the _shape_ of the data so the reviewer can still reason about it, drop the identifying parts: - Replace path segments with placeholders that describe the role: `///.json`. - Replace lists of concrete examples with a count plus opaque IDs: instead of three full URLs, write "3 affected cards across 3 user realms (job IDs 209668, 209743, 209962)". -- Crop or blur screenshots before attaching them. Check the browser address bar, breadcrumb trail, sidebar realm list, and any visible card titles. +- Crop or blur captures before attaching them. Check the browser address bar, breadcrumb trail, sidebar realm list, and any visible card titles. - For log excerpts, replace user realm URLs with `` and keep the structural fields (timestamps, request IDs, status codes, durations). If a reviewer genuinely needs the concrete identifiers to reproduce a bug, write "see for affected realm slugs" in the PR and put the unredacted list in the Linear ticket. Linear is staff-only and is the right place for that detail. @@ -74,7 +74,7 @@ Apply this check at **two** moments — not just when calling a GitHub tool: In both cases, ask: 1. Does this contain a path of the form `//...` where the first word could be a real username? -2. Does this quote a log line, DB row, card document, or screenshot taken from staging or prod? +2. Does this quote a log line, DB row, card document, or capture taken from staging or prod? 3. Is this a hardcoded test value, fixture, or comment derived from something a real user actually has in their realm? 4. Would a search engine indexing this give someone outside Cardstack information about a specific user's data? @@ -82,4 +82,4 @@ If yes to any: redact to the placeholder form above, or move the concrete detail ## Why this skill exists -PR comments are indexed by search engines, archived by third parties, and remain public forever. Pasting CloudWatch output, DB query results, or screenshots that include real user realm slugs into a PR comment ships that information out of Cardstack permanently — even if the comment is later edited or the PR is closed. +PR comments are indexed by search engines, archived by third parties, and remain public forever. Pasting CloudWatch output, DB query results, or captures that include real user realm slugs into a PR comment ships that information out of Cardstack permanently — even if the comment is later edited or the PR is closed. diff --git a/.claude/skills/pr-review/SKILL.md b/.claude/skills/pr-review/SKILL.md index 4687e5daa52..1b4ca49c508 100644 --- a/.claude/skills/pr-review/SKILL.md +++ b/.claude/skills/pr-review/SKILL.md @@ -17,7 +17,7 @@ Expect most of the work to stay invisible. A review that traced six paths and fo Load these companion skills before writing a single comment; each applies in **both directions** — to the diff under review and to the review's own output: - **pr-comment-attribution** — every comment the review posts — inline comments, thread replies, and the review body — begins with the `[Claude Code 🤖]` prefix. -- **pr-privacy** — scan the diff for user data the author is about to publish (fixtures, code comments, screenshots, PR text), and scan the review's own comments before posting: quoting a log line or DB row into a review comment publishes it. +- **pr-privacy** — scan the diff for user data the author is about to publish (fixtures, code comments, captures, PR text), and scan the review's own comments before posting: quoting a log line or DB row into a review comment publishes it. - **evergreen-comments** — the review's own prose is evergreen (no ticket IDs, no PR numbers, no journey narration), and the diff's new or edited comments, docs, and skill files are checked for temporal wording, tracker references, and journey narration as a standing review dimension. If the diff touches an area with a dedicated domain skill (index-query-engine, search, gts-component-conventions, ember-best-practices, …), load it — those files carry the invariants the change must preserve, which is exactly what a review checks. diff --git a/.claude/skills/search-shape-diagnosis/SKILL.md b/.claude/skills/search-shape-diagnosis/SKILL.md index 66b7bc7ffbe..7894577d734 100644 --- a/.claude/skills/search-shape-diagnosis/SKILL.md +++ b/.claude/skills/search-shape-diagnosis/SKILL.md @@ -1,6 +1,6 @@ --- name: search-shape-diagnosis -description: Read the per-request query-shape telemetry the federated search emits — one JSON log line per `_federated-search` request on the `boxel:search-shape` channel (the same `| json` convention as `boxel:client-perf` and `boxel:screenshot-perf`), carrying the query's *shape* with every caller-supplied value elided, plus which cache answered, the result counts, and the wall-clock. Use it to answer what a search load was actually made of, which the request rate alone cannot show: (1) what a burst of searches asked for — the filter trees, type anchors, page sizes and fieldsets behind a spike, and how many distinct shapes (`shapeHash`) it decomposes into; (2) which shapes dominate a window by volume, by latency, or by rows returned; (3) how much of a load the caches absorbed — `miss`/`join`/`hit` on the live-search cache and `job-miss`/`job-hit`/`not-modified` on the indexer's job-scoped cache — and therefore how much of it reached the index at all; (4) separating prerender search load from live browser load via `linkMode`, since a prerender skips the `loadLinks` relationship-assembly pass and so costs a different amount for the same query; (5) attributing live search load to a person or a tab session, by joining `correlationId` to the `server-request` event on `boxel:client-perf`; (6) attributing in-render search load to the indexing job that caused it via `jobId` and `consumingRealm`; and (7) authoring a replay that exercises the same index paths a real load did; and (8) auditing the link-shape policy, which picks how much of each result's link graph a live read carries from the load the process is under and may overrule a caller — answering "was this page served a degraded shape, and why" from `requestedLinkMode` / `linkModeDowngraded` / `linkShapeLoad` / `linkShapeLevel` beside the served `linkMode`, and "is the policy engaging more than it should" from the per-realm transition records and sampled no-change records on the `boxel:link-shape-policy` channel, whose `dwellMs` is the flapping signal. Carries the traps that make naive aggregates silently wrong: a cache hit reports no counts, an incomplete merge reports no total, `correlationId` is minted per search so nothing on the line groups one render (`jobId` is a whole index pass), `linkMode` is what was *served* rather than what was asked for, `linkShapeLoad` is a sustained mean that will not match a health line's instantaneous `inFlightSearch`, and a search the admission gate sheds emits no line at all — so under saturation this counts admitted searches rather than arriving ones. Joins to `realm:requests` and the `realm:search-timing` stage breakdown on `correlationId`. For staging/prod this layers on `aws-access` (the AWS session and Loki auth) and `tail-logs` (the Loki wrapper); hand off to `indexing-diagnostics` when the cost is inside the indexer rather than in what was asked for, and to the `search` skill when the task is authoring a query rather than reading one. Use when someone asks what a search spike was made of, which queries a realm is issuing, why search load is high, what a representative query looks like, or wants to replay a real load. +description: Read the per-request query-shape telemetry the federated search emits — one JSON log line per `_federated-search` request on the `boxel:search-shape` channel (the same `| json` convention as `boxel:client-perf` and `boxel:capture-perf`), carrying the query's *shape* with every caller-supplied value elided, plus which cache answered, the result counts, and the wall-clock. Use it to answer what a search load was actually made of, which the request rate alone cannot show: (1) what a burst of searches asked for — the filter trees, type anchors, page sizes and fieldsets behind a spike, and how many distinct shapes (`shapeHash`) it decomposes into; (2) which shapes dominate a window by volume, by latency, or by rows returned; (3) how much of a load the caches absorbed — `miss`/`join`/`hit` on the live-search cache and `job-miss`/`job-hit`/`not-modified` on the indexer's job-scoped cache — and therefore how much of it reached the index at all; (4) separating prerender search load from live browser load via `linkMode`, since a prerender skips the `loadLinks` relationship-assembly pass and so costs a different amount for the same query; (5) attributing live search load to a person or a tab session, by joining `correlationId` to the `server-request` event on `boxel:client-perf`; (6) attributing in-render search load to the indexing job that caused it via `jobId` and `consumingRealm`; and (7) authoring a replay that exercises the same index paths a real load did; and (8) auditing the link-shape policy, which picks how much of each result's link graph a live read carries from the load the process is under and may overrule a caller — answering "was this page served a degraded shape, and why" from `requestedLinkMode` / `linkModeDowngraded` / `linkShapeLoad` / `linkShapeLevel` beside the served `linkMode`, and "is the policy engaging more than it should" from the per-realm transition records and sampled no-change records on the `boxel:link-shape-policy` channel, whose `dwellMs` is the flapping signal. Carries the traps that make naive aggregates silently wrong: a cache hit reports no counts, an incomplete merge reports no total, `correlationId` is minted per search so nothing on the line groups one render (`jobId` is a whole index pass), `linkMode` is what was *served* rather than what was asked for, `linkShapeLoad` is a sustained mean that will not match a health line's instantaneous `inFlightSearch`, and a search the admission gate sheds emits no line at all — so under saturation this counts admitted searches rather than arriving ones. Joins to `realm:requests` and the `realm:search-timing` stage breakdown on `correlationId`. For staging/prod this layers on `aws-access` (the AWS session and Loki auth) and `tail-logs` (the Loki wrapper); hand off to `indexing-diagnostics` when the cost is inside the indexer rather than in what was asked for, and to the `search` skill when the task is authoring a query rather than reading one. Use when someone asks what a search spike was made of, which queries a realm is issuing, why search load is high, what a representative query looks like, or wants to replay a real load. allowed-tools: Read, Grep, Glob, Bash --- @@ -8,7 +8,7 @@ allowed-tools: Read, Grep, Glob, Bash A federated search is a `QUERY` (or `POST`) whose query travels in the request body. Access logs carry method, URL, status and timing and never bodies — so without this channel a search load is legible only as a rate: how many ran and how long each took, with nothing about what any of them asked for. -`boxel:search-shape` closes that. One flat JSON line per `_federated-search` request, emitted from the single call site in `packages/realm-server/handlers/handle-search.ts`, defined in `packages/runtime-common/search-shape.ts`. It is the search sibling of `client-perf-diagnosis` (browser telemetry) and `screenshot-perf-diagnosis` (capture telemetry), and shares their emit convention. +`boxel:search-shape` closes that. One flat JSON line per `_federated-search` request, emitted from the single call site in `packages/realm-server/handlers/handle-search.ts`, defined in `packages/runtime-common/search-shape.ts`. It is the search sibling of `client-perf-diagnosis` (browser telemetry) and `capture-perf-diagnosis` (capture telemetry), and shares their emit convention. The module header in `search-shape.ts` defines what each member means. This skill is about querying the channel and the ways a reasonable query returns a wrong number; where it touches a field's meaning it does so only far enough to explain the consequence, and points at the header for the rest. @@ -363,4 +363,4 @@ Reconstruct the request body from the shape (see the `search` skill for the wire ## Where to run these -`boxel:client-perf`, `boxel:screenshot-perf` and `boxel:actions-queue` each have a dashboard under `packages/observability/grafanactl/resources/dashboards/boxel-status/`; this channel is read from Grafana Explore with the recipes above. The traps are what any panel set has to respect: split by `linkMode`, constrain counts to the miss outcomes, group by `shapeHash` rather than `filter`, and show the `realm:search-admission` shed count beside any request rate. +`boxel:client-perf`, `boxel:capture-perf` and `boxel:actions-queue` each have a dashboard under `packages/observability/grafanactl/resources/dashboards/boxel-status/`; this channel is read from Grafana Explore with the recipes above. The traps are what any panel set has to respect: split by `linkMode`, constrain counts to the miss outcomes, group by `shapeHash` rather than `filter`, and show the `realm:search-admission` shed count beside any request rate. diff --git a/docs/card-def-field-def-relationships.md b/docs/card-def-field-def-relationships.md index 0cdbd2443a3..7573b1fedaf 100644 --- a/docs/card-def-field-def-relationships.md +++ b/docs/card-def-field-def-relationships.md @@ -241,7 +241,7 @@ class Person extends CardDef { } ``` -This example builds on to the field and card definitions in the previous item (7). The screenshot below shows the differences in appearance when a `Person` card instance is rendered in `edit` format. +This example builds on to the field and card definitions in the previous item (7). The capture below shows the differences in appearance when a `Person` card instance is rendered in `edit` format. Note that the `linksTo` and `linksToMany` fields (named `currentLocation` and `previousLocations`) render in `embedded` format provided in the `Country` card definition. diff --git a/mise-tasks/lib/env-vars.sh b/mise-tasks/lib/env-vars.sh index 3d93dd3bf2a..9310a228e66 100755 --- a/mise-tasks/lib/env-vars.sh +++ b/mise-tasks/lib/env-vars.sh @@ -237,9 +237,9 @@ else fi -# Media cache (declared screenshots: poster/thumb/rendition captures). +# Media cache (declared captures: poster/thumb/rendition). # Without a store the capture pipeline is disabled and every -# `_screenshot/...?name=` URL 404s forever, so local dev defaults to a disk +# `_capture/...?name=` URL 404s forever, so local dev defaults to a disk # store under the same ~/.local/share/boxel home the dev certs use. # # An object directory must pair one-to-one with a media_cache_ledger: the diff --git a/packages/base/Theme/boxel-brand-guide.json b/packages/base/Theme/boxel-brand-guide.json index b5586e4a21f..184a978060f 100644 --- a/packages/base/Theme/boxel-brand-guide.json +++ b/packages/base/Theme/boxel-brand-guide.json @@ -132,7 +132,7 @@ "motionLanguage": "# Animation Physics: Mass Without Bounce\n\n**Elements have weight. Transitions have purpose.**\n\nWhile playful brands embrace elastic easing and overshoot, Box Elements move with industrial precision. Think heavy machinery that's perfectly calibrated\u2014smooth motion, hard stops, no wobble.\n\n## Motion Principles\n\n**1. Parallax Scrolling** \nAs content scrolls, the background grid moves at 0.5x speed while foreground boxes move at 1x. This subtle depth cue makes flat UI feel spatially aware without requiring shadow-heavy 3D effects.\n\n**2. The Curtain Wipe** \nPage transitions use solid color blocks (Teal or Lime) that slide up to cover the entire viewport before revealing new content. It's like a film cut, not a fade. This reinforces the Box Element's \"bounded space\" metaphor\u2014the screen itself is just another box.\n\n**3. No Bounce, Ever** \nEasing functions use `cubic-bezier(0.2, 0, 0.2, 1)` (smooth deceleration to hard stop). No spring physics. No elastic snapback. When a Box Element arrives, it *arrives*. This communicates stability and precision.\n\n**4. Stagger Reveals** \nWhen multiple boxes appear (e.g., loading a dashboard), they cascade in with 40ms delays between each. Not simultaneous. Not random. Ordered and predictable\u2014like watching a terminal compile output.\n\n**The whimsy:** Motion isn't decoration. It's information about system state. A drawer that slides open tells you where it came from. A box that fades in tells you nothing. Be opinionated about physics.", "technicalSpecs": "# Implementation Standards\n\n**How Box Elements get built.**\n\n## Border System\n\n- **Width:** Always `1px`. Never 2px. Never 0.5px.\n- **Color (Light):** `#E2E8F0` (nearly invisible)\n- **Color (Dark):** `#333333` (slightly brighter than container)\n- **Style:** Solid. No dashed, dotted, or gradient borders.\n\n## Border Radius\n\n- **Standard:** `0.625rem` (10px)\n- **Override:** Only for specific micro-components like Data Pills, which may use proportional radius at smaller scales\n- **Sharp corners:** Reserved for deliberate \"raw code\" contexts (e.g., syntax highlighting blocks)\n\n## Grid System\n\n- **Column count:** 12 (fluid)\n- **Gutter:** 24px (3\u00d7 base spacing unit)\n- **Visible grid:** Optional background pattern at `opacity: 0.1`\u20142px dots with 20px spacing\n- **Breakpoints:** 640px \u00b7 768px \u00b7 1024px \u00b7 1280px \u00b7 1536px\n\n## Glassmorphism Effects\n\nFor floating elements that need to maintain context:\n\n```css\nbackdrop-filter: blur(12px);\nbackground: rgba(255, 255, 255, 0.8); /* light mode */\nbackground: rgba(30, 30, 30, 0.8); /* dark mode */\n```\n\nUse sparingly. Primarily for sticky headers and modal overlays where seeing the grid beneath adds clarity.\n\n## Z-Index Hierarchy\n\n- **Base grid:** 0\n- **Box Elements:** 1\n- **Interactive elements (hover):** 2\n- **Floating panels:** 10\n- **Tooltips:** 100\n- **Modals:** 1000\n\nNo arbitrary values. If you need a new layer, define it in the system.\n\n## Shadow Protocol\n\n**Light mode:** \nMinimal shadows for separation: `0 1px 2px 0 rgb(0 0 0 / 0.05)`\n\n**Dark mode:** \nAlmost no shadows\u2014rely on luminance contrast instead. If shadow is needed: `0 1px 2px 0 rgb(0 0 0 / 0.5)`\n\n**Spec truth:** If implementation requires documentation longer than the code itself, simplify the system.", "wallpaperImages": [], - "compositionRules": "# Layout Patterns for Box Element Composition\n\n**Great interfaces aren't designed\u2014they're architected.**\n\n## The Split-Screen Protocol\n\n50% structured content (white background) | 50% visual or data (slate background)\n\nThis asymmetric split creates natural reading rhythm. Text lives in the bright half, visuals breathe in the dark half. The vertical divider becomes a visual anchor that guides scanning patterns.\n\n## The Mega-Header\n\nStart pages with type that touches browser edges. No timid centering\u2014claim the full viewport width. This establishes hierarchy immediately: if it's edge-to-edge, it's primary.\n\n## Corner Label Notation\n\nEvery Box Element carries a tiny Mono label in its top-left corner:\n\n```\n\u250c\u2500 MODULE 01 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 [box content here] \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n```\n\nThese labels aren't just decorative\u2014they're **wayfinding metadata**. When a user screenshots your interface, they should be able to reference specific boxes by their label. It's documentation disguised as design.\n\n## Acid Interruptions\n\nA grid of neutral grey text should be interrupted by a **single cell** filled with Lime (`#C3FC33`). This focal point guides the eye to the CTA or key metric. One bright spot in a sea of neutral = impossible to miss, yet the grid's structure prevents it from feeling chaotic.\n\n**Composition truth:** If every element screams for attention, nothing gets heard. Strategic density + strategic brightness = effective hierarchy.", + "compositionRules": "# Layout Patterns for Box Element Composition\n\n**Great interfaces aren't designed\u2014they're architected.**\n\n## The Split-Screen Protocol\n\n50% structured content (white background) | 50% visual or data (slate background)\n\nThis asymmetric split creates natural reading rhythm. Text lives in the bright half, visuals breathe in the dark half. The vertical divider becomes a visual anchor that guides scanning patterns.\n\n## The Mega-Header\n\nStart pages with type that touches browser edges. No timid centering\u2014claim the full viewport width. This establishes hierarchy immediately: if it's edge-to-edge, it's primary.\n\n## Corner Label Notation\n\nEvery Box Element carries a tiny Mono label in its top-left corner:\n\n```\n\u250c\u2500 MODULE 01 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 [box content here] \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n```\n\nThese labels aren't just decorative\u2014they're **wayfinding metadata**. When a user captures your interface, they should be able to reference specific boxes by their label. It's documentation disguised as design.\n\n## Acid Interruptions\n\nA grid of neutral grey text should be interrupted by a **single cell** filled with Lime (`#C3FC33`). This focal point guides the eye to the CTA or key metric. One bright spot in a sea of neutral = impossible to miss, yet the grid's structure prevents it from feeling chaotic.\n\n**Composition truth:** If every element screams for attention, nothing gets heard. Strategic density + strategic brightness = effective hierarchy.", "qualityStandards": "# Execution Checklist\n\n**How to know if you're doing it right.**\n\n## Typography Audit\n\n\u2713 **Extreme size contrast:** Headlines should be 3\u00d7 body size minimum \n\u2713 **No middle sizes:** If it's not Display, Body, or Mono, question it \n\u2713 **Tight kerning on Display:** Headlines should feel like single words, not letter collections \n\u2713 **Mono for metadata only:** System font should never be used for marketing copy \n\n## Spatial Audit\n\n\u2713 **8px alignment:** Every element snaps to grid (use browser DevTools to verify) \n\u2713 **Visible borders:** Every Box Element has a 1px frame \n\u2713 **Corner labels present:** Boxes should be identifiable at a glance \n\u2713 **No floating elements:** Everything lives inside a container \n\n## Color Audit\n\n\u2713 **Teal = action/selection only:** Never use for decoration \n\u2713 **Lime = success/highlight only:** Should be rare\u2014that's what makes it effective \n\u2713 **No gradients on backgrounds:** Solid fills only (gradients OK for accent graphics) \n\u2713 **High contrast:** WCAG AA minimum (4.5:1 for body text, 3:1 for large text) \n\n## Motion Audit\n\n\u2713 **No bounce:** All easing curves end in hard stop \n\u2713 **Directional transitions:** Slides, not fades (shows where things come from/go to) \n\u2713 **Stagger timing consistent:** 40ms intervals between sequential animations \n\u2713 **Purposeful motion:** Every animation communicates state change \n\n**Quality truth:** If you can't explain why a design decision was made in one sentence, it probably wasn't intentional. Boxel is opinionated on purpose.", "typographySystem": "# The Three-Voice Type System\n\n**Every font has a function. Every weight has a reason.**\n\nBoxel's typographic architecture mirrors its data model: clear hierarchy, no ambiguity, zero decorative elements.\n\n## Voice 1: The Statement (Display)\n**IBM Plex Sans Bold** \nWhen you need to say something once and have it stick. Used for primary headlines, hero statements, and any text that should feel like a terminal command about to execute. Tight kerning creates word-level cohesion\u2014these aren't letters, they're *declarations*.\n\n## Voice 2: The Narrative (Body)\n**IBM Plex Sans Regular / IBM Plex Serif** \nWhere humans talk to humans. Sans for UI explanations and product descriptions. Serif for testimonials and case studies\u2014adding just enough warmth to signal \"a person said this.\" Maintains readability from 14px up to 18px without losing personality.\n\n## Voice 3: The Machine (System)\n**IBM Plex Mono Medium** \nThe voice of state. Used for labels, metadata, technical annotations, and any UI element that represents system information. Always uppercase when functioning as a label (`STATUS: ACTIVE`, `MODULE 01`). The monospaced rhythm creates scannable consistency\u2014your eye can parse twenty Mono labels faster than twenty Sans labels because character width becomes predictable.\n\n**Typography Truth:** If your interface has more than three font sizes visible simultaneously, you're not building hierarchy\u2014you're building noise. Boxel uses extreme contrast instead: massive Display, comfortable Body, compact Mono. The gaps between them do the work.", "brandColorPalette": [ diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index 7e543590b40..5a77f0db71b 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -99,20 +99,20 @@ import { cardTypeName, fileNameFromUrl, referenceNamesFile, - isDeclaredScreenshotFormat, - isValidScreenshotName, - DECLARED_SCREENSHOT_FORMATS, - SCREENSHOT_NAME_MAX_LENGTH, - SCREENSHOT_NAME_PATTERN, - SCREENSHOT_MAX_VIEWPORT_WIDTH, - SCREENSHOT_MAX_VIEWPORT_HEIGHT, - SCREENSHOT_MAX_DEVICE_SCALE_FACTOR, - SCREENSHOT_MAX_PHYSICAL_EDGE_PX, - SCREENSHOT_DEFAULT_DEVICE_SCALE_FACTOR, - type DeclaredScreenshotRoster, - type DeclaredScreenshotSpecPayload, - type DeclaredScreenshotFormat, - type ScreenshotsMeta, + isDeclaredCaptureFormat, + isValidCaptureName, + DECLARED_CAPTURE_FORMATS, + CAPTURE_NAME_MAX_LENGTH, + CAPTURE_NAME_PATTERN, + CAPTURE_MAX_VIEWPORT_WIDTH, + CAPTURE_MAX_VIEWPORT_HEIGHT, + CAPTURE_MAX_DEVICE_SCALE_FACTOR, + CAPTURE_MAX_PHYSICAL_EDGE_PX, + DECLARED_CAPTURE_DEFAULT_DEVICE_SCALE_FACTOR, + type DeclaredCaptureRoster, + type DeclaredCaptureSpecPayload, + type DeclaredCaptureFormat, + type CapturesMeta, } from '@cardstack/runtime-common'; import { captureQueryFieldSeedData, @@ -141,7 +141,7 @@ import FileDefFittedTemplate from './default-templates/file-def-fitted'; import FileDefIsolatedTemplate from './default-templates/file-def-isolated'; import type { FilePreviewComponent } from './file-formats/file-preview-stage'; import { ImagePreview } from './file-formats/image-preview'; -import { IMAGE_THUMB_SCREENSHOTS } from './file-formats/image-captures'; +import { IMAGE_THUMB_CAPTURES } from './file-formats/image-captures'; import CaptionsIcon from '@cardstack/boxel-icons/captions'; import FileIcon from '@cardstack/boxel-icons/file'; import ImageIcon from '@cardstack/boxel-icons/image'; @@ -2602,7 +2602,7 @@ interface InternalFieldInitializer { // userland `@field` under one of these would shadow the getter via the // prototype chain silently, so the decorator refuses them by name (the // `boxel/no-reserved-field-names` lint rule is the authoring-time backstop). -const RESERVED_FIELD_NAMES = ['screenshotURLs', 'screenshotsMeta']; +const RESERVED_FIELD_NAMES = ['captureURLs', 'capturesMeta']; // our decorators are implemented by Babel, not TypeScript, so they have a // different signature than Typescript thinks they do. @@ -2986,12 +2986,12 @@ export type BaseDefComponent = ComponentLike<{ }; }>; -// One declared screenshot (an entry in a CardDef/FileDef `static -// screenshots` slot): exactly one of `render` or `format` names what to +// One declared capture (an entry in a CardDef/FileDef `static +// captures` slot): exactly one of `render` or `format` names what to // draw, and the rest parameterizes the capture box. // // `render` is a *capture-only* component: referenced only from this -// declaration and rendered only by the screenshot render route — never +// declaration and rendered only by the capture render route — never // exposed through the card's format API or `@fields`. Capture-only restricts // where the component renders, not what it may use: it gets the full author // surface (`@model`/`@fields`/`@context`) and may render linked data. @@ -2999,7 +2999,7 @@ export type BaseDefComponent = ComponentLike<{ // A capture-only component whose content readies asynchronously (a video // frame seeked onto a canvas, a PDF page paint, a WebGL first frame — work // invisible to image-paint waiting) signals readiness through the DOM: while -// unready it renders a `data-screenshot-pending` attribute on any element, +// unready it renders a `data-capture-pending` attribute on any element, // and removes it when painted. The capture engine waits (bounded) for no // such element to remain; a component that never resolves its pending // element fails that slot's capture rather than persisting an unready frame. @@ -3008,7 +3008,7 @@ export type BaseDefComponent = ComponentLike<{ // A component that learns its content can never become ready — a corrupt or // password-protected document, an undecodable video — should not leave the // pending attribute standing until the engine's timeout: swap in a -// `data-screenshot-failed` attribute instead (remove the pending attribute, +// `data-capture-failed` attribute instead (remove the pending attribute, // set the failed one), which fails the slot immediately. Set the attribute's // value to a short human-readable cause; the engine carries it into the // slot's failure diagnostics, so an unreadable file is distinguishable from @@ -3017,18 +3017,18 @@ export type BaseDefComponent = ComponentLike<{ // resolving readiness over an unpainted box would persist a blank frame as // if it were real content. // -// Clear the attribute with `el.removeAttribute('data-screenshot-pending')` +// Clear the attribute with `el.removeAttribute('data-capture-pending')` // from the async continuation (and set the failure signal with -// `el.setAttribute('data-screenshot-failed', cause)`) — never by +// `el.setAttribute('data-capture-failed', cause)`) — never by // re-rendering it off a tracked property -// (`data-screenshot-pending={{if this.pending 'true'}}`). Capture +// (`data-capture-pending={{if this.pending 'true'}}`). Capture // pages run in backgrounded tabs, where the browser throttles the timers a // tracked update's render flush rides, so the flip can sit unflushed past // the engine's whole wait; the engine watches for the DOM mutation itself, // which a direct attribute mutation produces immediately. // // `format` reuses one of the card's display formats instead. A format-based -// screenshot referenced by that same format's own markup (say, a fitted +// capture referenced by that same format's own markup (say, a fitted // template that embeds its own `format: 'fitted'` capture) is circular — // use a dedicated `render` component there. // @@ -3044,24 +3044,24 @@ export type BaseDefComponent = ComponentLike<{ // deterministically too: seek to an exact timestamp rather than "current // frame", position WebGL cameras explicitly, and avoid ambient animation // loops. Nondeterministic output doesn't break anything visibly — the row -// publishes and the durable screenshot URLs don't change — but every +// publishes and the durable capture URLs don't change — but every // reindex captures "new" bytes, wasting renders and storage churn and // defeating image caching (each rotation changes the image's ETag, so // every viewer re-downloads it). // What the capture renders: a capture-only `render` component, or one of the // card's own display formats. Shared by both output kinds below. -type ScreenshotSpecSource = +type CaptureSpecSource = | { render: BaseDefComponent; format?: undefined } - | { format: DeclaredScreenshotFormat; render?: undefined }; + | { format: DeclaredCaptureFormat; render?: undefined }; -// A raster screenshot: a pixel tile of the render, sized by a capture box. -type RasterScreenshotSpec = { +// A raster capture: a pixel tile of the render, sized by a capture box. +type RasterCaptureSpec = { // CSS px of the capture box (the fitted envelope). width: number; height: number; // Output px = size × deviceScaleFactor. Defaults to - // SCREENSHOT_DEFAULT_DEVICE_SCALE_FACTOR (2). Each edge × the effective - // scale must stay within SCREENSHOT_MAX_PHYSICAL_EDGE_PX. + // DECLARED_CAPTURE_DEFAULT_DEVICE_SCALE_FACTOR (2). Each edge × the effective + // scale must stay within CAPTURE_MAX_PHYSICAL_EDGE_PX. deviceScaleFactor?: number; // 'transparent' or any CSS color. Default 'white'. 'transparent' requires // an alpha-capable `type` ('png' or 'webp') — jpeg has no alpha channel. @@ -3076,7 +3076,7 @@ type RasterScreenshotSpec = { // the capture envelope, used exactly as given; for a format-based capture // the rendered variant is emergent from the template's own container-query // breakpoints, deterministic per box. Preview a candidate box with - // `?format=fitted&envelope=WxH` via the `_screenshot/` DSL on a dev realm, + // `?format=fitted&envelope=WxH` via the `_capture/` DSL on a dev realm, // then codify it here. A box declared near one of the template's // breakpoints can flip variants when those breakpoints are tuned — the // same fragility any responsive design has. @@ -3091,13 +3091,13 @@ type RasterScreenshotSpec = { // edit), or — for file-backed defs — the file's content hash, so a // metadata-only edit skips recapture. Default 'generation'. // 'file-content' is only legal on FileDef chains: a CardDef has no file - // bytes to key by, so getScreenshots refuses it there. + // bytes to key by, so getCaptures refuses it there. keyBy?: 'generation' | 'file-content'; // Encoded image type. Default 'png'. type?: 'png' | 'jpeg' | 'webp'; -} & ScreenshotSpecSource; +} & CaptureSpecSource; -// A pdf screenshot: a paged document of the render, laid out under the card's +// A pdf capture: a paged document of the render, laid out under the card's // own print CSS. It has no capture box — `width`/`height` (and the raster-only // `deviceScaleFactor`/`background`) are refused, and paper comes from the // card's `@page { size }` rule (Chrome's default paper otherwise). Its source @@ -3106,7 +3106,7 @@ type RasterScreenshotSpec = { // (`isolated`/`embedded`) — the box formats (`fitted`/`atom`) need an envelope // a pdf entry cannot give. A pdf is never a thumbnail — that fallback chain // wants an image — so `useAsThumbnail` is refused too. -type PdfScreenshotSpec = { +type PdfCaptureSpec = { type: 'pdf'; width?: undefined; height?: undefined; @@ -3115,11 +3115,11 @@ type PdfScreenshotSpec = { useAsThumbnail?: undefined; // What invalidates the capture; see the raster note above. keyBy?: 'generation' | 'file-content'; -} & ScreenshotSpecSource; +} & CaptureSpecSource; -export type ScreenshotSpec = RasterScreenshotSpec | PdfScreenshotSpec; +export type CaptureSpec = RasterCaptureSpec | PdfCaptureSpec; -const SCREENSHOT_SPEC_FIELDS = new Set([ +const DECLARED_CAPTURE_SPEC_FIELDS = new Set([ 'render', 'format', 'width', @@ -3130,10 +3130,10 @@ const SCREENSHOT_SPEC_FIELDS = new Set([ 'keyBy', 'type', ]); -const SCREENSHOT_KEY_BY_VALUES = new Set(['generation', 'file-content']); -const SCREENSHOT_IMAGE_TYPES = new Set(['png', 'jpeg', 'webp']); +const DECLARED_CAPTURE_KEY_BY_VALUES = new Set(['generation', 'file-content']); +const RASTER_OUTPUT_TYPES = new Set(['png', 'jpeg', 'webp']); -function screenshotOwnerName(owner: typeof BaseDef): string { +function captureOwnerName(owner: typeof BaseDef): string { return owner.name || owner.displayName || 'card'; } @@ -3141,15 +3141,15 @@ function screenshotOwnerName(owner: typeof BaseDef): string { // capture engine cannot honor is refused by name rather than ignored, so a // typo'd declaration fails loudly at read time instead of silently capturing // something other than what the author meant. -function assertValidScreenshotSpec( +function assertValidCaptureSpec( owner: typeof BaseDef, name: string, spec: unknown, -): asserts spec is ScreenshotSpec { - let prefix = `screenshot "${name}" on ${screenshotOwnerName(owner)}`; - if (!isValidScreenshotName(name)) { +): asserts spec is CaptureSpec { + let prefix = `capture "${name}" on ${captureOwnerName(owner)}`; + if (!isValidCaptureName(name)) { throw new Error( - `${prefix}: name must be a URL-safe path segment (${SCREENSHOT_NAME_PATTERN.source}, at most ${SCREENSHOT_NAME_MAX_LENGTH} characters)`, + `${prefix}: name must be a URL-safe path segment (${CAPTURE_NAME_PATTERN.source}, at most ${CAPTURE_NAME_MAX_LENGTH} characters)`, ); } if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) { @@ -3157,7 +3157,7 @@ function assertValidScreenshotSpec( } let entry = spec as Record; for (let key of Object.keys(entry)) { - if (!SCREENSHOT_SPEC_FIELDS.has(key)) { + if (!DECLARED_CAPTURE_SPEC_FIELDS.has(key)) { throw new Error(`${prefix}: unknown field "${key}"`); } } @@ -3175,9 +3175,9 @@ function assertValidScreenshotSpec( ) { throw new Error(`${prefix}: render must be a component`); } - if (hasFormat && !isDeclaredScreenshotFormat(entry.format)) { + if (hasFormat && !isDeclaredCaptureFormat(entry.format)) { throw new Error( - `${prefix}: format must be one of ${DECLARED_SCREENSHOT_FORMATS.map( + `${prefix}: format must be one of ${DECLARED_CAPTURE_FORMATS.map( (f) => `"${f}"`, ).join(', ')}`, ); @@ -3197,7 +3197,7 @@ function assertValidScreenshotSpec( entry.format !== 'embedded' ) { throw new Error( - `${prefix}: a pdf screenshot's format must be 'isolated' or 'embedded' — the box formats ('fitted', 'atom') need an envelope a pdf entry cannot describe`, + `${prefix}: a pdf capture's format must be 'isolated' or 'embedded' — the box formats ('fitted', 'atom') need an envelope a pdf entry cannot describe`, ); } for (let field of [ @@ -3208,21 +3208,21 @@ function assertValidScreenshotSpec( ] as const) { if (entry[field] !== undefined) { throw new Error( - `${prefix}: '${field}' is a raster capture field and cannot appear on a pdf screenshot — paper comes from the card's print CSS (@page)`, + `${prefix}: '${field}' is a raster capture field and cannot appear on a pdf capture — paper comes from the card's print CSS (@page)`, ); } } if (entry.useAsThumbnail !== undefined) { throw new Error( - `${prefix}: useAsThumbnail cannot appear on a pdf screenshot — a thumbnail must be an image`, + `${prefix}: useAsThumbnail cannot appear on a pdf capture — a thumbnail must be an image`, ); } if ( entry.keyBy !== undefined && - !SCREENSHOT_KEY_BY_VALUES.has(entry.keyBy as string) + !DECLARED_CAPTURE_KEY_BY_VALUES.has(entry.keyBy as string) ) { throw new Error( - `${prefix}: keyBy must be one of ${[...SCREENSHOT_KEY_BY_VALUES] + `${prefix}: keyBy must be one of ${[...DECLARED_CAPTURE_KEY_BY_VALUES] .map((v) => `"${v}"`) .join(', ')}`, ); @@ -3230,8 +3230,8 @@ function assertValidScreenshotSpec( return; } for (let [field, max] of [ - ['width', SCREENSHOT_MAX_VIEWPORT_WIDTH], - ['height', SCREENSHOT_MAX_VIEWPORT_HEIGHT], + ['width', CAPTURE_MAX_VIEWPORT_WIDTH], + ['height', CAPTURE_MAX_VIEWPORT_HEIGHT], ] as const) { let value = entry[field]; if ( @@ -3251,10 +3251,10 @@ function assertValidScreenshotSpec( typeof dsf !== 'number' || !Number.isFinite(dsf) || dsf <= 0 || - dsf > SCREENSHOT_MAX_DEVICE_SCALE_FACTOR + dsf > CAPTURE_MAX_DEVICE_SCALE_FACTOR ) { throw new Error( - `${prefix}: deviceScaleFactor must be a number between 0 (exclusive) and ${SCREENSHOT_MAX_DEVICE_SCALE_FACTOR}`, + `${prefix}: deviceScaleFactor must be a number between 0 (exclusive) and ${CAPTURE_MAX_DEVICE_SCALE_FACTOR}`, ); } } @@ -3265,14 +3265,14 @@ function assertValidScreenshotSpec( let effectiveScale = typeof entry.deviceScaleFactor === 'number' ? entry.deviceScaleFactor - : SCREENSHOT_DEFAULT_DEVICE_SCALE_FACTOR; + : DECLARED_CAPTURE_DEFAULT_DEVICE_SCALE_FACTOR; for (let field of ['width', 'height'] as const) { if ( (entry[field] as number) * effectiveScale > - SCREENSHOT_MAX_PHYSICAL_EDGE_PX + CAPTURE_MAX_PHYSICAL_EDGE_PX ) { throw new Error( - `${prefix}: ${field} × deviceScaleFactor must be <= ${SCREENSHOT_MAX_PHYSICAL_EDGE_PX} physical pixels (deviceScaleFactor defaults to ${SCREENSHOT_DEFAULT_DEVICE_SCALE_FACTOR})`, + `${prefix}: ${field} × deviceScaleFactor must be <= ${CAPTURE_MAX_PHYSICAL_EDGE_PX} physical pixels (deviceScaleFactor defaults to ${DECLARED_CAPTURE_DEFAULT_DEVICE_SCALE_FACTOR})`, ); } } @@ -3292,20 +3292,20 @@ function assertValidScreenshotSpec( } if ( entry.keyBy !== undefined && - !SCREENSHOT_KEY_BY_VALUES.has(entry.keyBy as string) + !DECLARED_CAPTURE_KEY_BY_VALUES.has(entry.keyBy as string) ) { throw new Error( - `${prefix}: keyBy must be one of ${[...SCREENSHOT_KEY_BY_VALUES] + `${prefix}: keyBy must be one of ${[...DECLARED_CAPTURE_KEY_BY_VALUES] .map((v) => `"${v}"`) .join(', ')}`, ); } if ( entry.type !== undefined && - !SCREENSHOT_IMAGE_TYPES.has(entry.type as string) + !RASTER_OUTPUT_TYPES.has(entry.type as string) ) { throw new Error( - `${prefix}: type must be one of ${[...SCREENSHOT_IMAGE_TYPES] + `${prefix}: type must be one of ${[...RASTER_OUTPUT_TYPES] .map((v) => `"${v}"`) .join(', ')}`, ); @@ -3320,15 +3320,15 @@ function assertValidScreenshotSpec( } } -// The one read path for `static screenshots`: merges declarations by name up +// The one read path for `static captures`: merges declarations by name up // the prototype chain (a subclass adds new names and overrides inherited // ones wholesale, per name), validating each entry and the merged result's // at-most-one-`useAsThumbnail` constraint. Read through this rather than the // static directly — a plain property read sees only the nearest declaration, // dropping everything an ancestor declared. -export function getScreenshots( +export function getCaptures( cardOrFileClass: typeof CardDef | typeof FileDef, -): Record { +): Record { // Collect declaration levels base-most first so a subclass's entry lands // after (and thus overrides) its ancestor's. let levels: { @@ -3337,7 +3337,18 @@ export function getScreenshots( }[] = []; let current: unknown = cardOrFileClass; while (typeof current === 'function') { - let descriptor = Object.getOwnPropertyDescriptor(current, 'screenshots'); + // A declaration under the name these slots used to carry would otherwise + // be read by nothing: the card would index clean and simply have no + // captures, which is the one failure here that never surfaces. Refuse it + // by name instead, so the card's own error doc says what to rename. + if (Object.getOwnPropertyDescriptor(current, 'screenshots')) { + throw new Error( + `${captureOwnerName( + current as typeof BaseDef, + )} declares 'static screenshots', which is no longer read — rename it to 'static captures'`, + ); + } + let descriptor = Object.getOwnPropertyDescriptor(current, 'captures'); if (descriptor) { let declarations = descriptor.get ? descriptor.get.call(current) @@ -3345,7 +3356,7 @@ export function getScreenshots( if (declarations != null) { if (typeof declarations !== 'object' || Array.isArray(declarations)) { throw new Error( - `static screenshots on ${screenshotOwnerName( + `static captures on ${captureOwnerName( current as typeof BaseDef, )} must be an object mapping names to specs`, ); @@ -3355,7 +3366,7 @@ export function getScreenshots( } current = Object.getPrototypeOf(current); } - let merged: Record = {}; + let merged: Record = {}; // keyBy 'file-content' needs file bytes to key by, so it is only legal // when the class being read is file-backed. Checked against the merge // target rather than the declaring owner — a FileDef chain can never feed @@ -3365,10 +3376,10 @@ export function getScreenshots( cardOrFileClass === FileDef || cardOrFileClass.prototype instanceof FileDef; for (let { owner, declarations } of levels) { for (let [name, spec] of Object.entries(declarations)) { - assertValidScreenshotSpec(owner, name, spec); + assertValidCaptureSpec(owner, name, spec); if (spec.keyBy === 'file-content' && !isFileBacked) { throw new Error( - `screenshot "${name}" on ${screenshotOwnerName( + `capture "${name}" on ${captureOwnerName( owner, )}: keyBy 'file-content' requires a file-backed def (FileDef or a subclass); a card has no file content to key by — use 'generation' or omit keyBy`, ); @@ -3381,9 +3392,9 @@ export function getScreenshots( ); if (thumbnails.length > 1) { throw new Error( - `${screenshotOwnerName( + `${captureOwnerName( cardOrFileClass, - )} declares more than one useAsThumbnail screenshot (${thumbnails + )} declares more than one useAsThumbnail capture (${thumbnails .map((name) => `"${name}"`) .join( ', ', @@ -3398,14 +3409,14 @@ export function getScreenshots( // capture-only component, which cannot serialize — it is flagged // `render: true` and re-resolved in-page by slot name when its capture // renders. -export function serializeDeclaredScreenshots( +export function serializeDeclaredCaptures( cardOrFileClass: typeof CardDef | typeof FileDef, -): DeclaredScreenshotRoster { - let roster: DeclaredScreenshotRoster = {}; - for (let [name, spec] of Object.entries(getScreenshots(cardOrFileClass))) { +): DeclaredCaptureRoster { + let roster: DeclaredCaptureRoster = {}; + for (let [name, spec] of Object.entries(getCaptures(cardOrFileClass))) { // A pdf entry carries none of the raster geometry (validation refuses it); // its width/height/deviceScaleFactor/background are all absent here. - let payload: DeclaredScreenshotSpecPayload = {}; + let payload: DeclaredCaptureSpecPayload = {}; if (spec.width !== undefined) { payload.width = spec.width; } @@ -3437,32 +3448,32 @@ export function serializeDeclaredScreenshots( return roster; } -// Shared body of the `screenshotURLs` getter on CardDef and FileDef (two -// sites, matching how `static screenshots` itself is declared — FieldDef has +// Shared body of the `captureURLs` getter on CardDef and FileDef (two +// sites, matching how `static captures` itself is declared — FieldDef has // no addressable URL, so it gets neither). Every declared slot name appears // as a key; the value is the capture's durable served URL when -// `meta.screenshots` (the serve-time join, or the prerender render context's +// `meta.captures` (the serve-time join, or the prerender render context's // declaration-derived form) holds the name, and `undefined` otherwise — // not-yet-captured, capture-errored, or an unsaved instance. `undefined` // rather than a placeholder URL is deliberate: it is the absence signal // consumption fallback chains (e.g. a thumbnail falling through to an icon // default) rely on, which a placeholder would defeat. -function composeScreenshotURLs( +function composeCaptureURLs( instance: CardDef | FileDef, ): Record { let urls: Record = {}; try { for (let name of Object.keys( - getScreenshots(instance.constructor as typeof CardDef | typeof FileDef), + getCaptures(instance.constructor as typeof CardDef | typeof FileDef), )) { urls[name] = undefined; } } catch { // An invalid declaration fails loudly at its authoring surfaces (the - // capture roster read, `getScreenshots` callers); a consuming template + // capture roster read, `getCaptures` callers); a consuming template // must stay render-safe, so here it reads as "nothing declared". } - let captured = getCardMeta(instance, 'screenshots'); + let captured = getCardMeta(instance, 'captures'); if (captured) { for (let [name, entry] of Object.entries(captured)) { urls[name] = entry.url; @@ -3473,24 +3484,22 @@ function composeScreenshotURLs( // The durable URL of the capture feeding `cardThumbnailURL`: the declared // slot flagged `useAsThumbnail` (at most one across the merged declarations, -// enforced by `getScreenshots`), read with `screenshotURLs`' semantics — a -// URL exactly when `meta.screenshots` holds the slot, `undefined` otherwise. +// enforced by `getCaptures`), read with `captureURLs`' semantics — a +// URL exactly when `meta.captures` holds the slot, `undefined` otherwise. // On live loads meta joins the persisted manifest, so an uncaptured slot // reads `undefined` and the fallback chain's terminal rung (the icon // default) engages. In the prerender render context meta is -// declaration-derived (`screenshotsMetaFromRoster`), so this rung asserts +// declaration-derived (`capturesMetaFromRoster`), so this rung asserts // the durable URL before any capture exists: persisted HTML embeds a URL // that 404s until the capture lands, then self-heals — and a slot whose // capture never succeeds stays a 404 (a blank tile), not the icon default. -// Same render-safety posture as `composeScreenshotURLs`: an invalid +// Same render-safety posture as `composeCaptureURLs`: an invalid // declaration reads as "no thumbnail capture". -function thumbnailScreenshotURL( - instance: CardDef | FileDef, -): string | undefined { +function thumbnailCaptureURL(instance: CardDef | FileDef): string | undefined { let name: string | undefined; try { name = Object.entries( - getScreenshots(instance.constructor as typeof CardDef | typeof FileDef), + getCaptures(instance.constructor as typeof CardDef | typeof FileDef), ).find(([, spec]) => spec.useAsThumbnail)?.[0]; } catch { return undefined; @@ -3498,7 +3507,7 @@ function thumbnailScreenshotURL( if (!name) { return undefined; } - return getCardMeta(instance, 'screenshots')?.[name]?.url; + return getCardMeta(instance, 'captures')?.[name]?.url; } export class FieldDef extends BaseDef { @@ -3926,18 +3935,18 @@ export class FileDef extends BaseDef { return this[meta]?.resourceCreatedAt; } - // See CardDef.screenshotURLs — the same reserved, meta-derived getter for + // See CardDef.captureURLs — the same reserved, meta-derived getter for // file-backed defs. The prerender pass captures a URL's file rendering // alongside its instance rendering, so a file family's declared names // resolve here just as a card's do. - get screenshotURLs(): Record { - return composeScreenshotURLs(this); + get captureURLs(): Record { + return composeCaptureURLs(this); } - // See CardDef.screenshotsMeta — the same reserved, meta-derived getter for + // See CardDef.capturesMeta — the same reserved, meta-derived getter for // file-backed defs. - get screenshotsMeta(): ScreenshotsMeta | undefined { - return getCardMeta(this, 'screenshots'); + get capturesMeta(): CapturesMeta | undefined { + return getCardMeta(this, 'captures'); } // The four shared format shells own identity, facts, budgets, and state for @@ -3973,11 +3982,11 @@ export class FileDef extends BaseDef { // less surprising. static markdown: BaseDefComponent = DefaultMarkdownFallbackTemplate; - // Opt-in declared screenshots (see CardDef.screenshots): merged by name up - // the prototype chain via `getScreenshots`. File families whose capture + // Opt-in declared captures (see CardDef.captures): merged by name up + // the prototype chain via `getCaptures`. File families whose capture // derives from the file's bytes (a video's poster frame, say) declare // `keyBy: 'file-content'` so a metadata-only edit skips recapture. - static screenshots?: Record; + static captures?: Record; static async extractAttributes( url: string, @@ -4070,7 +4079,7 @@ export class ImageDef extends FileDef { // excludes vectors, so an SVG must not pay for captures nothing reads. // File-content-keyed, so a metadata-only edit never re-decodes the pixels. // See `image-captures` for the boxes and the capture-only components. - static screenshots: Record = IMAGE_THUMB_SCREENSHOTS; + static captures: Record = IMAGE_THUMB_CAPTURES; // CS-10787: emit a markdown image reference. If no URL is available we // fall back to a placeholder that names the image — useful to downstream @@ -4145,7 +4154,7 @@ export class CardDef extends BaseDef { }, }); // The thumbnail fallback chain: an author-set URL wins, then an authored - // ImageDef link, then the capture the card's `static screenshots` flags + // ImageDef link, then the capture the card's `static captures` flags // `useAsThumbnail` — so every opted-in card gets a live preview with no // template edits (the default fitted template already renders this field). // Falsy past all three rungs is the absence signal consumers use to fall @@ -4159,7 +4168,7 @@ export class CardDef extends BaseDef { return ( this.cardInfo.cardThumbnailURL || this.cardInfo.cardThumbnail?.url || - thumbnailScreenshotURL(this) + thumbnailCaptureURL(this) ); }, }); @@ -4200,12 +4209,12 @@ export class CardDef extends BaseDef { // turndown (registered on `globalThis` by `packages/host`). Subclasses can // override `static markdown` to author bespoke markdown directly. static markdown: BaseDefComponent = DefaultMarkdownFallbackTemplate; - // Opt-in declared screenshots: captures rendered at indexing time and - // served from the realm's `_screenshot/…?name=` route. Names are URL path + // Opt-in declared captures: captures rendered at indexing time and + // served from the realm's `_capture/…?name=` route. Names are URL path // segments. Declarations merge by name up the prototype chain — read them - // via `getScreenshots`, never off the static directly, or ancestors' + // via `getCaptures`, never off the static directly, or ancestors' // entries are dropped. FieldDef has no addressable URL, so it has no slot. - static screenshots?: Record; + static captures?: Record; static get hasCustomEditTemplate(): boolean { return this.edit !== CardDef.edit; @@ -4244,33 +4253,33 @@ export class CardDef extends BaseDef { return realmURLString ? new URL(realmURLString) : undefined; } - // The durable served URLs of this instance's declared screenshots, keyed - // by slot name — one key per `static screenshots` declaration, `undefined` - // until a capture exists (see `composeScreenshotURLs`). `undefined` is the + // The durable served URLs of this instance's declared captures, keyed + // by slot name — one key per `static captures` declaration, `undefined` + // until a capture exists (see `composeCaptureURLs`). `undefined` is the // deliberate absence signal, so a template must guard its `` — // Glimmer omits the attribute for an undefined value, which renders a // broken/empty image: // - // {{#if @model.screenshotURLs.card}} - // preview + // {{#if @model.captureURLs.card}} + // preview // {{/if}} // // A getter rather than a `@field` (like FileDef's timestamp getters) so it // never round-trips on a write; the name is reserved by the `@field` // decorator so a userland field can't shadow it. - get screenshotURLs(): Record { - return composeScreenshotURLs(this); + get captureURLs(): Record { + return composeCaptureURLs(this); } - // The instance's `meta.screenshots` entries verbatim — the dimensional - // companion to `screenshotURLs` for consumers that need more than the URL: + // The instance's `meta.captures` entries verbatim — the dimensional + // companion to `captureURLs` for consumers that need more than the URL: // each captured slot's width/height/deviceScaleFactor (a `srcset` // assembler's inputs) and its `useAsThumbnail` flag. Same absence - // semantics: a slot appears only once `meta.screenshots` holds it, so an + // semantics: a slot appears only once `meta.captures` holds it, so an // uncaptured live instance reads `undefined` here. Reserved like - // `screenshotURLs`, so a userland field can't shadow it. - get screenshotsMeta(): ScreenshotsMeta | undefined { - return getCardMeta(this, 'screenshots'); + // `captureURLs`, so a userland field can't shadow it. + get capturesMeta(): CapturesMeta | undefined { + return getCardMeta(this, 'captures'); } [getMenuItems](params: GetMenuItemParams): MenuItemOptions[] { diff --git a/packages/base/command.gts b/packages/base/command.gts index 382fc639e02..596f70bf80c 100644 --- a/packages/base/command.gts +++ b/packages/base/command.gts @@ -230,7 +230,7 @@ export class ReadBinaryFileResult extends CardDef { export class DownloadFileToRealmInput extends CardDef { @field sourceUrl = contains(StringField); // the URL to download @field realm = contains(StringField); // target realm (defaults to the writable realm) - @field path = contains(StringField); // destination path within the realm, e.g. "Screenshots/card.png" + @field path = contains(StringField); // destination path within the realm, e.g. "Captures/card.png" @field useNonConflictingFilename = contains(BooleanField); } @@ -252,7 +252,7 @@ export class GenerateThumbnailOutput extends CardDef { @field imageDefIdentifier = contains(StringField); } -export class ScreenshotCardInput extends CardDef { +export class CaptureCardInput extends CardDef { @field card = linksTo(CardDef); @field format = contains(StringField); // 'isolated' | 'embedded' // The capture surface, exposed one JSON-primitive field per parameter. @@ -278,23 +278,21 @@ export class ScreenshotCardInput extends CardDef { // One captured image. `url` is the durable served MediaCache URL the capture // persisted under — the only reference the tool returns; a re-capture rotates // its bytes, never the URL. -export class ScreenshotCapture extends FieldDef { +export class Capture extends FieldDef { @field name = contains(StringField); @field url = contains(StringField); @field width = contains(NumberField); @field height = contains(NumberField); } -export class ScreenshotCardOutput extends CardDef { - static displayName = 'Screenshot Result'; +export class CaptureCardOutput extends CardDef { + static displayName = 'Capture Result'; - @field captures = containsMany(ScreenshotCapture); + @field captures = containsMany(Capture); - static embedded = class Embedded extends Component< - typeof ScreenshotCardOutput - > { + static embedded = class Embedded extends Component {