stoneware

Documentation

Styling

Co-located CSS, collected by the build, with no import and no link tag to maintain.

Put a stylesheet next to the code it styles. The build finds it, bundles every sheet into one content-hashed file, and injects the <link> into <head> for you.

routes/index.tsx        lib/Card.tsx        islands/Counter.tsx
routes/index.css        lib/Card.css        islands/Counter.css
        │                    │                     │
        └────────────────────┴─────────────────────┘
                             │
                    styles-4kq2n7wd.css      one file, hashed
                             │
                    <link> injected into <head>
one .css beside each thing it styles

There is nothing to import and nothing to remember. Deleting a component deletes its styles with it, because the two live in the same folder and the build stops finding one when you remove the other.

Membership is by location, not by import

This is the part worth understanding, because it is not how most bundlers work. Routes and lib/ are server modules the bundler never sees — an import "./Card.css" there resolves to a path string at runtime and would never reach a stylesheet. Scanning three directories gives one rule everywhere instead of a different rule per directory.

Files are sorted before bundling, so the cascade is deterministic and the content hash changes only when the CSS does. Two builds of the same source produce the same filename, which is what makes the immutable cache header on it safe.

The three scanned directories are routes/, islands/ and lib/. Anything under public/ is still served as-is at the URL root, which remains the right place for a stylesheet you want at a fixed, unhashed URL.

Which files are picked up

  routes/**/*.css     collected and bundled
  islands/**/*.css    collected and bundled
  lib/**/*.css        collected and bundled

  public/**/*.css     NOT bundled - served as-is at its own URL
  anywhere else       not found at all
the discovery rule, in full

Recursive, so lib/styles/tokens.css and islands/nav/menu.css are both found. Three directories and no configuration: if a stylesheet is not being applied, it is almost always sitting outside all three.

public/ is the deliberate exception. Everything there is served byte-for-byte at its own URL, so a stylesheet you want to link yourself — a vendor file, a print sheet, something a third party fetches — goes there and gets its own <link>. It is never merged into the bundle, and it is never content-hashed, so it revalidates on each deploy instead of being cached forever.

The order sheets are concatenated

One bundle means one cascade, and the order is fixed rather than incidental. Sheets are sorted by their full path, which produces this:

  1. islands/**   islands/Counter.css, islands/nav/Menu.css
  2. lib/**       lib/styles/00-tokens.css, lib/ui/Card.css
  3. routes/**    routes/about.css, routes/index.css

  and within each, alphabetically:
     lib/styles/00-tokens.css   before   lib/styles/90-print.css
concatenation order, alphabetical by full path

The directory order falls out of the same sort — "islands" sorts before "lib" sorts before "routes" — so it is stable, but it is alphabetical rather than designed. Do not rely on a routes/ sheet overriding a lib/ one by position; use specificity, or a numeric prefix, and the intent stays readable.

the convention that makes the cascade explicitsh
lib/styles/00-tokens.css      # custom properties, @font-face
lib/styles/10-base.css        # element defaults, resets
lib/styles/20-layout.css      # containers, grid
lib/styles/40-components.css  # buttons, cards
lib/styles/90-utilities.css   # last word, highest specificity
Sorting is what keeps the content hash stable. An unsorted directory scan returns files in whatever order the filesystem gives, so the same sources would produce different bytes on different machines and the hash would churn on every build.

What production does to it

  stoneware dev                   stoneware build
  ──────────────────────────      ──────────────────────────
  concatenated, readable,         minified
  with a comment naming
  each source file                styles-4kq2n7wd.css
                                  hashed by content
  rebuilt when a .css
  under routes/, islands/         Cache-Control: immutable,
  or lib/ changes                 max-age=31536000
the same sources, two modes

The hash is the whole caching strategy. Because the filename changes whenever the bytes change, the file can be cached for a year and a deploy still takes effect immediately — the page simply asks for a different filename. Nothing has to be purged and no cache header has to be tuned.

Minification is Bun's own CSS minifier, on in production and off in development. Development keeps the file readable, with a comment above each section naming the file it came from, so a rule you cannot place is one Ctrl-F away from its source.

Style objects inside an island

Islands can set style from a value, because an island genuinely re-renders when its signals change. An object is serialized the way you would expect, and a number gets px unless the property is one that takes a bare number.

This works under the strict default policy, and the reason is worth knowing: after hydration the client writes styles through the CSSOM — element.style.setProperty — which a Content-Security-Policy does not govern. It is the style attribute in HTML that style-src blocks, not the DOM property.

  server-rendered HTML       style="width:40%"
                             a strict CSP refuses to apply this

  after hydration            element.style.setProperty(...)
                             applied, and updated on every change
the same island, before and after hydration

So the initial paint is the one place to be careful. Give the element a class that looks right on its own and let the style object carry only what changes — otherwise the first frame is unstyled on a strict policy and correct a moment later. Development warns when a style attribute is emitted under a policy that will not run it, naming the element.

islands/Meter.tsxtsx
import { signal } from "stoneware/signals";

const pct = signal(40);

export default function Meter() {
  return (
    <div class="meter">
      <div class="meter-fill" style={{ width: `${pct.value}%`, opacity: 0.8 }} />
    </div>
  );
}
  { backgroundColor: "red" }   ->  background-color:red
  { marginTop: 8 }             ->  margin-top:8px
  { opacity: 0.8 }             ->  opacity:0.8      (unitless)
  { zIndex: 3 }                ->  z-index:3        (unitless)
  { "--brand": "#639" }        ->  --brand:#639     (passed through)
  { color: null }              ->  omitted entirely
how a style object is serialized

Unitless properties are the ones where a bare number is already valid CSS: opacity, z-index, flex and its parts, order, line-height, font-weight, zoom, grid-row and grid-column. Everything else numeric gets px, and 0 stays 0.

This is for values that change, not for styling in general. A style attribute is the highest-specificity thing on the page and it is invisible to your stylesheet — reach for it when the number is computed, and for a class when it is not.

The style attribute does not work here

The renderer accepts style={{ color: "red" }} and serialises it correctly. The browser will then refuse to apply it, because the default policy sets style-src without unsafe-inline — and that governs style attributes, not only <style> blocks. The element is there, the declaration is in the HTML, and nothing happens.

  style={{ color: "red" }}     emitted, then ignored by the browser

  class="note"                 works, is cacheable, and lives beside the
  + note.css                   component the build collects it from
what a class buys that an attribute does not

Development warns when it sees one, naming the element and the fix. It stays silent if you have widened the policy or set csp: false, because then nothing is being blocked. The attribute is still emitted either way — a diagnostic that rewrote your markup would be worse than the problem.

An island that genuinely needs to drive a value at runtime — a progress bar, a scroll gauge — sets a CSS custom property through the CSSOM instead. CSP does not govern that, and the value stays in a stylesheet where it belongs.

Why not CSS Modules

Bun supports CSS Modules in the bundler, but its runtime returns the file path rather than the generated class map. An island is rendered in both places — once on the server for the initial HTML, once in the browser on hydration — so the two would disagree about what a class is called, and the markup would not match the stylesheet.

the mismatchtsx
import styles from "./Counter.module.css";

styles.button  // bundler: "Counter_button_a1b2c3"
               // runtime: undefined — the import is a path string

Rather than ship scoping that works in one half of a render and silently fails in the other, v0.1 does not offer it. Scoping is naming discipline for now — a prefix per component is enough at this size, and real scoping can arrive later without changing where files live.

Something wrong in the framework itself rather than the page? Open an issue on GitHub.