That's it kit

Start here

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.

<link rel="stylesheet" href="https://kit.thatsit.app/kit.css">
<script src="https://kit.thatsit.app/theme-init.js" data-key="my-app:theme"></script>
<script type="module">
  import "https://kit.thatsit.app/kit.js";
  import "./src/app-main.ts";
</script>

A component

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.

import { LitElement, html, css } from "https://kit.thatsit.app/lit.js";
import { sharedStyles, toast, SETTINGS_ICON } from "https://kit.thatsit.app/kit.js";

export class AppMain extends LitElement {
  static properties = { settingsOpen: { state: true } };
  static styles = [sharedStyles, css`
    :host { display: grid; gap: 14px; padding: 18px; }
    /* only kit tokens here: var(--panel), var(--text-muted), var(--radius) ... */
  `];
  render() {
    return html`
      <tk-header><h1 slot="title">My app</h1>
        <button slot="actions" class="icon-btn" aria-label="Settings" @click=${() => (this.settingsOpen = true)}>${SETTINGS_ICON}</button>
      </tk-header>
      <tk-sheet heading="Settings" .open=${this.settingsOpen} @tk-close=${() => (this.settingsOpen = false)}>...</tk-sheet>
      <tk-toasts></tk-toasts>`;
  }
}
customElements.define("app-main", AppMain);

What the kit is

  • 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.

--bgThe page, inputs, the sheet#f1f1f1 · #171717
--panelA raised surface: cards, notices, dialog rows, chart tiles#e7e7e7 · #1f1f1f
--surfaceA control's track or an inactive fill (the switch off, a scrubber)#aaaaaa · #555555
--code-bgCode blocks and keys#f1f5f9 · #1e293b
--text-primaryBody text, headings, active controls#000000 · #ffffff
--text-secondarySupporting text: descriptions, form labels, card copy#555555 · #a1a1aa
--text-mutedCaptions, placeholders, icons at rest, empty states#64748b · #94a3b8
--border-colorBorders of inputs, buttons, the sheet; the panel edges#555555 · #555555
--lineHairlines: dividers, card edges, badges#cfcfcf · #333333
--underline-colorUnderlines in running text#646464 · #646464
--primaryBrand amber: the one thing to look at, primary buttons, focus, links, the switch on#ffaa00 · #ffaa00
--secondaryBrand blue; rare, for a second brand accent, never a status#0032a0 · #0032a0
--accentStatus red: errors, needs attention, destructive. Always with words#e4002b · #e4002b
--successStatus green: done, goal reached. Always with words#00843d · #00843d
--overlayThe dim behind a sheet or drawerrgba(0,0,0,.45) · rgba(0,0,0,.6)
--shadow-colorThe one shadow colour (sheet, raised drawer)rgba(0,0,0,.1) · rgba(0,0,0,.3)
--ringSoft focus halo where an outline cannot sitrgba(255,170,0,.3) · rgba(255,170,0,.4)
--chart-1First data series, always. Never a status#e69500 · #ffaa00
--chart-2Second series#006eb8 · #5aa6e6
--chart-3Third series#00978d · #3cc9a8
--chart-4Fourth series; charts stop here#8a2f7a · #b85aa6

How to choose

  • 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-body
label: small caps caption
.label: display family, 0.75rem, 0.12em tracking, lowercase, muted. Section captions and tile labels.
mono: 0x1F 18.4K 12:30 --primary
--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

UseSizeWhere
App titledisplay, set by tk-header<h1 slot="title">
Section headdisplay 1.2–1.5rema sheet's heading; a page's h2
Card or row titlebody 1rem, weight 600.card h3, .row .text span
Body15pxeverything else
Caption, hint, change line0.85rem, muted or secondary.form .hint, figcaption, tk-stat change
Label, badge0.72–0.75rem.label, .badge
Big numberdisplay or mono, 1.6–2.4rem, tabulartk-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>
WidthNavDetails
up to 899px (phones)drawer from the header's menu buttondrawer from the header's details button
900–1199pxdocked column, --sidebar-w 280pxdrawer
1200px and updockeddocked 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.

A linkCurrent
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.
<button class="btn">Save</button>
<button class="btn ghost">Cancel</button>
<button class="btn" disabled>Saving…</button>
<button class="btn">${PLUS_ICON} New</button>
<button class="icon-btn" aria-label="Settings">${SETTINGS_ICON}</button>
<button class="btn ghost" aria-pressed="true">Left-handed</button>
<div class="seg" role="group" aria-label="Range"><button class="on">Day</button><button>Week</button><button>Month</button></div>
<a class="u" href="#">A link</a>
  • .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.

A name is needed to save.

Hints sit under the field in muted text.

<div class="form">
  <label for="level">Level</label>
  <select id="level"><option>Beginner</option><option>Intermediate</option></select>

  <label for="goal">Daily goal (min)</label>
  <input id="goal" type="number" inputmode="numeric" min="5" max="120" step="5" value="20">

  <label id="lh-label">Left-handed</label>
  <button class="switch" role="switch" aria-checked="false" aria-labelledby="lh-label" @click=${this.toggle}></button>

  <label for="name">Name</label>
  <input id="name" aria-invalid="true" aria-describedby="name-err">
  <p class="error" id="name-err">A name is needed to save.</p>

  <label><input type="checkbox" checked> Sound on</label>
</div>

Pattern CSS

.form { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 14px 16px; align-items: center; }
.form label { color: var(--text-secondary); }
.form .hint, .form .error { grid-column: 1 / -1; margin-top: -10px; font-size: 0.85rem; }
.form .hint { color: var(--text-muted); }
.form .error { color: var(--accent); }
.form input[aria-invalid="true"], .form select[aria-invalid="true"] { border-color: var(--accent); }
.form input:not([type="checkbox"]):not([type="radio"]), .form select { width: 220px; max-width: 100%; justify-self: end; }
.form .switch { justify-self: end; }
.form label:has(input) { display: inline-flex; align-items: center; gap: 8px; color: var(--text-primary); }
.form label:has(input[type="checkbox"]) { grid-column: 1 / -1; }
.form textarea { width: 100%; grid-column: 1 / -1; }
@media (max-width: 480px) { .form input:not([type="checkbox"]):not([type="radio"]), .form select { width: 160px; } }
input[type="checkbox"], input[type="radio"] { accent-color: var(--primary); width: 18px; height: 18px; margin: 0; }
  • 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.
<span class="status"><span class="dot running"></span> running</span>
<span class="badge">3 open</span>

Notices

Practice is saved on this device only. Nothing leaves it.

Offline since 12:30. Changes are kept and sent when you are back.

.notice { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; background: var(--panel); border-left: 3px solid var(--primary); border-radius: var(--radius); }
.notice.alert { border-left-color: var(--accent); }
.notice svg { width: 18px; height: 18px; flex: none; margin-top: 2px; color: var(--text-muted); }
.notice.alert svg { color: var(--accent); }
.notice p { margin: 0; }

Progress

tk-progress is the one progress bar; it reads out as a progressbar with its value and goal. For the whole picture, see Data.
<tk-progress label="today" unit="min" .value=${12} .max=${30}></tk-progress>

Empty and loading states

No boards yetAdd one to start tracking your keymaps.
Loading sessions…
An empty state says what would be here and offers the first step. Loading is the running dot and words in a polite live region; there is no spinner.
.empty { display: grid; justify-items: center; gap: 10px; padding: 28px 16px; text-align: center; color: var(--text-muted); border: 1px dashed var(--line); border-radius: var(--radius); }
.empty strong { color: var(--text-secondary); font-weight: 600; }
.empty svg { width: 28px; height: 28px; }

Overlays

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.

Sheet

<tk-sheet heading="Sheet" .open=${this.open} @tk-close=${() => (this.open = false)}>
  <p>Slotted content.</p>
</tk-sheet>

Settings dialog

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.
<tk-sheet heading="Settings" .open=${this.settingsOpen} @tk-close=${() => (this.settingsOpen = false)}>
  <div class="form">
    <label for="level">Level</label><select id="level">…</select>
    <label id="lh">Left-handed</label><button class="switch" role="switch" aria-checked=${this.leftHanded} aria-labelledby="lh" @click=${this.toggleHand}></button>
    <label for="goal">Daily goal (min)</label><input id="goal" type="number" inputmode="numeric" .value=${this.goal}>
  </div>
</tk-sheet>

Confirm

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
Numpadnever used
new
<span class="label">boards</span>
<div class="rows">
  ${boards.map((b) => html`<div class="row">
    <div class="text"><span>${b.name}</span><small>${b.keymaps} keymaps</small></div>
    <div class="actions">
      <button class="icon-btn" aria-label="Rename ${b.name}">${PENCIL_ICON}</button>
      <button class="icon-btn" aria-label="Delete ${b.name}">${TRASH_ICON}</button>
    </div>
  </div>`)}
</div>
.rows { display: grid; border-top: 1px solid var(--line); }
.row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line); min-height: 44px; }
.row .text { flex: 1; min-width: 0; display: grid; }
.row .text span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row .text small { color: var(--text-muted); }
.row .actions { display: flex; gap: 2px; margin-right: -8px; }

Cards

Streak

Six days in a row. One more makes a week.

session
Started
12:30
Length
18 min
Average
34 wpm
A card holds one subject. Lists of cards are rows in disguise; use rows.
.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px 16px; display: grid; gap: 8px; }
.card h3 { margin: 0; font-size: 1rem; font-weight: 600; }
.card p { margin: 0; color: var(--text-secondary); }
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 18px; margin: 0; }
.kv dt { color: var(--text-muted); }
.kv dd { margin: 0; font-variant-numeric: tabular-nums; }

Data

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.
<tk-stats>
  <tk-stat wide label="average" value="34" unit="wpm" change="+5 vs previous 2 weeks" good="up" .trend=${wpms}></tk-stat>
  <tk-stat label="sessions" value="42"></tk-stat>
  <tk-stat label="streak" value="6" unit="d"></tk-stat>
</tk-stats>
<tk-timeseries y="wpm" goal="40" unit="wpm" .data=${days}></tk-timeseries>
<tk-bars label="key" value="wpm" goal="40" unit="wpm" .data=${keys}></tk-bars>
<tk-progress label="today" unit="min" .value=${12} .max=${30}></tk-progress>
<tk-heatmap end="2026-11-09" weeks="12" unit="min" legend .data=${cal}></tk-heatmap>

Icons

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.

tokens classes files

Slotted content.

“Work” and its 3 keymaps go away. This cannot be undone.