CSS
First-party styles shared by browser Html programs and server-rendered ServerHTML. Author nice, real-looking CSS; Ship owns the value language end-to-end — measured units, Color.t, keywords, and calc/min/max/clamp trees — checked against property expectations generated from @webref/css. Dynamics use ${…} holes backed by CSS custom properties, not per-render style-list assembly.
Status: settled. Property expectations come from the vendored snapshot in crates/ship-css-spec; checked atoms and stylesheet construction are target-neutral. Vite packages browser CSS. Backend builds emit one compiler-owned, content-addressed application stylesheet linked by the full document.
Goals
Vanilla CSS authorship that DevTools, designers, and agents already know.
Typed all the way down:
width: 12pxisFloat<px>;50%isFloat<percentage>;#111827isColor.t;calc(1rem + 4px)is a typed calc tree — not an opaque string with a typed hole bolted on.Type-safe
CSS.Classhandles fromcss { … }.Checked global stylesheet rules from static module-level
css(Global) { … }bindings.Property RHS expectations generated from
@webref/cssformal syntax (styled-ppx-shaped architecture; Ship-owned parser inship-css-spec).Compile-time composition (
include, multi-class lists fold to constants).Stable, readable class identity separated from stylesheet content identity.
Browser and backend packaging consume one checked stylesheet artifact.
Vite still owns browser HMR/minification; backend workflows own their static stylesheet asset.
Tight stdlib integration:
Coloris a top-level domain type; CSS consumes it. Time usesDuration. Units use the measured-numeric system.
Non goals
Tailwind / utility-string styling.
Runtime CSS-in-JS injection for app chrome.
Property-constructor DSLs (
CSS.width(CSS.px(12))) as the authoring surface — the surface stays CSS-looking; the meaning is Ship.OCaml
{| |}quoting; top-levelcss name { … }items; author-written root.fooselectors insidecss { }.Bare Ship expressions inside css bodies (v1): Ship values enter only via
${…}.Per-route CSS splitting, critical-CSS inference, and runtime rule injection.
Nonced request-specific style blocks. Server holes use inline custom properties; strict
style-src-attr 'none'applications use static classes.
Owned value language
Inside css { }, value positions are a Ship-owned grammar that looks like CSS and lowers to the same types used elsewhere:
| Written in css body | Ship meaning |
|---|---|
12px / 1.5rem / 100vh | Float<px> / Float<rem> / Float<vh> |
50% | Float<percentage> |
#111827, #fff, rgb(…) | Color.t |
calc(1rem + 4px), min(…), clamp(…) | typed function trees |
auto / flex / … | property-allowed keywords |
${size} / ${fill} | Ship expression hole — only embed form in v1 |
calc(1rem + 4px) is valid CSS and valid Ship: different length units may mix inside calc; the result is still a length for length-accepting properties.
Unit naming
CSS unit identifiers keep CSS names:
px,rem,em,vh,vw,deg,turn,rad,grad,ms,s, …CSS
%has no letter name → Ship unit atom ispercentage(50.0<percentage>↔50%). Never invent shortenings likepct.
${…} holes
Ship expressions (bindings, Color.ink(), etc.) must use ${…}:
let chip = (~fg: Color.t, ~pad: Float<px>) => css {
color: ${fg};
padding: ${pad};
border-radius: 999px;
};
Html.span(class = chip(fg = Color.ink(), pad = 8.0<px>), ["Live"])
Holes lower to style-scoped properties such as var(--ship-a19f24c0-0) plus a small value bag at the use site. Scoping prevents two dynamic classes on one element from overwriting each other. Static literals stay ordinary CSS text in the extracted stylesheet.
Class authoring
The binding is the class (implicit root):
let title = css {
margin: 0;
font-size: clamp(1.8rem, 4vw, 2.6rem);
line-height: 1.05;
width: min(50%, 40rem);
padding: calc(1rem + 4px);
color: #0f172a;
&:hover {
color: ${Color.ink()};
}
};
Html.h1(class = title, ["Scrum"])
Every css { … } expression is a compile-time atom. Its class identity comes from canonical module/binding identity plus deterministic occurrence, never from rule contents. Editing a declaration preserves the readable class token and changes the stylesheet content digest. It is not a runtime Style AST.
Global rules
Rules that cannot live beneath a generated class selector use the explicit Global mode:
let navigation_transitions = css(Global) {
@view-transition {
navigation: auto;
}
:root {
color-scheme: light;
}
};
css(Global) is valid only as the direct value of a static module-level let binding. Its body is emitted verbatim into the same compiler-owned, content-addressed stylesheet as ordinary classes. Global rules cannot use include or ${…} holes, produce the opaque marker CSS.Global, and cannot be passed to ServerHTML.class.
Color
Color is a top-level stdlib domain type (all targets), not nested under CSS:
type t;
let white: pure fn!() -> t;
let ink: pure fn!() -> t;
// … palette constructors
let hex: pure fn(String) -> t, exn [ :parse(...) ];
let rgb: pure fn(Int, Int, Int) -> t, exn [ :parse(...) ];
let rgba: pure fn(Int, Int, Int, Float) -> t, exn [ :parse(...) ];
let to_css: pure fn!(t) -> String;
Color-like CSS properties expect Color.t in holes and color literals. The compiler renders Color.t to a CSS color string in the var bag — call sites stay Color.t end-to-end (model, palette, flood-fill, and CSS).
Spec-driven checking
Property expectations are generated from a vendored @webref/css snapshot:
Crate:
crates/ship-css-specFormal syntax parser: owned Rust (CSSTree-shaped definition syntax, no dep)
Regen:
cargo run -p ship-css-spec --bin generate-css-specwith the compiler watcher stoppedInventory:
crates/ship-css-spec/generated/INVENTORY.mdShip maps CSS
<type>terminals to value kinds (Length,Percentage,Color,Duration,Angle,Keyword,Calc, …) and checks both static values and${…}holes against the property's generated expectation. Unknown exotic syntax is an explicit diagnostic / escape — not the default for everyday CSS.
Composition
include
let status = css {
margin: 0 0 18px;
color: #b45309;
font-size: 14px;
};
let status_hidden = css {
include status;
visibility: hidden;
};
Ship preamble inside the css body. Emit a prejoined multi-class constant. include must resolve to CSS.Class; cycles are compile errors.
One class per element
class takes one CSS.Class.t. Classes combine with include inside a css block, and if or match picks one:
Html.button(class = if deleting { btn_pending } else { btn }, ...)
ServerHTML
CSS.Class is portable and opaque. ServerHTML.class consumes it directly; application code does not extract or interpolate the generated token:
let card = (~accent: Color.t) => css {
border-color: ${accent};
padding: 1rem;
};
let view = ServerHTML.article(
[
ServerHTML.class(card(accent = Color.ink())),
],
[
ServerHTML.text("Card"),
],
);
The renderer emits one class attribute and, when holes exist, one escaped style attribute containing only the generated custom properties. Multiple class applications merge without duplicate attributes. Externally owned classes enter through CSS.Class.unsafe_external, on the server as in the browser.
Full ServerHTML.Document values link the application stylesheet in <head>. The build artifact owns the exact content-addressed URL; fragments carry markup only, so Datastar patches never duplicate links or scripts. Development output keeps source provenance and a readable debug manifest. Production URLs are immutable because stylesheet content identity is separate from class identity.
Inline custom properties are governed by CSP style-src-attr. A nonce on a <style> element does not authorize style="..."; applications that forbid style attributes must keep server-used classes static.
Rejected spellings
css status { margin: 0; } // no top-level css item
let x = css {| padding: 8px; |} // no OCaml quoting
class = CSS.join([a, b]) // not the lead API
class = a + b // not the lead API
let styles = css { .status { … } } // no author root selectors
color: Color.ink(); // v1: use ${Color.ink()}
width: ${12.0<px>}; // ugly for static — write 12px
class = "btn …" is a type error: class takes a CSS.Class.t. Names owned outside Ship, such as a highlighter's language-rust, go through CSS.Class.unsafe_external. style stays a plain String.
Pipeline
all Ship source
→ parse structured css values + ${holes}
→ ship check (CSS.Class / CSS.Global, webref expectations, Color.t / units)
→ stable style ids + ordered stylesheet artifact + content digest
→ browser: Vite package / HMR
→ backend: typed static asset / document link
→ renderer: class tokens + scoped custom-property values
css { }, css(Global) { }, CSS.Class, CSS.Global, and Color are target-neutral. Rendering and packaging remain target-specific consumers of the same checked representation.
VDOM
Prefer identity-stable class tokens.
Holed css applied via
class=carries{ $class, "--ship-<style>-N": value, … }; runtime setsclassNameandstyle.setPropertywithout wiping fullcssTextfor var-only updates.
Migration
Move static chrome from
style = [("prop", "val"), …]into namedcss { }.Continuous colors / sizes →
${Color.t}/ measured holes.Drop string template classes (
.btnin Viteindex.html) as apps migrate.Flagships: pixel-editor and scrum.
Related
01-syntax.md (measured numerics;
${…})