← Back to 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.tstokens.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
Ground & surfaces
Text ink
Icon ink
Hairlines
Control tracks
Brand ramp
Feedback washes
Paper
Floating on paper
Non-color theme tokens
--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 rgba(93, 84, 68, 0.14), 0 6px 16px rgba(93, 84, 68, 0.1) · 0 0 0 1px rgba(255, 255, 255, 0.06), 0 3px 10px rgba(0, 0, 0, 0.5) — 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 Courier New · code & versions

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 · Advanced Controls --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 and version strings: masked keys, inline code.
--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
--border-width 1px · the one hairline

Elevation

--shadow-control The tight lift on a small raised control: the modal close disc, a selected segment thumb.
--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

Two curves: the springy overshoot for anything that pops in or celebrates, the glide for anything that settles or leaves. 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 Anything that pops in or celebrates: dialog fly-ins, download-done, the swatch ring.
--ease-glide Anything that settles or leaves: the polaroid, the clear ripple.
--duration-fast 0.15s · presses & hovers--duration-base 0.2s · standard transitions--duration-slow 0.35s · whole-surface entrances

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.

4 --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.
500 --z-ripple The clear-confirmation ripple, over the preview wash.
900 --z-corner-button The muted corner buttons (Settings Button).
901 --z-panel The Actions Panel drawer — caps its own subtree, including the flyouts.
901 --z-flyout Orders the flyout inside .actions-panel only; the root-context tie with --z-panel is inert.
950 --z-banner The Install Banner, taking over the corner controls while shown.
999 --z-clear-accept-zone The drag-accept ring, below the button it rings.
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 (36)

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

Spot color · never tinted (37)

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
feedback
more-colors
parent-center
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.

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

Part 2

Components & chrome

The shared building blocks: the primitives in lib/components/design/, the settings furniture, and the global chrome classes in app.css — plus a named index of the deliberately bespoke chrome.

Primitives

Button design/Button.svelte

Segmented picker design/SegmentedPicker.svelte

A control with a selected state is a picker, not a Button. segment is the raised-thumb track; chip is the borderless toggle grid; radio vs toggle semantics stay with the caller. A form that must post without JavaScript renders the same chrome over real native radios through inputName. labels="collapsible" lets a call site drop the words at a width of its own choosing: each option keeps its accessible name and a 44px square target, so the collapse costs the visible label and nothing else. Use it where an icon already says what the option is.

underline is the third skin, for a standalone page switching between two views of itself rather than setting something: a rule under a row of labels, the live one replacing its stretch of that rule with a brand segment and taking the brand ink with it. Icons follow that ink rather than --icon-ink, so the live tab moves as one mark. It is also the one variant that owns its own width — it hugs the left on a sheet, and at phone width the cells split the row evenly so each segment is a whole cell. A caller whose sheet reaches the screen edges supplies the bleed past its own gutter; the beta page does.

Status message design/StatusMessage.svelte

The success wash, as a form shows it after a submit resolves.

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.

Advanced controls

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. It takes no props and answers for itself: absent while the content fits, present while there is more of it under the fold, absent again once the end is on screen. Its one contract is positional — render it as the last child of the scrolling content, because it plants its end-of-content sentinel wherever it stands, and a copy lifted out of the scroller measures the wrong end. Depth is the inherited --scroll-cue-height, declared by the call site on any ancestor; both specimens below take the default. How far down it reaches is not the call site's to set: a scroller clips at its padding box, so it measures its own scroller's bottom padding and fades to that edge however deeply the scroller pads. The sentinel's observer leaves its root implicit — one component serving a dialog, a settings pane and a whole page without being told which — which is also why a specimen still under this page's own fold reports more below until you bring it up.

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

Settings furniture

The shared rows every Settings section is built from — pure props, themed end to end, icons from the app set. Live and interactive below.

Turns a finished drawing into an AI picture based on it.

Offers a small set of distinct styles to pick from before the picture is generated.

Saves each AI picture and the drawing behind it to your photos

Button size 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 and icon tint step idle (0.4) → hover → pressed. 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 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".
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 3

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 for Android
brand mark · page/BrandMark.svelte, as PageShell wears it
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 ↑