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
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.
--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.
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.
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.
Weights
Body prose stays at the untokenized 400 default; everything heavier goes through a token.
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 40px1–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 hairlineElevation
--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 entrancesStacking
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.
--z-canvas-chrome The floor of the shared root context — chrome that must clear the canvas layers.--z-clear-preview The full-viewport paper wash previewing a drag-to-clear.--z-ripple The clear-confirmation ripple, over the preview wash.--z-corner-button The muted corner buttons (Settings Button).--z-panel The Actions Panel drawer — caps its own subtree, including the flyouts.--z-flyout Orders the flyout inside .actions-panel only; the root-context tie with --z-panel is inert.--z-banner The Install Banner, taking over the corner controls while shown.--z-clear-accept-zone The drag-accept ring, below the button it rings.--z-clear-button The Clear Button itself.--z-notch The safe-area Notch Band; its tie with --z-clear-button resolves by DOM order.--z-clear-coachmark The tutorial ghost button, above the real one.--z-palette The Color Palette bar.--z-waiting-polaroid The AI Waiting Polaroid — the only way back into a minimized run, over the palette edge it is pinned beside.--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)
chevron-*theme-*mobile-*volume-*fullscreen-**-homescreenSpot color · never tinted (37)
brush-* · the Brush Menu entries, in menu ordersize-brush-1…5 · the brush stroke previews, inked by currentColorsize-magic-1…5 · the magic stroke previews, carrying the brush’s rainbowsize-eraser-1…5 · the eraser hole previews (--paper / --hole-stroke)line-weight-* · the Stroke Width Button, per active tooltrash-* · the Clear Button pairRecipes
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.
Card
--surface · --border hairline · --radius-lg · --space-4 padding · --text-strong heading over --text body and --text-soft meta
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
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
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.
- Short enough to sit in the box whole.
- So the cue never lights: there is nothing below to reach.
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
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
Modal title
The centered card — surface, radius, --shadow-pop — shared by all four modals.
Monochrome icons inside re-ink automatically:
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
Polaroid frame · .polaroid-frame
--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
DrawingCanvas.svelte The full-screen paper drawing surface.ColorPalette.svelte Swatch bar — left edge in landscape, top in portrait; trims swatches by priority as space shrinks.ColorPicker.svelte The last swatch, a honeycomb of palette hexagons that opens the hex color picker.ActionsPanel.svelte Bottom-corner drawer of chunky floating action buttons (brush, width, coloring, camera, AI, undo).BrushMenu.svelte · StrokeWidthMenu.svelte The two flyouts — built on the shared flyout classes above.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.SettingsButton.svelte · FullscreenToggle.svelte Corner-button instances (shared chrome above); positioning stays per-component.NotchBand.svelte Thin strip filling the top safe-area inset so the camera notch sits on chrome, not paper.InstallBanner.svelte Bottom-center pill inviting "Add Splotch to your home screen".PointerHalos.svelte Touch-feedback rings under fingers while drawing.AiDial.svelte The generation-progress dial, with AiConfetti.svelte for the reveal.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).app.css (.polaroid-*) The save-screenshot flight — flash, frame, and glide are created imperatively on <body>.Page chrome
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).SettingsModal.svelte · settings/CompactShell.svelte One section list, two responsive shells — drill-in on compact screens, sidebar when wide.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.ErrorScreen.svelte The crash boundary’s friendly restart surface.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.
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.
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.