Start drawing

Splotch design system

The visual language, rendered live from its sources. If it's not on this page, it's not part of the visual language.

Sources: lib/design/tokens.ts · tokens.css (npm run gen:tokens) · lib/palette.ts · the icon set · the shipped components

Part 1

Foundations

The vocabulary every style is built from. Consume these by reference (var(--…), imports), never by copied value.

Color

Every color, grouped by family. Chips paint the live variable — flip the theme in the top bar and the values follow. Tap any chip for its usage rule and both theme values.

Brand & unthemed
On scrim · both themes
Ground & surfaces
Text ink
Icon ink
Hairlines
Control tracks
Brand ramp
Status
Crayon details
Crayon hues · both themes
Paper
Floating on paper
Non-color theme tokens
--surface-rgb 255 255 255 · 35 35 43 — --surface as RGB channels, for a fade to the surface that must paint without color-mix().
--step-wash-strength 9% · 24% — Crayon contribution to a numbered-step disc: subtle on light sheets, lifted on dark grounds.
--step-ink-strength 45% · 0% — Crayon contribution to a numbered-step digit: tinted in light mode, full --text-strong at night.
--tape-strength 100% · 45% — Crayon contribution to tape fills on --surface. Use green, blue, orange or pink. Purple is dark-only; yellow does not hold AA at night.
--squiggle-strength 100% · 70% — Crayon contribution to decorative squiggles on --surface.
--blob-shadow 0 3px 8px color-mix(in srgb, var(--brand-solid) 35%, transparent) · none — The lift under a filled blob button; omitted on dark sheets.
--glass-tint-rgb 252 251 248 · 33 31 41 — Paper RGB channels for the Bare toolbar glass tint.
--rule-blend multiply · screen — How the printed margin combines with the drawing ink.
--rule-opacity 1 · .55 — Strength of the first printed margin pass.
--rule-secondary-opacity .6 · .33 — Strength of the second printed margin pass.
--lineart-filter none · invert(1) — Coloring-page line art inversion — dark mode turns lines to chalk.
--lineart-blend multiply · screen — The blend mode pairing --lineart-filter.
--float-shadow 0 2px 6px rgb(93 84 68 / 14%), 0 6px 16px rgb(93 84 68 / 10%) · 0 0 0 1px rgb(255 255 255 / 6%), 0 3px 10px rgb(0 0 0 / 50%) — The one themed lift for everything floating on the paper — cards, open flyouts, page sheets.

Crayon palette

The drawing inks from lib/palette.ts, in the palette's own order — purple first because it's the default selection. The bar shows as many of them as the viewport fits and drops the rest by trim priority, so a small phone still gets a rainbow rather than a handful. Black is near-black ink that presents (and paints) as white on dark paper.

Purple
Indigo
Blue
Teal
Mint
Green
Lime
Yellow
Orange
Brown
Red
Pink
Magenta
Grey
Black

Paper

The canvas is warm off-white paper under a low-alpha handmade-paper grain (icons/handmade-paper.webp, tiled). Dark paper keeps the same grain webp — only the color beneath changes. --paper-margin is the flat tone behind the rotation-locked sheet.

light paper · #fcfbf8
dark paper · #211f29
light margin · #f1efeb
dark margin · #1a1922

Type scale

Five body steps plus the display tier, one role each — if two steps both look right, take the smaller. Titles stop at xl; only the H1 of a whole page takes display.

Splotch says hello --font-size-xs 12px · fine print
Splotch says hello --font-size-sm 14px · UI chrome
Splotch says hello --font-size-md 16px · body prose
Splotch says hello --font-size-lg 18px · ledes & section heads
Splotch says hello --font-size-xl 22px · titles
Splotch says hello --font-size-display fluid 34–46px · page H1
Splotch says hello --input-font-size ≥16px · text inputs (16px floor)
Splotch says hello --font-mono ui-monospace · code

Weights

Body prose stays at the untokenized 400 default; everything heavier goes through a token.

Let them make a mess. --font-weight-bold 700 · headings
Settings, not a paywall --font-weight-semibold 600 · buttons, active states, sub-heads
Sound · Night Mode · Tool drawer --font-weight-medium 500 · quiet labels
Full usage rules & the input-size floor
--font-size-xs Fine print: token values, timestamps, badge counts.
--font-size-sm UI chrome: buttons, labels, rows, nav — the workhorse.
--font-size-md Body prose on parent pages and modals.
--font-size-lg Ledes and section headings.
--font-size-xl Titles — the ceiling inside any surface: modal titles, card titles, section H2s. Nothing between this and the display tier.
--font-size-display The H1 of a whole page — PageShell’s hero, the crash screen — fluid from phone to desktop.
--input-font-size Every text input — floors the size at 16px so iOS Safari never zoom-strands the canvas (ADR-0076).
--font-family The app-wide sans stack; reference it, never restate it.
--font-mono Raw code: masked keys, inline code, ledger ids. A version number is a label and stays in the sans.
--font-weight-medium Quiet labels: settings rows, list leads.
--font-weight-semibold Buttons, active states, sub-heads.
--font-weight-bold Headings. Body prose stays at the untokenized 400 default.

Spacing, radius & hairlines

--space-1
4px
--space-2
8px
--space-3
12px
--space-4
16px
--space-5
20px
--space-6
24px
--space-7
32px
--space-8
40px

1–2 inside a control · 3–4 between controls and card padding · 5–6 tap targets and gutters · 7–8 section and page breaks.

Radius

--radius-sm 8px · inline chips
--radius-md 12px · controls
--radius-lg 16px · cards & sheets
--radius-pill 999px · pills & toggle tracks
--radius-blob-1 58% 42% 55% 45% / 45% 58% 42% 55% · first paint daub
--radius-blob-2 45% 55% 48% 52% / 55% 45% 58% 42% · second paint daub
--radius-blob-3 52% 48% 55% 45% / 45% 55% · third paint daub
--border-width 1px · the one hairline

Elevation

Toolbar glass

--glass-rail Paper tint strength under the Bare color rail. 77%
--glass-strip Paper tint strength under Bare tools, flyouts and corner controls. 85%

Disabled

--disabled-opacity A whole disabled control: toggle rows, chips, buttons, gate keys. Never for text or a glyph on its own — those change ink token. 0.65

Shadows

--shadow-control The tight lift on a small raised control: a selected segment thumb, a tool popover.
--shadow-pop The deep overlay lift under whole modal cards.
--float-shadow The one themed lift for everything floating on the paper — cards, open flyouts, page sheets.

Motion

Three curves: the springy overshoot for a two-state pop, the glide for anything that settles or leaves, and the symmetric drawer curve for two things that move as one. Press states scale down (0.9–0.96); hovers swap to a wash or hover token. Tuned one-shot choreography — the AI reveal, the polaroid flight — carries its own timing. The lanes run each easing curve on a loop.

--ease-pop A two-state pop or a single-segment keyframe: download-done, the drawer cascade. Hand-drawn overshoot runs linear instead.
--ease-glide Anything that settles or leaves: the polaroid, the swatch ring, the undo ghost.
--ease-drawer Two things that must move as one: the drawer and its chevron. Symmetric, so neither gets ahead.
--duration-fast 0.15s · presses & hovers--duration-base 0.2s · standard transitions--duration-slow 0.35s · whole-surface entrances--duration-exit 0.2s · exits

Stacking

The cross-component chrome order, low to high — one list, but not one stacking context, so a bigger number doesn't always win. Everything resolves in the root context except --z-flyout, which only orders the flyout inside .actions-panel; the --z-clear-button/--z-notch tie is real and resolved by DOM order.

3 --z-toolbar-paper Bare action glass panes, above ink and below controls.
4 --z-pointer-halo Brush ring and eraser footprint, above toolbar glass and below controls.
5 --z-canvas-chrome The floor of the shared root context — chrome that must clear the canvas layers.
400 --z-clear-preview The full-viewport paper wash previewing a drag-to-clear.
600 --z-clear-ink The departing page above the clear wash, beneath the Clear Button it flies into.
900 --z-corner-button The muted corner buttons (Settings Button).
901 --z-panel The bottom dock and its Actions Panel drawer; each caps its own subtree.
901 --z-flyout Orders the flyout inside .actions-panel only; its tie with --z-panel is inert.
950 --z-banner The Install Banner inside the bottom dock, clear of the corner controls.
998 --z-clear-accept-zone The drag-accept ring, below the button it rings.
999 --z-rail-glass The Bare palette rail glass and its printed margin rules, covering the drag-accept ring and the clear flood like the Buttons palette surface.
1000 --z-clear-button The Clear Button itself.
1000 --z-notch The safe-area Notch Band; its tie with --z-clear-button resolves by DOM order.
1001 --z-clear-coachmark The tutorial ghost button, above the real one.
1002 --z-palette The Color Palette bar.
1003 --z-waiting-polaroid The AI Waiting Polaroid — the only way back into a minimized run, over the palette edge it is pinned beside.
1004 --z-polaroid The save-screenshot polaroid flight — the top of the chrome order.

Icons

A first-party SVG set rendered inline through <Icon> — no icon font, no CDN set, no emoji-as-icons. Monochrome glyphs bake a near-black fill and get re-inked via fill: var(--icon-ink) on themed surfaces. Full-color "spot" icons carry their own palette and are never tinted wholesale — the split below is COLOR_ICONS in Icon.svelte. Individual paths inside a spot icon can still take a per-theme fill: they paint with var(--icon-<icon>-<part>), declared in iconTokens.ts and emitted into tokens.css beside the semantic tokens. Flip the theme toggle above to see which parts move.

Monochrome · re-inked (42)

up
down
left
right
chevron-*
light
dark
auto
theme-*
portrait
landscape
rotate
mobile-*
on
off
volume-*
fullscreen
exit
fullscreen-*
add
install
*-homescreen
accessibility
android
backspace
button-style-flat
button-style-raised
camera-party
check
close
customize
dashboard-customize
download
external
flag
folder
github [#142] github
home
loading
lock
more-vert
phone-tablet
photo-size-select-small
picture-stack
reduce-motion
settings
share-ios
swipe-down

Spot color · never tinted (52)

new
improved
fixed
release-* · release-note headings, inked by currentColor
pen
crayon
magic
eraser
brush-* · the Brush Menu entries, in menu order
1
2
3
4
5
size-brush-1…5 · the brush stroke previews, inked by currentColor
1
2
3
4
5
size-magic-1…5 · the magic stroke previews, carrying the brush’s rainbow
1
2
3
4
5
size-eraser-1…5 · the eraser hole previews (--paper / --hole-stroke)
brush
eraser
magic
line-weight-* · the Stroke Width Button, per active tool
closed
open
trash-* · the Clear Button pair
appearance
camera
controls
dottie-another-idea
dottie-bright
dottie-giggle
dottie-hiccup
dottie-kind-eyes
dottie-lean
dottie-question
dottie-retry
dottie-stumped
dottie-sunny
feedback
ink-splotch
more-colors
parent-center
refresh
save-picture
setup
shapes
sound
undo
wand-stars
whats-new

Recipes

The defaults, composed — how a handful of tokens assemble into the surfaces the app builds. Start from one of these before inventing a new arrangement; if a recipe and a rule disagree, the token's usage rule wins.

  1. 1Install as App
  2. 2Set up playtime
  3. 3Start drawing

Numbered steps

Shared step-number discs use --step-wash-strength and --step-ink-strength. Light sheets keep a crayon tint; night digits use --text-strong over a lifted disc. Layouts supply size and an optional crayon hue.

Coloring Book

Line-art pages for the crayons to color in — open one up and let them scribble.

Two books · updated with each release

Card

--surface · --border hairline · --radius-lg · --space-4 padding · --text-strong heading over --text body and --text-soft meta

Saved on this device — forget it any time.

Form row

--font-size-sm --font-weight-medium label · --input-font-size input on --surface with a --border hairline at --radius-sm, typing in --text-strong over a --text-soft placeholder · --font-size-xs --text-soft help

Not sure? Start here

A brand-tinted aside that guides without shouting — the wash flips with the theme and the ink stays readable on it.

Callout

--brand-wash fill · --radius-lg · --space-4 padding · --text-strong lead, --text body. For a form's submit result, use the StatusMessage primitive instead

Ready when they are

Open it up, hand over the device, and let them make a mess. That's the whole idea.

CTA block

A card composed with the Button primitive's brand variant — --font-size-lg lede · --space-3 between body and action

Sidebar TOC

A scrollspy table of contents over one continuous document — live: pick a row and the track follows. --border hairline track down the full list · active segment 3px --brand · active row --brand-wash fill with --brand-text ink · --radius-sm on the trailing corners only. Icons and a second line are per-item. Use it wherever a column indexes one scrolling page; use the Button primitive's chip variant for anything that actually switches pages

Focus

Every interactive element gets the global brand outline. Override only outline-offset: use a negative offset for a filled pill inside a clipped track. Never remove an outline without a replacement ring.

Access codes · 12

The section owns the space below its label.

Rule label

Section marker on standalone pages. Never used inside dialogs; there the SettingsSection heading applies. --space-5 below the label; --space-8 between sections.

Part 2

Primitives

Reusable controls and content patterns, with their supported variants and states.

Dialog header design/DialogHeader.svelte

Back and close share a 48px target and --icon-ink glyphs. Back has a flat fill with extra space before the title; close has an outlined disc. The title can wrap; optional actions sit beside close.

Appearance

Button design/Button.svelte

Four variants, one set of states. brand is the primary action; wash is secondary or selected; outline is the quiet secondary for dense rows and toolbars; danger confirms something destructive. Disabled drops every variant to the same neutral treatment (--control-track fill, --text label at --disabled-opacity; outline keeps a --border hairline instead), so a parked button cannot be mistaken for a live one on either paper. Busy is the busy prop: it implies disabled, sets aria-busy, keeps the variant’s fill, and adds a 14px ring in currentColor before the label. The call site supplies the present-tense verb. The specimen below plays once as it comes into view; tap it to replay.

States DefaultHoverPressedDisabled brand wash outline danger
Sizes lg 16px label, 14px vertical padding · a screen’s one decisionmd 14px label, 12px vertical padding · the defaultsm 14px label, 8px vertical padding · inline retry, dense rowshero 18px bold label, 56px pill · kid-facing recovery
Busy Tap to replay the busy state.

Focus

Tab through these controls to see the same brand ring follow each shape.

Copy link Wrong: browser default outline (static comparison, outside the tab order).
--focus-ring-width · 2px
Every interactive element gets the ring from the global rule. Never outline: none without a replacement ring.
--focus-ring-offset · 2px
A component overrides only outline-offset: negative for filled surfaces that cannot spill, such as a pill inside a track.

Segmented picker design/SegmentedPicker.svelte

Choose segment for a single choice, chip for independent toggles, or underline for switching views. Radio mode selects one option; toggle mode lets the caller support deselection or multiple choices.

Variants below show labels, icon-only labels, a compact deselectable control, native form radios, view tabs, and multiple selection. Use inputName for a form that must work without JavaScript. Collapsible labels keep each icon’s accessible name and touch target.

Status message design/StatusMessage.svelte

Success message: the action is complete.

Warning heading. Describe what needs attention, with an optional detail.

Rule label design/RuleLabel.svelte

Heading

Description or section content.

Heading with count · Count

The optional count follows the heading. Content sits below the rule.

Strong heading

The heavier tier: section heads inside Settings, over the controls they name.

Disclosure design/Disclosure.svelte

The primitive owns the bordered shell, the hidden native marker, and the › chevron that rotates on open. Padding, type, color, and background stay with the call site, through the forwarded class.

Disclosure heading

Help text is one calm sentence, styled by the call site.

Scroll cue design/ScrollCue.svelte

The fade that says a scroller's content carries on below: absent while content fits, present while more remains under the fold, and absent again once the end is on screen.

When content mounts or appears in stages, pass contentPending to show the fade immediately. Clear it once the content is whole to resume the observed fit and scroll-end behavior.

For a bounded pane, wrap its scroller in ScrollCue. The children snippet receives an end-marker snippet to render as the scroller's last child. The fade paints beside the scroller as an overlay, covering its content edge while leaving scrollbar gutters clear. Its position is independent of content padding.

For content that scrolls with the document, or an existing scrolling dialog, render ScrollCue without children as the last child of the scrolling content. This form places its own sentinel and sticky fade there; the scroller declares --scrollport-bottom-padding beside its bottom padding so the fade reaches the scrollport edge. Both forms observe the sentinel through every scrolling ancestor, including this page. A specimen below the page's fold can therefore report more content until you bring it into view.

Depth is the inherited --scroll-cue-height. Set it on an ancestor of the fade; for the wrapper form that means above ScrollCue, since the scroller is the fade's sibling. These specimens use the default depth.

  • The cue is lit: there is more of this list below the fold.
  • It is a reading of live scroll state, never decoration.
  • One IntersectionObserver over a sentinel answers all three states.
  • So there is no scroll listener, and nothing measured per frame.
  • The fade sticks to the scrollport and costs the content no height.
  • Grow what is above it and it re-reads itself.
  • Show a surface that was hidden and it re-arms.
  • Two lines left.
  • Last line — and the fade has stood down.
Live — scroll it. The fade retires on the last line and lights again the moment you leave it.
  • Short enough to sit in the box whole.
  • So the cue never lights: there is nothing below to reach.
The same box over content that fits. Same markup, no fade — the third state costs the call site nothing.
  • The cue is lit: there is more of this list below the fold.
  • It is a reading of live scroll state, never decoration.
  • One IntersectionObserver over a sentinel answers all three states.
  • So there is no scroll listener, and nothing measured per frame.
  • The fade sticks to the scrollport and costs the content no height.
  • Grow what is above it and it re-reads itself.
  • Show a surface that was hidden and it re-arms.
  • Two lines left.
  • Last line — and the fade has stood down.
Bounded pane — the marker scrolls with the lines; the fade stays at the content edge.

Visually hidden design/VisuallyHidden.svelte

Text a screen reader announces and the page never shows, such as the word an icon stands in for or what a badge means. It renders a span; a role="status" announcement takes as="p", and a field shown with only a placeholder takes as="label" with for.

Open the three-dot menu

A screen reader reads the line above as “Open the three-dot menu.”

External mark design/ExternalMark.svelte

The paint blob that ends every link opening outside Splotch, so a grown-up sees it leave the app before the parental gate asks. It goes inside the <a>, straight after the label, and adds “(opens outside Splotch)” to the link's name. inline takes the link's ink in running prose; standalone sits muted beside an icon-led link on its own line. Filled buttons and mailto: links go without.

Read the source on GitHub(opens outside Splotch), where every line of this page lives.

View on GitHub(opens outside Splotch)

Part 3

Components & chrome

Settings rows, shared chrome classes, and the app’s bespoke surfaces.

Settings furniture

The shared rows every Settings section is built from — pure props, themed end to end, icons from the app set. The slider drags relatively (grab anywhere, slide the value) and wears a 36px disc thumb so the handle is obvious; the tick marks the snap detent where a row defines one. Live and interactive below.

Optional help describes the setting.

Slider row 60%

A control with a selected state is a picker, not a Button — the theme picker, the orientation segment, and the controls chips all render through the SegmentedPicker primitive above.

Shared chrome classes

Global classes in app.css, not components — dialogs and imperative DOM need them unscoped. Specimens below use the real classes; only placement is overridden, which stays with the consumer anyway.

Modal shell + close button · .modal-shell .modal-close-btn

Flyout menu + options · .flyout-menu .flyout-option

Paper-card popover on --float-surface with --float-shadow — live: pick an option and the selected entry wears the brand ring.

Corner button · .corner-button

Muted canvas-corner chrome: whole-button opacity steps idle (0.5, the floor that keeps the glyph above 3:1 on both papers) → hover → pressed over a full-ink glyph. Drawer toggle, Fullscreen Toggle, Settings Button.

Polaroid frame · .polaroid-frame

The save-screenshot effect: the drawing rides inside the frame, flies in on --ease-glide, and settles at −4°. Photographic paper stays white in both themes on purpose — it reads as a physical polaroid, not a themed surface.

Named chrome

Bespoke, single-instance chrome — named so it's discoverable, not recreated (the running app is its documentation; the canonical glossary is in the architecture skill). Each map shows where it lives on the canvas.

Canvas chrome

Drawing Canvas DrawingCanvas.svelte The full-screen paper drawing surface.
Color Palette ColorPalette.svelte Swatch bar — left edge in landscape, top in portrait; trims swatches by priority as space shrinks.
Gradient Swatch ColorSwatch.svelte · ColorPicker.svelte The last swatch, a honeycomb of palette hexagons that opens the hex color picker.
Actions Panel ActionsPanel.svelte Bottom-corner drawer of chunky floating action buttons (brush, width, coloring, camera, AI, undo).
Brush Menu / Stroke Width Menu BrushMenu.svelte · StrokeWidthMenu.svelte The two flyouts — built on the shared flyout classes above.
Clear Button ClearButton.svelte The red drag-to-clear trash control; its rest gradient is the unthemed --clear-gradient-rest, and ClearCoachmark.svelte paints the tutorial ghost from the same token.
Settings Button / Fullscreen Toggle SettingsButton.svelte · FullscreenToggle.svelte Corner-button instances (shared chrome above); positioning stays per-component.
Notch Band NotchBand.svelte Thin strip filling the top safe-area inset so the camera notch sits on chrome, not paper.
Install Banner InstallBanner.svelte Bottom-center pill inviting "Add Splotch to your home screen".
Save Failure Banner SaveFailureBanner.svelte Top-of-canvas notice telling the grown-up a picture was not saved, with Try again.
Pointer Halos PointerHalos.svelte Touch-feedback rings under fingers while drawing.
AI Dial AiDial.svelte The generation-progress dial, with AiConfetti.svelte for the reveal.
Waiting Polaroid AiWaitingPolaroid.svelte The picture being made, pinned to the canvas's top-left corner past the palette while the child keeps drawing — and the only way back into it (ADR-0117).
Polaroid overlay app.css (.polaroid-*) The save-screenshot flight — flash, frame, and glide are created imperatively on <body>.

Page chrome

PageShell + RuleLabel page/PageShell.svelte The standalone-page chrome: ground, centered sheet, crayon-strip masthead, wordmark, hero. Worn by every standalone page (/beta, /changelog, /feedback, /privacy, /admin).
Settings shells SettingsModal.svelte · settings/CompactShell.svelte One section list, two responsive shells — drill-in on compact screens, sidebar when wide.
Sidebar TOC nav/SidebarToc.svelte The guide-rail table of contents the wide Settings sidebar, /design and /changelog all wear — one hairline track down the list, with the reading position thickening and tinting its own segment. Each host keeps its own scrollspy; the component takes the answer and renders it.
Error Screen ErrorScreen.svelte The crash boundary’s friendly restart surface.
Part 4

Brand & voice

How Splotch sounds and signs its name — the copy rules and the brand marks.

Voice & copy

Two voices, one maker. Kid-adjacent copy is playful and warm; parent-facing copy (Settings, store listings, privacy) is plain and direct. Sentence case everywhere — Title Case only for proper feature names (Night Mode, Guided Access). "You" is the parent, "they" is the child, "we" makes the maker's promises. No emoji in UI chrome. Feature bullets lead with verbs, and copy is honest about why a tradeoff exists.

Kid-adjacent — playful & warm
Open it up, hand over the device, and let them make a mess. That's the whole idea.
A blank page and a box of crayons — no ads, no accounts, nothing to buy.
Parent-facing — plain & direct
Your key is stored only on your device. We never keep a copy.
Splotch collects nothing in the background. No ads. No tracking. No analytics.

Mascot & wordmark

Splotchy — a rainbow-crayoned splotch — is the mascot and the PWA icon. There is no drawn logo: parent pages sign themselves with the brand mark below — the crayon strip (seven pills in rainbow order, hues looked up from the drawing palette) beside a small-caps wordmark — while page titles are plain Quicksand headings.

splotchy.svg
Splotch beta
brand mark · page/BrandMark.svelte, as PageShell wears it

Dottie

Dottie is Splotch's little purple companion: joyful, friendly, and warm. Her soft, uneven silhouette stays the same across expressions, with flat color and a few rounded lines giving her personality. She keeps her purple body and dark features in both themes.

Everyday expressions

Sunny dottie-sunny
Kind eyes dottie-kind-eyes
Little lean dottie-lean
Giggle dottie-giggle
Bright dottie-bright

Error expressions

A little stumped dottie-stumped
Oh, a hiccup dottie-hiccup
A small question dottie-question
One more go dottie-retry
Another idea dottie-another-idea
Rendered live from tokens.ts · palette.ts · the icon set. If it's not here, it's not part of the language. Back to top ↑