One palette, one set of shared widgets, for every thingino web app.
Live demo: https://thingino.github.io/theme/ — every token and every component on one page, rebuilt and redeployed on each push.
The apps grew up separately and each ended up carrying its own inline copy of the same CSS. The help-balloon block in the image builder and in verify was byte-identical; webflash's copy differed only in whitespace; the same twelve hex values were retyped in four places. This repo is where those live now.
Change a color here, rebuild, and the apps pick it up on their next sync.
| File | What it is | Depends on |
|---|---|---|
src/tokens.css |
Every color, face and radius, as --th-* custom properties. Declares only, selects nothing. |
nothing |
src/components.css |
Shared widgets: log console (with scrollbars), drop zone, file and device info, footer, help balloons, confirmation popover, the 720px shell. | tokens |
src/bootstrap.css |
Skins a stock Bootstrap 5.3 dark build into the thingino look. | tokens, Bootstrap |
src/aliases.css |
Opt-in bare-name aliases (--bg to --th-bg) for buildscope. |
tokens |
src/rtl.css |
Shared [dir="rtl"] mirroring: the utility flips image-builder and webflash each carried a copy of. Inert on LTR pages. |
Bootstrap utilities |
node scripts/build.mjs concatenates those into dist/:
| Bundle | Contents | For |
|---|---|---|
thingino-theme.css |
tokens + components + bootstrap | the image builder, verify, webflash |
thingino-base.css |
tokens + components | apps with no framework |
thingino-tokens.css |
tokens | palette only |
thingino-aliases.css |
aliases | buildscope, on top of the above |
thingino-rtl.css |
rtl | standalone; already embedded in thingino-theme.css |
tokens.json |
the same values for JS | charts, React, anything computing a color |
dist/ is committed so an app can vendor a single file without needing node.
CI runs --check and fails if dist/ is out of step with src/.
Load it after Bootstrap, last of your stylesheets:
<link href="vendor/bootstrap.min.css" rel="stylesheet">
<link href="vendor/bootstrap-icons.min.css" rel="stylesheet">
<link href="vendor/montserrat.css" rel="stylesheet">
<link href="vendor/thingino-theme.css" rel="stylesheet">Under a bundler, after the Bootstrap import:
import 'bootstrap/dist/css/bootstrap.min.css'
import 'bootstrap-icons/font/bootstrap-icons.css'
import './vendor/thingino-theme.css'Reading tokens from JS:
import { tokens } from '@thingino/theme/tokens.json'
ctx.fillStyle = tokens['--th-accent']The theme does not bundle Montserrat. Each app ships its own subsets, via
@fontsource or a vendored montserrat.css, so it downloads only the scripts
it renders. The theme just names the face.
Prefer .btn-outline-thingino for actions: the family's pages read
all-outline at rest. Solid .btn-thingino is the armed state of a page's
primary action (the image builder's Build, verify's Verify), disabled until
there is something to act on; both share the flat grey disabled treatment.
Every button carries a Bootstrap Icons glyph before its label
(<i class="bi bi-hammer me-1"></i>Build); icon-only buttons, like the ?
help toggle, skip the label. That is markup convention, not CSS: the theme
does not bundle Bootstrap Icons, each app vendors its own.
Browsers request font files only after parsing the stylesheet that names
them, so on a cold load icons sit invisible (bootstrap-icons is
font-display: block) and text paints in the fallback face for seconds.
Preload the woff2s your first paint uses:
<link rel="preload" href="vendor/fonts/bootstrap-icons.woff2?<hash>" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="vendor/fonts/montserrat-400.woff2" as="font" type="font/woff2" crossorigin>The href must match the CSS url() exactly, query string included, or the
browser fetches the font twice; font fetches are CORS-mode even same-origin,
hence crossorigin. None of the original apps preload yet; webtorrent-viewer
is the reference.
Components are written with the .th-* name first and the class or id the
existing apps already carry grouped alongside it:
.th-console,
#log { ... }That is deliberate: it makes adoption a single <link> with no markup churn.
As an app's markup moves to the .th-* name, its legacy selector comes out of
the group here.
buildscope draws its panels one step darker than the Bootstrap apps do:
surfaces on --th-surface-1 (#161b22) with hard --th-line borders and an
8px corner, where stock Bootstrap dark keeps cards on #212529 with
translucent borders. Same tokens, different surface mapping. The theme ships
that look as an opt-in variant, inert inside thingino-theme.css until asked
for:
<html data-th-alt>That one attribute moves cards, modals, dropdowns, list groups, toasts,
inputs and borders onto the ramp. It deliberately does not bring
buildscope's 13px density or typography (those are part of being a data-heavy
viewer, not part of the ramp), and buttons need nothing: buildscope's .btn
already is .btn-thingino. --bs-body-bg stays untouched in this layer too;
every surface is retinted through its own component variable.
Try it on the demo: thingino.github.io/theme/#alt, or the toggle at the top of the page.
Never set --bs-body-bg. The apps override the page background only,
which leaves cards, modals and dropdowns on Bootstrap's own #212529 grey.
Retinting --bs-body-bg flattens every card into the page, in all three
Bootstrap apps at once, and looks like a bug in each of them separately.
Load order matters, except where it can't. The two rules that must survive
being loaded before Bootstrap (some builds inline page CSS ahead of the
bundle) are written as html:root and html body, which outrank Bootstrap's
:root and body whichever way round they land. Everything else assumes the
theme comes last.
$EDITOR src/tokens.css
node scripts/build.mjs
node scripts/preview.mjs # http://localhost:8080/preview/index.html draws every token and every component on one page, with
the swatches generated from the built tokens.json, so it cannot drift from
what the apps actually get. It borrows Bootstrap and Montserrat from a sibling
app checkout rather than committing its own copy: the apps own that version
pin, and a second copy here would be free to drift from it unnoticed.
That same page is what gets published. scripts/site.mjs assembles _site/
(the page at the root, dist/ and vendor/ beside it) and the pages
workflow deploys it on every push that touches src/, preview/, scripts/
or dist/. The local server maps requests onto the identical layout, so one
set of relative paths works in both places and the published copy cannot break
in a way the local one hides:
node scripts/site.mjs # build _site/ exactly as CI willCI clones thingino-verify alongside to borrow the same vendored assets, so
the deployed demo renders against the Bootstrap an app is really shipping.
Then push the result out to the apps:
node scripts/sync.mjs # dry run: what would change, in which checkout
node scripts/sync.mjs --write # copy it in; review and commit in each reposync.mjs only copies files. It never commits and never pushes, so a theme
change lands in each app as a reviewable diff on your own schedule. Each copy
gets the theme commit appended to its banner (· thingino/theme@<sha>), so a
vendored file always answers "which theme is this?" on its own, and whether an
app is behind is one line to check. If that
ever gets tedious, the natural upgrade is a workflow here that opens the four
PRs itself.
| Repo | Bundle | Notes |
|---|---|---|
| thingino-image-builder | thingino-theme.css |
rtl.css must stay the last stylesheet: it mirrors the notice sweep |
| thingino-verify | thingino-theme.css |
|
| thingino-dfu (webflash) | thingino-theme.css |
can drop its html:root / html body specificity hacks once the theme loads after Bootstrap |
| buildscope | thingino-base.css + thingino-aliases.css |
aliases let its ~1400 existing lines keep reading var(--bg) unchanged |
Both apps enforce default-src 'self' in a CSP <meta>, and the image builder
serves offline through a service worker. That is why the theme is vendored into
each app rather than served from one origin: a shared stylesheet loaded
cross-origin would be blocked outright, and would put a network dependency in
front of a firmware verifier.
MIT.