@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

Latest Version0.5.0
Licensenone
Keywords-
Repositorygit+https://github.com/blueforge-studio/blueforge-org.git
Author-
Homepagehttps://github.com/blueforge-studio/blueforge-org#readme
Bugs[object Object]

Versiones

VersiónPublicada
0.5.02026-10-21
0.4.02026-10-15
0.3.02026-10-15
0.2.12026-10-15
0.2.02026-10-15
0.1.02026-10-15

Cadena de suministro por versión

VersiónFirmaSBOMAtt
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

Cargando el grafo de 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). --only keeps a shot's slot.
  • Actions: goto, click, fill, press, select, wait, scroll-to, hover. Targets are { testid }, then { role, name? }, then { text }. wait takes a testid, role, text or url, or a fixed { ms: 0..10000 } pause. Prefer waiting for an element only a hydrated page renders (a testid that appears after a client fetch): a click straight after goto on a Next.js page can land before React has hydrated and do nothing. { ms } is the fallback when there is no such element. fill values 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-scheme and prefers-reduced-motion emulated, CSS animations and transitions off, Date frozen, fonts and network awaited, masked regions flattened. Two screenshots taken stabilityGapMs (250 ms) apart must agree, up to 3 rounds, or the shot is nondeterministic.

  • 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 stale with 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 sourceSha256 means 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 below epsilon the 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-light and 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>..HEAD decides 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 with watch: 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. --force bypasses 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 is stale (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 (the capture_state write API does not exist yet), and there is no dev-db impact derivation, so a shot with no watch: 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_RENDITION mirrors blueforge RENDITIONS.screenshot[1024]; if that changes, change it here or every comparison reads "changed". The pixel threshold (8 per channel) and epsilon are 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_state table exists, nothing writes it yet).
  • from in the sidecar (the schema change that lets assets.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.