@fromluke/mobile-previewer
Mobile device shell for previewing React prototypes to clients. Drop your prototype inside <Previewer>, push to GitHub, share the Vercel link.
import { Previewer } from '@fromluke/mobile-previewer'
import '@fromluke/mobile-previewer/styles.css'
import Prototype from './prototype'
export default function App() {
return (
<Previewer client="Clean Simple Eats" title="PDP Optimizations • Rd 1">
<Prototype />
</Previewer>
)
}
- ≥ 789px: black canvas, top bar (client + title left, device dropdown right), centered phone shell. Smooth CSS-transition morph between iPhone 15 Pro / 15 Pro Max / SE. Selected device persists in
localStorage. - < 789px (including mobile landscape): shell hides, your prototype renders full-bleed responsive.
API
<Previewer
client="Client Name" // small text, top-left
title="Project Name" // big text, top-left
defaultDeviceId="iphone-15-pro" // optional
devices={DEVICES} // optional — override the device list
shellBreakpoint={789} // optional — viewport width below which the shell hides
storageKey="mp:device" // optional — localStorage key; pass null to disable persistence
>
{/* your prototype */}
</Previewer>
Built-in devices: iphone-15-pro, iphone-15-pro-max, iphone-se. Add more by passing your own Device[]:
import { Previewer, DEVICES, type Device } from '@fromluke/mobile-previewer'
const PIXEL: Device = {
id: 'pixel-8',
label: 'Pixel 8',
screen: { width: 412, height: 915, radius: 36 },
shell: { paddingX: 12, paddingY: 12, radius: 48 },
}
<Previewer devices={[...DEVICES, PIXEL]} ... />
Authoring prototypes — constraints
The prototype renders inline inside a fixed-size container (no iframe). So:
- Use
100%, not100vw/100vhfor sizing. Container is393×852for 15 Pro, etc. - Scroll the inside, not the page. Set
overflow: autoon a wrapper inside your prototype. - Anything fixed (
position: fixed) anchors to the viewport, not the phone screen. Useposition: stickyorposition: absoluteparented to your prototype root.
Develop the shell
npm install
npm run dev # demo playground at http://localhost:5173
npm run build # library bundle → dist/
npm run typecheck
The demo lives in src/demo/. The library entry is src/index.ts.
See CLAUDE.md for package-internal architecture notes.
Publishing flow
Once set up, the only command you ever run to ship a change is npm version patch && git push --follow-tags. GitHub Actions does the rest.
How the pieces fit together
local edits
│
│ npm version patch ← bumps package.json + creates git tag v0.1.1
│ git push --follow-tags ← pushes commits AND the tag
▼
GitHub
│
│ .github/workflows/release.yml fires on `v*.*.*` tags
│ 1. checkout + npm ci
│ 2. typecheck + build
│ 3. verify tag matches package.json version
│ 4. npm publish --provenance
│ 5. create GitHub Release with auto-generated notes
▼
npm registry + GitHub Releases (both updated from a single push)
Source of truth split:
- GitHub = the source code. Commits, history, issues, releases.
- npm = the published artifact (just
dist/). Consumers install from here.
You don't push to both. You push to GitHub; npm publishes from CI.
One-time setup
Do these once, in order.
1. Create the npm account + Granular Access Token
- Sign up / log in at https://www.npmjs.com.
- (Publishing under
@fromluke?) Make sure you own thefromlukeusername, or create an npm org with that name at https://www.npmjs.com/org/create. To use a different scope, search/replace@fromlukeacross this repo + any consumers. - Go to https://www.npmjs.com/settings/[your-username]/tokens/new and fill out the Granular Access Token form:
- Token name:
github-actions-mobile-previewer - Bypass two-factor authentication (2FA): check it (CI can't do interactive 2FA)
- Allowed IP ranges: leave blank (GitHub Actions IPs are dynamic)
- Packages and scopes → Permissions:
Read and write- Then below, pick "Only select packages and scopes" → add
@fromluke/mobile-previewer(scope narrowly — a leaked token only affects this one package)
- Then below, pick "Only select packages and scopes" → add
- Organizations → Permissions:
No access - Expiration:
365 days(set a calendar reminder to rotate) - Click Generate token, copy it — it's only shown once.
- Token name:
2. Create the GitHub repo
- https://github.com/new
- Owner
fromluke, namemobile-previewer, visibility Public (npm provenance requires a public repo; private works if you remove"provenance": truefrompackage.json). - Don't initialize with README / license / gitignore — you have them locally.
- Create repository.
3. Push your local code
cd ~/repos/mobile-previewer
git add .
git status # sanity check
git commit -m "Initial mobile-previewer scaffold"
git remote add origin https://github.com/fromluke/mobile-previewer.git
git branch -M main
git push -u origin main
4. Add the token + workflow permissions on GitHub
In the new repo:
- Settings → Secrets and variables → Actions → New repository secret
- Name:
NPM_TOKEN - Value: the token from step 1
- Name:
- Settings → Actions → General → Workflow permissions → "Read and write permissions" → Save (lets the release workflow create GitHub Releases)
5. Verify CI ran green
Actions tab on the repo → CI workflow on your initial push should be green. If red, fix before tagging.
6. (Optional) first manual publish — only if 0.1.0 isn't on npm yet
npm login
npm publish --access public
From here on, every publish goes through CI — never manual.
Day-to-day: shipping an update
# edit code...
git commit -am "Tighten dropdown caret"
# One command — bumps version, builds, commits, tags, pushes everything.
# The `postversion` script pushes the tag, which fires release.yml.
npm run release # 0.1.0 → 0.1.1
# or: npm run release:minor / npm run release:major
Under the hood npm run release runs:
npm version patch— bumpspackage.json, runsprepublishOnly(build), creates a commit + tag.postversion—git push --follow-tags, pushes the commit + tag.- GitHub Actions sees the
v*.*.*tag → publishes to npm with provenance.
Watch the workflow run at https://github.com/fromluke/mobile-previewer/actions. When it goes green, the new version is live on npm.
In each prototype:
npm install @fromluke/mobile-previewer@latest
git commit -am "Bump mobile-previewer"
git push # Vercel redeploys automatically
Or pin to a range like "^0.1.0" so npm update keeps prototypes current without per-version commits.
CI on every push
.github/workflows/ci.yml runs on every push and PR:
npm cinpm run typechecknpm run buildnpm pack --dry-run(validates the publish manifest before tag time)npm auditon production deps, fails on high/critical
Catches regressions before they can be tagged.
Spinning up a prototype
For each client + project (e.g. clean-simple-eats/homepage):
cd ~/repos/clean-simple-eats
npm create vite@latest homepage -- --template react-ts
cd homepage
npm install
npm install @fromluke/mobile-previewer
Replace src/main.tsx:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Previewer } from '@fromluke/mobile-previewer'
import '@fromluke/mobile-previewer/styles.css'
import { Homepage } from './prototype/Homepage'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Previewer client="Clean Simple Eats" title="Homepage • Rd 1">
<Homepage />
</Previewer>
</StrictMode>,
)
Delete the default Vite styles in src/index.css (the previewer brings its own — keep only html, body, #root { margin: 0; padding: 0; } and a body { background: #000 }). Then:
git init
git add .
git commit -m "Initial prototype"
# Create the repo at https://github.com/new (e.g. "cse-homepage")
git remote add origin https://github.com/fromluke/cse-homepage.git
git branch -M main
git push -u origin main
Then on Vercel:
- https://vercel.com/new → import the GitHub repo.
- Framework preset: Vite. Defaults are fine.
- Deploy → you get a URL like
cse-homepage.vercel.appto share with the client.
Every git push to main redeploys.
Security posture
- No runtime network access from the lib code. The only outbound request is the Google Fonts stylesheet for Inter + Roboto Mono (preconnect +
<link>injection in src/loadFonts.ts). URLs are hardcoded constants. - No
dangerouslySetInnerHTML, noeval, noFunction()constructor. All rendered text passes through React's standard escaping. localStorageaccess is guarded (typeof windowcheck + try/catch). Safe in Safari private mode, disabled-storage environments, and SSR.- CSS is scoped under
.mp-root/.mp-responsive— can't bleed into the consumer's prototype DOM. files: ["dist"]restricts npm tarball to compiled output only. Source files, configs,.claude/,.github/never ship to consumers.- npm provenance is enabled via
publishConfig.provenance: true+ the release workflow's OIDC token, so each published version is cryptographically linked to the exact public commit it was built from. Visible on the package page on npmjs.com. - Automation token in CI bypasses 2FA but is tied to a single npm account; rotate it from npmjs.com → tokens if compromised.
npm auditruns in CI on production deps, fails the build at--audit-level=high.