Source: docs/EVALS.md · Revision: 5cb1e99cae08dbd2dcee5488dec0dc61389ebadf
Pinata evals
This is the human-readable catalog of how Pinata is evaluated, rendered live at /reqs/evals. The formal, executable definition of done is the mission validation contract (validation-contract.md, assertion IDs VAL-*); this catalog explains the same intent in plain language so anyone can add scenarios. When the two disagree, the contract wins.
How evaluation works
- One gate.
npm run validateruns lint and integrity, typecheck, unit and component tests, the deterministic docs check, the production build, and Chromium end-to-end tests — the same command locally and in CI. - Real integrations. Milestone validation exercises the real Browserless, Turso, private Blob, and Vercel surfaces. Mocks support focused unit tests; they are never the definition of done.
- Live checkpoints. After each milestone's automated validation passes, the owner drives a headed shared-browser session while the agent inspects the same DOM, console, network, and persisted state.
- Escapes become assertions. Any issue the owner reports is written down as a
given / when / expected / observedcontract assertion before a fix is implemented.
Chickpea scenarios
The primary real-world target is Chickpea (chickpea.co) plus its explicit /pricing, /about, and /privacy URLs, captured on desktop and mobile. Representative scenarios:
- Full-page captures include lazy-loaded content near the bottom of the page, on both viewports.
- Deep zoom on a dense pricing-table cell stays readable, and a pin dropped on that cell persists to the exact natural pixel after reload.
- Choosing the right nearby element from a dense table ranks the cell ahead of its row and table wrappers.
- The closed mobile menu's hidden descendants never appear as selectable metadata, while a visible header element does.
- Desktop and mobile annotations of the same page stay fully independent.
- The explicit URL array preserves its submitted order in the page tree.
- A failed viewport capture does not discard its successful sibling, and retry replaces only the failed attempt.
Dogfood scenarios
Pinata captures and annotates its own requirements hub. The dogfood URL array, in order:
/reqs/reqs/architecture/reqs/milestones/reqs/decisions/reqs/evals
Representative scenarios:
- Navigate the hub from the landing page without typing a URL; every route refreshes cleanly; an unknown
/reqs/*address lands on a bounded 404 with a working link home. - Decision cards match
docs/decisions/decisions.jsonexactly, including provenance quotes and supersession links — there is no second decision dataset. - Capture the five routes above as a Pinata project and annotate a requirement from inside Pinata itself.
- Hostile markup or unsafe links in a document render inert; external links identify their destination and withhold the referrer.
- The hub stays usable at phone width, at 320 CSS pixels with 200% zoom, and with reduced motion preferred, with no horizontal scrolling of the page.
Landing page scenarios
How a first-time visitor meets Pinata (VAL-LANDING-001, VAL-LANDING-002, VAL-LANDING-003, D066/D067):
- An anonymous visitor opening
/sees the pinata mark — the same inline SVG mark as the favicon — directly above the URL capture entry (a required root URL, an optional add-more-URLs control, and one visible primary action), plus a brief value proposition. The page renders with no console errors, no/api/*traffic, and no external image requests. - Below the hero, a fully static example shows a marked-up capture: a screenshot region with two numbered pins, a two-entry comment thread, and a visible DOM metadata panel. It renders from bundled fixture data with no database or API dependency and depicts saved comments only — no reply control.
- An anonymous visitor who submits the capture entry is routed to the sign-in prompt (focus lands on the password field) without any project write; after signing in, the editor lands back on
/with the entered URLs retained in the always-active form, and creating the project issues exactly onePOST /api/projectsanswered201, with the project listed on the same page.
Editor entry scenarios
How Lucas gets from the password prompt to a project. Representative scenarios (VAL-AUTH-008, VAL-AUTH-009):
- While the project list loads, the region reports itself busy and says so in text; loading, empty, populated, and failure never render at once.
- With zero projects, the named list says it is empty and offers exactly one create action; submitting once shows the project exactly once, already in the workspace, with no address-bar navigation.
- When the list read fails, the failure is announced, sign-out stays available, any half-typed create form keeps its safe input, and one press of Try again issues exactly one read; a press while that read is in flight does nothing.
- Reload and Back/Forward after a create never resubmit the form and never create a second project.
- Login, list, and create controls all have accessible names, associated errors, visible focus, and no keyboard trap; a wrong password announces a generic error and clears the password field.
- End-to-end runs delete their own rows in teardown, not in a trailing test, so an aborted run cannot leak run-scoped projects, pages, captures, or idempotency keys into the real database.
Role and thread scenarios
- A founder link opens a read/reply-only view; editing controls are absent visually and to assistive technology.
- Founder replies and editor follow-ups interleave chronologically and cannot be edited or deleted by anyone, enforced below the application by database triggers.
- Rotating a link invalidates the old link and every old session; revoking ends access without deleting project history.
- A stale reply composed before logout, rotation, or revocation is rejected and adds nothing.
Milestone 1 live checkpoint (2026-09-09/10)
The first live checkpoint: the owner drove their own browser against the local production build on 127.0.0.1:3100 (commit 71c8ee2, run id checkpoint-m1-2026-09-09T211026Z, server running with the D052 local auth-bypass flag) with the seeded Chickpea project — root plus /pricing, /about, /privacy, Desktop and Mobile, nine ready captures and one failed Mobile-root attempt. The session spanned roughly 2026-09-09T21:30Z to 2026-09-10T02:40Z with intermittent engagement across three mid-session fix cycles; the owner did not state active time. Per the escapes-become-assertions rule, each finding below was written down before its fix was implemented. Overall outcome: conditional acceptance — capture and organization validated; the product is not yet usable because pins and pin comments do not exist (the very next milestone, D051). Verbatim closing statement:
okay, i thought we had something usable. this doesn't let us create the most fundamental thing - creating a pin with an annotion. definitely move onto that next.
M1-LIVE-1 — the stage must show the actual screenshot
- Given a project with ready captures in the editor workspace
- When the owner selects a capture and interacts with the stage
- Expected the selected capture's real screenshot renders in the stage
- Observed the stage was a text placeholder and clicking its controls did nothing. Verbatim: "okay. so, a thing is captured, but I click on the buttons and nothing happens. please review / explain."
- Outcome rejected as shipped; fixed mid-session (editor-capture-image-stage, commit
b21385a), now formalized as VAL-CAPTURE-015.
M1-LIVE-2 — the entire capture is in view on first open
- Given a ready tall capture (the Chickpea pages run ~9k–13k px)
- When the owner opens it in the stage
- Expected the whole capture is visible at once, without scrolling
- Observed the capture rendered at natural size and ran far off screen. Verbatim: "the content is being successfully fetched/screenshotted! but it should be presented such that the entire page is in view. see screenshot."
- Outcome fixed mid-session (capture-stage-fit-view-and-hints, commit
0a27d77): fit-to-view is the default with a labeled natural-size toggle; the contract's VAL-CANVAS-002 now carries this as a user-directed requirement.
M1-LIVE-3 — the pin-drop mechanism must be discoverable from the page
- Given a ready capture on screen
- When the owner looks for how to drop a note on it
- Expected the page itself explains the interaction, and the mechanism exists
- Observed no pin mechanism exists yet and nothing on the page said so. Verbatim: "what hte heck is the interactin mechanism to drop a note? I can't figure it out? add instrucitons on teh page itself to make it self-documented"
- Outcome open requirement, captured as VAL-CANVAS-009 (self-documenting canvas interactions) for the canvas/pins milestone; the interim stage gained a plain-language hint in the meantime.
M1-LIVE-4 — wide captures must pan horizontally at natural size
- Given a wide Desktop capture viewed at natural size
- When the owner tries to scroll right to see the rest of the page
- Expected the stage pans on both axes; the document never overflows horizontally
- Observed the image overflowed the page and nothing scrolled horizontally. Verbatim: "this still goes off the page when expanded. i can't scroll right either - the page scroll seems fixed horizontally - so i can't view the entire page that's been captured." In the same exchange the "canvas update" hint jargon was impenetrable: "what does \"pins and comments arrive with the canvas update. Use natural size to scroll into fine detail.\" mean? i literally can't figure out how to create a pin and comment on it. tell me what to do."
- Outcome fixed mid-session (capture-stage-natural-scroll-and-plain-copy, commit
71c8ee2): the scroll region is bounded on both axes (minmax(0, 1fr)tracks,min-width: 0on the detail and scroll containers) and the hint now says plainly that pinning and commenting are not available in this build yet.
M1-LIVE-5 — owner-driven retry recovers a capture end to end
- Given a failed capture attempt sitting next to ready siblings
- When the owner presses the variant's Retry control themselves
- Expected one fresh attempt is dispatched through the real provider and appears as a new ready version
- Observed exactly that: the owner triggered "Retry Desktop capture" unaided and the retry produced a ready Version 2 end to end through the real Browserless provider, with no worker intervention.
- Outcome accepted behavior — positive evidence for the retry and dispatch-driver design (VAL-CAPTURE-008, D049).
Published boundaries
Every runtime boundary is exported exactly once from src/lib/boundaries/ (policy version 2026-09-23.3, constant POLICY_VERSION). Unit tests import the same constants and compare them against this page, docs/ARCHITECTURE.md, and the deployed /reqs routes; any drift between code, docs, and deployed content fails the gate, and duplicating one of these literals anywhere else in the application is a defect.
| Constant | Value | Policy |
|---|---|---|
POLICY_VERSION | 2026-09-23.3 | Dated catalog version; bumps on any boundary change. |
Editor session
| Constant | Value | Policy |
|---|---|---|
EDITOR_SESSION_ABSOLUTE_LIFETIME_MS | 43,200,000 ms (12 hours) | A session is never valid past its absolute expiry. |
EDITOR_SESSION_RENEWAL_THRESHOLD_MS | 7,200,000 ms (2 hours) | Renewal is allowed only when the remaining lifetime is inside this threshold; a renewal sets a fresh absolute expiry. |
AUTH_REQUEST_MAX_BYTES | 1,024 bytes | Login/logout request bodies larger than this are rejected before parsing. |
EDITOR_PASSWORD_MAX_CHARS | 256 | The password field accepts at most this many characters. |
URL and input limits
| Constant | Value | Policy |
|---|---|---|
MAX_SUBMITTED_URL_ROWS | 32 | Rows beyond the limit are rejected before any normalization or persistence. |
MAX_UNIQUE_PAGE_URLS | 16 | Unique normalized page URLs retained per project. |
MAX_URL_BYTES | 2,048 bytes | Per submitted row, measured as UTF-8. |
BLANK_URL_ROW_POLICY | ignore | Blank optional array rows are ignored; the root URL is always required. |
PROJECT_REQUEST_MAX_BYTES | 69,632 bytes | Project-create bodies larger than this are rejected before parsing. |
PROJECT_TITLE_MAX_CHARS | 120 | Longest accepted project title; blank defaults to the root host. |
IDEMPOTENCY_KEY_MIN_CHARS | 8 | Shortest accepted mutation idempotency key. |
IDEMPOTENCY_KEY_MAX_CHARS | 128 | Longest accepted mutation idempotency key. |
URL normalization fixtures
Normalization trims surrounding whitespace, rejects rows over MAX_URL_BYTES, then parses once with WHATWG URL, lowercases scheme and host, converts IDNA hosts to punycode, strips a single trailing host dot, removes the default :443 port and the fragment, resolves dot segments, treats backslashes as path separators, drops an empty query, and turns an empty path into /. Path case, percent-encoding (so %7E and ~, and encoded separators such as %2F, stay distinct), and query order/duplicates are preserved. Canonicalization happens before any address check, so every IP literal — including numeric spellings and private, link-local, carrier-grade, multicast, and metadata addresses — is rejected as ip-literal, while hosts that cannot exist publicly (single-label names and the reserved localhost, local, internal, intranet, home.arpa, invalid, test, and onion suffixes) are rejected as not-public. Public cross-origin HTTPS rows are allowed. These exact fixtures pin the behavior:
| Fixture | Input | Expected |
|---|---|---|
| scheme-and-host-are-lowercased | HTTPS://EXAMPLE.COM/Pricing | https://example.com/Pricing |
| empty-path-becomes-root | https://example.com | https://example.com/ |
| default-port-443-removed | https://example.com:443/pricing | https://example.com/pricing |
| non-443-port-rejected | https://example.com:8443/ | reject: port |
| fragment-removed | https://example.com/pricing#team | https://example.com/pricing |
| query-order-and-duplicates-preserved | https://example.com/pricing?ref=a&ref=b | https://example.com/pricing?ref=a&ref=b |
| empty-query-dropped | https://example.com/pricing? | https://example.com/pricing |
| encoded-tilde-preserved | https://example.com/%7Eme | https://example.com/%7Eme |
| encoded-slash-preserved | https://example.com/a%2fb | https://example.com/a%2fb |
| idna-host-becomes-punycode | https://bücher.example/ | https://xn--bcher-kva.example/ |
| trailing-dot-stripped | https://example.com./pricing | https://example.com/pricing |
| backslash-treated-as-slash | https://example.com\pricing | https://example.com/pricing |
| dot-segments-resolved | https://example.com/a/../b/./c | https://example.com/b/c |
| trailing-slash-is-distinct | https://example.com/pricing/ | https://example.com/pricing/ |
| credentials-rejected | https://user:pass@example.com/ | reject: credentials |
| http-scheme-rejected | http://example.com/ | reject: scheme |
| ip-literal-rejected | https://127.0.0.1/ | reject: ip-literal |
| numeric-ip-spelling-rejected | https://2130706433/ | reject: ip-literal |
| ipv6-loopback-rejected | https://[::1]/ | reject: ip-literal |
| relative-input-rejected | /pricing | reject: relative |
| blank-input-rejected | (blank) | reject: blank |
| surrounding-whitespace-trimmed | https://example.com/pricing | https://example.com/pricing |
| cross-origin-https-allowed | https://docs.example.org/guide | https://docs.example.org/guide |
| unsupported-scheme-rejected | javascript:alert(1) | reject: scheme |
| localhost-rejected | https://localhost/ | reject: not-public |
| single-label-host-rejected | https://intranet/ | reject: not-public |
| private-range-literal-rejected | https://10.0.0.5/ | reject: ip-literal |
| metadata-address-rejected | https://169.254.169.254/latest/meta-data/ | reject: ip-literal |
| root-and-slash-are-one-page | https://example.com or https://example.com/ | https://example.com/ (same page) |
/pricing and /pricing/ normalize to different pages; https://example.com and https://example.com/ normalize to the same page. Rejections happen before any capture attempt or project row exists.
Capture dimensions, time, and bytes
| Constant | Value | Policy |
|---|---|---|
DESKTOP_VIEWPORT | 1440 × 900 CSS px, DPR 1 | Desktop captures render at natural CSS-pixel dimensions. |
MOBILE_VIEWPORT | 390 × 844 CSS px, DPR 1 | Mobile captures add a mobile user agent and touch emulation. |
MAX_DOCUMENT_HEIGHT_PX | 16,384 px | Taller documents fail as document-too-tall. |
MAX_DOCUMENT_PIXELS | 25,000,000 px | Larger documents fail as too-many-pixels. |
MAX_IMAGE_BYTES | 8,388,608 bytes (8 MiB) | Larger screenshots fail as image-bytes-exceeded. |
ALLOWED_IMAGE_CONTENT_TYPES | image/png, image/webp | Only these decode into a stored screenshot; anything else fails as invalid-image. Capture asks for the first entry, PNG, so pixels are lossless and one stabilized layout is reproducible. |
MAX_PROVIDER_RESPONSE_BYTES | 16,777,216 bytes (16 MiB) | Larger provider responses fail as provider-bytes-exceeded. |
NAVIGATION_TIMEOUT_MS | 30,000 ms | Per-navigation budget; exceeding it fails as navigation-timeout. |
NETWORK_IDLE_TIMEOUT_MS | 5,000 ms | Post-navigation network-idle budget. |
LAZY_SCROLL_STEP_PX | 800 px | Lazy-loading scroll increment. |
LAZY_SCROLL_MAX_STEPS | 24 | Enough steps to reach the bottom of a maximum-height page. |
LAZY_SCROLL_STEP_DELAY_MS | 250 ms | Settle delay per scroll step. |
TOTAL_CAPTURE_TIMEOUT_MS | 90,000 ms | Whole-capture deadline, inside the provider's 120-second session cap; exceeding it fails as total-timeout. |
CAPTURE_INVOCATION_MAX_DURATION_MS | 300,000 ms (5 minutes) | Lifetime of one capture-running function invocation; every capture route's maxDuration equals it. |
CAPTURE_CONTINUATION_MARGIN_MS | 60,000 ms | Reserve beyond one capture deadline an invocation must have left to start another; otherwise the chain is handed to a fresh invocation. |
MAX_REDIRECT_HOPS | 5 | Every hop is revalidated under the same public-HTTPS rules. |
DNS_TIMEOUT_MS | 3,000 ms | Per-query DNS budget; a query that outlives it fails the capture as dns-failed. |
MAX_CNAME_HOPS | 8 | CNAME hops followed before the chain is refused as unresolvable. |
REDIRECT_PROBE_TIMEOUT_MS | 5,000 ms | Per-hop budget for the server-side redirect preflight. |
Capture destination admission
A capture target is admitted only when it canonicalizes to an absolute public HTTPS URL and resolves publicly. Canonicalization runs first, so every IP spelling is rejected as a literal before any range check. DNS then follows a bounded CNAME chain and queries both A and AAAA: the host is admitted only if every answer parses and every answer is public. A timeout, a server failure, an ambiguous answer from either family, an empty result, a loop, or a single non-public answer rejects the host. Rejections never disclose a resolved address.
Every top-level redirect hop is revalidated under the identical scheme, credential, port, canonical-host, DNS-answer, and address-range rules, up to MAX_REDIRECT_HOPS; a chain that stays public completes and the capture stores both the requested and the final public URL.
Browserless runs in a different network, so application-side DNS proves nothing about what the remote browser resolves. The capture function therefore installs a request guard that revalidates every top-level navigation and refuses subresource requests to credentialed hosts, reserved hosts, non-HTTP(S)/WebSocket schemes, and all IP-literal hosts, while ordinary public subresources continue to load. The guard never disables web security, TLS validation, sandboxing, or the provider's private-network blocklist.
These prefixes are never a capture destination:
| Range | Why |
|---|---|
0.0.0.0/8 | this network |
10.0.0.0/8 | private |
100.64.0.0/10 | carrier-grade NAT |
127.0.0.0/8 | loopback |
169.254.0.0/16 | link-local and cloud metadata |
172.16.0.0/12 | private |
192.0.0.0/24 | IETF protocol assignments |
192.0.2.0/24 | documentation (TEST-NET-1) |
192.88.99.0/24 | 6to4 relay anycast |
192.168.0.0/16 | private |
198.18.0.0/15 | benchmarking |
198.51.100.0/24 | documentation (TEST-NET-2) |
203.0.113.0/24 | documentation (TEST-NET-3) |
224.0.0.0/4 | multicast |
240.0.0.0/4 | reserved and broadcast |
::/96 | unspecified and IPv4-compatible |
::ffff:0:0/96 | IPv4-mapped |
64:ff9b::/96 | NAT64 |
64:ff9b:1::/48 | local-use NAT64 |
100::/64 | discard-only |
2001::/32 | Teredo |
2001:2::/48 | benchmarking |
2001:db8::/32 | documentation |
2002::/16 | 6to4 |
fc00::/7 | unique-local |
fe80::/10 | link-local |
ff00::/8 | multicast |
Capture attempts, concurrency, and staleness
| Constant | Value | Policy |
|---|---|---|
MAX_CAPTURE_ATTEMPTS_PER_PROJECT | 64 | Initial attempts plus retries; a maximum-size project starts with 32. |
MAX_ACTIVE_CAPTURES | 2 | Matches the Browserless free-tier concurrency limit; dispatch beyond it fails as quota-exceeded. |
MAX_AUTOMATIC_CAPTURE_RETRIES | 1 | Automatic retries the server creates in a row for one page device after a retryable failure or a stale attempt, counted since the last attempt a person asked for; the next failure waits for a manual retry (D076). |
STALE_CAPTURE_AGE_MS | 300,000 ms (5 minutes) | A capturing attempt older than this computes to stale and becomes retryable. The durable concurrency lease for the attempt expires at the same age, so an abandoned claim frees its Browserless slot exactly when the attempt becomes retryable. |
CAPTURE_CLEANUP_WINDOW_MS | 3,600,000 ms (1 hour) | Orphan-cleanup retry window; a known orphan past it is still deleted on sight, never kept. |
CAPTURE_REQUEST_MAX_BYTES | 1,024 bytes | Hard cap on a capture mutation body, enforced before parsing. |
CAPTURE_POLL_INITIAL_INTERVAL_MS | 2,000 ms | First delay between read-only capture-progress polls. |
CAPTURE_POLL_MAX_INTERVAL_MS | 10,000 ms | Backoff ceiling for capture-progress polls (doubling from the initial interval). |
CAPTURE_POLL_DEADLINE_MS | 600,000 ms (10 minutes) | A poller stands down after this even if work looks unfinished; longer than the stale age so abandonment is always observed as computed stale first. |
Concurrency admission is durable: capture_leases holds exactly MAX_ACTIVE_CAPTURES slot rows in Turso, and claiming a slot is one atomic conditional upsert, so the limit holds across browsers and application instances. When every slot is held, dispatch fails as quota-exceeded (HTTP 429) without consuming the attempt — it stays pending and any later authorized client can resume it. A lease releases when its attempt reaches a terminal state and is reclaimed in place once it expires; a late release can never free a slot another attempt already reclaimed. Polling issues hierarchy GETs only — it can never create, duplicate, or mutate an attempt — and stops on any terminal or computed-stale state.
DOM manifest schema
| Constant | Value | Policy |
|---|---|---|
MANIFEST_SCHEMA_VERSION | 1 | Persisted per capture as dom_manifest_version. |
MAX_MANIFEST_ELEMENTS | 500 | Overflow keeps the capture ready with a manifest-truncated warning. |
MAX_MANIFEST_BYTES | 262,144 bytes (256 KiB) | Exact persisted UTF-8 JSON size; never exceeded. |
MANIFEST_TEXT_MAX_CHARS | 120 | Short visible text per element. |
MANIFEST_ACCESSIBLE_NAME_MAX_CHARS | 120 | Accessible name per element. |
MANIFEST_MAX_CLASSES | 8 | Bounded class hints per element. |
MANIFEST_HINT_MAX_CHARS | 64 | Any single safe hint value (id, class, testId). |
MANIFEST_MAX_COMBINING_MARKS | 8 | Consecutive combining marks kept on one base character. |
MANIFEST_RECT_MAX_PX | 100,000 px | Absolute bound on a rectangle coordinate or extent; anything larger is dropped. |
MANIFEST_PATH_MAX_DEPTH | 12 | Structural-path segments per element. |
MANIFEST_RECT_DECIMALS | 2 | Decimal places on document-space rectangle coordinates. |
MANIFEST_ELEMENT_KEYS | id, kind, tag, role, text, accessibleName, hints, path, rect | The exact element key set; no other keys may appear. |
MANIFEST_HINT_KEYS | id, classes, alt, title, testId | The exact hint key set. |
MANIFEST_ELEMENT_KINDS | landmark, heading, link, control, image, table-cell, details, summary, text | The bounded element-kind enum. |
Supported motion and tolerances
Capture freezes or pauses what it safely can from the same stabilized layout state as the screenshot, and warns on the rest:
| Case | Policy |
|---|---|
| CSS animations | frozen |
| CSS transitions | frozen |
| Text carets | hidden |
| Web Animations API | paused |
| Video elements | paused |
| Animated images (GIF/APNG/WebP) | first-frame |
| Canvas/JS-driven animation | unsupported-warn |
| Sticky/parallax layers | as-rendered |
Exactly what each policy does, so the result is reproducible rather than merely still:
- frozen — capture-only CSS sets
animation: noneandtransition: none, so the element renders its base style instead of whichever frame the clock happened to be on. Freezing in place would be still but not reproducible. - hidden —
caret-color: transparent, which removes the blinking caret without moving or resizing the field. - paused — every
document.getAnimations()animation is paused and rewound to time zero; every<video>is paused. A rewound Web Animation is reproducible; a paused video frame is not, so video regions are excluded from reproducibility comparisons. - first-frame — an animated image is covered by a canvas holding the frame
createImageBitmapdecodes, which is the format's default (first) frame. When the bytes cannot be re-read (opaque cross-origin response), the capture warns instead of claiming a freeze. - unsupported-warn — a visible
<canvas>may be repainted by script at any moment; capture cannot freeze it and says so in a warning. - as-rendered — sticky and parallax layers are captured where they sit after the scroll returns to the top, and the capture warns that their position is scroll-dependent.
Every warning code is derived from this matrix (motion-unsupported:canvas-js, motion-as-rendered:sticky-parallax, motion-first-frame:animated-image), so no capture can invent a motion warning outside the published cases.
| Constant | Value | Policy |
|---|---|---|
MOTION_ANCHOR_TOLERANCE_CSS_PX | 1 px | Anchor geometry must survive stabilization within one CSS pixel. |
MOTION_MASKED_MAX_DIFF_RATIO | 0.001 | At most 0.1% of pixels in a masked deterministic region may differ. |
Capture outcome catalog
Every capture outcome maps to exactly one row: its persisted status, whether retry is offered, the HTTP status of the API response (— marks a computed or persisted state rather than a response), whether it consumes a persisted attempt, and whether it surfaces as a warning on a ready capture. Public messages and remediation text live in the catalog and are bounded by MAX_PUBLIC_MESSAGE_BYTES | 256 bytes.
| Code | Status | Retryable | HTTP | Consumes attempt | Warning |
|---|---|---|---|---|---|
| invalid-url | failed | no | 422 | no | no |
| dns-failed | failed | yes | 502 | yes | no |
| unsafe-redirect | failed | no | 422 | yes | no |
| target-unreachable | failed | yes | 502 | yes | no |
| browserless-auth | failed | no | 502 | yes | no |
| browserless-provider | failed | yes | 502 | yes | no |
| navigation-timeout | failed | yes | 504 | yes | no |
| total-timeout | failed | yes | 504 | yes | no |
| document-too-tall | failed | no | 422 | yes | no |
| too-many-pixels | failed | no | 422 | yes | no |
| provider-bytes-exceeded | failed | yes | 502 | yes | no |
| image-bytes-exceeded | failed | no | 422 | yes | no |
| invalid-image | failed | yes | 502 | yes | no |
| quota-exceeded | failed | yes | 429 | no | no |
| blob-failure | failed | yes | 502 | yes | no |
| finalization-failure | failed | yes | 502 | yes | no |
| stale-lease | failed | yes | — | yes | no |
| cleanup-pending | ready | no | — | yes | yes |
| manifest-truncated | ready | no | — | yes | yes |
Private asset delivery
Screenshots are stored in a private Vercel Blob store; the only way bytes reach a browser is GET/HEAD /api/captures/<captureId>/asset, which verifies the actor's authority on every request — including requests that end in 304, 206, or an empty HEAD — and never redirects to or names the provider. A capture id resolves through its project: a ready capture of a live project with a complete storage record is served; every other case (anonymous, expired, tampered, or ended authority; nonexistent, pending, capturing, failed, deleted-project, or integrity-failed captures) receives the one bounded generic denial with no bytes. Served bytes are revalidated against the persisted content type, byte length, and SHA-256 before anything is returned, and the strong ETag is exactly that SHA-256. A single bytes=<start>-<end?> range is honored (206 with Content-Range); suffix, multi-range, reversed, and non-numeric ranges are rejected without bytes, and an unsatisfiable range answers 416 with the published length. If-None-Match (weak forms and * included) and If-Modified-Since are answered from the persisted hash without a provider read.
| Constant | Value | Policy |
|---|---|---|
ASSET_CACHE_CONTROL | private, no-store, max-age=0 | Every asset response; no browser or intermediary may retain private bytes after authority ends. |
ASSET_VARY | Cookie | Asset authorization rides on the authority-carrying Cookie header, so any cache key must include it. |
ASSET_RANGE_UNIT | bytes | The only range unit served; exactly one range with an explicit start. |
Geometry minimums
| Constant | Value | Policy |
|---|---|---|
MIN_SHAPE_SIZE_PX | 8 px | Minimum rectangle/circle extent in screenshot-natural pixels; circles stay square. |
MIN_ARROW_LENGTH_PX | 16 px | Minimum arrow start-to-end distance in screenshot-natural pixels. |
Login and reply quotas
Both quotas are enforced in the durable store, so they hold across tabs and application instances, and both recover after exactly the published window.
| Constant | Value | Policy |
|---|---|---|
LOGIN_MAX_FAILURES | 5 | Failed editor logins allowed per client per window before generic throttling. |
LOGIN_GLOBAL_MAX_FAILURES | 100 | Failed editor logins allowed per window across all clients before generic throttling. |
LOGIN_WINDOW_MS | 900,000 ms (15 minutes) | Login throttle window and recovery interval. |
REPLY_MAX_PER_WINDOW | 30 | Founder replies accepted per window. |
REPLY_WINDOW_MS | 3,600,000 ms (1 hour) | Reply rate-limit window and recovery interval. |
Login enforcement semantics (D098, superseding the single shared bucket of D026): every attempt is counted in two rate_limit_buckets rows before the password is checked — a per-client row keyed by the SHA-256 digest of the editor-login scope and the client address (Vercel's x-real-ip, else the first x-forwarded-for entry, else a fixed unknown), limited to LOGIN_MAX_FAILURES, and a global row keyed by the scope alone, limited to LOGIN_GLOBAL_MAX_FAILURES. Keys are digests; a password or secret is never part of one. Reserving before verifying means parallel requests cannot multiply the guesses a window allows, and the per-client row means one guessing address cannot lock the editor out, while the global row still bounds guessing spread across many addresses. The window is fixed at the first attempt in it; throttled attempts receive the same generic 429 with a bounded Retry-After header, never reach the password check, and never extend the window. A correct attempt succeeds immediately once window_started_at + LOGIN_WINDOW_MS has passed; a successful login clears its client row and returns its one slot to the global row.
Feedback, annotation, and interaction limits
| Constant | Value | Policy |
|---|---|---|
FEEDBACK_BODY_MAX_CHARS | 2,000 | Shared maximum length of an original comment or a thread reply. |
MAX_ANNOTATIONS_PER_CAPTURE | 200 | Persisted annotations per capture, across all kinds. |
NEARBY_CANDIDATES_MAX | 8 | Nearby DOM candidates offered when placing a mark. |
ANNOTATION_REQUEST_MAX_BYTES | 16,384 bytes | Byte cap for annotation create and move requests. |
CLIENT_REQUEST_TIMEOUT_MS | 15,000 ms | Every client request reaches a terminal state within this budget. |
MIN_HIT_TARGET_CSS_PX | 24 px | Shared minimum pointer/touch hit target (WCAG 2.2 AA, 2.5.8). |
PLACEMENT_SLOP_SCREEN_PX | 6 px | Pointer travel at or below this between press and release is a click (drop a pin, select a pin); more is a drag (pan the camera, move a pin). |
ARROW_HIT_TOLERANCE_CSS_PX | 12 px | Half the width of the band around an arrow shaft that takes the pointer; an arrow has no area, so selecting one is a distance-to-segment test, and twice this is the shared minimum hit target. |
Performance protocol and budgets
| Constant | Value | Policy |
|---|---|---|
PERFORMANCE_PROTOCOL | 3 measured runs, Chromium, desktop viewport, 16 GB RAM / 10 logical cores, maximum-dimension fixture annotated to the per-capture annotation maximum, evenly by kind and height | The fixed measurement protocol; every run must pass every budget. |
PERF_IMAGE_TO_USABLE_P95_MS | 3,000 ms | p95 from image response to a usable canvas. |
PERF_PAN_ZOOM_CYCLES | 60 | Pan/zoom cycles per run. |
PERF_SELECTION_CYCLES | 100 | Selection cycles per run. |
PERF_INPUT_TO_PAINT_P95_MS | 100 ms | p95 input-to-paint latency during the cycles. |
PERF_LONGEST_TASK_MS | 200 ms | Longest allowed main-thread task. |
PERF_RETAINED_HEAP_MAX_BYTES | 268,435,456 bytes (256 MiB) | Post-GC retained heap ceiling. |
PERF_DETACHED_NODES_MAX | 25 | Detached DOM nodes tolerated after GC. |
PERF_CAMERA_REQUEST_BUDGET | 0 | Camera cycles issue zero annotation writes and no increasing reads. |
Quality attributes under test
- Warm, playful, focused design; the whole product demos live in under a minute.
- No horizontal page overflow, clipped controls, or hover-only content at any supported width.
- Keyboard-operable controls with visible focus; reduced-motion preference respected.
- Bounded, actionable error states that never leave silent partial project state.
- No application secret or founder capability in any public response, asset, log, or committed file.