site-kit
What every Caltho site is built with, published to npm as @caltho/site-kit. Each client's site is its
own repository with its own design, started from site-starter.
The kit is not a design system. It's the contract that lets CalOS manage any of those sites, and the
plumbing every one of them needs.
The idea
A site's content (its pages and what's on them) lives in CalOS, and the owner edits it there. Publishing in CalOS rebuilds the site with that content: no one pushes code to change a page. The design, meaning how a block looks and what kinds of block there are, is the site's own code.
For CalOS to edit a site it hasn't seen, the site declares what's editable. That's all this takes:
// content/schema.ts
import { defineSite, image, link, text, z } from "@caltho/site-kit/content";
export default defineSite({
hero: z.object({ heading: text, text: text.optional(), image: image.optional(), actions: z.array(link).default([]) }),
faq: z.object({ heading: text, items: z.array(z.object({ q: text, a: text })).min(1) }),
// …whatever this site's design has
});
- A page is
path,title,description, an optionalmenulabel, and a list of blocks. A block is one of the kinds the site declared, with the fields it declared. site-kit preparechecks the content against that, in every build, and says every problem with where it is (pages[0] (/) › blocks[1] (faq) › items[0] › q). A build never publishes a page with a hole in it.- It also writes
.site/schema.json, the declaration as JSON Schema. CalOS draws its editing forms from that: text boxes, picture pickers (with required descriptions), lists to add to, reorder and remove. Add a kind of block to a site and it appears in CalOS with no work on CalOS's side. - A new page is a new entry in the list with a path; the site's catch-all route renders it.
Editing on the page. A site gives CalOS a page at /calos-preview that draws a draft with the
site's own components, as CalOS sends it (SitePreview from @caltho/site-kit/next/preview):
// app/calos-preview/page.tsx
import { previewOrigins } from "@caltho/site-kit/next";
import { Preview } from "@/components/Preview"; // "use client": <SitePreview origins={origins} render={(page) => <SitePage page={page} />} />
export default function CalosPreview() { return <Preview origins={previewOrigins()} />; }
Components read content only through the kit (site(), image()), so the same components draw the
built pages (from .site/prepared.json) and the draft (from CalOS). A click on the preview is matched
back to the field it meant with nothing added to the components. Which block a click landed in is
exact where the page puts <BlockMark at={i} /> (from @caltho/site-kit/next) before each block it
draws: a block may then draw nothing (a list with nothing in it yet) or several elements. It draws
nothing on the live site. Without marks, blocks are found among main's children, by their words
when there isn't one for each. Only the business's own CalOS
addresses, which the build is given with the content, may frame the page or send it anything.
Forms are separate and optional. A block can post to any address (a CalOS form, another service, or none); the kit only checks it's a URL.
What's in it
| part | what it does |
|---|---|
Content (@caltho/site-kit/content) | defineSite and the pieces to build blocks from (text, md, image, link, href, business, …); the checks, including that every link lands on a page and every #part exists; the JSON Schema. |
| prepare | Loads the content from CalOS (CALOS_CONTENT_URL, CALOS_SITE_TOKEN; the site's own while nothing's published there) or content/site.json, sends CalOS the schema, checks it, writes the schema, and makes every picture the content names as WebP at the widths screens need plus a 1200×630 share crop, named by content so they cache for a year. |
| finish | After the framework's build: a content security policy in every page (each inline script named by hash), _headers (security headers, long caching, noindex on preview addresses), and _redirects from CalOS (CALOS_REDIRECTS_URL; a build that can't reach CalOS fails rather than lose them). Where CalOS counts the site's visits (its content answer says where they go), the analytics script in every published page but the preview, with its addresses in the policy, and phone and map links marked so a tap is counted (phone_tap, directions_tap; a link marked data-track already is left as it is). No cookies. The standard privacy policy then leaves out its claim of no analytics, saying nothing in its place: what to say of the counting is the business's (extra). |
| check | Every page in a real browser on a computer and a phone: fails on anything the policy blocks and anything that doesn't load. What axe finds (WCAG 2.0–2.2 A and AA, best practice) is said with where, for the site to weigh (a colour the business chose over the contrast rule is their choice); --strict fails on it too, --allow=rule,rule leaves named rules out. |
| Search | Titles that fit, canonical addresses, the business as schema.org data (hours, areas, address), FAQ and breadcrumb data, robots.txt, the sitemap. |
| Policies | Privacy, accessibility and terms, written from the business's facts and what the site does. Optional: a site calls policy() where it wants them. |
Helpers for components (@caltho/site-kit/lib) | The pieces that touch no files: md (Markdown that can't run HTML), plain, policy, the search data, imagesIn, clock, dayRange, telHref. |
Next.js helpers (@caltho/site-kit/next) | The prepared content, a page by its path, page metadata, Img (the prepared widths), Md (Markdown that can't run HTML), JsonLd, BlockMark (where each block starts, for the preview), previewOrigins. The content is a store: on the server it reads .site/prepared.json; in the preview, what CalOS sends. |
Preview (@caltho/site-kit/next/preview) | SitePreview: CalOS's draft drawn with the site's own components, a click matched to its field, outlines with each section's name. |
Schema (@caltho/site-kit/schema) | What CalOS needs of a declaration with no site code: validate content against the JSON Schema with messages a person can act on, make a blank block or entry, list the kinds of block, and the checks across pages. |
Sections
Sections any site can use, built once here (accessible, sized for each screen, inside the content security policy) so a site gets them with a kit update rather than code of its own. CalOS offers them in its editor like the site's own, and Cal can add one. Today: gallery (photos in a grid, each with an optional caption, opening its largest copy; 2 to 4 across, square, landscape, portrait or as taken) and slideshow (photos one at a time, fading from one to the next, with captions).
Your own section (own) is built in CalOS rather than in code: a heading and intro over one to
four columns (or a wide one beside a narrow one), each a list of parts (heading, text, photo, button,
list, quote, facts in a row, price, photos in a grid, slideshow, space, table, icon, logos, video),
left or centred, plain or in the brand colour. A column can be drawn as a card (card: true). Its
parts wear the site's own classes, so what's built looks like the site.
- table: 2 to 5 columns (the first over the rows' labels, its heading may be ""), 1 to 20 rows,
a column picked out (
highlight, counting from 1 after the labels). A cell "yes" or "no" is a tick or a dash, read out as the word. A real table from 640px up; below, each row a small card. - icon: one of the kit's line icons by name (
ICON_NAMES: check, calendar, clock, users, …), drawn inline in the brand colour, small, medium or large. - logos: 2 to 12, in a row in grey, with a line above if it has one.
- video: a YouTube or Vimeo address and what the video is (its title, for a screen reader), shown
in YouTube's no-cookie player or Vimeo's with tracking off.
finishlets the player in (frame-src) only on a page that shows one, and in the preview. One that's wanted again is saved by name in the content'ssavedlist, and CalOS offers it beside the site's own kinds.
Slideshow (from @caltho/site-kit/next) is also a building block a site's own sections use: SCV's
hero moves through its photos behind its words with <Slideshow fill label="…">{photos}</Slideshow>.
It waits while it's off screen, pointed at or focused, never moves for someone who's asked for less
motion, and has a button to stop it and one for each photo.
A site takes them in once, three lines:
// content/schema.ts: the kit's sections beside the site's own (one of its own by the same name is kept)
import { sections } from "@caltho/site-kit/sections";
export const blocks = { ...sections, hero, about /* … */ };
// the site's page: every block after its mark, framed when it has a background, the kit drawing
// whatever kind the site's own `render` leaves (returns null for)
<KitBlocks blocks={page.blocks} render={(block, i) => <Block s={block} />} level={(i) => (i === 0 ? 1 : 2)} />
// app/layout.tsx
import "@caltho/site-kit/sections.css";
A section of the business's own has a background (tone: plain, soft, brand or dark, from the
Look's --look-*, with --look-page and --look-ink from the Look's background and ink), space
above and below (spacing: tight, normal, roomy) and can have a photo behind it (background, under
a scrim). On soft, brand or dark it's a card inset within the content's width in the theme's corners
(shape: "card", the default) or a band across the page (shape: "band"). On brand, dark or a photo the kit keeps its words and buttons readable over the site's own
colour classes. presets (from @caltho/site-kit/sections) are ready-made ones (an opening, words
over a photo, three reasons, a client's words, prices, a call to get in touch, contact, common
questions, a photo and words, facts in a row, a comparison, features with icons, a logo strip, a
video), each an own block to add and edit like any other.
From then on each section the kit adds reaches the site with cal sites kit update. They're drawn
from the site's Look (--look-*: its type scale, colours, buttons, width and spacing) and need no
classes of the site's; a site may still give its own for any part, in place of the kit's:
<KitBlocks … classes={{ headingLarge: "font-display text-3xl" }} />.
The theme
The Look is the site's theme. Beside its fonts, brand and accent colours, corners and buttons, it has
the page's colours by role (page, ink, muted, line, soft), colours of the site's own by name
(palette), a type scale (type: display, h1, h2, h3, body and small, each a size in px, a weight,
tracking in em and a line height), the content's width and the space between sections. A site's
look options are its design as it is (the defaults CalOS shows); its CSS reads the variables
(--look-page, --look-swatch-sand, --look-type-h2-size, --look-width, --look-section-pad, the
full list in src/content/look.ts) so a change in CalOS reaches every page. --look-gutter is the
space each side of the content (the site's gutter option in px on a wide screen, 40 unless said,
half on a phone): a site's own wrap reads it so its sections and the kit's line up.
The menu edited in CalOS can have links under a link (children, up to 8, one level): menuOf
returns them, and the site's header draws them (the starter: a dropdown on a wide screen, indented
in the phone's menu). blockLabel(block, kindTitle) (@caltho/site-kit/schema) is what a block is
called in a list: its heading, an own section's first heading part, else its kind.
Every section's Layout (placement, on unless a site sets placement: false) has a Background: a
colour (a role, or one of the site's own by its slug) and a photo under a shade. KitBlocks draws it as
a frame around the section (frameClass), its words light when the colour or photo is dark
(backgroundIsDark, from the Look's values at build and in the preview). The schema a site writes
carries the ready-made sections its own kind takes, as x-presets at its root.
Share cards
Each page is shared (Open Graph) with a 1200×630 card made at build from its words: its title and description, or the Share title and Share text an owner can set in CalOS's page settings, with the business's name. Every publish rebuilds the site, so a card always says what the page says. A Share picture chosen for a page is used instead.
A site sets how its cards look in content/schema.ts:
export default defineSite(blocks, {
share: {
background: "#13262f", color: "#ffffff", muted: "#b8c7cf", accent: "#f2b134",
backgroundImage: "public/og.png", // drawn behind, stretched to 1200×630
logo: "public/logo.svg", // instead of the business's name in words
fonts: [{ name: "Brand Sans", path: "assets/fonts/BrandSans-Bold.woff", weight: 700 }],
prefer: "picture", // a page's first picture before its card
render: (card, h) => h.el({ width: "100%", height: "100%", padding: 72 }, card.title), // a card of its own
},
});
share: false turns cards off: pages are shared with a picture from them. The kit carries DM Sans (SIL
Open Font License, src/fonts/OFL.txt) for cards that don't name fonts of their own.
A build
site-kit prepare # before: content checked, schema written, pictures ready
next build # the framework, reading .site/prepared.json
site-kit finish out # after: security policy, _headers, _redirects
site-kit check out # then: the browser check
This is the whole contract between a site and CalOS: content in at build, the declaration out, redirects
in at build, files out. A site in another framework uses the same prepare and finish and reads
.site/prepared.json its own way.
prepare reads content/schema.ts directly, so it needs Node 22.18 or later.
Working on it
bun install
bun run typecheck && bun test src && bun run build
To try a change in a site before releasing it: bun pm pack, then in the site
bun add file:/path/to/caltho-site-kit-<version>.tgz.
Releasing: raise version in package.json and merge to main. The Publish workflow sees a version
that isn't on npm yet, checks and builds it, and stages it with npm's trusted publishing (no token is
stored anywhere). A person then approves it, which is the 2FA step: npm stage list, then
npm stage approve <id> (or approve on npmjs.com). Only then is it live, and a site's Dependabot picks it up.
Set on the package once: Settings › Trusted publisher › GitHub Actions: organization trycaltho,
repository site-kit, workflow publish.yml, permission "stage publish".