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.
--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.
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: 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 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--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 hairlineElevation
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.65Shadows
--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 · exitsStacking
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-toolbar-paper Bare action glass panes, above ink and below controls.--z-pointer-halo Brush ring and eraser footprint, above toolbar glass and below controls.--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-clear-ink The departing page above the clear wash, beneath the Clear Button it flies into.--z-corner-button The muted corner buttons (Settings Button).--z-panel The bottom dock and its Actions Panel drawer; each caps its own subtree.--z-flyout Orders the flyout inside .actions-panel only; its tie with --z-panel is inert.--z-banner The Install Banner inside the bottom dock, clear of the corner controls.--z-clear-accept-zone The drag-accept ring, below the button it rings.--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.--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 (42)
chevron-*theme-*mobile-*volume-*fullscreen-**-homescreenSpot color · never tinted (52)
release-* · release-note headings, inked by currentColorbrush-* · 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.
- 1Install as App
- 2Set up playtime
- 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.
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
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.
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
Focus
Tab through these controls to see the same brand ring follow each shape.
--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
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.
- Short enough to sit in the box whole.
- So the cue never lights: there is nothing below to reach.
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.
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.
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.ColorSwatch.svelte · 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".SaveFailureBanner.svelte Top-of-canvas notice telling the grown-up a picture was not saved, with Try again.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.
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
dottie-sunnydottie-kind-eyesdottie-leandottie-giggledottie-brightError expressions
dottie-stumpeddottie-hiccupdottie-questiondottie-retrydottie-another-idea