npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@mrmartineau/shotframe

v0.1.0

Published

App Store screenshots from raw simulator captures: a device frame, a heading and a line of copy, rendered at exactly the size the store wants.

Readme

shotframe

App Store screenshots from raw simulator captures: a device frame, a heading and a line of copy, rendered at exactly the size the store asks for.

You shoot the screens in the simulator. shotframe puts each one in a bezel under some words, on a background taken from your app's own palette, and writes the set to fastlane/screenshots/ ready to upload.

shotframe init      # a config, and a sources/ folder to shoot into
shotframe           # build the set

No dependencies. It renders with whatever Chromium you already have.

How it works, and why it's built this way

The frame is a small HTML page — a flexbox column, a gradient, an <img> — and headless Chrome takes the picture. The page is authored in CSS points and rendered at the device's scale factor, so iphone-6.9 is laid out at 440×956 and comes out at 1320×2868, which is what App Store Connect wants. Your capture is placed at its natural size and never resampled: the pixels the simulator produced are the pixels that ship.

Two things it will stop you doing:

  • Copy that doesn't fit. Each panel measures its heading and body before it is photographed. A third line doesn't wrap harmlessly — it pushes the phone down and out of the frame, and the damage is easy to miss at thumbnail size. So it fails the build instead.
  • A set that jumps. Heading and body each get two lines of space whether they use them or not, so the phone sits at the same height in every panel. Without that, a short heading lifts the device and the set visibly jumps as someone swipes through it in the store.

Install

pnpm add -D @mrmartineau/shotframe

Then add it to your scripts:

{ "scripts": { "screenshots": "shotframe" } }

Or don't install it at all: pnpm dlx @mrmartineau/shotframe.

Installing it locally is worth it for one reason — shotframe init will then point the config's $schema at the installed copy, and your editor will give you completion and hover docs for every option below.

Quickstart

  1. shotframe init
  2. Shoot your screens on the right simulator and drop the PNGs in sources/. Shoot the whole set in one appearance: a light capture among five dark ones reads as a mistake rather than a choice.
  3. Name them in panels, with the copy you want above each.
  4. shotframe

While you're working on one panel's wording, shotframe --only watering rebuilds just that one.

Copy that overflows exits non-zero, so wiring shotframe into CI will catch a listing edit that no longer fits before it reaches App Store Connect.

Config

shotframe.config.jsonc, or shotframe.config.json if you prefer — both are found automatically, and comments and trailing commas work either way. .jsonc is the default init writes, because it is the extension editors and linters already read as JSON-with-comments: a commented .json file needs a line of config in biome or prettier before it stops being an error.

Only panels is required; everything else has a default.

{
  "$schema": "./node_modules/@mrmartineau/shotframe/schema.json",

  "device": "iphone-6.9",
  "sources": "sources",
  "out": "fastlane/screenshots/en-GB",

  "theme": {
    "background": "linear-gradient(180deg, #1d4527 0%, #0b1c10 100%)",
    "fonts": {
      "display": "\"SF Pro Rounded\", -apple-system, sans-serif",
      "text": "\"SF Pro Text\", -apple-system, sans-serif"
    },
    "colors": {
      "eyebrow": "#9cd19d",
      "heading": "#f0faef",
      "body": "#c2e6c2"
    }
  },

  "panels": [
    {
      "slug": "01-garden",
      "source": "garden.png",
      "eyebrow": "Your garden",
      "heading": "Everything you’re growing, in one place",
      "body": "Six houseplants on a windowsill, or half an acre."
    }
  ]
}

Every path is resolved against the config file's own folder, not your working directory, so shotframe -c apps/ios/shotframe.config.jsonc from the repo root does the same thing as running it from apps/ios.

device

| Preset | Pixels | | | --- | --- | --- | | iphone-6.9 | 1320 × 2868 | the size App Store Connect requires for iPhone | | iphone-6.7 | 1290 × 2796 | | | iphone-6.5 | 1242 × 2688 | | | iphone-5.5 | 1242 × 2208 | | | ipad-13 | 2064 × 2752 | | | ipad-12.9 | 2048 × 2732 | | | android-phone | 1080 × 1920 | Google Play's minimum phone size |

Or give it the numbers directly:

"device": { "width": 440, "height": 956, "scale": 3 }

width × scale and height × scale both have to be whole numbers — shotframe checks, because half a pixel is a rejected upload.

theme

The half that should come from wherever your app's own colours live. A listing that invents its own palette stops looking like the app it is selling, so pull these from your design tokens and change them together.

background takes any CSS background value, so a stack of gradients is fine. fonts.display is the heading; fonts.text is the eyebrow and body.

Fonts have to be installed on the machine doing the rendering — there is no network in the page. If you need a webfont, put an @font-face with a data: URI in css (below) and name it in fonts.

panels

In upload order. Order matters twice over: the store shows the first few in search results, and fastlane deliver uploads in filename order — which is why slugs are usually numbered.

| Key | | | | --- | --- | --- | | slug | required | output filename, without the extension | | source | required | the raw capture, a file inside sources | | heading | required | keep it under about 40 characters | | eyebrow | optional | small caps line above the heading | | body | optional | keep it under about 110 characters |

eyebrow and body are optional, but give every panel one or none — a set that mixes them sits the phone at a different height in each, which is the jumping problem again. shotframe warns if you do.

The rest

bezel is the device frame — pad, radius, screenRadius, background, shadow. These are page points describing how the bezel looks at the size it actually renders, not a scale drawing of a real handset.

type is the type scale: size, leading, weight, tracking for eyebrow, heading and body, plus uppercase on the eyebrow and opacity on the body.

layout is paddingTop, paddingX, gap (between the three lines of copy), stageGap (between the copy and the phone) and reservedLines.

css is appended after everything else, for the one thing the config didn't foresee.

chromePath points at a Chromium if the usual places are wrong. The SHOTFRAME_CHROME environment variable does the same thing.

With fastlane

Point out at the locale folder deliver expects:

"out": "fastlane/screenshots/en-GB"

Then fastlane deliver picks the set up in filename order. For more than one locale, run shotframe once per config — one file per language, each with its own out and its own translated copy.

Programmatic use

import { loadConfig, runBuild } from '@mrmartineau/shotframe'

const config = loadConfig('shotframe.config.jsonc')
const { written, overflows } = runBuild(config)

With a coding agent

The package ships an agent skill at skills/shotframe/SKILL.md, covering the config, the copy limits, and what each error means. Link it into whichever harness you use:

# Claude Code, for one project
mkdir -p .claude/skills
ln -sfn ../../node_modules/@mrmartineau/shotframe/skills/shotframe .claude/skills/shotframe

# Claude Code, everywhere
ln -sfn "$PWD/node_modules/@mrmartineau/shotframe/skills/shotframe" ~/.claude/skills/shotframe

# Codex
ln -sfn "$PWD/node_modules/@mrmartineau/shotframe/skills/shotframe" ~/.codex/skills/shotframe

Copy the folder instead of linking if you would rather it survive a node_modules wipe.

Licence

MIT