Reference bevel.css 0.2.0
Every class, variant and state, with the markup to copy.
Link bevel.css before your own stylesheet. Everything in it sits in @layer bevel.*, so your CSS, written outside any layer, overrides it without !important. States take the class or the ARIA attribute: .active or aria-current, .disabled or aria-disabled, .error or aria-invalid.
<link rel="stylesheet" href="/static/bevel-0.2.0.css">
<link rel="stylesheet" href="/static/app.css">
Tokens
Every custom property starts with --bv-. The swatches below read the live values, so an override in your app shows up here too.
:root {
--bv-color-link: #0066A0;
}
Neutrals
--bv-color-text--bv-color-muted--bv-color-input-text--bv-color-surface--bv-color-surface-alt--bv-color-stripe--bv-color-border--bv-color-border-soft--bv-color-border-strong--bv-color-border-bottom--bv-color-grid
Action and focus
--bv-color-link--bv-color-link-hover--bv-color-primary-top--bv-color-primary-bottom--bv-color-primary--bv-color-primary-active--bv-color-danger-top--bv-color-danger-bottom--bv-color-danger--bv-color-danger-active--bv-color-focus--bv-color-focus-glow
Navbar
--bv-color-navbar-top--bv-color-navbar-bottom--bv-color-navbar--bv-color-navbar-link--bv-color-navbar-link-active--bv-color-navbar-current--bv-color-brand
Labels and badges
--bv-label-default-bg--bv-label-default-border--bv-label-default-text--bv-label-success-bg--bv-label-success-border--bv-label-success-text--bv-label-warning-bg--bv-label-warning-border--bv-label-warning-text--bv-label-important-bg--bv-label-important-border--bv-label-important-text--bv-label-info-bg--bv-label-info-border--bv-label-info-text--bv-label-inverse-bg--bv-label-inverse-border--bv-label-inverse-text
Alerts
--bv-alert-warning-bg--bv-alert-warning-border--bv-alert-warning-text--bv-alert-error-bg--bv-alert-error-border--bv-alert-error-text--bv-alert-success-bg--bv-alert-success-border--bv-alert-success-text--bv-alert-info-bg--bv-alert-info-border--bv-alert-info-text
Form errors and code
--bv-color-error--bv-color-error-border--bv-color-error-focus--bv-color-error-glow--bv-color-code-text--bv-color-code-bg--bv-color-code-border
Typography
A heading sits 9px above what it introduces and 18px below whatever came before it, so it always reads with the block under it. At the top of a column, alert or well it gets no space above.
h1. Heading 1
h2. Heading 2
h3. Heading 3
h4. Heading 4
h5. Heading 5
h6. Heading 6
Apps Five on hearth-01
Deploy Ship something new
Backups The last one finished 6 hours ago
Body text
The lead paragraph: 20px on a 27px line, for the one sentence that sets up a page.
Body text is 13px on an 18px line. It carries links, strong words, small text that keeps the surrounding size and inline code such as hearth deploy recipes.
A longer paragraph wraps at about 70 characters a line, however wide the page. Each app keeps a small manifest next to its code, and hearth reads it to decide how to pull, build and run the app, which port it listens on, and whether it should come back after the machine restarts. Everything else has a sensible default, so most manifests are four or five lines long.
Code blocks
# hearth.toml
name = "recipes"
source = "git@github.com:you/recipes.git"
port = 8086
start_on_boot = true
2026-10-08T14:02:11Z eink-sync exited with status 1: open /var/lib/eink-sync/state/cache/2026-10/display-frame-buffer.bin: no such file or directory (retrying in 30s)
Monospace
code and pre use --bv-font-mono: the first of Menlo, Monaco, Consolas and Courier New that this machine has, or else its own monospace font. The block below is set in whichever one that is.
ABCDEFGHIJKLMNOPQRSTUVWXYZ
abcdefghijklmnopqrstuvwxyz
0123456789 0O 1lI| ({[<>]}) => != :: ; , . ' " ` ~ ! @ # $ % ^ & * _ + - = / \
Each font in the stack on its own. A sample in a serif face means this machine doesn’t have that font.
| Font | Ships with | Sample |
|---|---|---|
| Menlo | macOS 10.6 and later, iOS | 0O 1lI {} => |
| Monaco | macOS | 0O 1lI {} => |
| Consolas | Windows Vista and later | 0O 1lI {} => |
| Courier New | Windows, macOS | 0O 1lI {} => |
monospace | Every system, as its default monospace font | 0O 1lI {} => |
Rule
An <hr> is 2.0’s etched line.
Layout
The .container centers content at 724px, 940px or 1170px, and is fluid below 768px. Each .section starts 60px below the one before; the first sits 18px below the navbar. A .page-header opens each section.
<main class="container">
<section class="section">
<div class="page-header"><h1>Apps <small>Five apps on hearth-01</small></h1></div>
<div class="row cols-3">…</div>
</section>
</main>
Rows
With no modifier, .row fits as many equal columns as it can, each at least 220px wide. A row ends with 18px, like a table or an alert, whatever its columns end with: the last block in each column drops its own bottom margin.
Fixed columns
.cols-2, .cols-3 and .cols-4 fix the count from 768px up and stack below it.
Buttons
Variants
Blue is the one action you most expect someone to take; red is the one they can’t undo.
Elements
.btn works the same on <button>, <a> and <input type="submit">.
Sizes
With an icon
Pressed
Pressed buttons lose their gradient and sink. Use .active, or aria-pressed="true" on a toggle.
Disabled
Use the disabled attribute on buttons and inputs. Links can’t be disabled natively, so give them .disabled together with aria-disabled="true" and no href.
Groups and toolbars
A .btn-group joins buttons into one control. A .btn-toolbar lines up groups and loose buttons, and wraps when it runs out of room.
<div class="btn-toolbar">
<div class="btn-group" role="group" aria-label="Filter apps">
<button class="btn" aria-pressed="true">All</button>
<button class="btn">Running</button>
<button class="btn">Stopped</button>
</div>
<button class="btn btn-primary">Deploy an app</button>
</div>
Forms
A bare field already looks right. .control-group spaces a label, field and help text; .error or aria-invalid="true" marks a problem. Group radios and related checkboxes in a <fieldset> with a <legend>: it draws no box, and the legend reads like a label.
<div class="control-group error">
<label for="port">Port</label>
<input id="port" class="input-small" type="text" value="8081"
aria-invalid="true" aria-describedby="port-help">
<p class="help-block" id="port-help">Taken by chess-trainer. Try 8086.</p>
</div>
<div class="form-actions">
<button class="btn btn-primary">Deploy</button>
<button class="btn">Cancel</button>
</div>
Add-ons
Wrap a field and a .add-on in .input-prepend or .input-append to put a unit or a fixed part of the value beside it. The add-on goes on whichever side it sits in the markup, so the two classes work the same and both can wrap a field with one on each side. The field keeps its width modifier. A screen reader doesn’t read the add-on as part of the label, so give it an id and add that to the field’s aria-describedby.
Only 74 GB is free on hearth-01.
<label for="budget">Monthly budget</label>
<div class="input-prepend">
<span class="add-on" id="budget-unit">$</span>
<input id="budget" class="input-small" type="text" aria-describedby="budget-unit">
</div>
Tables
Default
| App | Status | Port | Note |
|---|---|---|---|
| chess-trainer | Running | 8081 | Moved to the new opening book last week; the old one is still in the repository if anything breaks. |
| money | Running | 8082 | |
| notebook | Deploying | 8083 |
Striped and bordered
Wrap a table in .table-scroll and it scrolls inside its own box on small screens instead of widening the page. Give the wrapper role="region", an aria-label and tabindex="0", so keyboard users can focus it and scroll with the arrow keys.
| # | App | Status | Port | Version | Deployed | Actions |
|---|---|---|---|---|---|---|
| 1 | chess-trainer | Running | 8081 | v0.9.2 | 2 hours ago | |
| 2 | money | Running | 8082 | v1.4.0 | yesterday | |
| 3 | notebook | Deploying | 8083 | v0.3.1 | just now | |
| 4 | feeds | Stopped | 8084 | v2.0.0 | ||
| 5 | eink-sync | Failed | 8085 | v0.1.0 | 3 days ago |
Condensed
| Time | Event | Duration |
|---|---|---|
| 14:02 | eink-sync exited with status 1 | 0.4 s |
| 12:10 | chess-trainer deployed | 41.2 s |
| 08:00 | Backup finished | 6 m 12 s |
| 03:30 | Nightly restart |
Bordered, no header
| Host | hearth-01 |
|---|---|
| Uptime | 18 days, 4 hours |
| Disk | 118 of 192 GB used |
| Kernel |
Wide
| App | CPU | Memory | Disk | Requests / day | Errors / day | Repository | Last log line |
|---|---|---|---|---|---|---|---|
| chess-trainer | 1.2% | 48 MB | 210 MB | 1,204 | 0 | github.com/you/chess-trainer | served /puzzles/daily in 12ms |
| money | 0.3% | 31 MB | 1.4 GB | 88 | 2 | github.com/you/money | imported 14 transactions from the October statement |
<div class="table-scroll" role="region" aria-label="Apps on hearth-01" tabindex="0">
<table class="table table-bordered table-striped">
<thead><tr><th>App</th><th>Status</th><th class="text-right">Port</th></tr></thead>
<tbody>
<tr><td>chess-trainer</td><td><span class="label label-success">Running</span></td><td class="text-right">8081</td></tr>
</tbody>
</table>
</div>
Pagination
A <nav class="pagination"> around a list of page links. Mark the current page with aria-current="page"; it sinks in like a pressed button. A page you can’t go to, such as Previous on the first page, is a <span>, and so is a gap between page numbers. 2.x markup, with .active and .disabled on the <li>, works too.
<nav class="pagination" aria-label="Pages">
<ul>
<li><span><span aria-hidden="true">‹</span> Previous</span></li>
<li><a href="?page=1" aria-current="page">1</a></li>
<li><a href="?page=2">2</a></li>
<li><a href="?page=2">Next <span aria-hidden="true">›</span></a></li>
</ul>
</nav>
Alerts
Use role="alert" for errors that need attention now and role="status" for everything else.
eink-sync stopped responding
It exited with status 1 three days ago and hasn’t restarted.
The last log line says it couldn’t open its frame buffer, which usually means the state folder was cleaned up while the app was running. Restoring the folder from last night’s backup and retrying is safe.
Backups are running late
The nightly backup took 41 minutes, up from 6. Something large may have landed in a data folder.
<div class="alert alert-error alert-block" role="alert">
<h4 class="alert-heading">eink-sync stopped responding</h4>
<p>It exited with status 1 three days ago.</p>
</div>
<div class="alert alert-info" role="status"><strong>Heads up!</strong> notebook is deploying.</div>
Labels and badges
Labels carry a word and badges carry a number; the color only reinforces it.
| Variant | Label | Badge |
|---|---|---|
| Default | Stopped | 1 |
| Success | Running | 2 |
| Warning | Deploying | 4 |
| Important | Failed | 6 |
| Info | Scheduled | 8 |
| Inverse | Archived | 10 |
Backups 7
OK The last backup finished 6 hours ago. 128 files changed since.
<span class="label label-success">Running</span>
<span class="badge badge-important">2</span>
Progress bars
A .progress track holds a .bar; set the bar’s width inline. The track is faint on white, so say the value in words too, above the bar or elsewhere in its table row. Give the track role="progressbar" for work under way, such as a backup, or role="meter" for a level, such as disk space or a budget, with aria-valuenow, aria-valuemin, aria-valuemax and a name.
Disk: 118 of 192 GB used
Groceries: $212.47 of $450.00
Dining out: $184.30 of $200.00
Subscriptions: $71.97 of $60.00
Backing up money: 1.1 of 1.4 GB (.progress-striped.active)
Importing 3 statements: 1 done (.progress-striped)
Nothing backed up yet
A level past its maximum fills the track: set aria-valuenow to the maximum and say the real value in aria-valuetext, as the Subscriptions bar does. The stripes move only on .active, and stop for anyone who asks for less motion.
In a table
In a cell, a bar drops its bottom margin and is at least 90px wide, so a column of bars keeps its size.
| Category | Spent | Budget | Used |
|---|---|---|---|
| Groceries | $212.47 | $450.00 | |
| Dining out | $184.30 | $200.00 | |
| Subscriptions | $71.97 | $60.00 |
<p id="disk">Disk: 118 of 192 GB used</p>
<div class="progress" role="meter" aria-labelledby="disk"
aria-valuenow="118" aria-valuemin="0" aria-valuemax="192">
<div class="bar" style="width: 61%"></div>
</div>
Hero unit
Add .hero-unit when a page needs a large callout, such as an app’s home page; leave it out and nothing changes. The heading is 60px from 768px up and 36px below it. Paragraphs inside are 18px on a 27px line.
Welcome to hearth
Five apps on hearth-01. Each one deploys with a git push and comes back on its own after the machine restarts.
<div class="hero-unit">
<h1>Welcome to hearth</h1>
<p>Five apps on hearth-01.</p>
<p><a class="btn btn-primary btn-large" href="/deploy">Deploy an app</a></p>
</div>
With a grid
Add .hero-unit-grid to swap the grey fill for the 18px grid on white, ruled like the band at the top of this page, with a faint edge.
Welcome to hearth
Five apps on hearth-01. Each one deploys with a git push and comes back on its own after the machine restarts.
<div class="hero-unit hero-unit-grid">
<h1>Welcome to hearth</h1>
<p>Five apps on hearth-01.</p>
<p><a class="btn btn-primary btn-large" href="/deploy">Deploy an app</a></p>
</div>
Stats
Add .stats to a <dl> for the few numbers a home page opens with: each <dt> is a muted label and each <dd> a 30px figure. Make the list a .row too, with each pair in a <div>, to set the figures side by side. A <small> in a figure holds its unit or the rest of a fraction.
- Apps running
- 4 of 5
- Disk used
- 118 of 192 GB
- Up for
- 18 days
- Last backup
- 6 hours ago
In wells
Give each pair .well to set it in a panel of its own.
- Requests today
- 1,292
- Errors today
- 2
- Slowest response
- 412 ms
<dl class="row cols-4 stats">
<div><dt>Apps running</dt><dd>4 <small>of 5</small></dd></div>
<div><dt>Disk used</dt><dd>118 <small>of 192 GB</small></dd></div>
</dl>
Wells
A .well sets a block into the page: something to keep beside the main content without competing with it. Its last block drops its bottom margin, so the padding alone sets the space inside.
hearth-01
Five apps, 118 of 192 GB used, up 18 days. The next backup starts at 03:30.
Empty states
Add .well-empty and put the well where a list would be when there’s nothing in it: say what would be here, and how to add the first one. Text is centered and muted, with two rhythm lines above and below.
No apps on hearth-01 yet. Each app deploys with a git push and comes back on its own after a restart.
Backups
Nothing backed up yet. The first backup runs tonight at 03:30.
Failed deploys
None in the last 30 days.
Search
No apps match “receipe”.
<div class="well well-empty">
<p><strong>No apps on hearth-01 yet.</strong> Each app deploys with a git push.</p>
<p><a class="btn btn-primary" href="/deploy">Deploy an app</a></p>
</div>
Patterns
Confirm before deleting
A button can’t ask “are you sure?” without JavaScript: a <dialog> needs a script to open. So the button that starts a delete is a plain .btn link to a confirmation page of its own, with an ellipsis because there’s another step. That page says what will go, in numbers where it can, and holds the only red button, which posts the form. The way back says what it does rather than “Cancel”. Every step works with the back button, and nothing is deleted by a GET.
The button that starts it, under the thing it deletes:
The page it opens:
Erase all data?
This deletes everything in money, and money can’t bring it back.
- Accounts
- 4
- Transactions
- 163
- Budgets
- 7
- Imported statements
- 23
Last night’s hearth backup keeps a copy for 30 days.
<!-- GET /settings: -->
<a class="btn" href="/settings/erase">Erase all data…</a>
<!-- GET /settings/erase: -->
<div class="page-header"><h1>Erase all data?</h1></div>
<p class="lead">This deletes everything in money, and money can’t bring it back.</p>
<dl class="row cols-4 stats">…</dl>
<form action="/settings/erase" method="post">
<div class="form-actions">
<button class="btn btn-danger" type="submit">Erase all data</button>
<a class="btn" href="/settings">Keep my data</a>
</div>
</form>
Utilities
.muted sets text in the muted grey, for asides and timestamps. It keeps 4.5:1 on white and on bevel’s grey surfaces: striped and hovered table rows, the hero unit and the form actions bar.
.text-right aligns to the end of the line, for numbers and trailing actions. On a paragraph it also lifts the line-length cap, so the text reaches the end.
.table-scroll wraps a wide table; see Tables.
<label class="visually-hidden" for="filter">Filter apps</label>
<input id="filter" type="search" placeholder="Filter apps">