Axe renders documents on-brand. A semantic CSS base styles plain HTML, and a matching set of components renders the standardized text formats the browser won't — CSV, Markdown, iCalendar, and TOML. One variable contract drives all of it, so point Axe at any of them and it comes out looking like your site.
It doesn't sit in a familiar category, and it isn't trying to. It isn't a utility framework (Tailwind), a component library (Bootstrap), or a design system. It's a small framework plus a curated set of components, held together by one idea: every piece takes a document and renders it on-brand through the same variable contract. The CSS base does that for semantic HTML; the components do it for the document formats HTML leaves on the floor.
The web runs on a document metaphor. A server sends a document and the requestor renders it; a browser, at its core, is a document viewer. But it's a selective one. It renders HTML, images, and PDF natively, and for nearly everything else it gives up and downloads the file. The axe viewer picks up a defined slice of what the browser abandons: standardized, text-based formats that carry visual structure worth rendering and have no native browser renderer. CSV, Markdown, iCalendar, TOML. It's the renderer the browser never shipped — point it at one of those files with ?url=, and it renders it on-brand. (Browsing the directories that hold those files is a separate tool, browse, which hands each file back to this viewer to render.)
That boundary is a door policy, not an accident. A format earns a place in the viewer when it is text, standardized, structurally renderable, unrendered by browsers, and read as documents. That last test is about how files in a format are used, not what the format was designed for — CSV was invented to move data between programs, and it is here because people sit and read tables. JSON stays out: browsers already render it. YAML stays out on the standardized test rather than the document one, because it has competing versions and implicit type coercion, so two parsers can disagree about what the same file means — and a renderer that silently picks one reading is worse than no renderer at all. TOML is in. It has one specification, one unambiguous data model, and a structure — tables, arrays of tables — that is genuinely renderable; and while it was designed for configuration, a great deal of TOML is written to be read: inventories, manifests, metadata records describing a set of files. Those are documents by use, whatever the format's origin. The set is still curated on purpose — which is why this is the axe viewer, not a universal one.
The components carry no look of their own, and that is deliberate. A standalone widget ships its own complete styling and imposes it on every host; an axe component ships almost none and wears the host's identity through the variable contract instead. That dependence is the reason the components live inside Axe rather than as separate libraries. They are built on the CSS base as a substrate, not decorated by it as a convenience — pull the base out from under the calendar and its toolbar buttons drop to bare browser defaults. The coupling isn't a packaging detail to engineer away; it is what the components are for. They are the proof that the contract is worth depending on.
The viewer reads, it never writes. It fetches a representation and renders it: no upload, no delete, no write surface. That is the document metaphor held to its word — a browser doesn't write to the server to render a page, and neither does the viewer.
- Copy the
axe/folder into your project. - Create a
brand.cssdefining your colors, fonts, and shape (or use the brand builder to generate one). - Import both in your project CSS or HTML:
<link rel="stylesheet" href="brand.css">
<link rel="stylesheet" href="axe/axe.css">- Write semantic HTML. No classes required for standard elements.
A page styled with Axe opens straight from disk — the CSS and the vendored scripts load over file:// with no server needed. The viewers in view/ are different: they fetch() the file you point them at, and browsers block fetch() of local files (every file:// document is treated as its own opaque origin), so a viewer pointed at a local document over file:// will report "Could not load." The fix is to serve the files over HTTP — any static server works, and no PHP or other backend is involved. The recommended option is Python's built-in server, since it's present wherever Python is:
cd path/to/axe # or your project root
python3 -m http.server 8000
# then open http://localhost:8000/view/?url=sample.csvAny other static server does the job too — for example php -S localhost:8000 if you already have PHP on hand.
When you want to hand someone a rendered document they can just open — no server, no internet — bake it with cli/cleave.py. It inlines the document and only the assets that format needs into one self-contained .html that the viewer renders in place (over file://), sidestepping the fetch restriction above.
cli/cleave.py report.md # -> report.html (a document)
cli/cleave.py deck.md --slides # -> deck.html (a slide deck)
cli/cleave.py data.csv # -> data.html (an interactive table)
cli/cleave.py team.ics # -> team.html (a calendar)
cli/cleave.py inventory.toml # -> inventory.html (a structured document)
cli/cleave.py report.md --brand mybrand.css # inline a brand paletteFor Markdown the render mode follows the same rules as the live viewer: --slides (or a mode: slides frontmatter key) makes a deck, otherwise it's a document. The output is portable and offline — email it, drop it on a share, open it from a USB stick. One caveat: the default output name swaps the extension for .html, so report.md and report.csv would both target report.html — pass an explicit output name to disambiguate.
On Debian, install it as a package and it works from anywhere:
curl -fsSL https://excelano.com/apt/setup.sh | sudo sh # one-time
sudo apt install cleaveThe package is named for the tool, not the framework. It installs the cleave command plus the Axe assets that command inlines, as its own data under /usr/share/cleave — it does not deploy Axe as a web framework, and nothing on a Debian box consumes Axe from there. Sites get Axe from this repository, by symlink or by vendoring.
From a checkout, symlinking onto your PATH works too: ln -s "$PWD/cli/cleave.py" ~/bin/cleave. cleave looks for the Axe assets in three places, in order — $AXE_ROOT if you set it, its own grandparent directory, then /usr/share/cleave. The checkout is checked before the package on purpose: running cleave from a working tree bakes that tree's viewer, not whatever version happens to be installed.
axe.css Framework core. Projects import brand.css + axe.css.
default.css Default brand baseline (a complete set of contract vars).
Sites override it with their own brand.css.
theme.js Theme detection and toggle. Include in <head>.
calendar.js iCalendar (.ics) engine: parser, day/week/month/list views, CSV/iCal export.
calendar.css Calendar styles. Uses the variable contract only.
toml.js TOML v1.0.0 engine: parser for the viewer's .toml renderer.
Validated against the official toml-lang/toml-test suite.
sample.csv Demo CSV (also the CSV-view demo and fixture).
sample.md Demo Markdown document (also the document-view demo and fixture).
sample.ics Demo calendar feed (also the viewer demo and round-trip fixture).
sample-slides.md Demo slide deck (also the slides-view demo and fixture).
sample.toml Demo TOML document (also the TOML-view demo and fixture).
kitchen-sink.html Reference page showing all styled HTML elements.
brand-builder.html Generates brand.css from color, font, shape, and shadow inputs.
README.md This file.
dependencies/
marked.min.js Markdown parser for the viewer (MIT licensed).
purify.min.js DOMPurify — sanitizes rendered Markdown (Apache-2.0 / MPL-2.0).
cli/ The command-line side: not web assets, not served.
cleave.py Bakes a CSV/Markdown/iCalendar/TOML file into one self-contained
HTML file that renders from disk (file://) with no server.
view/
index.html Axe viewer: renders one CSV, Markdown, iCalendar, or TOML file.
?url=path/to/file
Markdown renders as a document or, with ?view=slides (or mode: slides
frontmatter), as a native slide deck.
Everything in Axe is a web asset a site serves, with one exception: cli/ is the command-line side, and a site that deploys Axe should exclude it. It is a directory rather than a loose file so the exclusion is a single unambiguous rule, and so that nothing there is ever mistaken for something to serve. cleave needs the viewer and the stylesheets to do its job; the viewer and the stylesheets never need cleave.
The same one-way rule explains why two repositories that work together sit in different GitHub organizations. Axe lives in excelano because Excelano products ride it directly; browse lives in anderix because it is a personal-site tool that consumes Axe rather than something Axe or any Excelano deliverable depends on. The arrow points one way — browse needs the viewer and the stylesheets, and neither needs browse — so promoting the dependency without dragging the dependent along is the correct shape, not an oversight. Consumers are unaffected either way: sites pick Axe up through an axe -> ~/axe filesystem symlink that never knew which organization owned the repository.
Each project provides its own brand.css defining the visual identity. axe.css is universal and shared. Project-specific component classes go in the project's own stylesheet.
@import url('brand.css');
@import url('axe/axe.css');
/* Project-specific styles below */Axe provides two layout containers:
<main> is a full-width container (max 1100px, no surface background). Use it for app-like pages.
<article> is a constrained document panel (max 860px, surface background, shadow). Use it for prose and documents.
<section> groups content with border separators.
.grid is the only class the CSS base adds. It creates a responsive card grid. Children can be <article> or <a> elements. (The document components carry their own classes, namespaced under .axe-cal and the viewer chrome.)
The axe viewer renders .ics / .ical feeds the same way it renders CSV and Markdown. Point it at a feed and it opens with a component-owned toolbar above a scrolling view body: Day, Week, Month, and List tabs, a Today button with a direction arrow, prev/next navigation, a clickable title that opens a date picker, a timezone selector, and CSV / iCal export. On a narrow screen the right cluster collapses into a hamburger and List becomes the default view.
view/index.html?url=path/to/feed.ics
view/index.html?url=path/to/feed.ics&view=week
Append &view= to open on a specific view — day, week, month, or list. It's the same ?view= parameter Markdown uses for doc/slides, its valid values keyed to the file type. Omit it (or pass anything unrecognized) and the calendar opens on Month, exactly as before — on a narrow screen the existing responsive override still makes List the default.
calendar.js is the engine behind it: a single classic script with no dependencies and no build step, the same relationship marked.min.js has with Markdown. It parses RFC 5545 iCalendar, recurrence included, and renders four views — a Day and Week time grid with overlap-aware event columns and a live current-time line, a Month grid with true multi-day spanning bars, and a lazy-loading List — then exports back to CSV (RFC 4180) or iCalendar (round-trip stable). It also embeds in any page on its own.
<link rel="stylesheet" href="calendar.css">
<script src="calendar.js"></script>
<div id="cal"></div>
<script>
const cal = new Calendar(document.getElementById('cal'), {
url: 'feed.ics', // or source: '<raw iCal text>'
view: 'month', // 'day' | 'week' | 'month' (default) | 'list'
timezone: 'America/Chicago' // optional; defaults to the browser zone
});
cal.render();
</script>After it loads, cal.switchView('list'), cal.setTimezone('UTC'), cal.filter(e => …), and cal.export('csv' | 'ical') drive it.
The parser is standards-only: it reads compliant iCalendar and carries no vendor-specific branches. A feed that encodes data in a non-standard way (Scoutbook, for instance, writes all-day events as timed midnight-to-23:45) should be normalized by whatever serves it, never patched for inside the engine.
Event chips and bars are tinted by a per-event hue, computed deterministically from the category name in calendar.js. Color is always a redundant cue — the label rides along — so the calendar stays readable when it's ignored. The shared saturation and lightness, plus the fallback hue for uncategorized events, come from three brand tokens so a site can tune them (including per theme); they default to a mid blue and 404 harmlessly back to in-component fallbacks when undefined.
| Variable | Default | Purpose |
|---|---|---|
| --cal-cat-hue | 210 | Fallback hue for events with no category |
| --cal-cat-saturation | 55% | Saturation of all categorical chips and bars |
| --cal-cat-lightness | 50% | Lightness of all categorical chips and bars |
External ?url= fetches are denied by default for security (see SECURITY.md); enable specific hosts via EXTERNAL_ALLOWLIST in view/index.html. Even once allowlisted, the viewer fetches in the browser, so a remote feed only loads if that origin sends Access-Control-Allow-Origin — most calendar feeds don't. A locked-down remote feed needs a same-origin proxy that re-serves it, and that proxy is also the right place to normalize any non-standard encoding before the calendar sees it. Local and same-origin files load directly and are unaffected by the allowlist.
The axe viewer renders .toml the same way it renders CSV and Markdown. There is no schema and no configuration: the render is driven entirely by the shape the format itself defines, so nothing in the viewer knows what any key means.
view/index.html?url=path/to/inventory.toml
The mapping is the whole design:
| In the document | On the page |
|---|---|
| Keys with scalar values | A field grid, key beside value |
A sub-table [a.b] |
A nested section, heading and all |
A table array [[record]] |
A real table, one row per entry |
| A table array too deep to line up | A section per entry instead |
An inline table { ... } |
A nested field grid inside the value |
| A multi-line string | Prose, with the author's line breaks kept |
| The four date/time types | A readable date in a <time>, literal on hover |
| A path or URL | A link, resolved relative to the document |
Two of those rows carry most of the value. A table array is the repeated-record case, and rendering it as an actual table is what stops a reader skipping the file. Linking paths is what turns a metadata file into a finding aid: point a TOML document at the records it describes and the rendered page walks to them. Only http(s), mailto, bare email addresses, and relative paths ending in a document extension become links — the scheme test is a whitelist, because a document is untrusted input.
Numbers are shown as they were written. 48500.00 keeps its trailing zeros, 0xDEADBEEF stays hexadecimal, and a 64-bit integer keeps every digit — the renderer reads the literal from the parser's typed tree rather than a JavaScript number that has already rounded it. Dates are never converted to the reader's timezone: a document states the time it states, and TOML's three local types deliberately carry no zone to convert from.
A toolbar filter hides what doesn't match, which beats highlighting when the answer should end up on one screen. Filtering by a section's name keeps that section whole.
toml.js is the engine behind it: a single classic script with no dependencies and no build step, the same relationship calendar.js has with iCalendar. It implements TOML v1.0.0 and is validated against the official toml-test suite — 210 valid documents parsed correctly and 490 invalid ones rejected. The nine remaining cases in that suite are files whose bytes are not valid UTF-8; the decoder replaces those before any string reaches the parser, in the browser (Response.text()) exactly as in Node, so no string-taking parser can detect them.
It embeds in any page on its own:
const data = TOML.parse(text); // plain JS values
const tree = TOML.parse(text, { typed: true }); // typed nodes, with the literalsPlain mode returns objects, arrays, strings, booleans, numbers, and TOML.Date. Integers beyond Number.MAX_SAFE_INTEGER come back as BigInt rather than quietly losing digits. Typed mode returns each value with its TOML type, its parsed value, and the literal it was written as — the shape the renderer needs, and the reason the page can show 48500.00. Invalid input throws TOML.SyntaxError with a line and column.
Like every other component here, the renderer ships almost no look of its own: it wears the host's identity through the variable contract.
Brand files generated by the brand builder include light mode, dark mode, and system preference support out of the box. Include theme.js in your <head> to detect system preference and restore saved choices. Add a <button class="theme-toggle" aria-label="Toggle theme"></button> anywhere in your page to let users switch themes. Both the button styles and the script behavior are part of the framework.
Leave that button empty and the framework draws the icon for you — a sun in light mode, a moon in dark — tracking the resolved theme so it agrees with the painted page even before theme.js runs. The button must be genuinely empty (:empty matches no child nodes, not even whitespace, so write the tags adjacent), and it still needs an aria-label since the glyph is decorative. To use different icons, redefine --theme-toggle-icon-light and --theme-toggle-icon-dark in your brand or site CSS; their values are CSS content strings (for example "\2600" for ☀). Put your own markup inside the button instead and the default glyph steps aside.
Variables are split into two groups: a required contract that axe.css depends on, and an extended palette that the brand builder generates for convenience but that axe.css never references.
axe.css may only reference variables in this list. Any brand.css must define them. Adding a new variable to axe.css requires adding it here and to the brand builder output.
| Variable | Purpose |
|---|---|
| --color-bg | Page background |
| --color-surface | Card, main, elevated surface |
| --color-text | Primary body text |
| --color-text-muted | Secondary / caption text |
| --color-border | Borders and dividers |
| --color-accent | Links, buttons, primary emphasis |
| --color-accent-hover | Hover state for accent |
| --color-highlight | Marks, highlights, secondary accent |
| --color-highlight-hover | Hover state for highlight |
| --color-nav-bg | Navigation background |
| --color-nav-text | Navigation link color |
| --color-danger | Errors, destructive actions, now-line |
| --color-success | Confirmation, positive status |
| --color-warning | Caution, pending status |
| --font-body | Body typeface |
| --font-heading | Heading typeface |
| --font-mono | Code and monospace |
| --line-height | Base line height |
| --radius | Border radius (all corners) |
| --shadow | Subtle elevation shadow |
| --shadow-md | Medium elevation shadow |
The brand builder generates these from the two color inputs (primary and accent). axe.css never references them, but they're documented here so projects can use them consistently across brand guides, component styles, and overrides. They're theme-independent (unchanged between light and dark mode) since they describe the raw brand palette rather than UI roles.
| Variable | Purpose |
|---|---|
| --primary | Primary brand color (raw input) |
| --primary-tint-1/2/3 | Progressively lighter mixes toward white |
| --primary-shade-1/2/3 | Progressively darker mixes toward black |
| --secondary | Accent brand color (raw input) |
| --secondary-tint-1/2/3 | Progressively lighter mixes toward white |
| --secondary-shade-1/2/3 | Progressively darker mixes toward black |
Tints and shades are generated by RGB mixing toward white or black at stops of 30%, 60%, and 85%. RGB mixing desaturates tints naturally, producing UI-functional neutrals rather than saturated color ramps.
Build what you need, not what you might need. A pattern enters the framework when a real project requires it. No speculative additions.
Semantic first. Style HTML elements directly before reaching for classes. If a <button> can look right without a class, it should.
brand.css is always project-specific. axe.css is universal. axe.css must work with any valid brand.css. Never hard-code colors, fonts, or radii in axe.css.
Mobile first. Base styles target small screens. Use min-width media queries to expand.
JavaScript only where rendering needs it. The CSS base is styling alone; theme.js adds theme detection and the toggle. The components that render documents — the calendar engine behind the viewer — carry their own JavaScript, with no build step and a small set of vendored dependencies (a Markdown parser and a sanitizer). A component earns its script by rendering a format CSS can't.
When in doubt, put it in the project first. Promote to the framework when a second project needs it.
The viewer renders document content as live HTML in your site's origin, so treat every document you point it at as code. Markdown is sanitized with DOMPurify and calendar event URLs are scheme-checked before they become links, but those are mitigations, not a license to render untrusted input freely. External ?url= fetches are denied by default. Before you deploy the viewer, read SECURITY.md — it covers the threat model, the EXTERNAL_ALLOWLIST knob, and the operator responsibilities the framework cannot enforce for you. (The directory-browsing tool browse carries its own server-side lister and its own SECURITY.md.)
Axe carries a single version number so you can tell which build a site is running — it's vendored into several projects, and copies drift. The number is stamped in the file headers (axe.css, calendar.css, calendar.js), exposed as the --axe-version custom property, and as Calendar.version. To audit a deployment, curl https://site/axe/axe.css | head, or read getComputedStyle(document.documentElement).getPropertyValue('--axe-version') (or Calendar.version) in the console. Bump all of those together on release.
This is a deploy-tracking stamp, not a strict semver contract; git remains the source of truth for what changed. To see what moved between two stamps, including breaking changes to variable names, diff the tags: the tag list is the index, and GitHub's compare/v<old>...v<new> view — or git log v<old>..v<new> in a checkout — shows the commits between any two. There is no releases page and there is not meant to be one. Axe is consumed by vendoring, and cleave ships through the Excelano apt repository, so a release would carry no artifact and no note the tag range does not already hold.
The working copy carries a -dev suffix — 1.7.8-dev for work heading to 1.7.8 — and is stamped plain only when you tag. Every consumer vendors a subset of files rather than tracking this tree, so a site reports the version it last published, not whatever the working copy holds; the suffix is what separates a site running a tagged release from one running a snapshot of unreleased work. Stamp with ./set-version.sh, then re-publish the consumers a change affects; each keeps a small update-axe.sh in its repo root for that, and a copy left behind is invisible until someone checks. Sites that lag are a normal state, not a fault — that is the point of vendoring — but they are worth auditing after a release.
Built with the assistance of Claude (Anthropic).