One stylesheet, one theme script, one module. Everything on this page is built from those three files and the tokens they define; an app adds its own components and nothing else.
Lit comes from lit.js and only from there, so the page holds one copy. sharedStyles goes first in every static styles; it carries the classes below into the shadow root.
Tokens: colours, type, shape and layout as CSS custom properties, in light and dark. Apps never write a colour, a font family or a radius of their own.
Classes in sharedStyles: .btn, .btn.ghost, .icon-btn, .switch, .seg, .u, .label, .badge, .dot, .sr-only, and styled input, select, textarea.
Elements: tk-shell, tk-header, tk-sheet, tk-toasts, and the charts tk-stat/tk-stats, tk-sparkline, tk-timeseries, tk-bars, tk-heatmap, tk-progress.
Functions: toast(), trackViewport(), progressFraction(); icons as Lit templates.
Patterns (forms, cards, rows, empty states, notices) are a few lines of CSS on the tokens, shown below with their code. Copy them into the component that needs them.
Reference: api.json (the whole public surface), kit.d.ts, charts.html. The API is additive-only: nothing here is renamed or removed.
Colour
Every colour has a role. Light and dark values come with the token, so a component never picks a colour per theme. Use the theme button in the header to see both.
Surfaces stack --bg → --panel → --surface: page, raised card, control track. Nothing darker or lighter exists.
Text steps --text-primary → --text-secondary → --text-muted by importance, not by size.
--primary marks the one thing to look at. Two amber things on a screen compete; pick one.
--accent and --success say what state something is in, and the words beside them say the same. They are never series colours and never decoration.
Charts take --chart-1…--chart-4 in that order and never cycle; the chart elements do this themselves.
Need a tint? color-mix(in srgb, var(--primary), transparent 70%) keeps the token; a hex value does not.
Type
One family, Nunito, in three roles, plus a system monospace. Body is 15px on a 1.55 line; the base is set by kit.css, components inherit it.
Display: titles and section heads
--font-display, weight 400, letter-spacing 0.04em (tk-header, tk-sheet headings, h2 on this page)
Body: Nunito at 15px with a 1.55 line, the default everywhere. Keep paragraphs under about 72 characters and let the kit's size stand; tiny text is for captions only.
--font-mono for code, keys and anything aligned by character; numbers that change get font-variant-numeric: tabular-nums
Quote: used for a line of someone else's words.
--font-quote
Scale
Use
Size
Where
App title
display, set by tk-header
<h1 slot="title">
Section head
display 1.2–1.5rem
a sheet's heading; a page's h2
Card or row title
body 1rem, weight 600
.card h3, .row .text span
Body
15px
everything else
Caption, hint, change line
0.85rem, muted or secondary
.form .hint, figcaption, tk-stat change
Label, badge
0.72–0.75rem
.label, .badge
Big number
display or mono, 1.6–2.4rem, tabular
tk-stat value, a timer
Shape & motion
Corners are nearly square: --radius is 0.15rem. Pills (999px) belong to badges, dots and the switch, which the kit draws; the sheet has its own 6px. Nothing else rounds.
--radius
pill (kit only)
--shadow-color
--ring
Spacing
The kit's own rhythm: 6, 10, 14, 18 and 26px. Gaps inside a control are 6, between controls 10, between fields 14, around a pane 18, between sections 26. Rows that are tapped are at least 44px tall.
Motion
Transitions are 0.2s ease-out on colour, width and transform; the page background crossfades over 0.3s at a theme change.
The running dot pulses; nothing else loops.
sharedStyles turns every animation and transition off under prefers-reduced-motion, in each shadow root. Do not add motion that would need its own switch.
Layout
This page is a tk-shell: a nav panel on the left, a details panel on the right, a tk-header and one scrolling section. The document itself never scrolls (<html data-shell>), so iOS cannot drag the header away.
<tk-shell store-key="my-app:panels" nav-label="Pages" aside-label="Details">
<nav slot="nav">...page links (class="u", class="u on" for the current one)...</nav>
<section slot="aside">...details for the current page...</section>
<tk-header aside-badge="3"><h1 slot="title">Inbox</h1><button slot="actions" class="icon-btn" aria-label="New">+</button></tk-header>
<section>...the page; this element scrolls, the document does not (<html data-shell>)...</section>
</tk-shell>
Width
Nav
Details
up to 899px (phones)
drawer from the header's menu button
drawer from the header's details button
900–1199px
docked column, --sidebar-w 280px
drawer
1200px and up
docked
docked column, --aside-w 300px
An empty slot means no panel, no button. store-key remembers hidden panels; nav-hidden/aside-hidden set the default.
Never build a drawer, scrim or panel toggle: the shell traps focus, sets inert, closes on Escape and overlay, and moves focus back.
tk-header's actions slot is for one or two icon buttons; aside-badge shows a count on the details button.
A single-screen app without pages skips the shell and panes: tk-header, the content, tk-toasts.
Bottom padding ends with var(--safe-bottom) for the home indicator. trackViewport() keeps --app-top/--app-h right while the iOS keyboard is up.
Phone first: design at 390px wide, then let the columns appear. The design target for a details column is 300px; the charts fit it.
Buttons
One filled button per view, ghost for the rest, icon buttons for repeated actions in rows and headers. Every icon button has an aria-label.
The toggle button keeps its ghost look and flips aria-pressed; the segmented group moves .on. Both read their state to assistive tech, so no colour is needed to tell it.
.btn text is a verb: Save, Delete, Start. Not OK, Yes, Submit.
Disabled means “not possible now”, with the reason nearby; an action that takes time keeps its label and gets a toast when done.
Destructive actions use the same filled button and plain words (“Delete”), behind a confirm sheet when the loss is real. There is no red button.
Inline icons in a .btn are 16px and sit before the word; an .icon-btn is a 36px target with a 20px icon.
Forms
A two-column grid: label left, control right, hints and errors under both. The kit styles the inputs (16px text so iOS does not zoom); the grid is a pattern, copied below.
Every control has a label: for/id, or aria-labelledby for the switch. Placeholders are examples, not labels.
The switch is <button class="switch" role="switch" aria-checked> for an immediate on/off; a checkbox is for a choice that is saved with the form. Never a checkbox styled as a switch.
Numbers: type="number" inputmode="numeric" with min, max, step; the keyboard is right on phones and the browser validates.
Errors: aria-invalid="true" on the field, the message in --accent text linked by aria-describedby, shown after the user leaves the field, not while typing.
Controls are at most 220px wide and right-aligned; a textarea takes the full row.
Settings that take effect at once live in a sheet (below) and save on change; there is no Save button for them.
Feedback
Quiet by default. A toast for something that happened, a dot or badge for state that persists, a notice for something the user should read before going on.
Toasts
Toasts stack at the bottom and leave on their own. A toast never carries the only copy of information the user needs later.
import { toast } from "https://kit.thatsit.app/kit.js";
toast("Saved"); // info
toast("Offline; changes are kept", "warning");
toast("Could not sync", "error"); // one <tk-toasts></tk-toasts> on the page shows them
Status
live running needs you error
3 openv1.7312:30
A dot always has its word next to it; the four classes are the only states.
tk-sheet is the only overlay: a bottom sheet on phones, a centred panel on wide screens, with a heading, a close button, Escape and scrim handling. Content is slotted and styled by the component that owns it.
The form pattern inside a sheet. Each change applies at once and is saved; closing is the only exit. This is the settings dialog every app shares, so it is not rebuilt per app.
The heading is the question, the body says what is lost, the filled button is the action in plain words and the ghost one keeps things as they are.
<tk-sheet heading="Delete this board?" .open=${this.confirming} @tk-close=${this.cancel}>
<p>“Work” and its 3 keymaps go away. This cannot be undone.</p>
<div class="row-end"><button class="btn ghost" @click=${this.cancel}>Keep it</button><button class="btn" @click=${this.remove}>Delete</button></div>
</tk-sheet>
Lists & cards
A card is a --panel surface for one thing; rows are for many things of one kind, each with its actions on the right. Section captions use .label.
boards
Work3 keymaps · used today
Travel keyboard with a very long name that gets cut1 keymap
Numbers go in tk-stat tiles, histories in a sparkline or tk-timeseries, comparisons in tk-bars, habits in tk-heatmap, a goal in tk-progress. Rows go in .data; x, y, label and value name row fields. Nothing is drawn by hand and no colour is picked.
Every chart has a readout line, keyboard scrubbing, a hidden data table and a generated description. The full reference, with every attribute, is on charts.html and in the README.
Lucide, inline, following currentColor. Import the ones you use by name; ICONS maps every Lucide name to its template. Tap an icon to copy its import name.
import { SETTINGS_ICON, TRASH_ICON, ICONS } from "https://kit.thatsit.app/kit.js";
html`<button class="icon-btn" aria-label="Settings">${SETTINGS_ICON}</button>` // 20px, muted until hover
html`<button class="btn">${TRASH_ICON} Delete</button>` // 16px beside the text
html`<span class="hint">${ICONS["circle-help"]} How this works</span>` // set width and height yourself
An icon alone is a button with an aria-label; an icon beside words is decoration (the templates carry aria-hidden).
Sizes: 20px in .icon-btn, 16px in .btn, 18px in a notice, 28px in an empty state. Set width and height on svg anywhere else.
Need one that is not here? Ask for it to be added to the kit rather than pasting an SVG: pasted paths have been wrong before.
Accessibility
Focus: sharedStyles draws a 2px --primary outline on :focus-visible; never remove it. Order follows the DOM; drawers and sheets trap and return focus.
Names: every control has a visible label or an aria-label; icon buttons always. Buttons say what they do.
State: aria-pressed on toggle buttons, aria-checked on the switch, aria-expanded on panel buttons (the kit sets it), aria-invalid on bad fields. State is never colour alone: the dot has its word, the change line has its sign.
Live regions: toasts are role="status"; charts announce their readout at most every 400ms. Never put aria-live on text that changes with every keystroke.
Targets: 36px for icon buttons, 44px rows and primary actions on phones; inputs are 16px text so iOS does not zoom.
Contrast: the token pairs pass AA on their own surfaces. Muted text is for what can be missed.
Motion and forced colours: both handled by the kit; the charts keep system colours in forced-colors mode.
Tests: e2e tests use roles and names (getByRole("switch", { name: "Left-handed" })), which only work when the markup above is followed.
Rules
Import Lit from https://kit.thatsit.app/lit.js and the kit from https://kit.thatsit.app/kit.js, never from "lit".
Only the tokens on this page exist. There is no --tk-* colour; a made-up name renders as nothing, and the template's tests fail on it.
No hex colours, font families or radius values in app code; no colour per theme.
Use the kit's components and classes before writing CSS; copy a pattern from this page before inventing one; ask for the kit to grow before building a second copy.
Never hand-build panels, drawers, charts or progress bars. Never pick chart colours.
Phone first at 390px, dark and light, both checked in a screenshot before review.
Source: pi-web/kit. Published by CI on every change, additive-only, checked against the live api.json.
details
The right panel: context for the current page. Here, the quick reference.