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 generatebuilds the same site intosite/, andtaskdeploys 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-uion the Gitea registry. Put@dave:registry=https://git.site.quack-lab.dev/api/packages/dave/npm/in the app's.npmrc, runbun add @dave/quacklab-ui, thenimport "@dave/quacklab-ui/quacklab.css".bun update @dave/quacklab-uitakes 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.jsis the behaviour the rules below describe. Callwire()once fromimport { 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 exportsreport(text, state)for the status bar, the chart functionsbars,lines,stacks,pie,shares,heat,times,timelineandsteps, the formatterscount,signed,trend,bytes,agoandtook,halvesfor aq-mid,showAtfor a list at a caret (Combobox), andtokenandfontfor 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-sortonsort,aria-expandedonexpand,is-pinnedonpin, a slider's--v, the picks count. Whatwire()keeps for a control is adata-*state the page never renders (data-armed,data-next,data-dropping,data-moving), a field's validity (setCustomValidityon a bound that is no number and an edit whose save was refused), ARIA it owns (a saving edit'saria-busy, a combobox'saria-expanded,ariaActiveDescendantElementand its options'aria-selected, a focused gutter'saria-valuenow), theflexof a pane a gutter sized, or an element the page renders empty: aq-chartand the status bar's outcome slot. A boxwire()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 amarkevent, and a page that acts on its marked rows keeps them from that event and rendersdata-markedfrom 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 arangeevent, and gives its marks back torows(). - Edit
tokens.jsonandcomponents.css, then runtask generateto rebuildquacklab.css. Never editquacklab.cssby hand. The same task buildsbundle.js, which the design system page loads into every preview so a preview's script callsQuackLab.wire().task testruns every test once in Vitest: the formatters in Node, every preview, layout and behaviour in one headless Chrome. While working,task test:watchreruns only the tests that depend on each file saved. taskreleases the version tag on HEAD: it pushes master and the tag, writes the tag intopackage.jsonfor 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 changelogwrites it intosite/. components/<Name>/README.mdis each component's guide andcomponents/<Name>/preview.htmlits 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-warnandis-danger. A state's modifier, its text helper (q-ok,q-run,q-warn,q-danger) andreport()'s state ("danger") share one name;runtakes theaccentcolour, the others the token of the same name. - Build a screen as
q-page: aq-baron top, aq-mainholding panels in aq-grid(grouped intoq-stackcolumns where they read top to bottom) or split into list and detail withq-split, and aq-statusat the bottom. Onlyq-mainscrolls.
Colour
bgis 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.panelis one step up: the app bar, panels, tiles and the status bar.hoveris the row, list item or tile under the pointer.raisedis for hover on buttons and for strips on a panel. Do not add a fifth surface.lineborders controls and panels.line-softseparates rows inside them.- Text has four weights of attention:
textfor values a person acts on,bodyfor cell and log content,dimfor keys and resting button labels,faintfor labels, units, timestamps and empty cells. Putfaintonly onbg,panelorraised. accentis 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,warnanddangerare states, not decoration. Each one always sits beside a word (live,expiring,failed), becauseokanddangerhave 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-t1toq-t8, in that order, the classes takingtone-blue,warn,danger,tone-cyan,tone-orange,tone-violet,okandtone-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-r1toq-r5:ok,warnanddangerwith 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,warnanddangerare 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 infaint).- Never set a value in sans or a sentence in mono.
- Each style is a variable holding the whole
fontshorthand:font: var(--text-data).text-labelalso has--text-label-trackingfor its letter-spacing. Small machine text beside a value (sublines, tags, ticks, tips, the status bar) istext-meta. Never write a font size, weight or line height by hand.
Numbers
- Every number is mono with tabular figures (
q-num, ornumin 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 addsis-left,is-centreoris-rightto its cells (Table). - Never abbreviate a number: every whole digit is shown, thousands grouped with commas,
99,890,803, never99.9Mor99,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, so5stays5(count,signed,bytesand every chart axis). - Sizes carry a binary unit:
38.2 MiB,2.44 GiB,900 MiB(bytesreturns the figure and the unit apart, forq-unit). - Rates follow the count after a middle dot:
22,026 · 27/min. - Times are relative and short. Past is
4m ago, future isin 6m, durations aretook 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 asago()'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
-infaint, never0when 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,000inok,-509,800indanger, zero infaint(q-delta is-up,is-down,is-flat;signedandtrend).
Shape and space
- Corners are square everywhere (
radius-0); nothing is round. There are no shadows. Panels are set apart bypanelonbgand ahairlineborder. - Table rows, facet rows, tiles, panel heads and the status bar are all
row-htall; 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) iscontrol-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-hand the rest), never a number written by hand. The only sizes an app writes are column widths sized to their longest value, inchorrem, and shares of space in%andfr. Never pixels. Use the spacing scale:space-2between buttons,space-3inside 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-3at the sides and none above or below a row that is alreadyrow-htall. - Use the whole width. Put panels in
q-gridand set its number of columns as--cols. Keepmax-widthfor 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 leavesLam…,Lam…and loses everything that told the rows apart. It is aq-midholding the two halveshalves(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:
- How often will this person do this?
- At that frequency, what is the least it can cost?
- 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-bodyform with labelledq-fields and asavebutton. 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
Emptyuntil there is something to show. Table columns get widths inremorchsized for their longest value in acolgroup, and thenamecolumn 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 amin-widthinchthat fits its longest label. A control that comes and goes beside others isis-invisiblewhile gone, keeping its room. A message that comes and goes lives in a reservedq-statusslot, 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 indangerwith what failed, never swallowed. - Confirm destructive actions on the same control: the first click arms it (
data-armed, labeldelete?), 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-droptakes files dropped on it, such as the whole pane the files belong to. While files are dragged over it,wire()marks itdata-dropping, which covers it in anaccenttint inside a dashedaccentedge, with itsdata-droptext, if any, centred on anaccentchip (data-drop="drop to upload"), and keeps the browser from opening a dropped file. The page reads the files from thedropevent'sdataTransfer.files, and from thepasteevent'sclipboardData.filesfor 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 akbd, the underlined letter of its own label where the label holds the key, a cap beside it otherwise:ffocuses the bar's filter,1to5switch segments,s tpresses onsthent,Control+fwith ctrl held. While a sequence waits for its next key, the key caps of the controls that key can press fill withaccent. Inside adata-hotkey-scopeelement, 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,timelineandstepsfromquacklab.js: each takes an emptyq-chartand the data, draws the head, legend, plot and tips with the chart classes, and draws again when the chart changes width.bars,linesandstacksplace their values at evenly spaced labels;timesplaces them at their instants on a time axis the person moves,timelinelays spans of time out in lanes on that axis, andstepsplaces 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-1toseries-6in that order, and a series keeps its colour when a filter hides the others. A seventh series becomesseries-other. - Pies and scatter plots use at most
series-1toseries-3plusseries-other. Bars and lines may use all six. The ranked shares of more than three named things are asharesstrip, which names every one, or a table ofq-fills in their series colours, one row each (Fill). - Money gained and lost uses
okanddanger(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-wthick with square ends. Lines are 2 wide. Grid lines areline-soft, the baselineline. - Every mark shows its value on hover in a
q-tip. Lines get a crosshair.
Icons
- Icons are Lucide stroke icons, inline SVG at
1emincurrentColor. They sit beside a word in nav items and buttons, or alone in anis-iconbutton with anaria-label. - Item pictures (game items, avatars) are square images in a
q-item, or achip-hq-iconin a table'siconcell. - 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
altanddata-tip. ×removes,+adds, and the table header draws its own sort arrows.