Skip to content
storeware-aiPublic

About

The structure a Storeware theme renders into: the section stylesheet and the data contract a renderer fills.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

@storeware/theme-kit

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-schema

Two 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.

The stylesheet decides shape, and never decides value

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.

Load order, which is load-bearing

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.

What it deliberately does not contain

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 data contract

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 };

The structure is published and the element types are not

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.

Maps, not arrays

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.

Rendering nothing is normal

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.

Compatibility

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.

Licence

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.

About

The structure a Storeware theme renders into: the section stylesheet and the data contract a renderer fills.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages