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

@atelic-action/ui

v3.16.0

Published

Shared UI for the Atelic templates: site chrome, base styles, the component library, the access gate, and the app shell they install

Readme

@atelic-action/ui

Shared UI for the Atelic templates: the site chrome (header, menu, footer, sticky CTA bar, and credit band), the not found page and the routing that keeps it alive, the scroll spy hook, and the one base stylesheet. The marketing and artifact templates install it instead of carrying their own copies, so a chrome fix lands once and every site picks it up with bun update @atelic-action/ui --latest.

The package ships source, not a build. Its TSX and CSS arrive as written and compile inside each site's own Vite. The one exception is the access gate's server side, which ships compiled (see The Access Gate).

Component Index

Every export, grouped by the entry it is imported from. The gallery column links the section of ui.atelic.me that shows it; none marks an export that draws nothing. The composed documents (the writeup, the read, the monthly page, the note, and the sealed document) are not here: since 2.0.0 they live in @atelic-action/artifact, which builds them from the pieces this package exports.

| Export | Entry | What it is | Gallery | |---|---|---|---| | SiteHeader | @atelic-action/ui/chrome | The fixed bar: the lockup, the nav or a page's own stops, the call to action, the burger; transparent, solid, or dark | #site-header | | SiteMenu | @atelic-action/ui/chrome | The phone's full screen menu, on a native modal dialog | #site-menu | | Footer | @atelic-action/ui/chrome | The site's foot: brand column, nav, address, hours, copyright, credit band | #footer | | CreditBar | @atelic-action/ui/chrome | The developer credit band | #credit-bar | | StickyCTABar | @atelic-action/ui/chrome | The phone's bottom bar: the call to action and a call shortcut, there from the top on an interior page (fromTop) and sliding in after a scroll on a hero page | #sticky-cta-bar | | BrandLockup | @atelic-action/ui/chrome | The header's identity as one link, with the recipient's mark on a sent document | #brand-lockup | | SkipLink | @atelic-action/ui/chrome | The keyboard user's jump past the chrome | #skip-link | | Brand, BrandLogo, CallToAction, HoursRow, MenuContact, NavLink, Phone, PostalAddress, SiteLink, SocialLink, PageStop | @atelic-action/ui/chrome | The shapes the chrome renders from (types) | none | | NotFound | @atelic-action/ui/components | The body of the page an unknown path renders | #not-found | | Checklist | @atelic-action/ui/components | A short list of checks, each passed or failed | #checklist | | IconList | @atelic-action/ui/components | A list whose bullets are icons, one for the list or one per item, each in a tone | #icon-list | | DataTable | @atelic-action/ui/components | A table whose first column names each row, sortable, stacking on a phone | #data-table | | Hint | @atelic-action/ui/components | An info icon that opens a short explainer beside a word, or beside a table heading | #hint | | ReportCard | @atelic-action/ui/components | A name, a grade, and a line per row, in labeled groups, each row a link to its section | #report-card | | reportCardLabels, ReportCardLabels | @atelic-action/ui/components | The words a screen reader hears around a report card's letters by default, and their type | none | | Rank | @atelic-action/ui/components | A figure with its badge in a tone, for a table cell: a rank, a place in an answer, or a dash | #rank | | StatRow | @atelic-action/ui/components | A row of large figures with periods, deltas, and trends; a share's denominator set small after the figure (outOf) and a change set small beside it with a triangle (change), 3.14.0 | #stat-row | | Callout | @atelic-action/ui/components | One finding: an icon in its tone, the title, the explainer | #callout | | Disclosure | @atelic-action/ui/components | A section that folds: controlled, an app's card; uncontrolled, a native details fold that prints open | #disclosure | | Tabs, TabsItem | @atelic-action/ui/components | Panels behind a row of tabs, every panel kept in the page: a hash opens the tab or the panel holding what it names, and it prints every panel under its label | #tabs | | StackedBar | @atelic-action/ui/components | One bar split into the parts of a whole, with a key | #stacked-bar | | ColumnChart | @atelic-action/ui/components | A bar a period, the periods in labeled groups | #column-chart | | ThresholdScale | @atelic-action/ui/components | Zones on a scale and a pin for each reading | #threshold-scale | | StarRating, describeStarRating | @atelic-action/ui/components | A row of stars in half steps, read only or editable, and its spoken name | #star-rating | | GateLogin | @atelic-action/ui/components | The access gate's sign in screen | #gate-login | | Watermark | @atelic-action/ui/components | The signed in reader's confidential stamp, pill or tiled | #watermark | | readCookie, Tone | @atelic-action/ui/components | The cookie reader the watermark uses, and the tone type | none | | Button, PrimaryButton, SecondaryButton, Chip, Eyebrow, Lead, SectionHeading | @atelic-action/ui/primitives | The small pieces a page is set in; the two named buttons are the only call to action shapes a page uses | #primitives | | GradeChip, gradeLabel, WriteupGrade | @atelic-action/ui/artifact | A section's grade, A to D, as a chip in its color, the letter alone, and the grade type | #grade-chip | | SignOff, WriteupPerson | @atelic-action/ui/artifact | The close a document ends on, and the person it names | #sign-off | | Shot, WriteupShot | @atelic-action/ui/artifact | The owner's own screen, opening the full picture, over its caption; a portrait crop is marked data-portrait and held to 200px (1.8in in print) | #shot | | Paragraph, Runs, ArtifactAnchor, isEmpty, ArtifactLink, ArtifactParagraph | @atelic-action/ui/artifact | Plain data text a routine can write as JSON: a paragraph, its runs alone, one link, the empty test, and the two types | #paragraph | | ArtifactShell | @atelic-action/ui/artifact | The chrome around a document page: skip link, dark header, credit band, watermark | #site-header (its dark bar) | | AppErrorBoundary, ErrorFallback | @atelic-action/ui/artifact | The error boundary and the page it falls back to | none | | buildPageHead, canonicalURL, newTabProps, the ArtifactConfig types | @atelic-action/ui/artifact | A page's head, a link's new tab props, and a site's contract | none | | mintToken, sealReader, openReader, readerId, readerLink, whoIsReading | @atelic-action/ui/artifact and @atelic-action/ui/artifact/readers | Who opened a document; the second entry is the same six, built, for what Node runs | none | | Masthead, TitleCard, Footer, FailurePage | @atelic-action/ui/email | An email's frame: the wordmark row, the title card, the foot, the failure page | #email | | Card, Row, FoldRow, Eyebrow, SubEyebrow, Fold, BigFold | @atelic-action/ui/email | The containers and labels an email's sections sit in | #email | | Item, TitleLine, Note, EmptyRow, Stat, StatsRow, List, ListRow, LeadRow, MonoTable, ReadBlock, Badge | @atelic-action/ui/email | The rows inside a card | #email | | Scoreboard, GroupRow, TargetRow, Bar, WhatMoved, DayStrip, StatStrip | @atelic-action/ui/email | Targets against what was logged, what moved, and the week by day | #email | | RecordStack, RecordTimelineStrip, RecordTimelineLegend, Records | @atelic-action/ui/email | Records stacked for a phone, their timeline, and the fixed column table | #email | | EmailThemeProvider, useEmailTheme, eyebrowStyle, the plain text helpers (recordStackText, wrap, textTable, and the rest) | @atelic-action/ui/email | The palette context and the plain text twin (see Email) | #email (the text twin) | | renderEmail, renderFailureEmail | @atelic-action/ui/email/render | The whole email document, and the failure page's | #email | | atelicPalette, atelicFonts, toThemeCSS, themeTokenMap, Palette, Fonts | @atelic-action/ui/tokens | The palette and font stacks, with no React | none | | useScrollSpy | @atelic-action/ui/hooks | Which section is in the reading band; the header's stops use it | #site-header (the dark bar's stops) | | staticNotFoundRouting | @atelic-action/ui/routing | The router options that keep a prerendered not found page alive | none | | signToken, verifyToken, safeNext, needsSession | @atelic-action/ui/gate | The access gate's tokens, the safe post sign in path, and the wall's test | none | | POST, createRequestLink | @atelic-action/ui/gate/request-link | The function that emails a sign in link | none | | GET | @atelic-action/ui/gate/verify | The function that turns a link into a session | none | | default, createMiddleware | @atelic-action/ui/gate/middleware | The middleware that holds a host behind the gate | none | | artifactConfig, artifactPages | @atelic-action/ui/vite | An artifact site's whole Vite configuration, and its page list | none | | privateChunks, isPrivateContent, DOC_DIR | @atelic-action/ui/vite and @atelic-action/ui/vite/private-chunks | The plugin that keeps private content behind the wall; the second entry loads neither optional peer | none | | base.css, chrome.css, components.css, primitives.css, gate.css, artifact.css, star-rating.css | @atelic-action/ui/styles/* | The stylesheets, in the order under CSS Import Order | every section | | BlogIndex | @atelic-action/ui/sections | The post listing as a readable stack | #blog-index | | BlogPost | @atelic-action/ui/sections | One article: back link, title, byline and dateline, body | #blog-post | | CTABanner | @atelic-action/ui/sections | A mid page banner behind a directional wash, with the token gradient before any photography | #c-t-a-banner | | CTASection | @atelic-action/ui/sections | The standard closer: the site's own primary action and an optional ghost second | #c-t-a-section | | ComparisonTable | @atelic-action/ui/sections | A graded before and after that scrolls sideways on a phone rather than reflowing; a row's label can link to what explains it (stageHref, 3.14.0) | #comparison-table | | ContactForm | @atelic-action/ui/sections | Validated form with a honeypot that posts to endpoint (or hands off to mail); onLead replaces the default GA4 generate_lead event | #contact-form | | ContactInfoCard | @atelic-action/ui/sections | A dark card of direct contact rows: call, email, visit, portal | #contact-info-card | | CredentialList | @atelic-action/ui/sections | Credential cards with a mono code before each | #credential-list | | DeviceShowcase | @atelic-action/ui/sections | A desktop browser frame with a phone overlapping it | #device-showcase | | ExpandList | @atelic-action/ui/sections | A light accordion of numbered rows whose bodies stay in the HTML while closed | #expand-list | | FAQBrowser | @atelic-action/ui/sections | The help page: live search, a sticky JumpRail of categories with scroll spy, expand all, deep links; contactHref sets where the no match line points | #f-a-q-browser | | FeatureDetail | @atelic-action/ui/sections | Alternating copy and media rows with tags and a tick list | #feature-detail | | FeatureGrid | @atelic-action/ui/sections | Numbered cards with a hover lift; a card is a link or holds one, never both | #feature-grid | | Gallery | @atelic-action/ui/sections | A captioned wall of the business's own work, a shot brief where a photo is missing | #gallery | | Hero | @atelic-action/ui/sections | The home page opener: a full bleed image (or the token gradient), a headline with a serif accent, a pair of actions, trust figures, and a scroll cue | #hero | | HoursTable | @atelic-action/ui/sections | Day rows with dashed rules and mono times | #hours-table | | JumpRail | @atelic-action/ui/sections | Sticky in page pill tabs with scroll spy, used by FAQBrowser and on its own | #jump-rail | | LegalPage | @atelic-action/ui/sections | A legal document from data, its last revised date first | #legal-page | | LocationSection | @atelic-action/ui/sections | The local search section: service areas as prose, hours, directions, and the map | #location-section | | Locations | @atelic-action/ui/sections | More than one address: the flagship's map beside a card for each location | #locations | | LogoMarquee | @atelic-action/ui/sections | A dark band of chips scrolling without end: credentials, clients, or press | #logo-marquee | | MapEmbed | @atelic-action/ui/sections | The map: a screenshot linking to directions, or a click to load facade over the live embed | #map-embed | | MediaBand | @atelic-action/ui/sections | A plain full bleed image band, cropped from the center | #media-band | | MenuList | @atelic-action/ui/sections | A priced menu group of dotted rows, with fine print under it | #menu-list | | PageHero | @atelic-action/ui/sections | The interior page opener on the alternate band; children sit under the lead | #page-hero | | PressQuotes | @atelic-action/ui/sections | Rows of press quotes and awards with source and year, newest first (sortPress beside it) | #press-quotes | | PricingBand | @atelic-action/ui/sections | A brand colored band with the price in a card beside it, informational by design | #pricing-band | | PricingCard | @atelic-action/ui/sections | A standalone price card with a tick list and its booking action | #pricing-card | | ProcessSteps | @atelic-action/ui/sections | A dark band of numbered steps beside a frame | #process-steps | | ProfileCards | @atelic-action/ui/sections | Equal weight cards of media beside a short bio | #profile-cards | | RevealSlider | @atelic-action/ui/sections | Drag or arrow keys to compare two frames, each with its own toned shot brief before photography | #reveal-slider | | ReviewCarousel | @atelic-action/ui/sections | The aggregate beside one large review at a time, stepped by dots and arrows, each review linking to its source | #review-carousel | | ReviewWall | @atelic-action/ui/sections | The reviews page: tag filter and sort when the data carries them, clamped quotes behind Read more | #review-wall | | SocialStrip | @atelic-action/ui/sections | A static grid of the business's social photos linking to the profile, never a live embed | #social-strip | | SplitSection | @atelic-action/ui/sections | The two column band: copy beside copy, or media beside copy | #split-section | | SiteConfig and its member types, formatClock, formatHoursRange, isClosed, isHoneypotFilled, trackEvent | @atelic-action/ui/sections | The site contract the sections read, and the hours, honeypot, and lead event helpers | none | | Bubble, ImageFrame, ProseBlocks, QuoteCard, StarRow, TickList | @atelic-action/ui/primitives | The marketing primitives: a round tile holding an icon or a step number, a framed image, prose blocks (headings at two levels, paragraphs, lists, and figures: small captioned tiles in rows, an image or a silent looping clip played only while on screen, each opening large in a dialog), a quote card, a row of filled stars, a tick list | #marketing-primitives | | SiteShell | @atelic-action/ui/chrome | A marketing page's whole chrome: skip link, header, main, footer, sticky bar, mounting the base path provider | none | | BasePathProvider, useBasePath, useWithBase, joinBase, stripBase, hasBase, normalizeBasePath | @atelic-action/ui/routing | The base path a site is served under, read by Button, Chip, and every section that builds a link | none | | joinBase, stripBase, hasBase, normalizeBasePath | @atelic-action/ui/routing/base-path | The same four pure helpers, built, for what Node runs (a Playwright spec, a script) | none | | marketingConfig | @atelic-action/ui/vite | A marketing site's whole Vite configuration as one call | none | | sections.css | @atelic-action/ui/styles/sections.css | Styles for everything under sections, imported after primitives.css | none | | AppProvider, AppConfigProvider, useAppConfig, useNav, useUser, configureApp, getAppConfig, AppConfig, NavItem, AppUser, Register | @atelic-action/ui/app | The app shell's seam: the app hands in its config and nav, registers its user type, and reads them back | app | | AppLayout, AppPage, AppPageToolbar, Breadcrumbs, SectionCards, DesktopRail, MobileTabBar, MobileMenu, UserMenu, PageSkeleton, PageError, ActionButton, FormActionButtons, activeSection | @atelic-action/ui/app | The app frame: the rails, the tab bar, the page scaffold with its toolbar and right rail | app | | AuthGuard, CardErrorBoundary, ErrorFallback, GoogleGlyph, useAuthStore, useUnitsStore, apiFetch, publicFetch, session, the hooks and the wire types | @atelic-action/ui/app | Auth, transport, the two shell stores, and the helpers the shell runs on | app | | The shadcn kit (Button, Dialog, Select, OptionSelect, SearchInput, BadgeList, Callout, and the rest) | @atelic-action/ui/app/ui | The kit the shell is set in; a new kit component lands here, never in an app | app | | app.css | @atelic-action/ui/styles/app.css | The shell's Tailwind sheet: the token mapping, the default tokens, the base layer, the ap-* scaffold, and the @source that points Tailwind at the shell | none | | The eleven atelic-* commands | the bin map | The build guard, health stamp, link and wall checks, reader link, static server, print, sealing, and the marketing site's sitemap clean, base path check, and contact canary | none |

Install

bun add @atelic-action/ui

From 1.0.0 a caret range admits every minor and patch, so a plain bun update @atelic-action/ui takes them; a new major still needs --latest. A site still on a ^0.x range takes 1.0 with --latest once.

Peer dependencies: react and react-dom at ^19.0.0, and lucide-react at ^1.23.0. Three more are optional, needed only by a site whose vite.config.ts imports @atelic-action/ui/vite: vite, @tanstack/react-start, and @vitejs/plugin-react. The commands the package installs run under bun, with node for the sealing command.

Vite Config

Prerendering and server rendering have to compile the package too, so keep it out of Vite's SSR externals. That is the one line a consumer adds:

// vite.config.ts
export default defineConfig({
	ssr: { noExternal: ["@atelic-action/ui", "@atelic-action/artifact"] },
});

A site that renders a document needs @atelic-action/artifact kept internal the same way, since it too ships TSX and CSS as written; a site that renders none lists @atelic-action/ui alone.

CSS Import Order

Import the stylesheets in this order, from the root route or the site's base sheet:

  1. The site's fonts (fonts.css)
  2. @atelic-action/ui/styles/base.css
  3. @atelic-action/ui/styles/chrome.css
  4. @atelic-action/ui/styles/components.css
  5. @atelic-action/ui/styles/primitives.css
  6. @atelic-action/ui/styles/gate.css, on a site that mounts the access gate
  7. @atelic-action/ui/styles/artifact.css, on a site that hosts the artifact documents
  8. @atelic-action/artifact/styles/documents.css, on the same site, for the documents themselves
  9. The site's own CSS
  10. The site's theme.css, last

Every rule in the package sheets is scoped under .atelic-ui, the one root class a consumer wears: a site puts it on its outermost wrapper, an app on the region that holds a package component. Which sheets it imports decides what it gets, so a site importing base.css gets the resets and typography under that class and an app importing components.css alone gets none of them.

The package sheets sit inside @layer atelic-ui, so any rule a site writes outside a layer wins over them whatever its specificity. The package reads the theme tokens (--ink, --surface, --primary, --nav-height, and the rest) and defines none, so theme.css stays the one file a site edits to rebrand. The chrome's buttons wear the .btn classes, which primitives.css defines and a site may restyle.

Rendering From Props

Every component renders from props alone. None reads a config file or a router, so a site maps its own config onto the props in its shell:

import { Footer, SiteHeader, SkipLink, StickyCTABar } from "@atelic-action/ui/chrome";

<div className="atelic-ui">
	<SkipLink />
	<SiteHeader
		brand={{ name: site.name, logo: site.logo }}
		links={site.nav.map((item) => ({ label: item.label, href: item.to }))}
		primaryCTA={site.cta}
		variant="transparent"
		currentPath={pathname}
	/>
	<main id="main">{children}</main>
	<StickyCTABar primaryCTA={site.cta} phone={site.phone} />
	<Footer brand={{ name: site.name, logo: site.logo }} email={site.email} />
</div>;

A sent artifact's header names who the document is for as well as who it is from: pass recipient: { src, alt } on the brand (added 0.6.0) and the lockup reads "atelic × ", the sender's wordmark and run first, a quiet times sign, then the recipient's mark at wordmark height. Cut the mark for the surface the header wears (a light mark for the dark bar) and keep it an image; the chrome draws no second wordmark. A marketing site's lockup passes no recipient. The run after the wordmark reads --brand-accent when the theme sets it (a client branded host pins the sender's orange there) and falls back to --primary (0.6.1), and the recipient's mark sits at the same 30 pixel height as the sender's.

The menu is a native <dialog> opened with showModal(), so Escape, focus containment, and focus return come from the browser.

The Not Found Page

NotFound is the page an unknown path renders: a headline, a row of popular pages, and the closing call to action. It renders the body only, so a site wraps it in its own shell, and it wears the site's own classes (.page-hero, .eyebrow, .lead, .final-cta, .btn).

On a statically prerendered TanStack Start site the markup is the easy part. The host serves the /404 prerender for every miss, and the page survives hydration only with three pieces in the site:

  1. A catch all route, src/routes/$.tsx, rendering the site's page (noindexed). Never a dedicated /404 route: an unknown path then matches only the root, TanStack's hydrate throws, and the page goes blank.
  2. The router options: createRouter({ routeTree, ...staticNotFoundRouting(NotFoundPage) }). A miss hydrates through its pending state first, so pending has to render the same page or React reports a mismatch.
  3. The build: a { path: "/404", prerender: { enabled: true }, sitemap: { exclude: true } } entry in the Vite pages list, and a copy of dist/client/404/index.html to dist/client/404.html.
// src/shared/components/NotFoundPage.tsx
import { NotFound } from "@atelic-action/ui/components";

export function NotFoundPage() {
	return (
		<SiteShell site={site}>
			<NotFound
				title="This page wandered off."
				lead="The link may be old, or the page may have moved."
				links={site.nav.filter((item) => item.to !== "/").map(({ label, to }) => ({ label, href: to }))}
				closing={{ eyebrow: "Back on Track", title: "Let's get you where you were headed." }}
				primaryCTA={site.cta}
			/>
		</SiteShell>
	);
}

staticNotFoundRouting is for prerendered sites only. On a live app the pending state is a real loading moment and the page would flash through every slow load, so an app sets defaultNotFoundComponent alone. A browser test is the only proof any of this works; template-marketing's e2e/not-found.spec.ts is the one to copy.

Primitives

Button, Chip, Eyebrow, Lead, and SectionHeading are the small pieces a page is set in, the same in every template. Each renders from props and wears a class of the same name (.btn, .chip, .eyebrow, .lead, .section-head), styled by primitives.css from the theme tokens.

import { Button, SectionHeading } from "@atelic-action/ui/primitives";

<SectionHeading eyebrow="Pricing" title="One number" lead="Flat and posted." />;
<Button href="/contact" size="lg" arrow>
	Book a visit
</Button>;

PrimaryButton and SecondaryButton (3.13.0) are the two buttons a marketing page uses, by name: the primary is the large primary button with the arrow, the shape CTASection closes every page with, and the secondary is the large ghost beside it, always second. A page reaches for those and never picks a variant and a size by hand, so no two pages drift; the header's compact button is the header's own. Button stays underneath for the few other shapes (a form's submit, a dark surface).

Button is an anchor when it has an href and a real button otherwise. It puts the base path from BasePathProvider on a root path href (0.19.0), so a site served under a prefix needs no wrapper; with no provider above it the href renders as given. Its arrow is hidden from the name a screen reader hears.

Bubble (3.11.0) is the round tile set beside a heading or a numbered step, holding either a lucide icon the caller sizes (icon) or a short mono index (num, such as "01"); given both, the icon sits inside and the number rides a small badge on the lower right rim, the way an unread count sits on an app icon. It comes in three sizes (sm, md, lg: 36, 48, and 60 pixels across, md by default) and lifts its fill for a dark band with onDark, the way Chip does. It is decoration and wears aria-hidden, so the heading beside it carries the meaning, the step's number included.

The Access Gate

The gate puts a whole host behind an email sign in with no database: a reader on the allowlist asks for a link, the link starts a signed session cookie, and the middleware holds every page and file back from anyone without one. It is opt in and fails open, so a host with no GATE_SESSION_SECRET is fully public.

Vercel finds these three files by path, so a site keeps them, each a re export:

// api/auth/request-link.ts
export { POST } from "@atelic-action/ui/gate/request-link";

// api/auth/verify.ts
export { GET } from "@atelic-action/ui/gate/verify";

// middleware.ts
export { default } from "@atelic-action/ui/gate/middleware";

// The platform reads the matcher from this file and nowhere else. Only the
// two sign in functions skip the middleware; any other function is held.
export const config = { matcher: ["/((?!api/auth/).*)"] };

The default middleware holds back everything needsSession does: all of a host but the login page, what that page loads (/assets/ apart from /assets/doc/, /fonts/, /brand/, the favicon and logo), everything under /api/auth/ (the two sign in functions, and the one place under /api/ where a host must add nothing private), robots.txt, health.json, and a website build proxied under /proto. A site whose matcher already leaves its open paths out passes its own test, and one whose link opens something other than a document names it:

import { createMiddleware } from "@atelic-action/ui/gate/middleware";
export default createMiddleware({ needsSession: () => true });

import { createRequestLink } from "@atelic-action/ui/gate/request-link";
export const POST = createRequestLink({ noun: "preview" });

| Env | Read by | What it does | |---|---|---| | GATE_SESSION_SECRET | all three | The signing secret, and the switch: unset, the gate is off. Use 32 random bytes or more | | GATE_ALLOWLIST | all three | Comma separated emails that may receive a link, or a whole domain as @example.com, which admits every address on it. Taking an entry off, then redeploying, refuses its outstanding links and ends their sessions | | RESEND_API_KEY | request link | The sending only key the link goes out on | | GATE_BASE_URL | request link | The host the link points at; a bare host is read as https, and plain http is refused unless it is local. Unset, production uses the project's own domain and a preview uses the deployment it was asked on | | GATE_FROM, GATE_SUBJECT, GATE_LINK_TTL_MIN | request link | Sender, subject, and link lifetime in minutes (15, a day at most) | | GATE_SESSION_DAYS, GATE_WATERMARK_MODE | verify | Session lifetime in days (7, a year at most), and pill or tiled |

What it holds to, each with a test in tst/gate/:

  • Give each host its own secret. A token carries no host, so two hosts sharing a secret would accept each other's sessions.
  • Tokens are compact JWTs under HS256, signed and verified by jose. The algorithm is pinned, and a link token is never accepted as a session or a session as a link.
  • The emailed link is never built from a forwarded header, since a caller can name any host there.
  • A link request answers the same way after the same two second wait whether or not the address was on the allowlist, and a send still running shortly before then is given up. A send that fails says so in the function's log and nowhere else.
  • The post sign in destination is held to a path on the same host, 512 characters at most (safeNext).
  • An open path opens only as written; the held corner is held in any case. Under /api/, only /api/auth/ is open.

What it does not do, by having no store: a link can be used more than once until it expires, and the code does not rate limit link requests. That is handled outside the code, by a Vercel firewall rule on /api/auth/request-link. Rotating the secret ends every session at once. The gate_email and gate_wm cookies that feed the watermark are readable and unsigned on purpose: the watermark is drawn in the reader's own browser, so it deters and attributes nothing a reader set on removing it could not already remove.

This is one of the two corners of the package that ship compiled (the other is the Vite entry, under The Artifact Vite Config). Vercel runs a function's and a middleware's package imports as shipped and compiles nothing inside node_modules, so bun run build emits src/gate/ to dist/gate/, and prepack runs it before every publish.

A Marketing Site's Matcher

A website's other functions (the contact relay) are public by design, so a marketing site keeps all of api/ and the login page away from the middleware and leaves the rest of the deciding to it (0.19.0):

// middleware.ts
export { default } from "@atelic-action/ui/gate/middleware";

// Everything but api/ and the login page reaches the middleware, which lets a
// request through with no session only when the wall's open paths name it.
export const config = { matcher: ["/((?!api/|login$|login/).*)"] };

The default middleware holds by allowlist: with the wall armed, a request passes with no session only when the wall's open exact paths and open prefixes name it (OPEN_PATHS in @atelic-action/ui/gate), which covers what a login page loads (its hashed assets, fonts, and favicon). Anything else is held, so a page whose last path segment carries a dot (/blog/release-2.0) or one asked for by its file name (/about/index.html) stays behind the wall, where the old matcher let any path with a file extension through. A site with an open file of its own adds it, and never opens the held corner by doing so:

import { createMiddleware } from "@atelic-action/ui/gate/middleware";
export default createMiddleware({ open: { exact: ["/apple-touch-icon.png"], prefixes: ["/icons/"] } });

createNeedsSession in @atelic-action/ui/gate is the same test on its own. A needsSession a site passes still wins over both, for a matcher that already leaves its open paths out.

The Screen and the Watermark

The two pieces a reader sees are ordinary components, in @atelic-action/ui/components, styled by @atelic-action/ui/styles/gate.css (import it after components.css):

// src/routes/login.tsx: the screen alone inside the site's scope, no header
<div className="atelic-ui">
	<GateLogin
		brandName={site.recipient.name}
		eyebrow={site.gate?.eyebrow}
		headline={site.gate?.headline}
		lead={site.gate?.lead}
		builtBy={site.sender.name}
		contactEmail={site.sender.email}
	/>
</div>;

// the site's shell, once, inside its `.atelic-ui` wrapper, so every page behind
// the gate wears it (the styles are scoped there, like every sheet's)
<Watermark />;

GateLogin posts to /api/auth/request-link; a site served under a base path passes its own endpoint. Its button wears the site's .btn classes, as the chrome's do. Watermark reads the gate_email cookie and draws nothing without one. readCookie, exported beside them, is the reader Watermark uses and a site's own code can: empty during prerender, for a missing cookie, and for one that will not decode. GateLogin says so when an address is not an email, holds a second submit while the first is in flight, and announces each outcome to a screen reader.

The Artifact Documents

@atelic-action/ui/artifact is the pieces an artifact document is built from and the site around it, so a fix to a piece reaches every business's documents with one version bump. The composed documents themselves (Writeup, Read, Report, Note, and the sealed Gate, with their content types and their stylesheet) live in @atelic-action/artifact since 2.0.0, which depends on this package for everything below:

  • The pieces: GradeChip and gradeLabel with the WriteupGrade type, SignOff with the WriteupPerson type, Shot with the WriteupShot type (a crop taller than it is wide is measured in the browser, marked data-portrait, and held to 200px wide on screen and 1.8in in print; a layout that sizes its shots itself sets --op-shot-portrait-max: none to opt out, on screen and in print alike), and the plain data text (Paragraph, Runs, ArtifactAnchor, and isEmpty, with the ArtifactLink and ArtifactParagraph types: a paragraph is a string or runs of text and { text, href } links, so a routine can write a document as JSON).
  • The shell: ArtifactShell, the .atelic-ui scope with the skip link, the dark header, the credit band, and the watermark around a page.
  • The contract and the head: the ArtifactConfig types, and buildPageHead and canonicalURL with their PageMeta type.
  • Who opened it: mintToken, sealReader, openReader, readerId, readerLink, and whoIsReading. The same six are also built and exported alone as @atelic-action/ui/artifact/readers, because a Playwright spec and a script run under Node, which compiles nothing inside node_modules.
  • The error boundary: AppErrorBoundary and its ErrorFallback.
  • newTabProps, for the links a content module writes.

The package imports no router, so ArtifactShell takes the path being read as its currentPath prop. A site keeps a wrapper a few lines long that reads the path from its own router and passes it down:

import { ArtifactShell as Shell, type ArtifactShellProps } from "@atelic-action/ui/artifact";
import { useLocation } from "@tanstack/react-router";

export function ArtifactShell(props: Omit<ArtifactShellProps, "currentPath">) {
	const currentPath = useLocation({ select: (location) => location.pathname });
	return <Shell {...props} currentPath={currentPath} />;
}

The pieces import no stylesheet of their own. A site imports @atelic-action/ui/styles/artifact.css in its base sheet after gate.css, then @atelic-action/artifact/styles/documents.css, and then its theme.css, so every page carries the rules whichever route it is. This sheet holds the pieces alone (the sign off, the shot, and the shell's logo rule; the grade chip's rules sit in components.css, since ReportCard wears it) and sets no @page; both @page rules, the monthly page's and then the writeup's, moved with the documents into their sheet, where their order stays load bearing.

The Artifact Vite Config

@atelic-action/ui/vite holds the whole Vite configuration of an artifact site, so a site's vite.config.ts is one call that names its pages:

// vite.config.ts
import { artifactConfig } from "@atelic-action/ui/vite";

export default artifactConfig({ pages: ["/the-read", "/proposal", "/report"] });

artifactConfig returns the TanStack Start plugin with prerendering on, the crawler following links but kept out of /images/ (where a prerendered page would overwrite a real file), one prerendered page for /, for each path the site names, and for /404, and no sitemap; the React plugin; privateChunks(); the @ alias to the site's src, resolved from the directory Vite is run in; and ssr.noExternal: ["@atelic-action/ui", "@atelic-action/artifact"], so the documents compile too. Nothing links to an artifact page, so a page a site leaves out of pages ships with no HTML in it. artifactPages is the list alone.

A site with a real need of its own passes extend, which is merged over the result with Vite's mergeConfig:

export default artifactConfig({
	pages: ["/the-read"],
	extend: { server: { port: 5180 } },
});

privateChunks is the plugin inside it, exported for a site that writes its own configuration: it writes every chunk carrying private content to /assets/doc/, where the access wall holds it, and fails the build when one lands anywhere else.

A vite.config.ts is run by Node as shipped, so src/vite/ is built to dist/vite/ like the gate. @atelic-action/ui/vite imports @tanstack/react-start and @vitejs/plugin-react when it loads, so a site that imports anything from it has both installed. A site that writes its own configuration and wants only the plugin imports it from @atelic-action/ui/vite/private-chunks (0.18.0), which loads nothing from Vite but its types and needs neither peer:

import { privateChunks } from "@atelic-action/ui/vite/private-chunks";

The Commands

The package installs the scripts every artifact site used to carry as files, so a site's package.json calls a command by name and holds no script of its own. Each reads the site it is run in from the working directory: src/site.config.ts, public/, and dist/client are the site's, found from where the command is run, which is the site's root.

| Command | What It Does | Where a Site Calls It | |---|---|---| | atelic-check-access | Fails the build when src/site.config.ts says access: "private", the build is on Vercel, and the wall's three variables are not set; warns on a local build of a private site | First step of build | | atelic-stamp-health | Writes dist/client/health.json with the status, the commit, and the build time | Last step of build | | atelic-check-links | Asks for every external link in the prerendered pages and exits 1 when one is dead | check:links, after a build | | atelic-check-wall <host> [path ...] | Asks a deployed host for each document and held file with no cookie, and passes only when the wall answers every one and the sign in functions respond | check:wall, before any private link goes out | | atelic-reader-link <email> [path] [--token <token>] [--qr \| --no-qr] | Mints a named reader: prints the sealed line for analytics.identify and the link to send, and writes public/images/qr.png for a public read's first reader | reader | | atelic-serve-static | Serves dist/client on 127.0.0.1 the way Vercel does, on PORT or 4173 | The webServer command in playwright.config.ts | | atelic-print-sheets <url or dist/client> <out.pdf> [sheets] [page] [port] | Prints a page to PDF with headless Chrome, reports the sheet count, and fails when a count is given and missed | print | | atelic-seal-document <config.json> [out.json] | Seals a document into the payload that @atelic-action/artifact's Gate reads, one wrapped key for each reader. The payload names no one: no address, and readers numbered r1, r2 whatever the config calls them | seal | | atelic-clean-sitemap | Strips fragment and trailing slash duplicates out of dist/client/sitemap.xml; says so and passes when a based build wrote none | A marketing site's build, after vite | | atelic-check-base-path | Fails the build when a root path in dist/client lacks the base path, or vercel.json lacks (or strays from) the two rewrites a based cut needs; a VITE_BASE_PATH rehearsal only warns about vercel.json off Vercel | Last step of a marketing site's build | | atelic-check-images | Fails the build when public/images holds an image over 250 KB, a PNG over 64 KB, or a JPEG that is not a sharing card (og.jpg or <name>-og.jpg): every photo ships as WebP at about 2x its rendered width (template-marketing's CLAUDE.md, Image budget) | First step of a marketing site's build | | atelic-contact-canary | Posts a token bearing test message to $SITE_URL/api/contact and passes only on a Resend id and the expected destination (CONTACT_EXPECTED_TO, CONTACT_CANARY_TOKEN); a .invalid SITE_URL is the off switch | check:contact, run daily by the canary workflow |

A site's scripts, whole:

{
	"build": "atelic-check-access && tsc -b && vite build && cp dist/client/404/index.html dist/client/404.html && atelic-stamp-health",
	"check:links": "atelic-check-links",
	"check:wall": "atelic-check-wall",
	"reader": "atelic-reader-link",
	"print": "atelic-print-sheets",
	"seal": "atelic-seal-document"
}

The logic lives in src/scripts/ as modules with their functions exported, which is what the tests import, and each file in bin/ only reads the command line and calls one. The TypeScript commands open on #!/usr/bin/env bun and run as source, since bun runs TypeScript inside node_modules. The sealing command is plain .mjs under #!/usr/bin/env node, its logic in src/scripts/seal-document.mjs (named build-gate-payload.mjs before 0.18.0), and the print command is bash; it needs Chrome (the CHROME variable names another binary), python3, and curl.

Sections

@atelic-action/ui/sections is the marketing template's page library, moved here in 0.19.0 (ATE-643) so a fix reaches every website with one version bump. A section renders from props alone: the copy, the images, and the links come in as data, and the business's facts come in through a site prop typed from the SiteConfig contract, which the entry exports. A section that reads the config takes only the keys it uses (MapEmbedSite, LocationSectionSite, ContactInfoCardSite, Pick<SiteConfig, "cta"> for CTASection), so a whole SiteConfig passes as it is and an app can pass less. No section imports a router, a site file, or a stylesheet.

import { SiteShell } from "@atelic-action/ui/chrome";
import { CTASection, Hero } from "@atelic-action/ui/sections";

<SiteShell site={site} currentPath={pathname} heroNav>
	<Hero title="Bread worth the early line." primaryCTA={site.cta} />
	<CTASection site={site} title="Skip the line, keep the bread." />
</SiteShell>;

The Stylesheet

Every rule sits in @layer atelic-ui and answers to .atelic-ui. A site imports @atelic-action/ui/styles/sections.css in its base sheet right after primitives.css and before its own sheets, so the order runs fonts, base.css, chrome.css, components.css, primitives.css, sections.css, gate.css, the site's own CSS, then theme.css. The five marketing primitives' styles joined primitives.css. A section that composes another sits after it in the sheet, the order the template's bundle gave them. One change against the template: a contact card's value column may break a long email address rather than push the page wider than a phone.

Moving into the layer changes one thing for a site: a rule in its own unlayered CSS now wins over a section's rule whatever the specificity, where before the section's rule could win by being more specific or later. Polish a site made to a section moves to patterns.css and keeps working; a site rule that only ever lost to a section's should be checked against the page.

The Base Path

A site served under a prefix (/proto) says so once. BasePathProvider (from @atelic-action/ui/routing, a React context whose default base is "") hands the base to everything below it, and Button, Chip, ImageFrame, and every section that builds a link or an image source read it through useBasePath and useWithBase. A root absolute path gets the prefix; an external URL, a protocol link, a fragment, and a path that already carries the base pass through. SiteShell mounts the provider from its basePath prop, which defaults to site.basePath, so a page rendered inside the shell needs nothing more. The pure helpers (normalizeBasePath, joinBase, stripBase, hasBase) are exported beside it for a site's own code.

@atelic-action/ui/routing is TypeScript source like the rest, so Node cannot load it, and a Playwright spec runs under Node. The four helpers are therefore also built and exported alone as @atelic-action/ui/routing/base-path (1.0.0), the way the readers are, and a site's e2e/ imports them from there:

// e2e/base.ts
import { marketingBasePath } from "@atelic-action/ui/vite";
export { hasBase } from "@atelic-action/ui/routing/base-path";

The provider stays in the source entry on purpose. A built copy of it would be a second React context, and a site mounting that copy would hand its base to none of the sections, which read the source one.

SiteShell

SiteShell (in @atelic-action/ui/chrome) is the page wrapper: the .atelic-ui scope around the skip link, the header, <main>, the sticky CTA bar, the footer with its credit band, and the watermark, all mapped from the SiteConfig. It takes site, currentPath (the package imports no router), basePath, and nav (defaults to site.nav), plus heroNav, stickyCTA, footer, pageNav, navOnDark, hideNavCTA, and credit as before. A site keeps a wrapper a few lines long:

import { SiteShell as Shell, type SiteShellProps } from "@atelic-action/ui/chrome";
import { useLocation } from "@tanstack/react-router";

export function SiteShell(props: Omit<SiteShellProps, "currentPath" | "basePath">) {
	const currentPath = useLocation({ select: (location) => location.pathname });
	return <Shell {...props} currentPath={currentPath} basePath={import.meta.env.BASE_URL} />;
}

Every Section

| Section | What it is | |---|---| | Hero | The home page opener: a full bleed image (or the token gradient), a headline with a serif accent, a pair of actions, trust figures, and a scroll cue | | PageHero | The interior page opener on the alternate band; children sit under the lead | | LogoMarquee | A dark band of chips scrolling without end: credentials, clients, or press | | SplitSection | The two column band: copy beside copy, or media beside copy. ratio is even, narrow-first, or media, a fixed 420px shot column and the gap a card row uses, so a photo beside copy lines up with a case study entry above it; even and narrow-first stack below 920px, media below 761px | | PricingBand | A brand colored band with the price in a card beside it, informational by design | | MediaBand | A plain full bleed image band, cropped from the center | | CTABanner | A mid page banner behind a directional wash, with the token gradient before any photography | | CTASection | The standard closer: the site's own primary action and an optional ghost second | | FeatureGrid | Numbered cards with a hover lift; a card is a link or holds one, never both | | FeatureDetail | Alternating copy and media rows with tags and a tick list | | ProcessSteps | A dark band of numbered steps beside a frame | | PricingCard | A standalone price card with a tick list and its booking action | | MenuList | A priced menu group of dotted rows, with fine print under it | | ExpandList | A light accordion of numbered rows whose bodies stay in the HTML while closed | | ComparisonTable | A graded before and after that scrolls sideways on a phone rather than reflowing | | RevealSlider | Drag or arrow keys to compare two frames, each with its own toned shot brief before photography | | DeviceShowcase | A desktop browser frame with a phone overlapping it | | ReviewCarousel | The aggregate beside one large review at a time, stepped by dots and arrows, each review linking to its source | | ReviewWall | The reviews page: tag filter and sort when the data carries them, clamped quotes behind Read more | | PressQuotes | Rows of press quotes and awards with source and year, newest first (sortPress beside it) | | ProfileCards | Equal weight cards of media beside a short bio | | Gallery | A captioned wall of the business's own work, a shot brief where a photo is missing | | CredentialList | Credential cards with a mono code before each | | SocialStrip | A static grid of the business's social photos linking to the profile, never a live embed | | LocationSection | The local search section: service areas as prose, hours, directions, and the map | | Locations | More than one address: the flagship's map beside a card for each location | | MapEmbed | The map: a screenshot linking to directions, or a click to load facade over the live embed | | HoursTable | Day rows with dashed rules and mono times | | ContactForm | Validated form with a honeypot that posts to endpoint (or hands off to mail); onLead replaces the default GA4 generate_lead event | | ContactInfoCard | A dark card of direct contact rows: call, email, visit, portal | | FAQBrowser | The help page: live search, a sticky JumpRail of categories with scroll spy, expand all, deep links; contactHref sets where the no match line points | | JumpRail | Sticky in page pill tabs with scroll spy, used by FAQBrowser and on its own | | BlogIndex | The post listing as a readable stack | | BlogPost | One article: back link, title, byline and dateline, body | | LegalPage | A legal document from data, its last revised date first |

The primitives that came with them, in @atelic-action/ui/primitives: ImageFrame, ProseBlocks, QuoteCard, StarRow, and TickList, with Bubble (see Primitives) shown beside them in the gallery. StarRow stays beside StarRating on purpose: it draws only the filled stars a review shows, in whole steps and the brand color, named "4 out of 5 stars", where StarRating draws the whole scale with outlines in half steps and can take a rating.

The entry also exports the helpers the sections use, for a site's own code: formatClock, formatHoursRange, and isClosed for hours, isHoneypotFilled, and trackEvent.

Hours print on the 12 hour clock unless the site's config sets clock: "24h", which turns "9a to 5p" into "09:00 to 17:00" everywhere hours appear: the footer through SiteShell, LocationSection, and HoursTable (which takes clock as a prop when used on its own). formatClock and formatHoursRange take the same clock option for a site's own code.

The Marketing Vite Config and Commands

// vite.config.ts
import { marketingConfig } from "@atelic-action/ui/vite";
import { site } from "./src/site.config";

export default marketingConfig({ site, pages: [{ path: "/blog/first-post" }] });

marketingConfig carries what the template's vite.config.ts did: Vite's base and the router's basepath from site.basePath (or a VITE_BASE_PATH rehearsal), a refusal when a based site.url does not end with its base, TanStack Start prerendering with the crawler and the 404 page (copied to 404.html by the build), the pages a site names, a sitemap on the origin of site.url (sitemapHost to change it) and none for a based cut, React, the @ alias, and ssr.noExternal. extend merges a site's own needs over it. It leaves build.cssCodeSplit at Vite's default (1.0.0): every sheet rides in the site's one base sheet, so no component imports a stylesheet, and with the flag forced off TanStack Start fails the build looking for a style.css that is never written. A site that gives a component its own stylesheet again passes extend: { build: { cssCodeSplit: false } }. A site's scripts:

{
	"build": "tsc -b && vite build && cp dist/client/404/index.html dist/client/404.html && atelic-clean-sitemap && atelic-stamp-health && atelic-check-base-path",
	"check:links": "atelic-check-links",
	"check:contact": "atelic-contact-canary"
}

playwright.config.ts serves the build with atelic-serve-static, which serves a based build both under its prefix and without it, as the template's copy did.

Thinning a Marketing Site

A site that takes 0.19.0 deletes its copies and repoints its own files. The template's src/components/sections/, the five primitives and their sheets, the Button and Chip wrappers, chrome/SiteShell.tsx's body, siteConfig.ts, and the scripts all go; a route or section the site added that imports one of them repoints:

| Old import | New import | |---|---| | @/components/sections/<Name> (all 35, their types included) | @atelic-action/ui/sections | | @/components/primitives/Button, Chip | @atelic-action/ui/primitives (the wrappers die; the base comes from BasePathProvider) | | @/components/primitives/ImageFrame, ProseBlocks, QuoteCard, StarRow, TickList | @atelic-action/ui/primitives | | @/components/chrome/SiteShell | The site's few line wrapper above, around SiteShell from @atelic-action/ui/chrome | | @/components/siteConfig | @atelic-action/ui/sections (SiteConfig, NavItem, CTA, OpeningHours, Address, Founder, GateConfig, SiteLink, SocialLink) | | @/components/newTabProps | @atelic-action/ui/artifact (newTabProps) | | @/shared/lib/hours | @atelic-action/ui/sections (formatClock, formatHoursRange, isClosed) | | @/shared/lib/honeypot | @atelic-action/ui/sections in a component; api/contact.ts keeps its own copy, since Vercel runs a function's package imports uncompiled | | @/shared/lib/analytics (trackEvent only) | @atelic-action/ui/sections; the rest of the module, the click listener, stays in the site | | @/shared/lib/basePath (normalizeBasePath, joinBase, stripBase, hasBase) | @atelic-action/ui/routing; in a component, useWithBase() in place of withBase; in e2e/, @atelic-action/ui/routing/base-path | | @/shared/lib/basePathAudit, @/shared/lib/sitemap | Gone, inside atelic-check-base-path and atelic-clean-sitemap | | scripts/clean-sitemap.ts, check-base-path.ts, contact-canary.ts, stamp-health.ts, check-links.ts, serve-static.ts | atelic-clean-sitemap, atelic-check-base-path, atelic-contact-canary, atelic-stamp-health, atelic-check-links, atelic-serve-static | | vite.config.ts | marketingConfig, above | | The import "./<name>.css" in each component | @atelic-action/ui/styles/sections.css in the base sheet, after primitives.css | | middleware.ts | The marketing matcher in The Access Gate |

The .mkt wrapper a site's own routes render becomes .atelic-ui, the scope every package sheet answers to.

Email

@atelic-action/ui/email is the runner email design as React components, ported one to one from the jq library the runners compose their mail from (homebase runners/lib/email.jq). The components are born inline: every layout is a table, every style is an inline style object, and there is no CSS file and no className, because Gmail strips a style block and ignores media queries on some accounts. The same components therefore mount on a web page as happily as they render into a mail client.

Colors and fonts come from context, so a client branded email passes its own palette:

import { Card, Eyebrow, Footer, Item, Masthead, TitleCard } from "@atelic-action/ui/email";
import { renderEmail } from "@atelic-action/ui/email/render";
import { atelicPalette } from "@atelic-action/ui/tokens";

const html = renderEmail({
	title: "Week 40 Recruiter",
	preheader: "Three roles cleared every filter.",
	palette: { ...atelicPalette, accent: "#0B6E4F" },
	children: (
		<>
			<Masthead title="Recruiter" />
			<TitleCard
				eyebrowText="Week 40"
				headlineLines={["Three cleared", "every filter."]}
				lede="Two of them are remote."
			/>
			<Eyebrow text="Shortlist" />
			<Card>
				<Item
					name="Pinewood Cabinetry"
					right="4.5"
					subparts={["Staff engineer", "Remote"]}
					body="They answered inside a day."
					linkText="Posting"
					url="https://example.test/posting"
					last
				/>
			</Card>
			<Footer meta="Run 2026-10-05" />
		</>
	),
});

Mobile First

Runner emails are mobile first. A layout that squashes on a phone is fixed here, in the components every runner shares, never in a report page. A fixed column table is for numbers alone and never carries a name; names go in a RecordStack.

RecordStack takes records, each a RecordStackItem (title, url, meta, note, badge, callout), and renders one table row per record: the title on its own line at 15px, bold and wrapping freely, linked when url is set; a mono meta line beneath it with the items joined by middle dots (empty items drop); an optional note beneath that; a hairline between records and none after the last. No cell carries a width, so a name of any length wraps at 320 pixels instead of squashing. An optional badge (a stage word such as "Contacted") sits on the title line in a small mono uppercase pill on a line hairline, in its own right aligned unbroken cell, so the title keeps wrapping beside it and the badge stays on the title's first line. An optional callout ({ eyebrow, text }) sets a short task under the meta and the note in the accented Note idiom: the eyebrow in the accent, the text at 13px, and a 2px accent rule down its left edge. A record with neither renders exactly as it did before. recordStackText(records) is its plain text twin: each title on its own line under a two space indent with any badge after it in square brackets (Blue Heron Plumbing [Contacted]), the meta and the note wrapped under a four space indent, any callout as its eyebrow in upper case over its text under that same indent, a blank line between records, and no line past textWidth (68 columns, indent included).

The Company Cards additions (0.7.0, from the Company Cards IA design, 2026-09-29) ride on the same item and change nothing for a record without them. badges is a list of further pills after badge, each with a tone: faint is the quiet stage word, ink a bold pill in ink on a 1.5px border (the next touch), accent the same in the accent (New). timeline ({ days, touches, opens }, each day count back from today) draws the record's recent days between the title and the meta line as one table cell per marked day and one per run of empty days between them (seven cells for three marks, never thirty, since Gmail clips a message past about 100 KB), an 8px ink square on each send day and a 5px accent dot on each known open day, the two stacked when they share a day, all standing on a line hairline with a 1.5px ink tick at today's edge and an axis row beneath (30d on the left, today on the right); nothing is positioned, so it survives Gmail. RecordTimelineStrip renders one on its own and RecordTimelineLegend names the marks once above a list. aside ({ text, strong, tone }) sets a fact right on the meta line with its number in bold and colored by tone: up fresh, warm cooling, accent cold. In the text twin every pill follows the title in brackets, the timeline is one character per day (10d [#······@·o|]: # a send, o an open, @ both, · nothing, | today), and the aside ends the meta line; recordTimelineText and recordBadges are the helpers it uses.

StatStrip lays its stats out as inline block cells with an 88 pixel floor inside one centered cell, so six or seven stats flow onto a second row on a phone rather than shrinking. Each stat takes an optional delta ("+3 (12%)"), set small under its label and colored by its sign: one that opens on + wears the palette's up, one that opens on - its down, and anything else stays muted.

Masthead takes an optional meta ("Week 39 · 09/21 to 09/27") set in the eyebrow style at the right end of the row, so the title card no longer carries the week. It is a right aligned, unbroken block floated after the wordmark and title: on the same line while the row has room, and dropped under the title on its own line, still right aligned, when it does not (checked at 320 pixels). Eyebrow takes strong, which sets the label at 15px and weight 700 in ink rather than faint, with a slightly tighter tracking, so a section such as MQL · 15 reads as a heading on a phone.

const records = [
	{
		title: "Pinewood Cabinetry",
		url: "https://example.test",
		meta: ["Lead", "Longmont", "fit 14"],
		note: "Answered the audit inside a day.",
		badge: "Contacted",
		callout: { eyebrow: "Next", text: "Walk the Business Profile findings Thursday." },
	},
];

<Masthead title="Pipeline" meta="Week 39 · 09/21 to 09/27" />;
<Eyebrow text="MQL · 15" strong />;

<Card>
	<Row last={false}>
		<StatStrip stats={[{ n: 6, label: "Lead", delta: "+3 (12%)" }, { n: 2, label: "MQL", delta: "-1 (4%)" }]} />
	</Row>
	<Row last>
		<RecordStack records={records} />
	</Row>
</Card>;

const text = recordStackText(records);

renderEmail builds the document shell itself and puts only the rows through React, because React emits no doctype, React 19 hoists and reorders head tags, and it would escape the > in details>summary. renderFailureEmail is the same shell around FailurePage. Both live at @atelic-action/ui/email/render, apart from the components, so a site that mounts a component on a page never pulls React's server renderer into its browser bundle.

The Mapping

Where the jq takes a pre rendered html string ($rows, $body_html, $cells_html, a records cell's html, a stat's caption), the React prop is children or a ReactNode. Where the jq escapes a string argument, the prop is a plain string and React does the escaping.