Changelog
All notable changes to StoryShelf. Versions follow the fixed-version scheme from
scripts/release.mjs (every workspace package shares one version).
Unreleased
Section titled “Unreleased”Added: inherited counts, “How Storybook works”, and follow-ups of #298 (ADR 0004, 0007; #140, #84, #298, #293). New column builds.inherited_count (default 0; DDL and Drizzle schema in the SQLite, Postgres and MySQL packages, Build.inheritedCount, the fake tables), set by BuildModel.updateCounts next to the other counters and served as inheritedCount on the OpenAPI Build. The build page has an Inherited stat and its “N rendered, M inherited” line reads the stored counts; the project’s Builds page shows Inherited from baseline, the average inherited ratio of the last 20 builds (averageInheritedRatio, stored counts only, no snapshot reads; shown with testing). A project’s signed Storybook URL lifetime override now has a floor, MIN_STORYBOOK_URL_TTL_OVERRIDE (1050 seconds: MIN_CAPTURES_PER_SLICE captures at PER_CAPTURE_BUDGET_MS plus the grace), checked by storybookUrlTtlOverrideError in the project API (400 with the floor in the message) and the Capture settings form (hint and error); PER_CAPTURE_BUDGET_MS and MIN_CAPTURES_PER_SLICE moved to @storyshelf/core/utils (breaking for the old import paths in capture/render and capture/token-chunks). An axe unavailable: <reason> entry no longer counts as an accessibility finding: it is left out of the build total, the header badge, the filter and the per-snapshot counts, and shows as a neutral note (“axe did not run on N snapshots”) with the reason on the snapshot (isAxeUnavailable, a11yFindingsOf, A11ySummary.unavailable). The notification topic list gains build:failed. New docs page How Storybook works (the two apps, the iframe and __STORYBOOK_PREVIEW__ contact surface, the story index, play functions, Storybook 8/9/10 differences, a cheat sheet); docs/storybook-internals.md corrected (no executePlay in Storybook 8, 9 or 10; the runtime parameter fallback and the parameters.viewport default are implemented; STORY_FINISHED exists in 8).
Changed: Postgres and MySQL upgrade steps for new columns, build:failed for failed captures. runMigrations of @storyshelf/db-postgres and @storyshelf/db-mysql now add builds.inherited_count, builds.test_results and projects.run_tests to an existing database (ADD COLUMN IF NOT EXISTS, like the older columns), so an upgraded Postgres/MySQL install no longer fails when storing inherited counts or test results. A capture that leaves its build failed now also raises the project’s build:failed event (next to the admin sys:capture-failed alert), so subscriptions to that topic fire.
Added: render-settled signalling and Storybook 10 .test() runs (ADR 0004, decisions 9 and 10; #83 §10.2, §10.5). The Playwright and Puppeteer runners no longer wait networkidle plus a fixed 500 ms before a screenshot: a recorder registered before the page’s scripts listens on the preview’s channel (public Channel.on, nothing patched) and the capture continues on storyRendered / storyFinished / the story’s error event, then lets fonts load and two frames paint, then waits parameters.delay if the story sets one. A page with no channel falls back at once, and one that never announces the render falls back after 5 seconds (the play timeout when plays run and it is longer) to delay ?? 500 ms. Screenshots are unchanged on the Storybook 8, 9 and 10 fixtures and a build renders about 30 % faster; a story that errors while rendering or playing is still photographed and logged once with its render phase and Storybook’s message. New project setting runTests (default off; Settings > Tests, project API, OpenAPI) runs the Storybook 10 .test() entries (index subtype: "test") once per build in the iframe through the new optional CaptureRunner.runTests (TestOutcome, implemented by both runners): a failing test fails the build like a failing play (a flaky story’s does not, skipped stories’ tests are not run), takes no screenshot and adds no status. Results are one JSON column, builds.test_results (DDL, drizzle schema and core Build.testResults only, no migration), listed on the build page in a Storybook tests card and returned as testResults by the build API. StorybookAdapter.discoverTests and StoryEntry.parentId are new; discover still leaves test entries out. The fixtures/storybook-10 tests are now bare Story.test(...) statements (an exported call is not indexed) with a passing, a clicking and a failing test. Docs: ADR 0004, capture concept, interaction-testing guide, project settings, runner pages, storybook-internals.md §10.2 and §10.5.
Added: merge gate v2 — GitHub App check runs, per-snapshot annotations and storyshelf protect (ADR 0014, decision 13; #27, #32, #33, #35). A GitHub git channel can show a build as check runs posted by a GitHub App instead of commit statuses: channel config mode: "checks" with appId and installationId, the App’s private key (PEM) as the channel secret (encrypted at rest, never returned by the API or UI, scrubbed from logs; the form has a mode select, App ID, installation ID and key fields). notify-git signs the App JWT with node:crypto and caches the installation token per channel (no new dependency, no PAT). The run mirrors the build (queued, in_progress, completed as success, failure, cancelled, or neutral for a failed Storybook publish), is keyed by head SHA and the external id storyshelf:<name> and is updated, never duplicated. <prefix>/testing carries one annotation per new or changed snapshot linking to its review page (new UrlBuilder.snapshot, …/builds/<id>/diff?snapshot=<id>), posted in batches of 50, at most 200, with “and N more” in the summary. GitLab keeps statuses. statusesForBuild gains changes (input) and progress, informational, annotations, omitted (on DesiredStatus and the status target); MAX_ANNOTATIONS and StateAnnotation are exported from @storyshelf/core/adapter/notifier. New CLI command storyshelf protect prints the required-check rule, validates the branch’s existing rule through the GitHub or GitLab API and writes it only with --apply (dry run by default; needs an admin token). redactSecrets also scrubs JWTs. Label, branch and component-path URL encoding is now per segment (? and # in a value no longer break the path); the “A git channel needs a token” error now reads “needs a token or private key”. Docs: merge-gate guide (GitHub App setup, protect helper), notify-git, CLI, notifications.
Changed: owner decisions on the follow-ups of #275, #278, #280, #282, #284 and #291. A render pass no longer dies at the 24 hour signed-URL ceiling: a pass whose budget would outlast its token is rendered in slices of stories (renderInSlices), each with a freshly minted Storybook URL (#279). The component page’s preview frame of a private build asks GET /api/v1/projects/:slug/builds/:buildId/preview-token (class testing) every 40 minutes and re-points itself to a new one-hour token, so a page left open keeps working. The build page shows “Waiting for the Storybook to publish” in place of the verdict form until publishing ends (the 409 stays the server guard, #282). Accessibility findings gain a build-level total (an Accessibility findings card on the build page, a11y N in the review header) and an A11y filter on the snapshot list; they stay information only (#280). On boot, once adapters are ready with notifiers wired and a public origin resolved, the git state of open builds (newest 25 per project) is requested again, so a restart cannot leave a merge gate stale (#284). The MCP server advertises the testing tools (list_snapshots, get_capture_logs, get_build_insight, get_project_health) only when the instance has testing, read from the new testing field of GET /api/v1/health (#291).
Added: a real cancelled build status (ADR 0006, 0013, 0014; #287). Cancelling a build (POST …/cancel, Cancel build) now ends it cancelled, not failed. cancelled joins BUILD_STATUSES (zod and OpenAPI enums, the MCP list_builds filter, the build list filter chips) and TERMINAL_BUILD_STATUSES (purged like failed builds); no database migration is needed, the column is text. It is terminal by construction: BuildModel.update/setStatus refuse any other status for a cancelled build (the row comes back unchanged, logged at debug), CaptureAttemptModel leaves an attempt a cancel closed as cancelled, finalizeBuild skips a cancelled build and the orchestrator does not start capturing one, so a capture that ends after the cancel can no longer move the build (the late-write window of #287 is gone). Verdicts and snapshot approvals on a cancelled build answer 409 and the build page hides the verdict card. Retry resets it to pending (new BuildModel.resetStatus). A cancel raises the new build:cancelled webhook and notification topic (not on by default) and no build:failed or capture-failed alert. statusesForBuild has a case for it: NotifierState gains cancelled, shown as GitLab canceled and GitHub error with the description Cancelled on <prefix>/testing and <prefix>/review, and the pull-request comment says Cancelled. A cancelled build without a published Storybook reads failure on <prefix>/storybook.
Breaking: testing.queue is gone; testing requires runner (ADR 0006, decision 6; #257). The database queue is the only queue, so there is no enqueue-only instance, testing: {} throws, and the Lambda/Vercel/D1 checks apply to every testing instance. The captureQueue slot of the adapter lifecycle goes with it. The CaptureQueue and PollableCaptureQueue interfaces and the contract suite stay.
Changed: a detected public origin is used only once an admin confirms it; authentication keeps the request’s address (ADR 0007, decision 6; ADR 0014, decision 12; #267). What an admin’s browser reports is stored unconfirmed and no longer resolves, so no notification, email link, commit status or signed Storybook URL rests on it until it is confirmed on System (which now says so). PUBLIC_BASE_URL and platform addresses count as confirmed. OAuth redirect URIs and /.well-known/openid-configuration use the new resolver.authOrigin(requestOrigin): the confirmed origin, else the request’s own. ResolvedPublicOrigin.confirmed is removed (always true).
Changed: a remote browser target that needs a signed Storybook URL is unavailable without a secret or a confirmed origin, and the URL is the directory root (ADR 0007, decision 5; #273). The reasons now say what to set (NO_SECRET_REASON, NO_ORIGIN_REASON); settings saves naming such a target are refused and a build that reaches one fails at once with the reason. storybookBase accepts only <origin>/_s/<token>/ (no iframe.html, query or fragment) and the runners refuse any other form before connecting.
Changed: axe runs on the primary target only; parameters.a11y loses function values quietly (ADR 0004, decision 6; #241, #242). The first entry of a project’s browsers list is scanned (primaryTargetOf), the other targets are not, and the review page, filmstrip and component card name the browser the findings come from. A function value in parameters.a11y (and a rule item that held only one) is dropped and axe runs with the rest, with one debug log line; inPageReadA11yParameters now returns { params, dropped }.
Fixed: capture honours the configured zip caps and records its content in content_refs (#253). Capture-side extraction (single build and shards) used built-in caps; it now takes maxZipEntries and maxUnzippedBytes (CaptureJobOptions.limits). Content persisted by capture or by a zip publish is counted in content_refs, so retention no longer treats it as unused.
Changed: git channels in hosting-only mode, and notify-git is documented as dependency-free (ADR 0014; #263). notify-git needs a confirmed public origin and works in hosting-only mode, where it posts <prefix>/storybook (and <prefix>/review) for published builds; the git channel form is offered there too.
Added: cancel a build; a configurable, heartbeat-kept lease (ADR 0006; #248, #251). POST /api/v1/projects/:slug/builds/:id/cancel (class testing, developer role and above) and Cancel build on the build page (shown while the build is pending, capturing or comparing) cancel the build’s jobs of every kind, queued or running (CaptureQueue.cancel(buildId) on the database queue now covers capture, publish and notify jobs, not only the queue’s polled kinds), close its open capture attempts and end the build through the normal status announcement (cancelled since the entry above, first failed). The MCP server stays read and comment only. New option testing.leaseSeconds (default 300) sets the database queue’s lease, and a running job now renews it on a timer (every min(30 s, lease / 3)) as well as on progress, so a stage that prints nothing for longer than the lease is no longer redelivered while its process is alive; a dead holder’s lease still expires. New CANCELLABLE_BUILD_STATUSES in @storyshelf/core/types.
Changed: notifications are paused until a public URL is set; runners leave signals to the host; the dead in-process-queue check is gone (ADR 0014, decision 12; #265, #259, #261). While the public origin resolver (ADR 0007, decision 6) finds no origin, nothing is sent: git statuses and the pull-request comment are neither queued nor delivered inline, and message notifications (project and review events, site-wide sys:* alerts, per-user subscriptions) are skipped, because each carries a link. No channel is marked as errored; System, the project notification settings and the profile show “Notifications are paused until a public URL is set”, and one warn line is logged at boot and on the first skipped notification. When an admin confirms or reports the first origin, builds still pending, capturing, comparing or reviewing are requested again (requestOpenBuildStates); with PUBLIC_BASE_URL set at start-up delivery resumes with the next state change. statusesForBuild now requires absolute urls (the relative-link fallback and urls: null are removed), BuildStateDeps.publicOrigin is required and config.publicBaseUrl is no longer read there, and notifyCaptureFailed takes the resolver as an optional fifth argument. Webhooks carry ids only and stay as they are. @storyshelf/runner-playwright and @storyshelf/runner-puppeteer launch with handleSIGINT, handleSIGTERM and handleSIGHUP off, so a graceful drain finishes in-flight captures instead of failing them with TargetClosedError; each render closes its own browser. isStuckCapture and the “interrupted captures” notice of the compute-jobs page are removed.
Changed: review verdicts need a user, wait for publishing and raise build:pending; <prefix>/review shows progress and closes when review is turned off (ADR 0013, decision 3; ADR 0014, decision 5; #269, #271). A verdict is attributed to the signed-in user or to the owner of the API token that posted it; with authentication on, a caller that is no user (the bootstrap admin token, a token without an owner) gets 403. Only an instance with no authentication records an anonymous verdict (user_id stays nullable for that case), and only while the project requires one approval; with reviewApprovals above 1 it answers 409. A verdict answers 409 until the build’s Storybook is published (one shared probe, publishingOf, also behind <prefix>/storybook). A verdict that sends a build back to pending now raises build:pending (webhook and notification topic). The <prefix>/review description shows approval progress (Waiting for approvals: 1 of 2), and a project with review off posts a final success (Review is off) for accepted builds, so a pull request is not left with a stale pending status after the switch-off pass.
Changed: signed Storybook URLs follow the owner’s review (ADR 0007, decision 5; #275, #255). The route and the minted URL move from /s/<token>/ to /_s/<token>/ (STORYBOOK_TOKEN_PREFIX, one constant for the auth gate, the route table, redactSecrets and the minted URLs; it cannot meet the /_/:id short link). The token module is the single owner of signing, verification and the derived key (signStorybookToken/verifyStorybookToken take a SecretInput, so a rotation pair needs no unwrapping by callers), and one policy (publishedAccess: signed token, public build or signed-in viewer) with one file handler (servePublishedFile) now serves the published Storybook routes (/projects/:slug/storybook/build/:id/*, /_/:id/*) and the signed route alike. The route has a per-IP rate limit counted before the token is read (default 6000 requests a minute, testing.storybookUrlRateLimit; 429 with Retry-After). The token lifetime is derived from the pass (its render budget plus 5 minutes, at least 2 hours, at most 24) and can be overridden per project: new nullable column projects.storybook_url_ttl_seconds (DDL and Drizzle schema in the SQLite, Postgres and MySQL packages, Project.storybookUrlTtlSeconds, the fake tables), storybookUrlTtlSeconds on PATCH /api/v1/projects/:slug (60 to 86400, null clears it) and a field under project Settings -> Capture. Removed: the option testing.storybookUrlTtl and the STORYSHELF_CAPTURE_STORYBOOK_URL_TTL variable the forked child read; the lifetime now follows each job. Published files answer Access-Control-Allow-Origin: * for public builds and every signed URL (never with credentials; refusals and a signed-in viewer’s private build carry none), previewCorsHeaders is replaced by publishedCorsHeaders, and the component page now frames a private build under sign-in through a one-hour signed URL when the instance has testing and a secret (otherwise it keeps the Open link).
Added: signed Storybook URLs for remote browsers that cannot route back (ADR 0007, decision 5; #228). A reach: "url" target (every Puppeteer remote target, CDP-only services, BrowserStack) now renders: for each render pass and for the parameter pre-pass the job mints <publicOrigin>/s/<token>/ and passes it as storybookUrl. The token (signStorybookToken/verifyStorybookToken, @storyshelf/core/utils) is base64url("<projectId>:<buildId>:<expiresAt>") plus an HMAC-SHA-256 under a key derived from secret with the context storyshelf/storybook-url/v1; it grants read access to one build’s static files and nothing else. New route GET /s/:token/* (class testing; no session, CSRF or rate limit; one identical 404 for every refusal; Cache-Control: private, no-store, Referrer-Policy: no-referrer, X-Robots-Tag: noindex, nofollow, nosniff; previousSecret verifies during a rotation and never signs; empty, ., .., backslash and NUL path segments are refused). New option testing.storybookUrlTtl (seconds, default 7200, 60 to 86400; a signed token claiming more than 24 hours is refused). The token is scrubbed from the request log, the OpenTelemetry url.full attribute, attempt logs, failure text and the err serializer (redactSecrets knows /s/<token>). offeredTargets now describes a target that works: reach: "url" targets stay unavailable (“no secret”, “no public origin”) until both exist. The remote provider sees the URL (BrowserStack records visited URLs); see the Remote browsers guide.
Added: remote browsers (ADR 0007, decisions 1 to 5; #226). The runners can connect to a browser on another machine instead of launching one. @storyshelf/runner-playwright/remote exports remote({ name, engines, connect, sessions }) and the presets playwrightServer (a self-hosted playwright run-server, exposeNetwork on by default), azurePlaywright and browserstack; @storyshelf/runner-puppeteer/remote exports cdp. They go in the runner’s browsers list and offer one <engine>@<name> target per engine (chromium@grid) with its own baselines, never compared with a local render. Presets are plain configuration (no vendor SDK) and validate when the runner is created: a bad name, an engine the connection cannot serve, a non-WebSocket endpoint or two entries with one target throw. Playwright uses browserType.connect(endpoint, { headers, exposeNetwork }) (or connectOverCDP for a CDP connection), Puppeteer puppeteer.connect; with exposeNetwork: "<loopback>" the remote browser reaches the loopback Storybook server through the connection, so such a target needs no public origin. The environment of a remote render is measured inside the browser (browser version, OS, device pixel ratio and a digest of a fixed 44-font probe list), never from the host, so a provider-side image change is diffed and not waved through as an environment migration. A target’s sessions is its page cap, shared per target across the process (sessionGateFor; an explicit concurrency can only lower it). Connection-level failures (refused, dropped, timed out, no capacity, 429 and 5xx) are retried with exponential backoff before the render or crash isolation sees them; what is left fails the job at once as a TargetLaunchError, which carries the new transient flag (a service that is merely down stays offered; refused credentials and a Playwright version mismatch, which names both versions, mark the target unavailable). Endpoints, query values and header values never appear in messages or logs. render and extractParameters accept storybookUrl, the URL a reach: "url" target’s browser opens instead of the loopback server; nothing mints it yet, so such targets (BrowserStack, every Puppeteer remote target) cannot render until signed Storybook URLs (#228). New guide “Remote browsers” with the self-hosted farm setup, pinned to the playwright-core version (a test fails when the page drifts) and what a bare run-server guards: no authentication (the --path is the only gate) and --max-clients queues extra connections instead of refusing them. CaptureEnvironment.platform is the browser’s OS for a remote render. The test:integration suites of both runners now run a remote capture against a real run-server (Playwright) and a separate Chrome over CDP (Puppeteer).
Added: the review UI, the review route gate, review events and retention for unfinished reviews (ADR 0013, decisions 1, 2, 4, 8, 9 and 10; #233, #234, #235, #236). The build page now holds the review: a Review card (where the build stands, approvals against the requirement, each reviewer’s latest verdict and note, and Approve/Reject for approvers and admins), Changes in this build (the components added, removed and changed against the latest accepted build of the branch, else of the default branch, from the build_components hashes; GET /api/v1/projects/:slug/builds/:buildId/changeset returns the same data) and the build and story comments. With testing off these are the page’s content; with testing on the changeset sits above the visual diffs. A component page shows the story’s comments of the build it shows, with a form for developer and above, and the snapshot review thread includes the comments on its story. Project admins edit the switch and the approvals under Settings → General → Review. The review route class is now enforced: requireReview answers 404 for every route classed review (comments, verdicts, the changeset and the review forms) of a project with review off, on each request, and assertReviewEnabled is gone. Every OpenAPI operation carries x-storyshelf-class. The MCP server’s add_comment takes storyId; there is still no verdict tool. Three events join the webhook and notification topics: review:requested (a published build waits for a verdict; on by default for the project’s approvers and admins by email, off per user by disabling their subscription or leaving the topic out), review:comment (build and story comments; snapshot comments keep comment:created) and review:verdict. Retention now also purges pending builds whose last activity (upload, comment or verdict) is older than purgeTtlDays, and turning review off (settings form or PATCH) accepts the project’s pending builds with no capture attempts or snapshots in one pass, without an approval event. The comment, resolve and verdict forms post to page routes under /projects/:slug/builds/:id/ because a browser form cannot send the JSON the API requires (the forms posted to the API and got 415).
Added: build-level review and verdicts (ADR 0013, decisions 1 to 3 and 5; #230, #231, #232). New columns projects.review_enabled (default true) and projects.review_approvals (default 1), comments.story_id and the table build_reviews (buildReviews in Tables; DDL and Drizzle schema in the SQLite, Postgres and MySQL packages; new columns reach existing Postgres and MySQL databases only when recreated). The project API reads and patches the two project settings (reviewEnabled, reviewApprovals); a comment can anchor to a story (storyId, not together with snapshotId). GET and POST /api/v1/projects/:slug/builds/:id/reviews give a build a verdict (approver and admin; viewers read) and answer 404 for a project with review off. With testing off the verdicts decide a build through the pure deriveReviewStatus: approved once the required approvals are present among each reviewer’s latest verdict and none is rejected, rejected when any is, pending otherwise; with testing on a verdict is a sign-off and the snapshot rollup keeps deciding. publishVerdict now also accepts a published non-default build when the project has review off (no approval event or webhook); with review on, and by default, such a build stays pending until verdicts approve it, which changes what existing hosting-only projects see for pull-request builds (the default branch is never held). A verdict that approves or rejects a build announces it like any status change (build:approved / build:rejected webhook, notifications, git channels); refreshBuild and verdicts share announceBuildStatus. <prefix>/review is now posted for projects with review on when testing is off (the build’s pending, approved, rejected or failed as pending/success/failure), next to <prefix>/storybook; the git flags read the project’s switch instead of a constant.
Added: capture targets and the runner’s browsers list (ADR 0007, decisions 1, 3 and 7; #225). A project’s browser/browsers, a snapshot’s browser and every baseline key hold a capture target: an engine (chromium) or <engine>@<name> for a remote browser (CaptureTarget, parseTarget, engineOf, TARGET_NAME_PATTERN in @storyshelf/core/adapter/capture-runner). createPlaywrightCaptureRunner and createPuppeteerCaptureRunner take browsers (bare engine names and local(engine, options), default ["chromium"], first = default); duplicates, an empty list, an unknown engine, local("firefox") on Puppeteer and bare "chrome" on Puppeteer without an executablePath throw when the runner is created. At startup each local target is checked without launching anything and reported as ready or unavailable with a reason in metadata.targets (RunnerTarget: target, reach, status); System → Capture browsers lists them. The project API (browser, browsers), the General and Capture settings and the capture itself take targets: saving a target the runner does not offer or that is unavailable is refused with 400 and the reason, and a build of a project that already names one fails at once. A launch that fails marks its target unavailable and fails the job with the launch error (TargetLaunchError) instead of being isolated and retried as a story crash. The render input carries target instead of browser. Bare "chrome" is real Chrome (Playwright chrome channel), no longer bundled Chromium. Remote targets are not implemented yet (#226, #228).
Breaking: the Playwright runner offers only Chromium unless told otherwise (ADR 0007, decision 1; #225). The runner’s browser and supportedBrowsers options are replaced by browsers, and CaptureRunnerMetadata.supportedBrowsers by targets. A server whose projects use Firefox or WebKit must list them (browsers: ["chromium", "firefox", "webkit"]) and provision the binaries (playwright install); until then the System page shows them as unavailable and their builds fail with that reason.
Added: the public origin is resolved, not guessed (ADR 0007, decision 6; #227). One resolver (createPublicOriginResolver, @storyshelf/core/models) answers every absolute link built in the background: publicBaseUrl, then the platform’s own address (detectPublicBaseUrl(env), @storyshelf/core/utils: Heroku, Railway, Vercel, Netlify, Render, Fly.io, Azure App Service and Container Apps, DigitalOcean), then the origin a site admin’s browser reported, kept in the new site_settings table (siteSettings in Tables; DDL and Drizzle schema in the SQLite, Postgres and MySQL packages). System → Public origin shows what resolves, lets an admin confirm, replace or forget the stored value (POST /admin/public-origin, /clear, and /report for the page’s own script; site-admin and CSRF protected, and the report must carry the browser’s own Origin). Commit-status links and the pull-request comment use it, and are omitted while no origin is known (target_url is not sent); notification review links and the OAuth callback and /.well-known/openid-configuration helper use it too. The Host header is never used for background links.
Added: build state on git channels is a function of the build, delivered by durable notify jobs (ADR 0014, decisions 5 to 7; #221, #222). One pure function, statusesForBuild (@storyshelf/core/adapter/notifier), maps a build, the channel’s prefix and the testing/review flags to the states a git channel shows: <prefix>/testing (pending while capture runs and review is open, success when approved, failure when rejected or capture failed, the description saying whether review is open), <prefix>/storybook for every build with a commit (informational: success linking the published Storybook once its manifest exists, failure for a failed build that never published) and one pull-request comment per build with links to the build and the Storybook plus a summary. <prefix>/review is in the function for the review setting to feed (flags.review). A build that was never captured posts its statuses (a hosting-only instance posts <prefix>/storybook when publishing ends), and links are absolute from publicBaseUrl. Every transition (build creation, capture start and end, crash failure, review outcome) and the end of publishing call one publisher (BuildStatePublisher.request(buildId)), which queues a notify job per git channel on the database queue: the job names a channel and a build, reads the build when it runs (a retried or reordered job cannot post a stale state), is folded into a job already waiting for the same pair, and is retried with backoff (five attempts: 10 s, 20 s, 40 s, 80 s). A given-up delivery records the error on the channel and raises the new sys:notify-failed admin event. notify jobs run on a queue and consumer of their own (four at once), so a status never waits behind a capture, and stay out of the queue views. A host with no queue consumer (serverless) delivers inline without durability. postStatusesForBuild, emitBuildState, statusContext and the app’s postReviewStatus are removed; DispatchDeps carries buildState instead of notifiers and secret. QueueJobInput.maxAttempts and KindHandler.backoff let a job kind state its own attempts and retry delay.
Breaking: git hosts are notification channels; @storyshelf/git-github and @storyshelf/git-gitlab are replaced by @storyshelf/notify-git (ADR 0014; #220, #223). The notifier contract has two delivery kinds: message providers (Slack, Teams, email) send once, state providers (GitHub, GitLab, and a git-log provider for tests and dev) setState a commit status or a pull-request comment, idempotently. ShelfOptions.gitHosts, GitHostProvider, GitHostAdapter, the @storyshelf/core/adapter/git-host* subpaths and the git-host adapter category are gone: list gitNotifiers from @storyshelf/notify-git in notifications.notifiers. The project_status_configs table, StatusConfigModel and the /api/v1/projects/{slug}/status-configs routes are deleted with no copy of existing rows: configure each project again as a git channel (notification_channels: config { owner, repo, host?, prefix? }, the token as the encrypted secret) on the project’s Notifications settings page, which now manages chat, email and git channels together (the separate “Git status” tab is gone). Statuses are named <prefix>/testing (prefix defaults to storyshelf, no project slug and no second storyshelf/ added by the provider), so update required statuses in branch protection and give each project that shares a repository its own prefix. notification_channels gains last_error and last_error_at: a failed delivery is shown on the settings page and in the channel API, and cleared by the next success (new columns reach existing Postgres and MySQL databases only when recreated). isMerged is dropped: a build of a commit that is already merged is captured like any other. Message fan-out never sends to state channels.
Breaking: @storyshelf/worker and @storyshelf/queue-sqs, -redis, -azure and -gcp are removed (ADR 0006, decisions 5 and 6; #218, #219). The app is the consumer, so there is no separate worker to deploy and no remote queue to provision; CaptureQueue, PollableCaptureQueue, the contract suite and the testing.queue override stay in core. The CLI loses the worker init and worker serve commands and the queue and worker questions of server init; the scaffold is one server with the database queue, and the AWS, Azure and GCP Terraform stacks drop the queue resources and the worker service (the app service gets browser-sized resources). The live-cloud workflows drop their queue legs. The website’s capture, deployment and configuration pages describe testing, hosting only and the database queue, and carry the long-running-server requirement.
Breaking: the app consumes the database queue; the in-memory capture queue, the boot sweep and the stall handler are gone (ADR 0006, decisions 4 and 5; #217). createShelfApp wires ONE database queue (capture_jobs) and ONE consumer for every job kind (capture with testing, publish on a long-running host). Uploads enqueue onto it and the instance’s consumer wakes at once; every instance with testing consumes. InMemoryCaptureQueue, recoverStuckCaptures, recoverStalledCapture (INTERRUPTED_ERROR, STALLED_ERROR), the app’s stalled-capture handler and the boot requeue of capturing builds are deleted without a replacement: lease expiry and redelivery recover work, so two instances can start without resetting each other’s builds, and a job whose last attempt lost its lease fails its build (DatabaseCaptureQueueOptions.onExhausted). New testing.retries (default 2) is the one retry limit for failed attempts, lost leases and forked-child crashes; testing.queue is now a PollableCaptureQueue that replaces the database queue and, with a runner, is consumed in-process too. capturesLocally is replaced by the store’s capturesHere.
Breaking: forked capture is parent-push (ADR 0006, decision 5; #217). The app consumes the queue and pushes each leased job to the testing.workerEntry child over IPC (run/cancel down, progress/done/failed up); the child never reads the queue and inserts shard jobs into the database queue itself. ForkedCaptureQueue, IpcCaptureQueue and the poll/enqueue/ack/nack messages are deleted; createForkedCaptureHandler and the new protocol replace them. The concurrency slots live in the parent, so STORYSHELF_CAPTURE_CONCURRENCY no longer reaches the child (STORYSHELF_CAPTURE_RETRIES does). runCaptureChild now lives in core/src/capture/forked/child.ts (same export).
Added: testing gates the app; hosting-only mode (ADR 0006, decisions 1 and 3; #216). The app store now carries a real testing flag (options.testing is set) instead of inferring it from the capture queue. Without it, every route the route table classifies testing answers 404 (Testing is not enabled on this instance.; JSON on /api/v1, text on pages) through the new requireTesting middleware, mounted ahead of the CSRF and rate-limit guards; hosting, both and review routes are untouched. The navigation drops the Jobs entry and the capture, tests, git status and AI settings tabs; the System page shows a hosting-only notice with the number of hidden snapshots; boot logs the mode (a warning with hiddenSnapshots when snapshots exist); POST /api/v1/projects/:slug/builds returns testing, and storyshelf upload prints that no checks will run and skips the affected-capture post when it is false. testing now needs runner or queue (testing: {} throws). New assertServerless (@storyshelf/core/config): a process with testing.runner throws on AWS Lambda, warns on Vercel and Netlify, and STORYSHELF_ALLOW_SERVERLESS=1 overrides it; isServerlessHost moved to core. Database metadata gains supportsTesting; the Cloudflare D1 preset sets it to false, and createShelfApp refuses testing.runner on it. A remote-queue-only instance (testing: { queue } without runner) is exempt from both checks. New concept page “Hosting only”.
Added: the Storybook catalog, and library, component and build pages that render from it (ADR 0006, decision 9; #215). When publishing ends, the build’s index.json is read into a compact catalog.json (next to the build’s files) and one build_components row per component (project, build, story file, title, story count, hash; new table in all three database packages, purged with the build). A build published before the catalog existed is catalogued on first view. The library shows the components of the latest build of the selected branch whatever its status; the component page gains a Stories tab (a click-to-load preview, sandboxed with allow-scripts only, and every story and docs entry) and a catalog History (added, removed, renamed); the build page shows component and story counts and labels an accepted build with no attempts Published. All of it works without testing; with it the pages gain thumbnails, statuses, States, design and linked packages, and snapshot sections. Published Storybook files answer Access-Control-Allow-Origin: * for builds viewable without credentials (public builds, instances without sign-in) so the sandboxed frame can load; private builds under sign-in are not embedded. Upstream links fall back to the catalog when a build has no snapshots.
Added: publishing independent of capture (ADR 0006, decision 10; #213). An instance without testing now
publishes Storybooks of any size. A zip up to maxInlineUnzipSize (now defaulting to 50 MB, counted while the
upload streams instead of read from Content-Length) is extracted inline; a larger zip becomes a publish job
on the database queue, run by a consumer that every instance with a database starts at lifecycle.setup() and
drains at teardown() (capture shares the queue since #217). The file-by-file route (/dedup, /content, /manifest)
completes publishing when the manifest is stored. On serverless hosts (AWS_LAMBDA_FUNCTION_NAME, VERCEL,
NETLIFY) no consumer runs, and an over-limit zip is refused with 413 pointing to the file-by-file route;
storyshelf upload now falls back to that route by itself (the old tryDedupUpload stub is gone).
Extraction is capped by the new maxZipEntries (default 50 000) and maxUnzippedBytes (default 2 GiB), counted
while streaming, and refuses absolute and .. paths; maxInlineUnzipSize: 0 sends every zip to a job. When
publishing ends with no capture to run, a published build on the project’s default branch becomes approved, a
failed publish makes it failed, and other branches stay pending; no approval event or webhook fires. With
testing on, capture still sets the status. config.scratchDir now always defaults to
<os.tmpdir()>/storyshelf. PublishCompleter runs afterPublish hooks for the catalog. The one-time boot task
that would re-process builds already pending is dropped (no data migrations): such builds stay as they are.
Added: createDatabaseCaptureQueue (@storyshelf/core/capture), a database queue on the new capture_jobs table (ADR 0006); additive, not wired in yet (adapters gain tables.captureJobs; LeaseRenewal gains "cancelled"; queues may add cancel/inspect).
Added: createCaptureConsumer (@storyshelf/core/capture), the consumer loop for any pollable queue (handler per job kind, slots before polling, retries with backoff, lease by progress, cancel, drain on stop; optional metrics hooks). runCaptureChild moves into core and @storyshelf/worker re-exports it. Retention purge also drops finished capture_jobs rows and a removed build’s job rows. Not wired into the app yet.
Breaking: ShelfOptions.capture is now ShelfOptions.testing (ADR 0006). The option, its type
(ShelfCaptureOptions is now ShelfTestingOptions) and its error messages are renamed, and the
top-level compare and images options nest under it, so the shape is testing: { runner, queue?, concurrency?, workerEntry?, shardSize?, mode?, compare?, images? }. config.scratchDir stays where
it is. There is no alias or boot-time error for the old keys: rename them in your server, and
storyshelf server init now emits testing: { ... }. Behaviour is unchanged; runner stays
optional for now. Separately, packages/app/src/routes/classification.ts now classifies every
registered route as hosting, testing, review or both, and a test fails on any unclassified
or stale route (classification only, nothing is gated yet).
Changed: accessibility checks are on by default and visible in review (ADR 0004 §6). runA11y
now defaults to on for new projects (no migration: an existing database keeps the stored value) and
a project admin can turn it off in Settings > Tests or with runA11y on the project API (create,
read, PATCH, OpenAPI). The axe findings are no longer only in the runner log: each capture’s
violation lines are stored on its snapshot (snapshots.a11y_violations, JSON array, new nullable
column in the DDL; exposed as a11yViolations in the snapshot API) and shown on the snapshot review
page, in the filmstrip and on the component page. They still never block a build or change a
status. The default rule set is now the WCAG A/AA tags plus axe’s best-practice tag (an explicit
parameters.a11y.options.runOnly still replaces it); the page-level best-practice rules do not fire
for the default #storybook-root scan, so no rule is disabled. The MCP server and the CLI do not
mirror other project settings and do not expose it. Fixtures gained a BestPractice story.
Added: axe-core accessibility checks (ADR 0004 §6). With runA11y on, the Playwright and
Puppeteer runners now run axe-core over #storybook-root before the screenshot instead of the old
hand-rolled DOM scan (removed, with no fallback). Each violation is one a11y: annotation with the
rule id, impact, help text, a short selector and the node count (at most 10, then …and N more).
Stories’ parameters.a11y is honoured (disable, test: "off", config, options,
element/context; JSON-serialisable values only). If axe cannot run, the story gets one
axe unavailable annotation. Results stay non-blocking and never fail a build. axe-core (pinned)
is a dependency of @storyshelf/core only; the runners reuse it through @storyshelf/core/capture.
The fixtures gained Components/Accessibility stories and the Puppeteer runner a gated axe test.
Fixed: environment migrations and the screenshot sweep (ADR 0031 phase 4). A feature-branch
build no longer auto-accepts snapshots whose capture environment differs from the baseline’s: it
diffs them, and a difference is changed with the environment change shown on the review page.
The default branch still migrates without a diff. Fingerprint components a capture did not report
(screenshots spooled before a browser crash, a failed fc-list) are unknown rather than changed
and never overwrite the baseline’s, and a viewport size change diffs instead of migrating. The
screenshot sweep defers deletions while a build of the project is still capturing or a baseline
import or approval holds a reuse claim, so it can no longer delete an object a capture has just
reused. Local storage writes are atomic (temp file + rename) and list() returns /-separated
keys on every host. sweepScreenshotContent now needs tables.builds.
New: @storyshelf/image and an opt-in sharp image engine (ADR 0032). The pixel diff, its
worker pool, overlay rendering and AI thumbnails moved into the new leaf package
@storyshelf/image (@storyshelf/core/diff re-exports it). pixelmatch stays the comparator;
the PNG codec is selectable: createShelfApp({ images: { engine: "sharp" } }) or
STORYSHELF_IMAGE_ENGINE=sharp (the option wins), with pngjs the zero-dependency default.
sharp is an optional peer: install it yourself, and startup fails clearly when it is missing.
Remote workers take the same images/compare options on createCaptureWorker, and forked
capture children inherit them from the server. See the “Image engines” guide.
Upgrading (image engine):
diffImagesandbuildTriageEvidencenow return Promises (thesharpcodec is async):awaitthem. Code that readdiffImages(...).passedsynchronously now readsundefined.configureDiffPool({ threads })is deprecated in favour ofconfigureImages({ engine, threads }); the old name still works.
Breaking: Figma is now a design-provider adapter. Figma support moved out of core into
the optional @storyshelf/design-figma package behind a new DesignProvider adapter
(ADR 0029).
Upgrading:
- Install
@storyshelf/design-figmaand passdesign: [createFigmaDesign()]tocreateShelfApp— and tocreateCaptureWorkeron remote workers. Without a provider no design links are extracted or shown.storyshelf server initasks about it; the dev server and Fly demo are wired. - Re-enter the Figma access token once under Settings → Design (credentials moved to a
new
design_credentialstable). The oldfigma_nodes/design_tokenstables are dropped on startup; the metadata/render cache simply refetches. - The component page’s Figma tab is now
?tab=design.
Connect your own account: when a project has no design token, signed-in members can connect
their own Figma account for previews (ADR 0030). Set FIGMA_CLIENT_ID / FIGMA_CLIENT_SECRET
(a Figma OAuth app with redirect <publicBaseUrl>/connections/figma/callback, scope
file_content:read; needs auth enabled and config.secret) and pass
createFigmaDesign({ oauth: figmaOAuthFromEnv() }). The flow, token storage
(user_connections) and refresh are service-neutral (@storyshelf/core/adapter/oauth), so other
adapters can offer it too. A project token still takes precedence; a member’s token is used
only on their own visits, and what it fetches is shown only to them.
Added: @storyshelf/core/adapter/design (DesignProvider, DesignClient,
parseDesignUrl), designContractSuite in @storyshelf/core/test-helpers, and a per-viewer
cache scope (design_nodes.viewer_id).
Fixes
- The Puppeteer runner now runs
playfunctions on Storybook 8, 9 and 10 (it failed withCannot call fromId/raw() unless you call cacheAllCSFFiles() first), by loading just the story and running itsplayFunctionwith the mounted render’s context, as the Playwright runner already does.
Attempt logs
- The build attempt log no longer stops at “partitioned” during a render. Per browser engine it now records the partition counts, the runner’s
info/warn/errorlines (capture sizing,browser recycled,render failed for story, a11y warnings; debug stays out),captured N/M screenshotsevery 10 screenshots, and arender pass finishedsummary. Lines are written serially so order and sequence numbers stay correct under parallel rendering.
Streaming and page reuse
- Screenshots are written to storage as each is captured (new optional
onCaptureonCaptureRunner.render; both bundled runners support it), so memory no longer grows with the number of stories. Runners that ignore it keep working unchanged. - Opt-in page reuse (
reusePages/STORYSHELF_REUSE_PAGES=1) for both runners: pooled pages in their own contexts, reset between captures, discarded after a failure. Screenshots were byte-identical with reuse on and off on the bundled fixtures.
Capture memory
- Capture no longer opens a browser page for every story and viewport at once, which ran 500-story Storybooks out of memory on 1 GB hosts. Pages render with bounded concurrency, and snapshot persisting is capped at 4 at a time.
- Capture concurrency defaults are sized from the CPU and memory the process can use, container limits included (0.5 vCPU / 1 GB renders one page at a time;
ShelfConfig.captureConcurrencydefaults to 1 build unless the host has ≥ 2 CPUs and ≥ 3 GB). Override withconcurrencyoncreatePlaywrightCaptureRunnerorSTORYSHELF_CAPTURE_CONCURRENCY. - Page navigation timeout scales up on CPU-starved hosts (60 s under 2 CPUs, 90 s under 1 CPU; override with
navigationTimeoutMsorSTORYSHELF_NAVIGATION_TIMEOUT_MS), so slow Storybook loads are not reported as failures. - Chromium launches with low-memory flags (
--disable-dev-shm-usage,--disable-extensions,--disable-background-networking,--mute-audio) to avoid crashes on small container/dev/shm. @storyshelf/workersizesconcurrencyfrom the host the same way (1 capture, or 2 with ≥ 2 CPUs and ≥ 3 GB) instead of a fixed 2, andDEFAULT_WORKER_CONFIGno longer carriesconcurrency. Scaffolded workers passWORKER_CONCURRENCYonly when set.- The per-render time budget scales with the number of captures (15 minutes minimum, 15 seconds per capture), so slower one-page-at-a-time captures on small hosts are not cancelled as timed out. An explicit
renderTimeoutMsis unchanged. - Both runners restart the browser every 150 captures (
recycleBrowserEvery/STORYSHELF_BROWSER_RECYCLE_EVERY,0disables) and log acapture sizingline with the detected CPUs, memory and resulting settings at the start of each render. - The Puppeteer runner gets the same bounded concurrency, automatic sizing, timeout scaling and low-memory launch args as the Playwright runner.
- Storing a build’s Storybook statics now reads, hashes and uploads files with at most 8 in flight instead of reading every file into memory at once.
- The Fly demo app uses the automatic sizing (optional
CAPTURE_CONCURRENCYoverride) and caps the Node heap withNODE_OPTIONSinfly.toml.
0.6.2 — Lean scaffold, working Docker images, Node 24 (2026-10-08)
Section titled “0.6.2 — Lean scaffold, working Docker images, Node 24 (2026-10-08)”A fix-focused release. storyshelf server init now generates a smaller
src/-based project with a tsconfig.json, and its Docker images actually
start; npx storyshelf starts faster and reports the right version; and a
server that opts out of auth no longer fails to bundle.
Upgrading: Node 24 is now the minimum supported version (every package
declares engines.node >=24, and generated projects require it). Existing
scaffolded projects keep working and are not rewritten; regenerate to get the
new layout and fixed Dockerfiles.
CLI startup
- Command modules and their dependencies (the zip library, prompts) now load only
when that command runs, so
storyshelf -h,whoamiand the rest start in about 10 ms over bare Node instead of about 55 ms. storyshelf --versionreports the real package version (it was hardcoded to0.2.0).- Dropped the unused
honodependency from the CLI, sonpx storyshelfinstalls less.
Node 24 is the minimum
- Every published package declares
engines.node >=24(none did before), the release check fails if one does not, and the docs, README and CI example now say Node 24 instead of 22 or 20. - The slim server image is pinned to
node:24-alpineinstead of the floatingnode:lts-alpine.
First-run and release safety
@storyshelf/db-sqlitecreates the database file’s directory (all file-based presets), so a fresh scaffold or an empty Docker volume boots instead of failing withunable to open database file.- CI now smoke-tests the built artifacts: the CLI launched through a
.binsymlink (asnpxdoes), and a freshly scaffolded server that type-checks, boots, serves HTML and vendored assets, and shuts down cleanly. The two regressions that shipped in 0.6.0 and 0.6.1 would have failed it. - Generated projects (
npm start,dev,worker, the DockerCMD) andstoryshelf server serve/worker serveno longer pass--experimental-transform-types, which Node 26 rejects (bad option). Node 24 and later strip types without a flag, so entries run with plainnode; the generatedpackage.jsondeclaresengines.node >= 24. - The npm publish job skips versions that are already published, so a partly failed release can be re-run (the JSR job already did this).
CI
- The whole pipeline now runs on Node 24, the minimum supported version
(
devEngines.runtimeis24.x; it was>=24, which CI resolved to the newest Node). The smoke test asserts the Node major so the default cannot drift unnoticed. - The website build, link check and accessibility check run only when
apps/website/changed (scheduled and manual runs always run everything).
storyshelf server init scaffold
- Entry code now lives in
src/index.ts(andsrc/worker.ts) so you can split logic into more files;tsconfig.json,@types/nodeand atypecheckscript are generated so the editor resolves types.storyshelf server serve/worker servefindsrc/first and still run the older root layout. - Leaner output: notifications and OpenTelemetry wiring are opt-in (the prompts now default to no), imports from the same module are merged, and unused variables and dependencies are no longer emitted.
- Docker:
compose.yamlis only generated when the stack has more than one service (Postgres or a worker); a single container getsdocker:buildanddocker:runscripts instead. - Fixes: the generated Docker images could not run. They bundled the server
with esbuild into one ESM file, which crashes at startup (
Dynamic require of "util" is not supportedfrom a CommonJS dependency) and cannot find the vendored assets@storyshelf/appreads from disk; theCMDalso pointed at a path the bundle was never copied to, and the build copied aplaywrightpackage that is never installed. Images now install production dependencies and runsrc/index.tsdirectly with Node (asnpm startdoes), on a Playwright image pinned to theplaywright-coreversion. With a worker, the compose worker service was written inside thevolumes:block (invalid) and had noDATABASE_URL. - Heads-up: the generated server file moved from
server.tstosrc/index.ts; existing projects keep working and are not rewritten.
Packaging
@storyshelf/appno longer imports@storyshelf/auth, so a server that opted out of auth bundles and installs without it (it failed to resolve before). The sharedAuthcontract plusMIN_PASSWORD_LENGTHandunsignedTokencome from@storyshelf/core/auth;@storyshelf/authre-exports them.
Server runtime
@storyshelf/runner-playwrightloadsplaywright-coreon the first render instead of at import, taking ~220 ms of CPU and tens of MB off server boot (and off servers that hand renders to a remote worker).- Shutdown waits (up to 5 s) for the stuck-capture recovery and credential check
that run after setup, so an early SIGTERM no longer logs
database is not open.
0.6.1 — CLI and auth fixes (2026-10-06)
Section titled “0.6.1 — CLI and auth fixes (2026-10-06)”- CLI:
npx storyshelf(and any bin launched through a.binsymlink) now runs; it exited silently with no output because the entrypoint check did not resolve symlinks. - Auth: the env-driven admin login works with a valid default email.
0.6.0 — Better Auth, notifications, affected capture, UI redesign (2026-10-06)
Section titled “0.6.0 — Better Auth, notifications, affected capture, UI redesign (2026-10-06)”The first release where all 24 packages are published to both npm and JSR, with provenance, from CI. Highlights since 0.5.x: the Better Auth engine, notifications, affected capture, OpenTelemetry observability, the redesigned UI, and the sqlite/postgres/mysql adapter preset families.
Affected capture (@storyshelf/affected)
- New package: git diff + Storybook stats-graph tracing (with
untracedglobs) selects the stories a change can reach. The capture pipeline renders only those and inherits the rest unchanged from baselines; new stories always render. - The server records the affected computation per build and returns
baselineSha; the UI shows affected/full badges and inherited snapshots; the CLI prints an affected summary (--fullopts out) anddoctorchecks readiness. Checkouts without git get an auto-generated local identity. - Breaking (days-old key):
affected.untracedis now the top-leveluntracedinstoryshelf.json.storyshelf initstamps a$schemafor editor completion.
Auth engine: Better Auth (ADR 0023)
@storyshelf/authis now a Better Auth engine: password + OIDC presets, invite-only local accounts, profile and sessions, passkeys, SAML/SSO, and IdP group sync. Config-as-code, boot validation and mode logging.- The shared-password tiers are gone; use local accounts or an IdP.
- Composite multi-auth with per-provider state, customisable login UI via
UIConfig.auth, public relying-party discovery and change-password well-known routes, and a personal profile page with a display-name override.
Notifications (ADR 0024)
- New notifier adapter family:
@storyshelf/notify-chat(Slack, Teams, log) and@storyshelf/notify-email(SMTP, Mailpit, HTTP, log;nodemaileris an optional peer). Per-provider subpaths keep installs small. - Channels and per-user subscriptions (new
notification_channelsandnotification_subscriptionstables), fan-out for builds, comments and purge, reset emails, andsys:*admin alerts. Settings and profile UI included. - Action required after upgrade: run DB migrations for the new tables.
Database presets
@storyshelf/db-tursois folded into@storyshelf/db-sqlite(deprecated): presetsturso,better-sqlite3,bun-sqlite,d1.- Postgres presets (
pg,neon,neon-http,vercel,pglite) and MySQL presets (mysql2default,planetscale,tidb, new@storyshelf/db-mysql), with shared schema/DDL. All 14 choices appear inserver init. - The core DB contract moved to
@storyshelf/core/adapter/database.@libsql/client0.18; Node >= 24 is now accepted.
Storage and capture
- Content-hash dedup of Storybook assets (
content/<hash>+ per-build manifest,content_refstable, 7-day GC grace), withdedup/content/manifestAPI endpoints. Action required: run DB migrations. - Builds interrupted by a restart are requeued once, then failed. Per-story
diffThresholdis honoured. The orchestrator enforces a render timeout and caps per-failure error text. PNG magic and a 25 MiB cap are checked before storage writes. Workers back off when polling queues that do not long-poll. - Fixes: Storybook link resolution, default-branch approval, baseline-change
handling, the in-page a11y check, and a non-unique builds
(project, sha)index.
Operations
- Site-admin System page with adapter inventory, live health and safe server config; boot-time validation of adapter implementations fails fast.
- Server secret rotation for encrypted credentials (#107).
- Opt-in
Server-Timingheaders (serverTiming) and per-phase capture timings. - Library is the default project landing page, with “View Storybook” short
links (
/_/<slug>or/_/<buildId>) across projects, library and builds. - Adapter errors, health and the merge gate redact secrets.
Publishing and packaging
- Every package is published to npm (trusted publishing + provenance) and JSR
from
release.yml. The JSR job skips versions that already exist, so it is safe to re-run. - New subpaths
@storyshelf/core/orm,/orm/pg-coreand/orm/sqlite-corere-export Drizzle.@storyshelf/coreis the single owner ofdrizzle-orm;authandappno longer declare it. - New first-time publishes:
affected,app,auth,db-mysql,db-postgres,notify-chat,notify-email,observability,queue-azure,queue-gcp,queue-redis,runner-puppeteer,storage-azure,storage-gcs,worker(npm). LICENSE and README added where missing.
Brand mark: logo, favicon and social assets
- New StoryShelf mark (a bookshelf whose three shelves and books form an
S). Ships asapp/src/assets/mark.svg, served content-hashed under/assets/. - The app now always links its own favicon so a StoryShelf tab is
recognisable.
UIConfig.faviconis deprecated and ignored (still accepted by the schema so existing configs keep validating);SS_FAVICON_URLis no longer read by the dev-server or Fly app. Rebranding usesui.logo, which stays overridable and now defaults to the mark instead of a generic icon. - Website ships the full icon set:
favicon.svg,favicon.ico, 16/32px PNGs, a 180px apple-touch-icon, a Starlight header logo, and a redesigned 1200x630og-image.png(previously a cropped screenshot).
Review page: draggable wipe handle and pixel-level zoom (#40)
- The Swipe comparison now has a draggable handle on the divider (mouse, touch and pen); the slider below stays as the keyboard/screen-reader control and stays in sync.
- Zoom gains a 400% level, and zoomed-in screenshots render with
image-rendering: pixelatedso individual pixels are visible.+/-step the zoom;fstill cycles.
UI redesign: shell, review workspace, icon sprite (ADR 0025)
- New app shell: full-height icon sidebar (collapsible rail, mobile drawer),
slim neutral top bar with project switcher, theme menu, account menu and
toasts. The customer accent now tints (brand mark, active nav, primary
actions, derived
color-mixwashes) instead of flooding the top bar;BrandTheme.topbarBgis deprecated (still accepted, ignored). Default text colors now meet WCAG AA. - Review page is a workspace: thumbnail filmstrip with filters, review
progress, side-by-side / swipe / onion-skin / diff / flip comparison with
zoom and synced scroll, a sticky action bar, a
?shortcuts dialog, and auto-advance to the next open snapshot after approve/reject (HTMX, review page only). Bulk approve/reject now ask for confirmation. - The top bar is now a project picker plus breadcrumbs of the page’s ancestors
(
Builds › main · abc1234on the review page; the current page is named by its heading, and phones show just‹ Parent); the account menu, theme menu and sidebar collapse moved to the sidebar footer.PageHeaderno longer takesbreadcrumbs; pages passnav.trailinstead. - Projects overview cards (previews, “to review” pill, filter), builds list (status chips with counts, branch select, table), library story filter, settings sub-navigation, and a chrome-free sign-in/invite layout.
- Icons are served as one SVG sprite at a content-hashed, immutable URL; htmx moves to a content-hashed URL too (the unversioned URL stays but now revalidates instead of being cached for a year).
- Forms: refreshed field styling and focus states, pending spinner on submit,
confirmations on destructive actions, and success toasts after settings,
project, profile, token, webhook, member and comment actions (server-set
flash cookie). Validation errors now actually show: HTMX ignored the 400
pages, and the tokens/labels/members/status tabs never rendered
globalError. Comment forms no longer swap raw JSON into the page. - Fixes: review keyboard shortcuts no longer fire on Cmd/Ctrl+R; the diff view switch matched a renamed class; document-level listeners were re-registered on every HTMX body swap.
Observability: OpenTelemetry tracing, metrics, log correlation
- New
@storyshelf/observabilitypackage (single owner of all OTEL deps): env-driven SDK init (initObservabilityFromEnv, noop withoutOTEL_EXPORTER_OTLP_ENDPOINT),@hono/otelrequest spans withstoryshelf.req_id/enduser.id,db.*/storage.*adapter wrappers, capture metrics, W3C queue propagation, and a pinotrace_id/span_idmixin. Logs stay on stdout by design. - Core emits
http.clientandcapture.job(+ extract/render/persist phase) spans via@opentelemetry/api; queue payloads carry an optionaltraceparentso remote workers continue the uploading request’s trace.createShelfAppmounts the middleware, serves instrumented adapters, and flushes the SDK on teardown (new structuralShelfOptions.observability). storyshelf server init(and worker) scaffolds include the wiring preamble;createShelfLoggeraccepts amixin. Seedocs/observability.mdand ADR 0022. No endpoint ⇒ behavior unchanged. The wordtelemetryis reserved for a future opt-in usage-reporting feature — this package never sends data to StoryShelf.
Auth: admin-token bootstrap, user-bound tokens
- New
ShelfConfig.adminToken(STORYSHELF_ADMIN_TOKEN/ADMIN_TOKENenv): bearer grants site-admin API access before any user exists. Distinct fromsecret(session signing); never mints sessions. - API tokens are bound to their creating user (
tokens.user_id) and resolve to that user’s live project role; bearer callers are now subject to route role requirements. Legacy ownerless tokens resolve as viewer; tokens whose owner is deleted are denied. Re-issue CI tokens that approve or manage. - Project creation records the session creator as project admin.
- Action required after upgrade: re-create CI tokens used for
approve/reject/manage flows; run DB migrations for the new
user_idcolumn.
Auth: site-viewer auditor, OIDC team sync
- New site
viewerrole: read-only visibility into all projects without membership; explicit membership takes precedence. Request logs carry the user id on request end for read attribution. - OIDC adapter generalizes endpoints via Discovery (Keycloak layout fallback),
extracts group memberships from configurable claims, and ships Keycloak,
Okta, Entra ID, Cognito, and Auth0 presets.
adminGroups/viewerGroupsmap groups to site roles (exact match); Entra group overage fails closed. - New
project_group_mappingstable maps IdP groups to project roles per project (API + Members settings tab); login syncs memberships (highest role wins, removals revoke synced grants only, manual grants untouched). - Action required after upgrade: run DB migrations for
project_members.sourceand the newproject_group_mappingstable.
New adapters
@storyshelf/queue-redis— Redis-backed remote capture queue (ioredis).@storyshelf/storage-azure— Azure Blob Storage adapter.@storyshelf/storage-gcs— Google Cloud Storage adapter.
Adapter lifecycle
AdapterLifecycleis now mandatory and all-or-nothing: every adapter implementssetup/teardown/health. Storagehealthresults no longer reportlatencyMs(they return{ ok }).
JSR
@storyshelf/appships thehono/jsximport map so JSX rendering resolves on deno/JSR consumers.
0.5.5 — Publish pipeline fixes (2026-10-06)
Section titled “0.5.5 — Publish pipeline fixes (2026-10-06)”@storyshelf/core/ormsubpaths;authandappdrop their owndrizzle-ormdependency (fixes the JSR resolution failure forapp).- The JSR publish job skips versions already on JSR.
0.5.4 — First CI release with provenance (2026-10-06)
Section titled “0.5.4 — First CI release with provenance (2026-10-06)”- First release published by
release.ymlwith npm trusted publishing: all 24 packages with provenance. JSR stopped at@storyshelf/app(undeclareddrizzle-orm); fixed in 0.5.5. scripts/npm-trust-all.mjsis idempotent and verifies state.
0.5.3 — All packages on npm and JSR (2026-10-06)
Section titled “0.5.3 — All packages on npm and JSR (2026-10-06)”- Every package published to npm for the first time (published locally, without provenance); JSR packages created and linked to the repo.
@storyshelf/affectedgets a build config so itsdeno.jsonis generated; LICENSE and README added to the packages that lacked them.
0.5.2 — Docs, screenshots, cloud targets (2026-09-23)
Section titled “0.5.2 — Docs, screenshots, cloud targets (2026-09-23)”Cloud deploy targets
storyshelf server initgained AWS, Azure, and GCP Terraform reference stacks.- New adapters:
@storyshelf/storage-azure,@storyshelf/storage-gcs,@storyshelf/queue-azure(Storage Queues + Service Bus), plus GCP Pub/Sub. - Opt-in live-cloud test suites and CI workflows for AWS, Azure, and GCP.
Capture & UI
- Per-project capture browser switch; selectable Playwright or Puppeteer runner.
- Per-story viewports from the Storybook viewport config; runtime preview
fallback for story parameters;
waitForReadyhook honoured. - Per-attempt build log history with retry support; host-owned runtime loggers
via
Adapter.setLogger. - New library gallery with branch picker and per-story deep links.
- Refreshed UI theme; stylesheet served unescaped so fonts and selectors survive.
Auth
- Tiered shared passwords (admin/viewer) for the demo deployment.
Docs
- Core concepts section, grouped guides, Chromatic comparison and migration
guides, screenshots across the site,
storyshelf.js.orgas canonical domain.
0.5.1 — Pre-launch UX polish (2026-09-15)
Section titled “0.5.1 — Pre-launch UX polish (2026-09-15)”- Fixes from the pre-launch UX audit; clearer sidebar Developer section.
- Publish fixes: real semver for
catalog:specs, bundledLICENSEfor JSR.
0.5.0 — Rename to app, per-project browsers (2026-09-06)
Section titled “0.5.0 — Rename to app, per-project browsers (2026-09-06)”Breaking rename
@storyshelf/router→@storyshelf/app;createShelfRouter→createShelfApp(ADR 0018 was the split, this release makes the name permanent). Migrate imports and thecreateShelfRouter(...)call.- The old
@storyshelf/routername is deprecated on npm/JSR.
Capture
- Per-project browser and viewport configuration (
core,db,runner). - A11y annotation support,
staticServerutils, and browsers metadata (core,runner).
0.4.0 — db-postgres, worker, HTTP helper (2026-09-06)
Section titled “0.4.0 — db-postgres, worker, HTTP helper (2026-09-06)”New packages
@storyshelf/db-postgres— Postgres adapter for managed providers; the CLI gainedcreate server/initsupport for a Postgres option with a local Docker Compose service.@storyshelf/worker— remote capture worker with SQS queue and hybrid CLI.
Domain split (ADR 0018)
@storyshelf/router(the HTTP layer) is split out of@storyshelf/core; core stays domain-only. Renamedappin 0.5.0.- Models are dialect-agnostic over the widened
DatabaseAdapter(tables as interface); SQLite-specifics live indb-sqlite. Drizzle factories colocate undercore/src/db.
Retention & purging
- TTL branch GC: daily sweep removes stale baselines; orphan baseline storage objects are deleted on GC.
HTTP & logging
- Shared
httpJson/HttpErrorhelper incore(timeout + retry withRetry-After); fetch-based git providers (ADR 0019). - Single pino owner in core (
createShelfLogger), env-driven level default (ADR 0014).
CLI
- New
build,doctor,whoami,upload --dry-run,server serve,jsr archive/unarchivecommands. - Drops
zodfor hand-rolled config validation; publishes npm-clean on npm as barestoryshelf(and is no longer published to JSR under@storyshelf/cli). - Detects the user’s package runner for builds and scaffolding.
Tooling & hygiene
- oxfmt/oxlint pre-commit hooks with conventional-commit gate.
- LICENSE (MIT) + npm/JSR keywords on publishable packages;
primary export firstlint enforced; dense router/core modules broken down into helpers. - Topological publish order derived from manifests; GitHub Release split into its own job; OIDC check runs early.
0.3.2 — Release tooling & retention fix (2026-09-06)
Section titled “0.3.2 — Release tooling & retention fix (2026-09-06)”latestPerBranchretention query now uses the query builder and honorsorderBy(fixes camelCase-column failure on real databases).- Publish order derived topologically from manifests; validation gates once; npm/JSR publishers depend only on that gate (JSR failures never block npm).
- Release pipeline inline scripts extracted to
scripts/;check-npm-oidcfails fast before install.
0.3.1 — Release fix (2026-09-06)
Section titled “0.3.1 — Release fix (2026-09-06)”- Fix
ref_nameevaluation in the release workflow (double-brace expressions).
0.3.0 — Streaming uploads + webhook secrets (2026-09-06)
Section titled “0.3.0 — Streaming uploads + webhook secrets (2026-09-06)”Webhook secrets encrypted at rest (#45)
webhooks.secret(plaintext) is replaced bysecret_encrypted(AES-256-GCM underShelfConfig.secret). The legacy column is dropped by migration — pre-existing plaintext secrets are discarded, not migrated.- Action required after upgrade: re-create webhooks. Until then, deliveries
for old webhooks fail closed (skipped, never crash). Creation fails loudly
when
SECRETis unset. WebhookModelnow takes the server secret (new WebhookModel(db, secret));DatabaseAdapterno longer exposes top-levelmigrate()/close()(see #59 lifecycle);emitWebhookEventtakes the secret as its final argument.
Streaming uploads replace multipart (#53)
POST /api/v1/projects/:slug/buildsaccepts onlyapplication/jsonand returns{ build, uploadUrl }; the bundle streams viaPUT …/builds/:buildId/zip(newStorageAdapter.writeStream/readStream;maxUploadBytes, default 1 GiB, enforced with413). The multipart form route is removed — servers answer old CLI uploads with4xx.- Action required after upgrade: upgrade the CLI. Old servers are not
supported by the new CLI (no fallback); old CLIs cannot upload to new
servers. The deprecated
--storybook-diralias is removed (use--build-dir); JSON creation acceptslabels: [{ key, value }], and the CLI gained repeatable--label key=value. - Small zips persist Storybook statics inline at upload time.
Adapters
- Mandatory metadata category plus adapter lifecycle and two-tier health.
0.2.0 — Repository restructure (2026-09-05)
Section titled “0.2.0 — Repository restructure (2026-09-05)”Internal reorganization with a small, documented public-surface cleanup. See
docs/migration-0.2.md for the import-path changes.
Public surface (@storyshelf/core)
- Barrel is router-only:
createShelfRouter+ option/config/app types. All values moved to subpaths (new entries:core/logger,core/capture,core/adapter/capture-queue,core/paths,core/urls,core/diff). - Internal tooling (
store,middleware,retention, pages) has no public entry and may change without notice.
Core layout (@storyshelf/core)
schema/per-entity directory replacesschema.ts+schema-tables.ts(one module per entity, mirroringmodels/; DDL drift-guard test added).- Barrel (
@storyshelf/core) now also exportsmodels/*,middleware/*, and the requeststore. Model modules no longer re-export row types. index.tsx(482→~250 lines): capture dispatch (capture/dispatch.ts), merge/dedupe skip checks (capture/skip-checks.ts), and middleware factories (middleware/request-log.ts,store-scope.ts,auth-gate.ts) extracted.- Settings router split by tab area (
settings.handlers.ts+settings-*.ts);build-diffpage split into section components;ui/components.tsxis now a facade overui/{buttons,feedback,forms,layout}.tsx. - Test doubles moved to
test-helpers/(never shipped);status-fanout.test.tscolocated with its implementation.
Adapters
- New
createDrizzleAdapterfactory (core/adapter/database);db-sqliteanddb-tursoare thin driver shims. Adapter tests gained exact type inference. db-sqliteruns on thenode:sqlitebuiltin viadrizzle-orm/sqlite-proxy— zero native dependencies (better-sqlite3removed). Same signature, same WAL mode, same schema.- New shared
upsertReviewCommentflow (core/adapter/git-host/comments);git-github/git-gitlabcomment modules are thin provider bindings.
Apps
dev-serverandfly-appstay separate launchers owning their own adapter wiring, so deployments can diverge (locals vs Turso/S3). Shared code lives inpackages/, notapps/(a shared-assembly experiment was evaluated and reverted for exactly this reason).apps/packages are unversioned (the release script only versionspackages/).
CLI (@storyshelf/cli)
create/init/uploadaccept an optionalcwd(test seam, backward compatible).- Command test suite added (config, create, upload, init, retry, purge).
- All file-wide
oxlint-disableheaders removed (index,config,create,init,uploadrefactored into small helpers).
Repo hygiene
docs/repo-structure.mdadded (annotated map; bundler rule: tsdown for libs, rolldown for apps);AGENTS.md/README.mddrift fixed..oxlintrc.json: deleted thepackages/core/src/**wildcard and the hardcoded file list; legacy violations tracked per-file (see open issues) instead.turbo.jsonbuild outputs scoped todist/**.
0.1.3 and earlier
Section titled “0.1.3 and earlier”See GitHub releases (v0.1.x tags) for the pre-restructure history.