@blueforge-studio/promo-shots
Declarative promo screenshots: a *.shots.yml interpreter, deterministic light/dark capture, a pixel gate, and a push to the BlueForge asset store
Información del paquete
Versiones
| Versión | Publicada |
|---|---|
| 0.5.0 | 2026-10-21 |
| 0.4.0 | 2026-10-15 |
| 0.3.0 | 2026-10-15 |
| 0.2.1 | 2026-10-15 |
| 0.2.0 | 2026-10-15 |
| 0.1.0 | 2026-10-15 |
Cadena de suministro por versión
| Versión | Firma | SBOM | Att |
|---|---|---|---|
| 0.5.0 | |||
| 0.4.0 | |||
| 0.3.0 | |||
| 0.2.1 | |||
| 0.2.0 | |||
| 0.1.0 |
Haz clic en una versión para ver sus detalles.
Cadena de suministro
Procedencia
- Publicado por
- web:5
- Cuándo
- 2026-10-21T13:54:38Z
- sha256 (calculado por el registro)
- ae0f50e87aba017f320cac3a1e452aab341c3fdaa43c3265c01bff4067e5fec3
Observado por el registro en el momento de publicar.
Declarado por la compilación
Quien publicó no declaró nada sobre su compilación.
Adjuntos
- Firma
- ninguno
- SBOM
- ninguno
Dependencias
README
@blueforge-studio/promo-shots
Declarative promo screenshots. You describe which screens of an app are promo shots in a *.shots.yml; this
package captures each one in light and dark, decides whether it materially changed, and pushes only
what did to the BlueForge asset store.
Design: blueforge docs/plans/2026-10-14-promo-shots-automation.md. This is v0 (build order step 2).
Use
promo-shots lint promo.shots.yml # parse + plan, capture nothing
promo-shots run promo.shots.yml --dry-run # capture, decide, upload nothing
PROMO_SHOTS_TOKEN=… promo-shots run promo.shots.yml --base https://app.example.com
run options: --base, --api (default $BLUEFORGE_URL or https://blueforge.studio), --product,
--only a,b, --force, --dry-run, --out-dir <dir> (also write each PNG), --json. An unknown flag exits 2
and sends nothing, so a mistyped --dry-run can never run live. Credentials are read from the environment only
(PROMO_SHOTS_TOKEN = deploy token, or PROMO_SHOTS_ORG_KEY = org key) and are never printed. Exit: 0 ok (a run
that changed nothing is ok), 1 a shot was stale, 2 usage or file error.
The file
app: site
product: restaurant-os # blueforge.product.yml id; assets attach here
base: ${APP_URL} # ${VAR} comes from the environment, or pass --base
defaults:
viewport: { width: 1440, height: 900 }
themes: [light, dark]
settle: { network: idle, fonts: true, delay: 0 }
mask: ["[data-promo-mask]"] # regions flattened to a flat colour: clocks, avatars, live counters
epsilon: 0.001 # fraction of pixels allowed to differ before it counts as a change
shots:
- id: home
caption: "Menus your guests can read"
alt: "The restaurant home page with the weekly menu"
route: /
- id: booking
flow: book-a-table # optional: the *.user-flows.yml flow and step this belongs to
step: pick-time
steps: # a closed list of actions, no scripting
- goto: /book
- click: { testid: party-size-2 }
- wait: { testid: time-picker }
- Slot = the shot's position in the file (the store's screenshots are positional).
--onlykeeps a shot's slot. - Actions:
goto,click,fill,press,select,wait,scroll-to,hover. Targets are{ testid }, then{ role, name? }, then{ text }.waittakes atestid,role,textorurl, or a fixed{ ms: 0..10000 }pause. Prefer waiting for an element only a hydrated page renders (atestidthat appears after a client fetch): aclickstraight aftergotoon a Next.js page can land before React has hydrated and do nothing.{ ms }is the fallback when there is no such element.fillvalues must be placeholders like${persona.email}: a shots file cannot carry a literal secret. The schema is strict; an unknown key fails. - Light before dark. The store refuses a dark capture whose light capture is not stored, so the plan orders them and a failed light capture blocks its dark one.
Personas: cookie, header, localStorage, bearer
A persona is who is signed in when a shot is taken. It can carry any combination of the four mechanisms a real app uses; at least one is required, and a persona with none is refused at parse time (it would silently capture signed out).
personas:
demo:
cookies:
- name: better-auth.session_token # any name
value: ${DEMO_SESSION} # ALWAYS an env placeholder, never a literal (the schema refuses one)
# where it is sent, pick one (default: the run's base URL, which is what makes localhost work):
# url: http://localhost:3000
# domain: .blueforge.studio # + optional path (default /)
# optional: httpOnly, secure, sameSite (Strict|Lax|None)
headers: # an app that gates on a header
x-demo-token: ${DEMO_TOKEN}
bearer: ${DEMO_TOKEN} # shorthand for Authorization: Bearer <token>
localStorage: # a token the app reads at module init
- { key: demo.token, value: ${DEMO_TOKEN} }
shots:
- id: office
route: /console/office
persona: demo # or defaults.persona
The shapes mirror screen-audit's PersonaSetup (cookie plus url, header, localStorage, bearer); declaring
several is screen-audit's composite without the nesting. An explicit Authorization header wins over bearer, so
the two cannot be sent at once. Every value is read from the environment at run time; a missing or empty variable
stops the run before any browser work and the error names the variable, never a value. A persona applies only to the
shots that name it, to a fresh context per capture (so light and dark get the same session, viewport and framing),
and its values are never written to the report.
Mechanisms are applied before the first navigation: cookies through addCookies, headers (and bearer) through
setExtraHTTPHeaders, localStorage through an addInitScript, which is what lets an app that reads its token while
its module loads see the session.
What it guarantees
-
Deterministic capture: fixed viewport,
prefers-color-schemeandprefers-reduced-motionemulated, CSS animations and transitions off,Datefrozen, fonts and network awaited, masked regions flattened. Two screenshots takenstabilityGapMs(250 ms) apart must agree, up to 3 rounds, or the shot isnondeterministic. -
Never fabricates: an HTTP error, a near-empty page (< 20 visible characters), a page with a password field, an action that cannot be performed, or a capture that will not stabilise is reported
stalewith the reason. Nothing is uploaded for it and the previous asset stays live. One stale shot never stops the others. -
G2 pixel gate: equal sha256 to the store's
sourceSha256means nothing to do. Otherwise the new capture goes through the store's own resize and WebP encode and is compared with the stored 1024 rendition. At or belowepsilonthe old bytes are kept: no upload, no new sha, no cache bust. -
Same as light: a dark capture byte-identical to the shot's light capture (an app with no dark mode) is reported
same-as-lightand not uploaded: it would be one picture twice. The capture still goes to--out-dir. (The site also treats an identical dark/light pair as a single screenshot, so a stored copy would only be redundant.) -
G0/G1 (opt-in,
--since <commit>): before any browser work,git diff --name-only <commit>..HEADdecides which shots a change can affect. No changed files: every shot is skipped (zero compute). A global file (package.json, lockfiles,globals.css, tailwind/postcss config, tokens, theme, fonts,layout.tsx, a*.shots.yml) captures all. Otherwise a shot withwatch:globs (repo-relative; only**,*,?, others are rejected at parse time) is captured iff a changed file matches; one without falls back to "anything under--app-dir". Unknown is never skip: an unknown commit, a shallow clone or a failed diff captures everything.--forcebypasses the gates. Skipped shots are listed in the report with the reason.
v0 limits (not hidden)
- Signed-in pages need a session you provide. A shot with
persona:is captured with that persona's cookie(s), header(s), bearer token and/or localStorage entries (below). Nothing here creates a session or an account, and nothing here signs in with a password: the demo user and its tenant are provisioned elsewhere (plan §7) and the resulting session is exported into the environment. A page that still shows a password field isstale(login-wall), so an expired session never produces a login screen as a "promo shot". - G0/G1 are coarse and caller-fed. The last good capture's commit is passed with
--since; nothing stores it (thecapture_statewrite API does not exist yet), and there is no dev-db impact derivation, so a shot with nowatch:is impacted by any change under--app-dir. The post-deploy trigger (step 5) is not here. - The gate compares renditions, not originals (the store keeps no original).
STORE_SCREENSHOT_RENDITIONmirrors blueforgeRENDITIONS.screenshot[1024]; if that changes, change it here or every comparison reads "changed". The pixel threshold (8 per channel) andepsilonare untuned: tune them on the pilot. - Stability cannot be proved for slow changes. A clock showing minutes will agree across 250 ms. Mask it.
- Run state is not stored in the registry (the
capture_statetable exists, nothing writes it yet). fromin the sidecar (the schema change that letsassets.screenshots[]point at a shot) is not done, so this pushes to the store directly; nothing reads the shots file from the sidecar yet.- Needs the asset store's
?scheme=(blueforge #616) for dark captures.
Test
pnpm test. The end-to-end suite drives real Chromium against a local site and a fake store that enforces the
real store's rules; it skips itself where no browser is installed.