2026-10-29 10:01:43 +01:00
2026-10-09 15:38:43 +02:00
2026-10-24 16:04:49 +02:00

One dark, square, dense look for every self-hosted tool and dashboard: service controls, job runners, backup daemons, device panels. Each screen exists to answer what is running, what broke, and what to press next.

Using it

  • Every component, live and clickable: https://quacklab.site.quack-lab.dev. task generate builds the same site into site/, and task deploys it.
  • Load quacklab.css. It holds every component and every token, and it works the same in React, Svelte, Astro, Go templates or a single HTML file.
  • It is the npm package @dave/quacklab-ui on the Gitea registry. Put @dave:registry=https://git.site.quack-lab.dev/api/packages/dave/npm/ in the app's .npmrc, run bun add @dave/quacklab-ui, then import "@dave/quacklab-ui/quacklab.css". bun update @dave/quacklab-ui takes the newest release within the major version.
  • Oxanium and Source Code Pro ship in the package as variable fonts in fonts/, declared by the stylesheet, so a page loads nothing from anywhere else. A bundler copies them beside the built stylesheet.
  • quacklab.js is the behaviour the rules below describe. Call wire() once from import { wire } from "@dave/quacklab-ui/quacklab.js" and every control on the page, rendered now or later, gets the behaviour its component's guide describes. It also exports report(text, state) for the status bar, the chart functions bars, lines, stacks, pie, shares, heat, times, timeline and steps, the formatters count, signed, trend, bytes, ago and took, halves for a q-mid, showAt for a list at a caret (Combobox), and token and font for a canvas (Canvas). A bundler keeps only what a page imports.
  • It works the same under React, Svelte or plain DOM code, because wire() writes nothing the page renders. What is the page's own data reaches it as an event and the page renders it: aria-sort on sort, aria-expanded on expand, is-pinned on pin, a slider's --v, the picks count. What wire() keeps for a control is a data-* state the page never renders (data-armed, data-next, data-dropping, data-moving), a field's validity (setCustomValidity on a bound that is no number and an edit whose save was refused), ARIA it owns (a saving edit's aria-busy, a combobox's aria-expanded, ariaActiveDescendantElement and its options' aria-selected, a focused gutter's aria-valuenow), the flex of a pane a gutter sized, or an element the page renders empty: a q-chart and the status bar's outcome slot. A box wire() changes for the page (select all, picks), it clicks, so the page hears it through the box's own events. A table row's mark, data-marked, is both: wire() writes it as a person marks rows and sends the table a mark event, and a page that acts on its marked rows keeps them from that event and renders data-marked from them, which is also how it marks rows itself. A table showing only its rows in view (rows()) is told which rows to render by a range event, and gives its marks back to rows().
  • Edit tokens.json and components.css, then run task generate to rebuild quacklab.css. Never edit quacklab.css by hand. The same task builds bundle.js, which the design system page loads into every preview so a preview's script calls QuackLab.wire(). task test runs every test once in Vitest: the formatters in Node, every preview, layout and behaviour in one headless Chrome. While working, task test:watch reruns only the tests that depend on each file saved.
  • task releases the version tag on HEAD: it pushes master and the tag, writes the tag into package.json for the publish, and publishes the package. The tag is the only place the version lives.
  • Every release writes the changelog: each version, newest first, with the components it added or removed and what it changed, read from the version tags. It is https://quacklab.site.quack-lab.dev/changelog.html and the design system page's Changelog card; task changelog writes it into site/.
  • components/<Name>/README.md is each component's guide and components/<Name>/preview.html its example markup.
  • A component built to draw what an app already draws is done once a render of the app's own screen and a render of the component, side by side, show the same thing. Its guide and its preview agreeing with these rules is not that check.
  • Every class starts with q-. States the page sets are modifier classes: is-primary, is-sel, is-idle, is-done, and the four that carry a state colour, is-ok, is-run, is-warn and is-danger. A state's modifier, its text helper (q-ok, q-run, q-warn, q-danger) and report()'s state ("danger") share one name; run takes the accent colour, the others the token of the same name.
  • Build a screen as q-page: a q-bar on top, a q-main holding panels in a q-grid (grouped into q-stack columns where they read top to bottom) or split into list and detail with q-split, and a q-status at the bottom. Only q-main scrolls.

Colour

  • bg is the page, every input fill, every list and every head: panel heads and table heads alike, so each panel and table starts with the same band. panel is one step up: the app bar, panels, tiles and the status bar. hover is the row, list item or tile under the pointer. raised is for hover on buttons and for strips on a panel. Do not add a fifth surface.
  • line borders controls and panels. line-soft separates rows inside them.
  • Text has four weights of attention: text for values a person acts on, body for cell and log content, dim for keys and resting button labels, faint for labels, units, timestamps and empty cells. Put faint only on bg, panel or raised.
  • accent is the only interactive colour: primary buttons, focus rings, the selected row, running work and the current item. Never use it for decoration or headings. A selected segment, an on chip or a pressed button that stands for something with a colour of its own, such as a state, is tinted in that colour instead.
  • A link in data (an item, a name or a value that opens its own page) keeps the colour of the text around it and lights up under the pointer like a button, so a table of links reads as a table. Nothing is ever underlined to say it can be clicked. Only a link in prose takes accent.
  • ok, warn and danger are states, not decoration. Each one always sits beside a word (live, expiring, failed), because ok and danger have the same lightness and are told apart by hue alone.
  • Data colour is the page's to choose: a table column may take a series colour (is-colour) or a gradient along its values (is-ramp), and its key values weight (lead). The design system offers these and never assigns them.
  • The states of the page's own data (a board's stages, a merge request's states, an issue's status) take the colours the page defines for them. The page gives each state a class of its own that sets its colour as --c, .stage-todo { --c: oklch(0.76 0.12 255) }, and the pill, tag, marker, glyph, segment, chip or colour cell showing the state carries that class and draws in it. A page with no colours of its own for a set of states takes the tones, which read as text on every surface: q-t1 to q-t8, in that order, the classes taking tone-blue, warn, danger, tone-cyan, tone-orange, tone-violet, ok and tone-pink. A set of states keeps its colours wherever it shows. A state's colour sits beside its word or glyph too.
  • Levels ranked from calm to urgent, such as a priority from 1 to 5, take the page's own colours the same way, or the ranks q-r1 to q-r5: ok, warn and danger with the steps between mixed halfway.
  • Tints are mixed from a token or a state's colour: a 16% mix for a pill fill, 10% for a selected row, 12 to 14% for a failed tile or status slot. The colours of its own data's states are the only colours a page picks; surfaces, lines, text, accent, ok, warn and danger are always the tokens.

Type

  • Oxanium (sans) is for words: the title of the one thing a view shows (text-display, q-title), app name (text-title), panel titles (text-heading), sentences (text-body), list titles (text-small).
  • Source Code Pro (mono) is for anything a machine produced or a person types: values, ids, hosts, paths, numbers (text-data), buttons and inputs (text-control), logs (text-log), column headers, field keys and section labels (text-label, always upper case in faint).
  • Never set a value in sans or a sentence in mono.
  • Each style is a variable holding the whole font shorthand: font: var(--text-data). text-label also has --text-label-tracking for its letter-spacing. Small machine text beside a value (sublines, tags, ticks, tips, the status bar) is text-meta. Never write a font size, weight or line height by hand.

Numbers

  • Every number is mono with tabular figures (q-num, or num in a table cell), right aligned in columns, so digits line up and a ticking value never shifts its neighbours. A page that wants a column aligned otherwise adds is-left, is-centre or is-right to its cells (Table).
  • Never abbreviate a number: every whole digit is shown, thousands grouped with commas, 99,890,803, never 99.9M or 99,891k. The one stated exception is sizes, in binary units (bytes).
  • Decimals stop at three significant digits: 2.57, 12.3, 123, 1,234,568. A number of 100 or more has no decimals; trailing zeros are dropped, so 5 stays 5 (count, signed, bytes and every chart axis).
  • Sizes carry a binary unit: 38.2 MiB, 2.44 GiB, 900 MiB (bytes returns the figure and the unit apart, for q-unit).
  • Rates follow the count after a middle dot: 22,026 · 27/min.
  • Times are relative and short. Past is 4m ago, future is in 6m, durations are took 8s (ago, took); a week or more away is the date. Show the absolute time only where a person compares it with another clock. Every time, on a time axis, in a chart's tip or as ago()'s date, is shown in the viewer's own zone; a page whose times belong to UTC, such as a game's server time, declares it once on its root, <html data-zone="utc">, and every time on it is shown in UTC.
  • An empty value is - in faint, never 0 when nothing was measured. It is blank only in a column of counts of things waiting, such as open tasks per project down a list: there a blank reads as nothing to do, and the column shows figures only where there is something (List).
  • A unit or suffix sits in q-unit (faint) so the figure reads first.
  • Signed amounts carry their sign and their colour: +6,045,000 in ok, -509,800 in danger, zero in faint (q-delta is-up, is-down, is-flat; signed and trend).

Shape and space

  • Corners are square everywhere (radius-0); nothing is round. There are no shadows. Panels are set apart by panel on bg and a hairline border.
  • Table rows, facet rows, tiles, panel heads and the status bar are all row-h tall; a dense table's rows are a list item's height, row-dense-h. A field row is as tall as the text or control it holds, and fields draw no rule between their rows. Every control (button, input, select, segmented control, nav link) is control-h, so controls side by side line up.
  • Every size the stylesheet uses is a token in tokens.json (row-h, chip-h, rule-w, control-h and the rest), never a number written by hand. The only sizes an app writes are column widths sized to their longest value, in ch or rem, and shares of space in % and fr. Never pixels. Use the spacing scale: space-2 between buttons, space-3 inside cells, inputs, buttons, panel heads and bars.
  • Draw no empty space. Panels touch: no page gutter, no gap between panels, no margin around them. Padding only keeps text off a line, so it is space-3 at the sides and none above or below a row that is already row-h tall.
  • Use the whole width. Put panels in q-grid and set its number of columns as --cols. Keep max-width for long prose only.
  • Filling the width is for panels and columns. A control (a button, a segmented control, a row of chips, an input) is as wide as its content wherever it stands, and the room it leaves stays empty. The one exception is a table cell, which a control fills as its column's hit area.
  • Never truncate content while space sits unused elsewhere. A column, a cell or a panel is as wide as its longest value when that value is known, and the one holding names or free text takes the rest.
  • Text that can run out of room is cut in its middle, never at its end: La…01, La…02, where an end cut leaves Lam…, Lam… and loses everything that told the rows apart. It is a q-mid holding the two halves halves(text) returns, <span class="q-mid"><span>Lam</span><span>p01</span></span>, or that class on the slot itself (q-item-name q-mid). Each half gives up width in proportion to its own, so the text shows whole while it fits, and a copied selection holds it whole.

Cost of an interaction

Every interaction costs keystrokes, mouse travel, a hand moving off home position, or a decision. The cost a design may charge falls as the frequency rises. Before designing any interaction, answer three questions:

  1. How often will this person do this?
  2. At that frequency, what is the least it can cost?
  3. Does the design cost more than that?

Then design to the answer:

  • Rare (settings, a provider's OAuth details, one-time setup): a q-panel-body form with labelled q-fields and a save button. Explicit structure is worth more than a saved click here.
  • Frequent (adding to a list, editing a value, advancing a flow): drive the cost to nothing. Edit the text in place with q-edit, saved on blur or Enter. Use no Edit button to enter a mode and no Save button to leave it, because moving on to the next thing is the save.
  • Frequent and expected on arrival (the add box on a list, the search on a search page, the filter in the app bar): render it ready and focused on load. Never put it behind a loading state, a click to reveal or a click to activate.

A button stays right for an action that destroys something, for one that needs a confirmation separate from any natural gesture, and for a rare action. Never add a frequent field's button back because it looks more conventional. When a frequent interaction truly needs an exception, the reason goes in the component's guide in one sentence.

Behaviour

  • Nothing moves under the pointer. Clicking the same spot again without moving the mouse acts on the same thing, or on the one that took its place when the first click removed it, whatever the click, a refresh, a message or a label changing width did in between. The one move allowed is the move an action exists to make: a sort reorders the rows under its header, an expanded tree row pushes the rows below it down. Whatever a click opens, a detail column or a strip of filters, has its place on screen from the start, holding an Empty until there is something to show. Table columns get widths in rem or ch sized for their longest value in a colgroup, and the name column takes the rest. A state pill sits in a table column whose width the page sets. A button whose label changes, an armed button with its ? among them, gets a min-width in ch that fits its longest label. A control that comes and goes beside others is is-invisible while gone, keeping its room. A message that comes and goes lives in a reserved q-status slot, never in a banner that pushes content down. A refresh replaces values in place.
  • Every action produces a visible response where the person is looking: the button goes disabled aria-busy="true", the row's state pill changes, and the outcome lands in the status bar. A failure is shown in danger with what failed, never swallowed.
  • Confirm destructive actions on the same control: the first click arms it (data-armed, label delete?), a second click within a few seconds does it, and moving away disarms it. Never open a modal for it.
  • A modal (q-modal) is only for what the person must deal with before carrying on: an action the app refused, listed with the fix for each cause, or news they must read first. It appears at that moment and is gone once the problem is dealt with. The other uses are an item's detail opened from a screen with no room for it, as wide as its tables need, a short form that belongs to no screen, such as signing in, as narrow as the form, and a palette, a search over everything the app holds opened on a key. A confirmation, an outcome or a form a screen has room for never goes in one.
  • An element with data-drop takes files dropped on it, such as the whole pane the files belong to. While files are dragged over it, wire() marks it data-dropping, which covers it in an accent tint inside a dashed accent edge, with its data-drop text, if any, centred on an accent chip (data-drop="drop to upload"), and keeps the browser from opening a dropped file. The page reads the files from the drop event's dataTransfer.files, and from the paste event's clipboardData.files for files pasted while focus is inside it.
  • No helper text, hints or tooltips explaining a control. Labels name things; the design explains the rest.
  • Frequent actions have a key under the left hand, which stays on the keyboard while the right holds the mouse. A control takes its keys from data-hotkey, a keybinding (Keys), and shows them in a kbd, the underlined letter of its own label where the label holds the key, a cap beside it otherwise: f focuses the bar's filter, 1 to 5 switch segments, s t presses on s then t, Control+f with ctrl held. While a sequence waits for its next key, the key caps of the controls that key can press fill with accent. Inside a data-hotkey-scope element, a control's keys work only while focus is inside it (Keys). Enter opens a focused table row. wire() handles all of it. A key typed in a text field types there, unless ctrl, alt or meta is held; Escape leaves a field, and empties a search field as it leaves it.

Voice

  • Labels and buttons are lower case verbs or nouns: push now, refresh, forget, run now, reload caddy.
  • Column headers, field keys and section labels are short nouns in upper case: STATE, LAST RUN, IP RANGES.
  • Errors say what failed and the raw reason: obsidian: restic exited 1: repository is locked. No apology, no "something went wrong".
  • Placeholders say what to type and what happens: directory to watch, then Enter.

Charts

  • Draw charts with bars, lines, stacks, pie, shares, heat, times, timeline and steps from quacklab.js: each takes an empty q-chart and the data, draws the head, legend, plot and tips with the chart classes, and draws again when the chart changes width. bars, lines and stacks place their values at evenly spaced labels; times places them at their instants on a time axis the person moves, timeline lays spans of time out in lanes on that axis, and steps places its values at their own x on a linear axis. The SVG is drawn in the chart's own pixels, so its text stays at its type size.
  • Series colours come from series-1 to series-6 in that order, and a series keeps its colour when a filter hides the others. A seventh series becomes series-other.
  • Pies and scatter plots use at most series-1 to series-3 plus series-other. Bars and lines may use all six. The ranked shares of more than three named things are a shares strip, which names every one, or a table of q-fills in their series colours, one row each (Fill).
  • Money gained and lost uses ok and danger (q-s-up, q-s-down), because there the colour means good or bad.
  • One y axis per chart. Two measures of different size get two charts.
  • Every chart with two or more series has a legend. Label values sparingly: the last point of a line, the end of a bar. All text uses text tokens, never the series colour.
  • Bars are at most column-w thick with square ends. Lines are 2 wide. Grid lines are line-soft, the baseline line.
  • Every mark shows its value on hover in a q-tip. Lines get a crosshair.

Icons

  • Icons are Lucide stroke icons, inline SVG at 1em in currentColor. They sit beside a word in nav items and buttons, or alone in an is-icon button with an aria-label.
  • Item pictures (game items, avatars) are square images in a q-item, or a chip-h q-icon in a table's icon cell.
  • A picture the app supplies may name a value in place of a word (a damage type, a resource) where the screen repeats it often enough to be learnt; it carries the word as its alt and data-tip.
  • × removes, + adds, and the table header draws its own sort arrows.
S
Description
No description provided
Readme
2.9 MiB
0 Stars 1 Watchers 0 Forks
Languages
CSS 25%
JavaScript 24.8%
TypeScript 23.9%
HTML 23.8%
Go 2.4%