Ship

Old Docs · CSS
On this page · 18 sections

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: 12px is Float<px>; 50% is Float<percentage>; #111827 is Color.t; calc(1rem + 4px) is a typed calc tree — not an opaque string with a typed hole bolted on.

  • Type-safe CSS.Class handles from css { … }.

  • Checked global stylesheet rules from static module-level css(Global) { … } bindings.

  • Property RHS expectations generated from @webref/css formal syntax (styled-ppx-shaped architecture; Ship-owned parser in ship-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: Color is a top-level domain type; CSS consumes it. Time uses Duration. 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-level css name { … } items; author-written root .foo selectors inside css { }.

  • 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 bodyShip meaning
12px / 1.5rem / 100vhFloat<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 is percentage (50.0<percentage> ↔ 50%). Never invent shortenings like pct.

${…} 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-spec

  • Formal syntax parser: owned Rust (CSSTree-shaped definition syntax, no dep)

  • Regen: cargo run -p ship-css-spec --bin generate-css-spec with the compiler watcher stopped

  • Inventory: crates/ship-css-spec/generated/INVENTORY.md

    Ship 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 sets className and style.setProperty without wiping full cssText for var-only updates.

Migration

  1. Move static chrome from style = [("prop", "val"), …] into named css { }.

  2. Continuous colors / sizes → ${Color.t} / measured holes.

  3. Drop string template classes (.btn in Vite index.html) as apps migrate.

  4. Flagships: pixel-editor and scrum.