Ship

Book · CSS

CSS

Draft

Write real CSS whose values, holes, and classes Ship checks.

On this page · 7 sections

A css { … } expression is CSS as a browser reads it, checked by Ship. Its value is a CSS.Class.t. The binding is the class: declarations apply to it directly, nested rules reach it through &, and a body never names its own root selector. Every rule moves into one stylesheet at build time, so nothing injects styles while a page runs.

let title = css {
  margin: 0;
  font-size: clamp(1.8rem, 4vw, 2.6rem);
  width: min(50%, 40rem);
  padding: calc(1rem + 4px);
  color: #0f172a;

  &:hover {
    text-decoration: underline;
  }
};

A class renders as one short token: s and five base36 characters, derived from where the expression is written rather than what it declares. Editing a declaration changes the stylesheet but never the class an element carries. Tokens are assigned across the whole program, and when two places hash alike one of them grows a character, so no two classes ever share a token.

Classes

test✓
  ✓title: CSS.Class.t;

  let tokens = CSS.Class.tokens(title);
  vector [sf56jq]  let token = Vector.get(tokens, 0);
  expect ✓Vector.length(tokens) == 1;
  expect ✓String.char_count(token) == 6;
  expect ✓String.starts_with(token, "s");

  let heading = ServerHTML.h1(
    [ServerHTML.class(title)],
    [ServerHTML.text("Ship")],
  );
  expect ✓ServerHTML.render_fragment(heading) == "<h1 class=\"${token}\">Ship</h1>";

Typed values

Values are Ship values written in CSS spelling. 12px is a Float<px>, 50% is a Float<percentage>, #0f172a is a Color.t, and calc(1rem + 4px), min, and clamp are typed trees in which lengths of different units mix. Unit names are CSS's own; % alone has no letter name, so its unit is percentage. Each property's expected value comes from the formal syntax in the vendored @webref/css specification data.

Holes

A Ship value enters a body only through ${…}. A function returning css is a class with parameters. Its rule stays static in the stylesheet, and each hole becomes a custom property named after the class, which the element carries in its style.

let chip = (~fg: Color.t, ~pad: Float<px>) =>
  css {
    color: ${fg};
    padding: ${pad};
    border-radius: 999px;
  };

Holes

test✓
  ✓chip: fn(~fg: Color.t, ~pad: Float<px>) -> CSS.Class.t;

  let live = chip(fg = Color.ink(), pad = 8.0<px>);
  let token = Vector.get(CSS.Class.tokens(live), 0);
  expect ✓CSS.Class.custom_properties(live) == [
    CSS.CustomProperty {
      name = "--${token}-0";
      value = "#111827";
    },
    CSS.CustomProperty {
      name = "--${token}-1";
      value = "8.0px";
    },
  ];

  let badge = ServerHTML.span(
    [ServerHTML.class(live)],
    [ServerHTML.text("Live")],
  );
  expect ✓ServerHTML.render_fragment(badge)
    == "<span class=\"${token}\" style=\"--${token}-0:#111827;--${token}-1:8.0px\">Live</span>";

Because each property is named after its class, two holed classes on one element never overwrite each other's values.

let ring = (~width: Float<px>) =>
  css {
    outline: ${width} solid;
  };

Scoped Holes

test✓
  let live = chip(fg = Color.ink(), pad = 8.0<px>);
  let focused = ring(width = 2.0<px>);
  let chip_token = Vector.get(CSS.Class.tokens(live), 0);
  let ring_token = Vector.get(CSS.Class.tokens(focused), 0);

  let badge = ServerHTML.span(
    [ServerHTML.class(live), ServerHTML.class(focused)],
    [ServerHTML.text("Live")],
  );
  expect ✓ServerHTML.render_fragment(badge) == "<span class=\"${chip_token} ${ring_token}\" style=\"--${chip_token}-0:#111827;--${chip_token}-1:8.0px;--${ring_token}-0:2.0px\">Live</span>";  <span class="snqh2k s7qzxw" style="--snqh2k-0:#111827;--snqh2k-1:8.0px;--s7qzxw-0:2.0px">Live</span>

Domain values

Any type can fill a hole by implementing CSS.Value. Its kind says which family of CSS values it stands for, and the checker runs it at compile time to match the hole against the property. render produces the CSS text when the class is built. Color and Duration implement it in the standard library.

type Tone =
  | Calm
  | Alert

module behavior CSS.Value(Tone) {
  kind = () => :color;
  render = function {
    | Calm -> "#2f9e68"
    | Alert -> "#d84a4a"
  };
}

let notice = (tone: Tone, fade: Duration) =>
  css {
    color: ${tone};
    transition-duration: ${fade};
  };

Domain Values

test✓
  let alert = notice(Alert, Duration.millis(250));
  let token = Vector.get(CSS.Class.tokens(alert), 0);
  expect ✓CSS.Class.custom_properties(alert) == [
    CSS.CustomProperty {
      name = "--${token}-0";
      value = "#d84a4a";
    },
    CSS.CustomProperty {
      name = "--${token}-1";
      value = "250ms";
    },
  ];

A hole whose type cannot be the property's value is a check error, whether it is an Int where a color or length belongs or a Duration where a color belongs.

crates/ship-cli/tests/golden/check-css-holes/run.t

CSS hole typing rejects wrong color and length holes in browser programs, and a domain value whose CSS.Value kind does not fit its property.

✓
$ship check .
error: css hole type does not match property `color`
  --> client/BadCss/init.shp:16:9
    |
 16 |       color: ${n};
    |         ^ expected one of [Color]
help: use Color for colors, measured units (px/rem/percentage/…) for lengths, or String as an escape

error: css hole type does not match property `width`
  --> client/BadCss/init.shp:21:9
    |
 21 |       width: ${n};
    |         ^ expected one of [LengthPercentage, Calc]
help: use Color for colors, measured units (px/rem/percentage/…) for lengths, or String as an escape

error: css hole type does not match property `color`
  --> client/BadCss/init.shp:26:9
    |
 26 |       color: ${fade};
    |         ^^^^ expected one of [Color]
help: use Color for colors, measured units (px/rem/percentage/…) for lengths, or String as an escape

Composition

include builds one class on another. The result carries both tokens, base first, and the stylesheet orders the base rule ahead of the rules that specialize it. An element takes one class, so choosing between looks is an ordinary if or match.

let status = css {
  margin: 0 0 18px;
  color: #b45309;
  font-size: 14px;
};

let status_hidden = css {
  include status;
  visibility: hidden;
};

Composition

test✓
  let base = Vector.get(CSS.Class.tokens(status), 0);
  let hidden = CSS.Class.tokens(status_hidden);
  expect ✓Vector.length(hidden) == 2;
  expect ✓Vector.get(hidden, 0) == base;

  let status_for = (dismissed) =>
    if dismissed {
      | status_hidden
      | status
    };
  expect ✓CSS.Class.tokens(status_for(False)) == [base];
  expect ✓CSS.Class.tokens(status_for(True)) == hidden;

Classes from elsewhere

A class owned outside Ship, such as a highlighter's language-rust, enters through CSS.Class.unsafe_external. Nothing checks that a stylesheet defines it. An element merges every class it is given into one class attribute.

External Classes

test✓
  let highlighted = CSS.Class.unsafe_external("language-rust hljs");
  expect ✓CSS.Class.tokens(highlighted) == ["language-rust", "hljs"];

  let token = Vector.get(CSS.Class.tokens(status), 0);
  let block = ServerHTML.code(
    [ServerHTML.class(status), ServerHTML.class(highlighted)],
    [ServerHTML.text("fn main() {}")],
  );
  expect ✓ServerHTML.render_fragment(block)
    == "<code class=\"${token} language-rust hljs\">fn main() {}</code>";

Global rules

Rules that cannot live under a class, such as @view-transition or :root, use css(Global). It is allowed only as the value of a module-level let, takes no holes or include, and goes into the same stylesheet verbatim. Its value is a CSS.Global.t, which no element accepts as a class.

let page_transitions = css(Global) {
  @view-transition {
    navigation: auto;
  }

  :root {
    color-scheme: light dark;
  }
};

Global Rules

test✓
  ✓page_transitions: CSS.Global.t;

The stylesheet

A build writes one stylesheet whose name is a digest of its contents, so it can be cached forever. A full ServerHTML.Document links it in <head>, and fragments never do, so a fragment can patch a live page without duplicating the link. Each rule is introduced by a comment naming the binding that owns it, which keeps DevTools readable while the markup carries only tokens. Browser builds hand the same stylesheet to Vite.

Holes travel in style attributes, so a page with holed classes needs a content security policy that allows them; a <style> nonce does not cover style="…".