The structure a Storeware theme renders into: the stylesheet the bands are drawn with, and the shape of the data a renderer fills before it can draw them.
@storeware/theme-schema
says what a theme may be. This is the other half. Without it an agency can author
a valid theme and has no way to see what it will look like, and anybody building
a preview, a screenshot service or a second renderer is reverse engineering a
stylesheet out of rendered HTML and will be wrong about the parts that are empty.
npm install @storeware/theme-kit @storeware/theme-schemaTwo packages, and this one declares no dependency on the other. That is deliberate rather than an oversight. A renderer uses both, so a peer dependency looked obviously right and was written, and then removed: nothing in this kit imports the schema. The stylesheet is text and the data contract is generic over your own types, so the two are companions rather than layers. A dependency no line of code needs is a claim about the architecture that the architecture does not make, and it costs you an install of something you may not want.
import '@storeware/theme-kit/sections.css';Grids, containers, band rhythm, aspect ratios, and the rules behind every preset a section offers. Everything a theme is expected to change reads a custom property, so a theme restyles a shop completely without touching a rule in here.
Not everything is a custom property, and the honest version is worth having.
Roughly two dozen literals remain, mostly small structural quantities that no
theme has ever wanted to set: a play button's triangle spelled as a transparent
border, the type sizes of a countdown's digits, the gap inside a step marker.
One of them is a colour, color: #fff on the video overlay, and it is a real
loose end rather than a considered exception. Treat the rule as "values a theme
sets are properties", not as "there are no literals".
That division is the whole reason the sheet can be published. Shape is what every Storeware shop has in common. Value is what makes them different from each other.
The properties are the host's to declare, and thirty-four of them are read with no fallback. A page with no token layer beneath this sheet renders without them, so this list is the real installation instruction:
Colour --sw-bg --sw-surface --sw-fg --sw-muted --sw-line
--sw-line-strong --sw-accent --sw-accent-fg
Shape --sw-radius --sw-radius-full
Space --sw-space-1 through --sw-space-7, --sw-space-section
Type --sw-font-display
--sw-text-display-size --sw-text-display-leading --sw-text-display-tracking
--sw-text-headline-size --sw-text-headline-leading --sw-text-headline-tracking
--sw-text-title-size --sw-text-title-leading --sw-text-title-tracking
--sw-text-heading-size --sw-text-body-leading
--sw-text-small-size --sw-text-caption-size --sw-text-caption-tracking
--sw-text-ui-size
Eight of those are exactly what themeCss in @storeware/theme-schema emits
from a merchant's tokens, so wiring a theme to this sheet is one <style> block.
The rest are the host's own scale, and they are yours to choose.
Every other property the sheet reads carries a fallback equal to the literal
that was in the rule before the property existed. So a shop that has arranged
nothing sets none of them, every var() falls through, and the page renders what
it rendered yesterday. --sw-card-bg falls back to --sw-surface,
--sw-grid-gap to 1.5rem, --sw-image-ratio to 4 / 3. That rule is trivial
to break by accident and silent when broken, so it is asserted rather than
remembered.
The two structural hooks are .sw-section, the wrapper one band gets, and
.sw-band, the container inside it. Presets resolve to attributes on the
wrapper: presetAttributes in the schema package turns a merchant's
"preset": "image_scrim" into data-sw-layout="overlay" and
data-sw-emphasis="media", and the rules for those axes live here. Seven layout
groups and two emphasis groups rather than one group per preset name, so two
bands that mean the same thing get the same rules by construction instead of by
somebody remembering to keep two blocks in step.
The emphasis axis has three values and only two of them have rules. balanced
is the default and deliberately emits nothing, so a band nobody has re-presetted
carries no attribute and its markup is unchanged. Our storefront asserts that
absence rather than tolerating it.
import './globals.css';
import '@storeware/theme-kit/sections.css';After the token sheet, because of the thirty-four properties above. And after your own stylesheet, for two reasons worth being precise about, since "put it last" is otherwise the kind of instruction people reorder while tidying imports.
An application never styles one of these classes unqualified. The two sheets
do overlap: our own storefront styles .sw-section, .sw-card, .sw-hero-image
and .sw-hero-copy, and that overlap is the design rather than a leak, because
those classes are the vocabulary and an application's art direction has to reach
them. What makes the order safe is that it only ever reaches them through an
attribute on :root that it owns, which is strictly more specific than the bare
class declared here.
So the rule is not "disjoint class names". It is that anything of yours which touches a class in this sheet must outrank it, and then loading this sheet last cannot take a decision away from you. Our storefront asserts exactly that in a test rather than assuming it, because the argument only holds while it stays true. A first version of that test asserted the disjoint version and failed immediately on those four names.
A host's motion and imagery rules must stay more specific than everything in here. They reach the same elements through attribute selectors, and loading this sheet last is what keeps that layering intact.
The stylesheet is a subpath rather than something the entry point imports, so a consumer reading only the types pays nothing for it, and so load order stays the host's decision.
Two whole categories are absent, and in both cases the absence is the feature.
No chrome. No header, no footer, no navigation, no basket, no checkout, no
assistant, no trade band. Those belong to one shop's application rather than to
any shop's sections, and publishing them would invite a consumer to build against
the shape of our storefront and then be broken by us changing it. Our test suite
asserts that sw-header, sw-footer, sw-nav, sw-checkout and sw-assistant
are not in this file, because a boundary is worth more as a check than as an
intention.
No values. The art-direction attributes, the motion keyframes and the imagery treatments stay with the platform. They are this platform's design decisions expressed as CSS, and a published package changing one of them would be a breaking change for every theme ever authored against it. Density, grain, the easing of a reveal: those are ours to revise, and they are revised often enough that publishing them would make this package unshippable.
The registry in @storeware/theme-schema declares what each band needs. A
products band needs products, a featured_product band needs one by handle, a
blog_posts band needs recent posts of a named blog. dataPlanFor() coalesces
those needs into one plan for a whole page. KitSectionData is the shape that
plan is filled into.
import { NO_KIT_DATA, type KitSectionData } from '@storeware/theme-kit';
const data: KitSectionData = { ...NO_KIT_DATA, products: myProducts };That division is the design, and it was arrived at by being wrong first.
The obvious version of this file declared a KitProduct with five fields, a
KitArticle with an excerpt and a reading time, and so on, on the theory that a
host's richer types would satisfy them structurally. A compile-time assertion in
our storefront then held that theory against a real platform's own data, and it
failed three times running: alt is string | null where the guess said
string | undefined; the article card has a path and no blogHandle; the
review has an authorName and no author. Every one of those read plausibly.
The lesson is not that the guesses needed more care. It is that a fixed element shape can only ever be a guess, because the fields a band reads belong to whoever fetched them.
What genuinely is the same for every renderer is the shape of the collection: maps keyed by handle rather than arrays, one entry per name rather than one per band, reviews as a nullable pair of list and summary. That is what is worth publishing, and it is what is published.
So KitSectionData is generic over your own types, each parameter constrained by
the smallest thing a band actually reads and defaulted to that minimum.
KitProductLike asks for an id, a handle and a title, which is enough to link to
a product. KitImageLike asks for a URL. KitArticleLike asks for a path and a
title, a path rather than a handle because an article's URL depends on which blog
it belongs to and only the host knows how it builds one.
type ShopData = KitSectionData<MyProduct, MyCollection, MyArticle>;A host with rich types passes its own objects with no adapter and no cast. A host with nothing but a JSON file satisfies the same constraints with three-field objects. Neither is describing the other, and widening a host's type can never break this package.
Wherever a band looks something up by name, this is a map. Two bands featuring the same collection must be one read rather than two, and that is the property the whole arrangement exists to protect: adding a band to a page adds no query unless it asks for something no other band already wanted. Two product grids are one read. A hero and a gallery of twelve pictures are one read. A page of prose is none at all.
The alternative, each band fetching for itself, reads better in isolation and is an N+1 on every page of every shop. It arrives as a gradual slowdown with no single change to point at.
NO_KIT_DATA is a complete empty fill, so a host can render a page before
anything has loaded. Every band must draw nothing rather than throw when its data
is absent, which is the rule our own loader follows: a read that fails takes its
own band down rather than the page. A featured product whose handle was renamed,
a gallery with no pictures chosen yet, a shop with no reviews. All ordinary.
.sw-section:empty hides the wrapper a band left behind, so an unfinished
template reads as a shorter page rather than as a shop with holes in it.
Two smaller pieces complete the contract. KitPageSubject says what the page is
about, for the bands that read that rather than a setting: a reviews band names
no product and means "this one", and only the route knows which. KitTranslator
is a plain (key, vars) => string, so a host with its own catalogue or with none
satisfies it, and returning the key itself is a legitimate implementation that
renders a readable, if untranslated, page.
The rule the schema keeps, kept here too. Class names are added and never renamed. A custom property is read with a fallback unless the token layer is required to declare it. Once a rule is in a published package, changing it breaks every theme authored against it. Loosen freely, tighten never.
Unlike the schema, this half has no executable ratchet, and it is worth saying so rather than implying one. What is checked mechanically is the boundary: that the two stylesheets never style the same class at the same specificity, that the chrome stays out of this file, and that every property read without a fallback is one the token layer declares.
One thing to know before editing the sheet. It was extracted verbatim from our
storefront's globals.css, in the order it appeared there, and the order was
preserved rather than tidied. Six pairs of rules in it are specificity-identical
and work only because the second comes after the first. The reduced-motion
fallbacks for the marquee are the pair that matters, and gathering every
prefers-reduced-motion block into one place would turn them off.
Apache-2.0. Copyright 2026 Crawlog LLC. The full text is in LICENSE, and
NOTICE travels with it as the licence asks.
Apache-2.0 rather than the MIT of theme-schema, on purpose. The schema is a
specification, and MIT is right for one: a competitor implementing it makes
it the interchange format for storefront layouts, which is worth more than the
exclusivity. This is an implementation. It is the code that draws a shop, and
Apache's express patent grant is worth having on code that does the work rather
than describes it. The grant terminates for anyone who sues over it, which is a
protection MIT does not offer at all.
The direction matters. MIT is permissive enough that this Apache package can depend on that MIT one without friction, and a consumer combining them takes on Apache's terms for this half and MIT's for the other. The reverse would not have worked: an Apache specification would have put its patent and attribution terms on every implementation of the format, which is exactly the friction a format exists to avoid.