honest framework

honest-components

Atoms, molecules, organisms. Two mounting behaviors. One CSS namespace per component, for life.

Server-rendered components with zero client-side state. HTMX does the loading. The server does the rendering.
What you do now
// components/SearchBar.jsx
export function SearchBar({ onSearch }) {
  return <div className="search-bar">...</div>
}

// globals.css — one file, growing forever
.search-bar { ... }
.search-bar__input { ... }
.btn { ... }       // whose btn?
.card { ... }      // owned by who?

// No CSS ownership. No structural boundary.
// The stylesheet is a shared mutable global.
What honest-components does
// atoms/search-bar/search-bar.css
.search-bar { ... }         // only this atom
.search-bar__input { ... }  // enforced by namespace

// atoms/search-bar/manifest.json
{ "name": "search-bar",
  "tier": "atom",
  "data-component": "search-bar" }

// Mounted by canvas scan at startup. No import.
// CSS load is automatic. No build step.
// .btn belongs to whoever declares it.
What you do now
# app/views/shared/_search_bar.html.erb
<div class="search-bar">
  <%= form_with url: search_path do |f| %>
    <%= f.text_field :q %>
  <% end %>
</div>

# application.css — global, growing
.search-bar { ... }
.card { ... }   # whose card?

# Partials are just template files.
# No CSS ownership. No data contract.
What honest-components does
# atoms/search-bar/search-bar.html.erb
<div class="search-bar"
     data-component="search-bar">
  <input class="search-bar__input"
         type="text">
</div>

# atoms/search-bar/search-bar.css
# atoms/search-bar/manifest.json

# Canvas scans atoms/ at startup.
# One <link> per CSS file, automatic.
# Namespace enforced: .search-bar only.
What you do now

<div class="search-bar">
  <form method="get">
    <input name="q" type="text">
  </form>
</div>


.search-bar { ... }
.card { ... }   

What honest-components does

<div class="search-bar"
     data-component="search-bar">
  <input class="search-bar__input"
         type="text">
</div>

# atoms/search-bar/search-bar.css
# atoms/search-bar/manifest.json

# register(app, templates) at startup.
# Every atom CSS auto-loaded, one <link> each.
# .search-bar is this atom's. Nothing else's.
What you do now

<div class="search-bar">...</div>

# static/css/main.css
.search-bar { ... }
.card { ... }   # shared mutable global

# No ownership contract.
# CSS file grows with the project.
# Refactor one component: check everything.
What honest-components does

<div class="search-bar"
     data-component="search-bar">
  <input class="search-bar__input">
</div>

# atoms/search-bar/search-bar.css  (scoped)
# atoms/search-bar/manifest.json

# mount(app, atoms=["atoms/"])
# CSS loaded automatically per component.
# Delete the directory: it's gone everywhere.
What you do now
// templates/components/search_bar.html
<div class="search-bar">
  <form>
    <input name="q" type="text">
  </form>
</div>

/* static/css/main.css */
.search-bar { ... }
.card { ... }  /* owned by nobody */

/* Template partials with no ownership.
   CSS grows without structure. */
What honest-components does
// atoms/search-bar/search-bar.html
// atoms/search-bar/search-bar.css
// atoms/search-bar/manifest.json

// Canvas scans atoms/ at startup.
// Adds template path + CSS link per component.
// honest-check enforces namespace:
//   .search-bar__* only — nothing else.

// Delete the directory: CSS unloads.
// Add a directory: CSS appears.
What you do now
{{-- resources/views/components/search-bar.blade.php --}}<div class="search-bar">
  <form>
    <input name="q" type="text">
  </form>
</div>

/* resources/css/app.css */
.search-bar { ... }
.card { ... }  /* shared — no ownership */

/* Blade components share a global stylesheet.
   CSS ownership is a convention, not enforced. */
What honest-components does
{{-- atoms/search-bar/search-bar.blade.php --}}<div class="search-bar"
     data-component="search-bar">
  <input class="search-bar__input">
</div>

/* atoms/search-bar/search-bar.css — only this atom */
/* atoms/search-bar/manifest.json  — declares it */{{-- register($app, $templates) on boot. --}}{{-- One <link> per atom CSS, loaded automatically. --}}{{-- No build step. No shared global file. --}}
The conventional pattern
# Template partials in any framework:
# - One global stylesheet
# - CSS ownership by convention only
# - Refactor one component: audit all CSS
# - Add a partial: add a CSS rule somewhere
# - Remove a partial: maybe the CSS is dead
#
# The CSS file is a shared mutable global.
# Nobody owns it. Everyone reads from it.
The honest pattern
# atoms/search-bar/
#   search-bar.html  (template)
#   search-bar.css   (only .search-bar__*)
#   manifest.json    (name, tier, data-component)
#
# Canvas scans the directory at startup.
# Each component loads its own CSS.
# honest-check enforces namespace statically.
# Delete the directory: it's gone. Add one: it appears.
# No global stylesheet. No shared mutation.

Every component belongs to one of three tiers. The tier determines two things: how it mounts into the application, and what it is allowed to do.

An atom is the smallest thing: a button, an input, a label. It takes primitive values and renders one HTML element. No routes. No database. Pure presentation.

A molecule composes one or more atoms into a single-purpose unit: a form field (label + input + error text), a search bar, a card. Still pure presentation. No routes. No database.

An organism is a self-contained section of the page that talks to the server. It has a route handler. It queries the database. It receives typed input via classify(). An organism will typically contain more than one molecule.

Two mounting behaviors, not three

Atoms and molecules mount like static files. They live in atoms/ and molecules/ directories at the project root. At startup, the canvas scans both directories, adds them to the template search path, and emits one <link> tag per CSS file into <head>. No manifest. No registration function. No entry point. They are simply there. This also provides tree shaking: only installed components load. Add an atom, its CSS appears. Remove one, its CSS disappears.

Organisms mount like plugins. They are packages with a register() function, discovered at startup via Python entry points (or the equivalent in each target language). Registration mounts their routes and templates. It also enforces their CSS namespace.

One namespace per component, for life

There is one global stylesheet. Every component owns a unique prefix. A component's CSS never uses any class it does not own.

The button atom owns .button, .button__text, .button--primary. The data-table organism owns .data-table, .data-table__row, .data-table--loading. When a data-table organism renders a button atom inside it, the button's classes remain .button. The organism's CSS never mentions .button. They share the same page. They never overlap.

<!-- The organism's territory -->
<div class="data-table" data-component="data-table">
  <div class="data-table__header">Name</div>
  <div class="data-table__row">

    <!-- The atom's territory: completely independent -->
    <button class="button button--ghost" data-component="button">
      Edit
    </button>

  </div>
</div>

Visual integration happens through CSS custom properties in _variables.css. The button reads var(--color-primary). The data-table reads var(--color-surface). Neither component owns a color value. They only reference tokens owned by honest-themer.

For organisms, the canvas enforces namespace at mount time: any CSS selector in the organism's file that does not already start with the organism's BEM block name gets that prefix prepended automatically. Atom and molecule CSS files are never scanned — they are global static files. If two organisms declare the same BEM block name, the second fails to mount with an explicit error.

data-component does three things at once

Every component at every tier stamps its root HTML element with data-component="<block-name>".

  1. honest-observe hook. The bootloader finds every element carrying data-component and emits an instrumentation event automatically. Zero developer code required.
  2. BEM anchor. The data-component value is the BEM block name. data-component="button" means the CSS block is .button. One declaration, two purposes.
  3. Composition identity. When components nest, data-component identifies which elements belong to which component. Two separate instrumentation events. Two separate CSS namespaces. Composition does not create confusion about ownership.

The CSS token contract

Every organism ships a style.json declaring every CSS custom property its CSS file references, mapped to a human-readable description. Token values are absent by design — values belong to honest-themer and the active theme record. The component declares what it needs; honest-themer resolves the actual values at serve time.

{
  "block": "data-table",
  "tokens": {
    "--data-table-bg":        "Table background",
    "--data-table-border":    "Row and container borders",
    "--data-table-header-bg": "Header row background",
    "--data-table-accent":    "Sort indicator and selection highlight"
  }
}

Atoms and molecules have no style.json. Their CSS references tokens from _variables.css directly. The token surface is implicit in their CSS files.

The abstract principle

A component is a unit of encapsulation. It owns its HTML, its CSS, its behavior, and its server routes. Nothing outside it can touch its internals. Nothing inside it can reach outside its declared interface.

This is not a new idea. It is the same idea as information hiding in Parnas (1972): a module's internal design decisions are hidden from its clients. Clients interact through a declared interface. The internal implementation can change without affecting clients.

honest-components applies this principle to the server-rendered web. The three tiers are three levels of encapsulation granularity.

CSS namespace as a formal boundary

The BEM block name is the component's CSS namespace. Every CSS class the component uses must start with that namespace. No class outside that namespace may appear in the component's CSS file. No other component may use classes in that namespace.

This is a formal partition of the CSS class space. The global stylesheet is the union of all components' CSS files. Each file's contribution to the union is disjoint from every other file's contribution. The partition is enforced at organism mount time by the namespace scan.

The formal property is namespace isolation: for any two components A and B with block names a and b, the set of CSS classes used by A and the set of CSS classes used by B are disjoint. CSS changes to A cannot affect B. A broken A cannot cascade visual damage to B.

This property is achievable in any CSS architecture. Honest-components enforces it mechanically rather than by convention.

data-component as a triple-role attribute

The data-component attribute is doing three things simultaneously:

  1. honest-observe instrumentation hook — the bootloader finds [data-component] and emits an event. Zero developer code.
  2. BEM anchordata-component="button" means the CSS block is .button. One declaration, two facts.
  3. Composition identity — when components nest, data-component identifies which elements belong to which component.

The design principle here is multiplexed semantics: one attribute carries multiple semantically related meanings. This is economical — the developer writes one thing and gets three behaviors. It is also safe because the three meanings are non-conflicting: the instrumentation identity, the CSS identity, and the composition identity are the same value for the same reasons.

Atoms and molecules as static assets

The decision to mount atoms and molecules as static files rather than as registered packages is a consequence of their formal properties:

  • Atoms have no server routes, no database access, no application knowledge
  • Molecules have no server routes, no database access, no application knowledge
  • Both are stateless: their output depends entirely on their input parameters
  • Both are globally reusable: no application context needed

A component with these properties requires no registration mechanism. It is a static asset in exactly the same sense as a CSS file or a JavaScript file. The appropriate mounting behavior is the one used for static assets. Registration adds ceremony without benefit.

Organisms have server routes and database access. They have application context. They require registration because they are dynamically extending the application's behavior at startup. The distinction in mounting behavior follows directly from the distinction in formal properties.

Tree shaking as a structural consequence

When atoms and molecules mount as static assets via directory scan, the set of CSS files served is exactly the set of installed components. This is tree shaking by construction: unused components are not installed, therefore not scanned, therefore not served.

Traditional tree shaking requires a bundler that analyzes import graphs at build time and removes unused code paths. That analysis can fail (dynamic imports, reflection). honest-components achieves the same result without a bundler and without static analysis: the file system scan is the analysis. If the file is not there, it is not served.

Full specification

Component Interface Contract

Every component declares a manifest.json at the root of its package:

{
    "name":           "button",
    "tier":           "atom",
    "description":    "A single-purpose interactive element.",
    "data_component": "button",
    "parameters": [
        { "name": "text",    "type": "string",  "required": false, "default": "" },
        { "name": "variant", "type": "enum",    "values": ["default","secondary","ghost","destructive"], "default": "default" },
        { "name": "disabled","type": "boolean", "required": false, "default": false }
    ],
    "css_file":  "css/button.css",
    "css_block": "button"
}

Parameter types: string, boolean, integer, enum, safe_html, component_ref, data.

  • Atoms: string, boolean, integer, enum, safe_html only — no component_ref, no data
  • Molecules: all primitives plus component_ref — no data
  • Organisms: all types including data from the server

Mounting Behavior

Atoms and molecules are global static assets. At application startup: 1. Scan atoms/ directory, add to template search path 2. Scan molecules/ directory, add to template search path 3. Emit one <link> tag per CSS file found — injected into <head> before any component CSS 4. No manifest, no register(), no entry point

Tree shaking: only installed components load. Remove an atom, its CSS disappears from the page automatically.

Organisms are packages discovered and registered at startup: 1. Discover via entry_points(group="sizzl") (Python) or language equivalent 2. Call register(app, templates, get_pool, config) for each 3. Run CSS namespace scan on each organism's CSS file 4. Enforce namespace uniqueness — duplicate names fail with explicit error

The register() Contract

def register(app, templates, get_pool=None, config=None):
    # Exactly two things happen:
    add_template_dir(templates, ROOT / "templates")
    router = make_routes(templates, get_pool, config)
    app.include_router(router, prefix=f"/sizzl/{name}")

The get_pool callable is passed in, not imported. It is a thunk — called at request time, not at registration time. This is because the pool does not exist at module import time.

The idempotency guard if _mounted: return prevents double registration in test environments.

CSS Namespace Enforcement Algorithm

FUNCTION scan_css_namespace(css_text, block_name):
    GLOBAL_PATTERNS ← re.compile("^(html|body|:root|\\*|@media|@keyframes|@font-face|--)")
    FOR EACH line IN css_text:
        IF line contains "{" AND ":" NOT IN line.before("{"):
            selectors ← line.before("{").split(",")
            FOR EACH sel IN selectors:
                IF sel starts with "." + block_name: SKIP
                IF GLOBAL_PATTERNS.match(sel): SKIP
                ELSE: prepend "." + block_name + " " to sel

This is a pure function: CSS text in, scoped CSS text out. It is called at register() time before the CSS is served.

Atom and molecule CSS files are never scanned. They are global static files and their namespace is declared by convention, not enforced.

The data-component Attribute

Every component at every tier must stamp its root HTML element:

<div class="data-table" data-component="data-table">

The data-component value must match data_component in manifest.json and the BEM block prefix in the component's CSS file. All three are the same value. This is enforced by honest-check HC-C001.

The CSS Token Contract (style.json)

Every organism ships style.json:

{
    "block": "data-table",
    "tokens": {
        "--data-table-bg":     "Table background",
        "--data-table-border": "Row and container borders"
    }
}
  • Token names map to human-readable descriptions
  • Token values are absent — values belong to honest-themer
  • Every token declared here must appear in the organism's CSS via var(--...)
  • Every var(--...) in the organism's CSS must be declared here
  • Atoms and molecules have no style.json

The honest-type Marshalling Requirement

At the organism boundary, every input from an HTTP request must pass through classify() before the template renders:

manifest = classify(
    tokens = [*path_params.values(), *query_params.values()],
    vocab  = component_vocab,
    bind   = component_binding,
)
# Template receives manifest, never raw request strings

Raw request strings may not reach any atom or molecule template. The organism is the boundary.

Multi-Target Structure

An organism package contains one implementation per target language:

honest-organism-data-table/
    manifest.json
    css/data-table.css
    python/
        data-table.html    (Jinja2)
        routes.py
        vocab.py
    ruby/
        _data-table.html.erb
        data_table_controller.rb
        vocab.rb
    ...

Each implementation passes the "senior developer" test: a developer fluent in the target language reads the generated code as idiomatic.

Conformance Requirements

RequirementTest
Atom parameters contain no component_ref or data typesValidate manifest
Molecule parameters contain no data typesValidate manifest
Every component root element carries data-componentRender and inspect DOM
Organism CSS namespace scan runs at register()Verify scoped selectors in served CSS
Duplicate BEM block name fails at mount with explicit errorRegister two organisms with same name
Organism register() is idempotentCall twice, verify no double-mounting
style.json tokens match var(--...) references in CSSCross-reference both files
style.json contains no token values, only descriptionsValidate schema
classify() called before template renders in every organism routehonest-check HC-P001 equivalent
CSS files for atoms/molecules linked in <head> before organism CSSInspect page source order

Reference