From pnpm add to a visible adaptive result in about five minutes. The paved road has three moves: define with defineAdaptive, apply with attrs() and plan(), and inspect with explain(). Boundaries, host helpers, and target compilers remain available later as explicit escape hatches.
LiteShip / @liteship/* naming: GLOSSARY.md. For Cloudflare Workers hosting, see HOSTING.md and examples/cloudflare-astro/. Contributing to LiteShip itself (cloning the monorepo, building, running the gauntlet) is a different path: CONTRIBUTING.md.
- Node.js 22.13+
- pnpm 10+
- An Astro 7 project (
pnpm create astro@latestif you don't have one)
In your Astro project:
pnpm add liteshipliteship is the one-dependency facade over the whole stack: authoring verbs import from the liteship root, and host surfaces ride domain subpaths like liteship/astro. One package, one import path — the same wiring pnpm create liteship scaffolds.
One definition owns the input, named states, and style differences. Put it in a module the page can import:
// src/adaptive.ts
import { defineAdaptive } from 'liteship';
export const layout = defineAdaptive({
boundary: {
input: 'viewport.width',
at: [
[0, 'mobile'],
[768, 'tablet'],
[1280, 'desktop'],
],
},
style: {
base: { properties: { display: 'grid', gap: '1rem', 'grid-template-columns': '1fr' } },
states: {
tablet: { properties: { 'grid-template-columns': 'repeat(2, 1fr)' } },
desktop: { properties: { 'grid-template-columns': 'repeat(3, 1fr)' } },
},
},
});layout.boundary, layout.style, and their content labels are the same objects the lower-level constructors produce. defineAdaptive is composition over those owners, not a second implementation.
The official scaffold already registers the Astro integration. In an existing Astro project, add it once:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import { integration } from 'liteship/astro';
export default defineConfig({
integrations: [integration()],
});The page uses the definition directly. attrs() applies the runtime identity, plan() returns matching compiled CSS, and explain() makes the decision inspectable:
---
import { layout } from '../adaptive.js';
const plan = layout.plan();
const preview = layout.explain(940);
---
<main {...layout.attrs()}>
At 940px the selected state is <strong>{preview.boundary.state}</strong>.
</main>
<style is:inline set:html={plan.css}></style>Run pnpm dev and drag the window edge. The state marker changes mobile → tablet → desktop, and the style-address-scoped CSS follows that exact runtime marker—there is no query-container setup to add. Use layout.explain(currentWidth) when you need the selected state, satisfied thresholds, style source, admitted targets, and aggregate identity. Do not hand-author data-liteship-*; attrs() owns that serialization contract.
For client:llm streaming, LiteShip can render structured UI trees instead of model-emitted HTML. You define which components exist; the model references them by name. LiteShip validates props and renders through a trusted catalog — interactions surface as DOM events for your app to handle.
The generated-UI surface rides the governed liteship/genui expert subpath. Register a catalog (component names, prop schemas, allowed children):
// src/genui-catalog.ts
import { defineComponentCatalog } from 'liteship/genui';
export const appCatalog = defineComponentCatalog({
version: 'app-1',
components: {
Card: {
tag: 'section',
props: { title: { type: 'string', required: true } },
children: 'optional',
allowedChildNames: ['Text'],
},
Text: {
tag: 'p',
props: { text: { type: 'string', required: true } },
children: 'none',
},
},
});Wire the catalog into an LLM session (or add data-liteship-genui on the directive root to use the built-in demo catalog). Stream chunks use the discriminator { "_genui": true, "name": "...", "props": { ... } } — legacy token/text paths stay unchanged when the marker is absent.
import { createLLMSession } from 'liteship/astro';
import { appCatalog } from './genui-catalog.js';
const session = createLLMSession({
element,
target,
mode: 'replace',
getDeviceTier: () => 'animations',
genuiCatalog: appCatalog,
});Rendered output carries data-liteship-genui-render-hash for cache/replay; click handlers emit genui:interaction on the directive root — your app decides what they mean (navigation, tool call, or nothing). LiteShip owns render safety; it does not own render authority.
While running pnpm dev, open the liteship boundary inspector from the Astro dev-toolbar (click the liteship toolbar icon) — a panel that lists every [data-liteship-boundary] element, live signal values, draggable threshold notches, and a Copy defineBoundary button for paste-back into source. DOM edits are session-only (source files are untouched). Opt out with integration({ inspector: false }) in astro.config.mjs.
Use the lower-level route when a host integration needs to own assembly or when you are extending LiteShip itself:
import { defineBoundary } from 'liteship';
import { adaptiveAttrs } from 'liteship/astro';
const viewport = defineBoundary({
input: 'viewport.width',
at: [
[0, 'mobile'],
[768, 'tablet'],
[1280, 'desktop'],
],
});
const attrs = adaptiveAttrs({ boundary: viewport });defineBoundary exposes the continuous-to-named-state contract directly. adaptiveAttrs adds Astro-specific serialization and options. They are supported public APIs, but they require the author to assemble style compilation and inspection explicitly; start with defineAdaptive unless that control is the reason you are here.
defineAdaptive(...).plan() is the default CSS route. Target and integration authors can compile a lower-level boundary directly. The compilers ride the liteship/compiler subpath — already installed with liteship, nothing new to add.
compile() takes the boundary, a per-state property map, and an optional selector:
import { CSSCompiler } from 'liteship/compiler';
import { viewport } from './boundaries.js';
const result = CSSCompiler.compile(
viewport,
{
mobile: { 'font-size': '14px', padding: '0.5rem' },
tablet: { 'font-size': '16px', padding: '1rem' },
desktop: { 'font-size': '18px', padding: '2rem' },
},
'.card',
);
// `.raw` is the serialized CSS string; `.containerRules` is the
// structured form (rule per state) you'd feed into a build pipeline.
console.log(result.raw);
// @container viewport-width (...) { .card { font-size: 14px; padding: 0.5rem } }
// @container viewport-width (...) { .card { font-size: 16px; padding: 1rem } }
// @container viewport-width (...) { .card { font-size: 18px; padding: 2rem } }
// You can also call CSSCompiler.serialize(result) to produce the same
// string from the structured form. Handy when you want to inspect
// individual rules first.Give result.raw a home in the page — paste it into a <style is:global> block (Astro scopes plain <style> blocks, which would rename the .card selector out from under the compiled rules), or write it to a CSS file your build imports:
<style is:global>
/* `result.raw` goes here: the rules keyed on `.card`.
Re-run the compile after editing the boundary so the CSS and the
serialized boundary stay in agreement. */
</style>The compile step and the page must share one definition: the boundary's content address changes whenever the definition does, and CSS emitted against a stale definition stops matching. The paved-road plan() method keeps that ownership together automatically.
Everything so far flows server → client. The mutation channel is the other
direction: a client proposes a GraphPatch, and the server validates it
against its own current truth before anything mutates — the same refuse-seam an
AI proposal passes through (examples/05-ai-patch-refused).
Server side is one route; you own the endpoint and the store:
// src/pages/api/graph.ts
import type { APIRoute } from 'astro';
import { graphMutationRoute } from 'liteship/astro';
import { store } from '../../server/graph-store'; // your GraphStore: loadGraph + compare-and-swap saveGraph
export const prerender = false;
export const POST: APIRoute = ({ request }) => graphMutationRoute(store)(request);Client side, createGraphMutationClient tracks the current base and serializes
submits, and bindGraphForm (from liteship/runtime) turns a form submit into a
validated patch:
import { createGraphMutationClient } from 'liteship/graph';
import { bindGraphForm } from 'liteship/runtime';
const client = createGraphMutationClient({ url: '/api/graph', base, refreshBase });
bindGraphForm(form, { client, toOps: (data, base) => [/* your sealed ops */] });Three outcomes, one shape: applied (the new sealed graph — the client adopts
it), refused (invalid proposal — the server graph is byte-identical), and
error. A refusal carrying staleBase: true (HTTP 409) means the server's
truth moved past your base; with refreshBase wired, the client reloads and
re-proposes automatically, within a bound. Two users editing the same dashboard
stop clobbering each other for free.
The worked, runnable version is examples/06-mutation-roundtrip.
examples/README.md: the examples ladder — start withexamples/tutorial(five guided pages from boundaries to streaming/LLM), climb to the AI-refusal keystone, finish atexamples/06-mutation-roundtrip(client→server return leg), and seeexamples/07-stagger-revealfor a committed stagger preset (#124)- AUTHORING-MODEL.md: tokens, styles, and themes — the layer above boundaries (axis-varying values, per-state property sets, multi-variant theming), opening with a one-paragraph "what it feels like to author"
- ASTRO-STATIC-MENTAL-MODEL.md: signals → boundaries → named states → outputs, the theory-first frame
- ASTRO-RUNTIME-MODEL.md: how Astro hosts the runtime, directives, and the escalation path
- HOSTING.md: host-application first-hour checklist (CSP, Trusted Types, common failure modes)
- PUBLIC-EXPORTS.md: reviewed public-surface roster; run
pnpm run docs:buildin a source checkout for the generated API reference (includingBoundary.evaluate)
The liteship/migrate subpath provides fromMediaQueries, fromContainerQueries, fromDesignTokens, fromTailwindTheme, and fromCSSCustomProperties. DTCG input is pinned to the 2025.10 format contract.
Adapters return ordinary LiteShip definitions plus diagnostics. Unsupported or lossy input is reported or refused; it is never silently widened. Migration is a conversion step, not a compatibility runtime, so resolve the diagnostics and commit the resulting LiteShip definitions as application source.
The contributor path (cloning the monorepo, workspace install, Playwright browsers, pnpm run build with composite project references, the test loop and the full gauntlet) lives in CONTRIBUTING.md. The short version:
git clone https://github.com/freebatteryfactory/LiteShip.git
cd LiteShip
pnpm install
pnpm verify # first-run aggregate ending in the quick check profilepnpm scripts prints the categorized index of all dev scripts; pnpm run doctor is the on-demand preflight environment check.
The same value evaluates to different states each call. You probably reused a state name across the threshold list. defineBoundary requires unique state names; passing [[0, 'small'], [768, 'small']] throws at construction with a LiteshipValidationError. If the error fires at runtime in a hot path, the boundary was constructed lazily inside a render function — hoist it out.
The CSS doesn't update when the window resizes. Confirm the Astro integration is registered, spread layout.attrs() without overwriting its class, and emit layout.plan().css. On the lower-level route, confirm adaptiveAttrs() supplied the directive marker and rebuild after changing the boundary.
A GPU shader (or other directive) never starts on an element that also carries adaptiveAttrs. Two liteship directives on one element collide — each takes over the node, so adaptiveAttrs() (which stamps data-liteship-directive="adaptive") and a client:gpu on the same canvas silently fight, and one loses (usually the shader). The console warns once (directive-collision:…) naming both. Put each directive on its own element.
The boundary state flickers when dragging the window edge near a threshold. Add or increase hysteresis. The field is optional and the default is zero (no dead-zone). A value of 16–24 px is enough to absorb display jitter on most setups; the algorithm is a half-width dead-zone, so hysteresis: 20 requires the signal to move 10px past the threshold before committing the transition.
Boundary.evaluate returns the wrong state for a value at exactly a threshold. That's by design: thresholds are inclusive lower bounds. A boundary with [[0, 'mobile'], [768, 'tablet']] returns 'tablet' for 768, not 'mobile'. If you need exclusive bounds, offset the threshold by 1.
PowerShell shows Γåô / Γ£ô mojibake in logs. Your terminal is decoding the repo tooling's UTF-8 output as cp437. Use Out-File -Encoding utf8 or run chcp 65001 first.
Tests hang in browser mode. Make sure Playwright browsers are installed: pnpm exec playwright install.
Found a different issue? Open one at github.com/freebatteryfactory/LiteShip/issues.