PWA
@o/pipeline/pwa keeps installation metadata and device descriptors framework-free. createPWA() binds Pipeline’s shared generated descriptors to one app’s identity. The Node-only renderer runs from pnpm --filter @o/pipeline pwa:build; it may use React SSR and Sharp, but neither reaches the browser metadata entry.
Configuration
Each app owns its installed identity, launch URL, Apple status-bar choice, and optional legacy icon fallback. display defaults to standalone, which is what both current apps want; set it only to opt out of an app-like window. Note that minimal-ui is not a free choice on iOS: startup images need apple-mobile-web-app-capable, and that tag runs the app standalone regardless of what the manifest asks for, so declaring minimal-ui alongside it asks for two different things. On macOS Add to Dock the mode is honoured, where standalone and fullscreen hide the toolbar while minimal-ui and browser keep one. Shared image descriptors are bound inside createPWA(), so an app name or manifest-only change does not produce a new image URL.
import { createPWA } from "@o/pipeline/pwa";
export const pwa = createPWA({
app: {
apple: { capable: true, statusBarStyle: "default" },
description: "A documented app.",
id: "/",
name: "Example",
origin: "https://example.com",
scope: "/",
shortName: "Example",
startUrl: "/",
},
});
export const { manifest, icons, themeColorScript } = pwa;
Head and manifest data
Use the returned data to adapt a framework’s head API. pwa.head() resolves a document title, metadata, and links in one result. title, description, canonicalUrl, origin, and socialTitle are page or request overrides. socialTitle is useful when a framework’s document title includes the app name but its existing Open Graph title should remain page-specific.
import { pwa } from "./pwa.config";
const head = pwa.head({
canonicalUrl: "https://example.com/reference",
description: "Reference documentation.",
socialTitle: "Reference",
title: "Reference | Example",
});
Operate adapts head.title, head.meta, and head.links to TanStack Start. Design adapts the same data to Astro. Both keep their existing theme controller as the runtime owner; the shared theme-color script only follows the controller’s data-theme result.
Generated assets
Icons and social image

favicon-32.png
icon-180.pngicon.svg
icon-192.png
icon-512.png
icon-1024.png
icon-maskable-192.png
icon-maskable-512.png
icon-maskable-1024.png
og.pngiOS startup images
iPhone SE (1st generation)
320 × 568 CSS px at 2×

splash-light-320x568@2-portrait.png
splash-light-320x568@2-landscape.png
splash-dark-320x568@2-portrait.png
splash-dark-320x568@2-landscape.pngiPhone 6, 6s, 7, and 8, iPhone SE (2nd and 3rd generation)
375 × 667 CSS px at 2×

splash-light-375x667@2-portrait.png
splash-light-375x667@2-landscape.png
splash-dark-375x667@2-portrait.png
splash-dark-375x667@2-landscape.pngiPhone 6 Plus, 6s Plus, 7 Plus, and 8 Plus
414 × 736 CSS px at 3×

splash-light-414x736@3-portrait.png
splash-light-414x736@3-landscape.png
splash-dark-414x736@3-portrait.png
splash-dark-414x736@3-landscape.pngiPhone X, XS, 11 Pro, 12 mini, and 13 mini
375 × 812 CSS px at 3×

splash-light-375x812@3-portrait.png
splash-light-375x812@3-landscape.png
splash-dark-375x812@3-portrait.png
splash-dark-375x812@3-landscape.pngiPhone XR and 11
414 × 896 CSS px at 2×

splash-light-414x896@2-portrait.png
splash-light-414x896@2-landscape.png
splash-dark-414x896@2-portrait.png
splash-dark-414x896@2-landscape.pngiPhone XS Max and 11 Pro Max
414 × 896 CSS px at 3×

splash-light-414x896@3-portrait.png
splash-light-414x896@3-landscape.png
splash-dark-414x896@3-portrait.png
splash-dark-414x896@3-landscape.pngiPhone 12, 12 Pro, 13, 13 Pro, and 14
390 × 844 CSS px at 3×

splash-light-390x844@3-portrait.png
splash-light-390x844@3-landscape.png
splash-dark-390x844@3-portrait.png
splash-dark-390x844@3-landscape.pngiPhone 12 Pro Max, 13 Pro Max, and 14 Plus
428 × 926 CSS px at 3×

splash-light-428x926@3-portrait.png
splash-light-428x926@3-landscape.png
splash-dark-428x926@3-portrait.png
splash-dark-428x926@3-landscape.pngiPhone 14 Pro, iPhone 15, iPhone 15 Pro, iPhone 16
393 × 852 CSS px at 3×

splash-light-393x852@3-portrait.png
splash-light-393x852@3-landscape.png
splash-dark-393x852@3-portrait.png
splash-dark-393x852@3-landscape.pngiPhone 14 Pro Max, iPhone 15 Plus, iPhone 15 Pro Max, iPhone 16 Plus
430 × 932 CSS px at 3×

splash-light-430x932@3-portrait.png
splash-light-430x932@3-landscape.png
splash-dark-430x932@3-portrait.png
splash-dark-430x932@3-landscape.pngiPhone 16 Pro
402 × 874 CSS px at 3×

splash-light-402x874@3-portrait.png
splash-light-402x874@3-landscape.png
splash-dark-402x874@3-portrait.png
splash-dark-402x874@3-landscape.pngiPhone 16 Pro Max
440 × 956 CSS px at 3×

splash-light-440x956@3-portrait.png
splash-light-440x956@3-landscape.png
splash-dark-440x956@3-portrait.png
splash-dark-440x956@3-landscape.pngiPhone Air
420 × 912 CSS px at 3×

splash-light-420x912@3-portrait.png
splash-light-420x912@3-landscape.png
splash-dark-420x912@3-portrait.png
splash-dark-420x912@3-landscape.pngRetina iPad and iPad mini
768 × 1024 CSS px at 2×

splash-light-768x1024@2-portrait.png
splash-light-768x1024@2-landscape.png
splash-dark-768x1024@2-portrait.png
splash-dark-768x1024@2-landscape.pngiPad (7th through 9th generation)
810 × 1080 CSS px at 2×

splash-light-810x1080@2-portrait.png
splash-light-810x1080@2-landscape.png
splash-dark-810x1080@2-portrait.png
splash-dark-810x1080@2-landscape.pngiPad Air (4th and 5th generation), iPad Air 11-inch (M2 through M4), and iPad (10th generation and A16)
820 × 1180 CSS px at 2×

splash-light-820x1180@2-portrait.png
splash-light-820x1180@2-landscape.png
splash-dark-820x1180@2-portrait.png
splash-dark-820x1180@2-landscape.pngiPad mini (6th generation and A17 Pro)
744 × 1133 CSS px at 2×

splash-light-744x1133@2-portrait.png
splash-light-744x1133@2-landscape.png
splash-dark-744x1133@2-portrait.png
splash-dark-744x1133@2-landscape.pngiPad Pro 10.5-inch and iPad Air (3rd generation)
834 × 1112 CSS px at 2×

splash-light-834x1112@2-portrait.png
splash-light-834x1112@2-landscape.png
splash-dark-834x1112@2-portrait.png
splash-dark-834x1112@2-landscape.pngiPad Pro 11-inch
834 × 1194 CSS px at 2×

splash-light-834x1194@2-portrait.png
splash-light-834x1194@2-landscape.png
splash-dark-834x1194@2-portrait.png
splash-dark-834x1194@2-landscape.pngiPad Pro 11-inch (M4)
834 × 1210 CSS px at 2×

splash-light-834x1210@2-portrait.png
splash-light-834x1210@2-landscape.png
splash-dark-834x1210@2-portrait.png
splash-dark-834x1210@2-landscape.pngiPad Pro 12.9-inch and iPad Air 13-inch (M2 through M4)
1024 × 1366 CSS px at 2×

splash-light-1024x1366@2-portrait.png
splash-light-1024x1366@2-landscape.png
splash-dark-1024x1366@2-portrait.png
splash-dark-1024x1366@2-landscape.pngiPad Pro 13-inch (M4)
1032 × 1376 CSS px at 2×

splash-light-1032x1376@2-portrait.png
splash-light-1032x1376@2-landscape.png
splash-dark-1032x1376@2-portrait.png
splash-dark-1032x1376@2-landscape.pngThe renderer writes deployment artwork only to packages/pipeline/public/pwa/.generated/<hash>/, then writes its compact build facts to src/pwa/.generated/hash.ts. Existing Pipeline bootstrap copies the public collection into each consumer at /.pipeline/pwa/.generated/<hash>/. Each app owns its framework manifest endpoint at /manifest.webmanifest; it derives that document from its own pwa output without rendering images.
The hash is SHA-256 over codepoint-sorted logical filenames and encoded image bytes. It excludes app identity, timestamps, absolute paths, and generated descriptor facts. The runtime asset map derives URLs and descriptors from the shared icon and device specifications, with the generated hash and measured Open Graph dimensions as its only build facts.
Run these commands after changing a shared rendering input, device descriptor, or the shared Open Graph source:
pnpm --filter @o/pipeline pwa:build
pnpm --filter @o/pipeline pwa:check
Generation is explicit. It does not run from lint-staged, install, prepare, normal builds, or app metadata changes. Pipeline’s test command runs the read-only check before Vitest, so a forgotten regeneration fails without mutating the checkout. Stage regenerated shared outputs with their source changes.
Serve hash-addressed image files with Cache-Control: public, max-age=31536000, immutable. Each app’s /manifest.webmanifest must revalidate because its document text can change without a new image hash.
Only the current hash directory is retained. A page or installed app that still points at a deleted directory can show a missing asset until it reloads.
iOS snapshots the Home Screen icon and startup images when the app is added. Nothing invalidates that snapshot: a new collection hash changes the URLs, but an existing installation keeps the artwork it was installed with until the user deletes the app and adds it again. Treat that as a constraint on when artwork ships rather than a refresh step, because every installation made before the final icon lands keeps the placeholder. Social-preview caches are separate, and each platform expires them on its own schedule.
The collection includes the byte-preserved 1200 by 628 Open Graph source at src/pwa/assets/og.png.
Branding assets this needs supplied
Everything currently rendered derives from one placeholder: the Operate mark in packages/pipeline/src/experimental/logos/operate.tsx, a 12 × 12 path drawn on a flat canvas. That is enough to build and test the pipeline, and it is not enough to ship. This list records what real artwork has to arrive before the placeholder comes out, and which items cannot simply be scaled from the mark.
| Asset | Why it is needed | Current placeholder |
|---|---|---|
| Icon-grade app mark, vector | Every generated icon and splash derives from this one path. A UI-grade mark scaled into an icon slot reads as a logo in a box rather than an app icon. | operate.tsx, a 12 × 12 path shared with in-product UI |
| iOS app icon, 1024 square, opaque | iOS applies its own squircle mask and shadow, so the source must be full-bleed with no transparency and no pre-rounded corners. Artwork has to be composed for that crop. | Centred mark at 52% of the shorter edge on #FCFCFC |
| macOS app icon | macOS uses a rounded rectangle with its own inset, depth and shadow conventions. It is a different composition from the iOS square, not a re-export of it. | None; icon-1024.png is offered to ToDesktop as a stand-in |
| Maskable icon | Android crops to an arbitrary shape, so the mark needs a deliberate safe zone rather than the standard inset. | Same mark at 40% of the shorter edge |
Optical small sizes, 16 and 32 | A mark that reads at 1024 turns to mud at 32. Small sizes want a redrawn, simplified form, not a downscale. | favicon-32.png, a straight downscale |
| Splash canvas and mark treatment | The launch image is the first frame of the app. Its canvas must match the app background per appearance, and the mark’s scale on it is a composition decision. | Mark at 20% of the shorter raster edge on the canvas colour |
| Canvas colours, light and dark | These drive the splash background, the manifest background_color and the initial theme_color, so they are brand decisions rather than incidental values. | #FCFCFC light, #141414 dark, taken from --color-canvas |
| Mark colour | Applied to every generated icon and splash. | #117025 |
Open Graph card, 1200 × 628 | Shared social artwork. The current file is inherited from Design and is deliberately generic. | src/pwa/assets/og.png, reused unchanged |
Two constraints worth knowing before commissioning any of it.
iOS only accepts PNG manifest icons and ignores maskable, so the SVG and maskable families exist for other platforms rather than for Apple. And artwork timing matters more than it looks: iOS snapshots the Home Screen icon at install, so anyone who installs before the real icon ships keeps the placeholder until they delete and re-add the app.
Once approved artwork lands, replace the source, run the two commands above, and commit the regenerated collection. Nothing here is hand-exported per size.
iOS startup images
IOS_STARTUP_DEVICE_FAMILIES groups named Apple families by their shared CSS viewport and DPR, then derives four startup images per unique triple for light/dark portrait/landscape coverage. The current table has 22 triples and emits 88 startup images. Landscape links retain the portrait CSS viewport dimensions while their raster dimensions swap.
Family labels are checked against Apple’s iPhone Air, iPad, iPad mini, iPad Air, and iPad Pro display specifications. The iPhone 15 and 16 viewport mappings are checked against Chromium DevTools device presets, which specify 393 × 852 at 3× for iPhone 15, 15 Pro, and 16, and 430 × 932 at 3× for iPhone 15 Plus and 16 Plus.
Safe areas and toasts
App.Root owns the viewport-safe content boundary by default. It pads its outer canvas once, clips its content to that padded boundary, and marks descendants as having consumed the content safe area. Viewport-level portals retain the physical env(safe-area-inset-*) values.
Use var(--safe-area-inset-*) for in-flow content inside the padded root; use env(safe-area-inset-*) for anything positioned against the viewport or the root’s padding box.
@o/pipeline/theme.css provides physical and logical padding, margin, and inset safe-area utilities, including numeric spacing variants. See the theme utilities reference for the complete family and its physical/logical semantics.
Toaster owns viewport-safe defaults: 1.5rem on desktop and 1rem on mobile plus direct physical env() insets. Its mobile styling accounts for asymmetric left/right insets and Sonner’s RTL positioning. Callers can still pass explicit offset and mobileOffset values, while each app keeps its existing theme ownership.