Imagery, isometric & visual hierarchy
Lopifi has almost no imagery inside its data surfaces — by design. But the places where it does have imagery (empty states, onboarding, concept explanation, the payment card, brand marks) carry disproportionate weight, because they're the only pictures in an otherwise typographic product.
Implemented. This document is the specification and the description of what ships:
IsometricSpotinsrc/ledger/components/isometric-spot.tsx, distributed as@lopifi/isometric-spot. Eight scenes, seven placements on this site, one per entry point. The section below on what the implementation learned records the three things this spec originally got wrong.
The isometric system
Why isometric at all
Financial concepts are structural — money moves between accounts, a period rolls up into a statement, a transaction matches a bank line. Flat illustration renders these as metaphors (a piggy bank, a rocket) which are noise. Isometric renders them as diagrams with volume: you can literally show a flow from one object to another, in space, without perspective distortion.
That's the rule that decides when to use it: isometric explains a mechanism; it does not decorate a screen.
The projection
True isometric, 30°. All three axes equally foreshortened.
X axis 30° below horizontal, going right
Y axis 30° below horizontal, going left
Z axis vertical
Practical construction: draw on a square grid, scale Y to 86.602% (cos 30°), rotate 30°, skew −30°. Or in a design tool, apply the standard isometric transform per face.
Not 2:1 dimetric. The pixel-art 2:1 (26.565°) projection is for sprite work where you need whole-pixel steps. We're vector, at multiple DPRs, so use the true 30° — it's geometrically correct and it composites correctly when two illustrations sit side by side.
The isometric grid
The illustration grid derives from the layout grid: 8px base cell, transformed into the isometric plane. Every object's footprint is a whole number of cells. This is why two independently-drawn illustrations stack coherently — they're on the same lattice, same as the layout.
Canvas sizes (all on the 8px grid):
inline 96 × 96 inside a card, next to text
spot 160 × 160 empty states — THE DEFAULT
feature 240 × 240 onboarding steps, feature rows
hero 400 × 400 marketing, first-run
diagram fluid × 240 flow diagrams, multi-object
The canvas is square and fitted to the scene, not fixed: the component computes the projected bounds of every solid, pads them, and centres the result. A hand-set viewBox is how an object ends up clipped at one edge and floating at the other, and it is the single most common reason a set of illustrations stops looking like a set.
Shading
Three tones per object. No gradients, no cast shadows, no ambient occlusion.
Light comes from above and to the left, consistent with the elevation model in elevation and consistent across every illustration in the system.
| Face | Treatment | OKLCH operation |
|---|---|---|
| Top | Lightest | base L + 0.10 |
| Left | Base | base |
| Right | Darkest | base L − 0.10 |
Because tokens are in OKLCH, "10% lighter" is perceptually the same step at every hue. That's why the whole illustration set looks like one hand drew it.
Ground shadow: a flat isometric rhombus — the object's own footprint — at --iso-shadow, offset down-right by an eighth of a cell. No blur, no stacking, one per object.
Two rules that are easy to get wrong and obvious once seen:
- The shadow is drawn at the object's base height, not at zero. A block resting on a plinth casts onto the plinth. A shadow that falls through to the floor is exactly what makes a stack read as floating.
- A ground plane casts nothing. The plinth is the surface. Offsetting a copy of it just draws a second plinth half a step away, which reads as a manufacturing defect.
Palette
Illustrations use the product palette, not an illustration palette. This is the single decision that keeps them from looking like stock art.
Three tones per tone-family, as tokens, so a scene is themed by the page rather than redrawn for it:
--iso-top / --iso-left / --iso-right the accent object
--iso-neutral-top / --iso-neutral-left / --iso-neutral-right everything else
--iso-ground-top / --iso-ground-left / --iso-ground-right the plane it sits on
--iso-shadow the flat contact rhombus
The ground ramp sits a step below the neutral ramp. Without that separation a plinth reads as another object that happens to be underneath, rather than as the surface.
Rules:
- One accent object per illustration. Same discipline as the UI: the accent is the subject.
- Max 4 hues total, including neutrals.
- Every illustration ships light and dark variants. Not a filter — a re-render against the dark tokens, because
L + 0.10for a top face has to invert its relationship to the canvas. Ship as two SVGs, or one SVG usingcurrentColor+ CSS custom properties (preferred — one file, themed by the page).
<!-- themed by CSS, one asset for both modes -->
<path fill="var(--iso-top)" d="…" />
<path fill="var(--iso-left)" d="…" />
<path fill="var(--iso-right)" d="…" />
Line work
No outlines by default. Faces meet face-to-face; the tonal step defines the edge. Outlines make it look like a diagram from 2016.
The exception: a 0.5px line at --border on the silhouette only (not internal edges) when an illustration sits on a surface with insufficient contrast — typically a light-mode illustration on a white card. Use it as a fix, not a style.
The object library
Every scene is assembled from one vocabulary — plane, plinth, slab, column, wall — on one lattice. That shared vocabulary is what lets two independently authored scenes sit side by side without looking like they came from different sets.
The eight scenes that ship:
| Scene | What it shows | Where it is used |
|---|---|---|
ledger |
Bound ledgers stacked on a plinth | Home page, the Domain section |
vault |
A vault with its dial on the visible face | Architecture section, the API object model |
flow |
Three columns ascending — a quantity moving | Motion section, the MCP page |
grid |
Four modules on one base, one of them ranked | Foundations section |
stack |
A field of parts at different heights | Catalog section, the component index |
documents |
Sheets cascading into one | Craft section, the docs index |
tray |
An empty tray — the first-run empty state | EmptyState |
terminal |
The surface an agent talks to | Tools section |
Scenes are authored as non-overlapping footprints, which is not a stylistic choice: solids are painted back to front by their near corner's x + y + z, and the moment two of them interpenetrate no single sort key is correct and the scene needs a real depth buffer.
Motion for isometric
Isometric illustrations may animate once, on entrance, when the surface is first seen — and never loop.
Objects enter in Z order, back to front
Stagger 60ms · translateY(8px) → 0 · opacity 0 → 1
Duration 320ms · --ease-out-quint
Total sequence capped at 480ms
Never a looping float, never a rotating coin, never a bouncing arrow. An empty state that animates forever is a distraction on a screen the user is trying to leave. Respect prefers-reduced-motion by rendering the final frame with a 160ms opacity fade only.
The one exception: a flow diagram in onboarding may animate a dash-offset along a connector once when it scrolls into view (IntersectionObserver, { once: true }), because the motion is the explanation. Still capped, still one-shot, still paused off-screen.
What the implementation learned
Three things this specification originally got wrong, kept here because each one produced a drawing that looked subtly broken rather than obviously broken.
A CSS length inside an SVG is a user unit, not a pixel. The entrance in the motion catalog is translateY(8px), which is a restrained nudge on a card and throws an object clean off a ten-unit illustration canvas. Isometric entrance is therefore its own keyframe, ledger-iso-enter, expressed in the coordinate system actually in force.
A drawing that never changes should not ship JavaScript to draw itself. IsometricSpot is a server component. Reduced motion is handled by the theme's global reset rather than by a hook, which is the only reason it can be.
Detail belongs on a face you can see. The vault's dial was originally placed on the far side of the block and painted over it, because it was authored last. It looked like a floating rectangle. In this projection only two vertical faces of a solid are ever visible; anything meant to read as attached has to sit on one of them.
Where isometric is forbidden
- Inside any table, statement, or chart
- On a card that contains data
- As a background, watermark, or texture
- Behind text at low opacity
- Anywhere on a screen a user visits daily
If a user sees an illustration every day, it has stopped being an explanation and become wallpaper.
Logo treatment
Bank logos, merchant logos, and integration marks appear constantly in financial UI — in transaction rows, account lists, payment method pickers. They are the most common source of visual noise in the product, because they're third-party assets we don't control.
The containment rule
Every third-party logo is contained, never free.
Container 24 (table row) · 32 (list item) · 40 (card) · 48 (account header)
Radius 8px — NOT full. See below.
Background --surface-sunken
Border 1px --border-subtle
Logo inset ~20% of container (a 32px container holds a 20px mark)
Fit object-fit: contain, centered
Radius 8, not circular. Circular crops decapitate wordmarks and square logos alike. A rounded square is the only container shape that treats every third-party asset fairly. (People are circular; brands are rounded squares — that distinction also lets the eye separate "who" from "where" at a glance in a transaction row.)
Normalization
Third-party logos arrive at random sizes, with random padding, in random color modes. Normalize:
- Fixed container, contained fit, ~20% inset. Never let a logo touch its container edge.
- Neutral background always. Never render a logo on the raw surface — a white-on-white logo vanishes.
- Desaturate in dense contexts. In a 400-row transaction table, 400 full-color logos are visual static. Apply
filter: saturate(0.85)at row scale, full color at ≥ 40px. Subtle, and it makes the table dramatically calmer. - Never recolor to match the theme. A brand's blue is theirs.
- Never apply the container's radius to the logo itself. Contain, don't crop.
Fallback ladder
Assume the logo is missing, because it often is:
- Provided logo URL
- Cached logo from a resolver (Clearbit, Brandfetch)
- Monogram — first letter(s) of the name, Inter 500, on a background derived from a stable hash of the name (pick from the
--viz-*scale, so it's on-palette). Deterministic: the same merchant is always the same color. - Generic icon by category (
landmark,credit-card,shopping-bag)
Never a broken-image icon. Never an empty box. The monogram is good enough that it's often better than a bad logo.
Loading
loading="lazy", decoding="async", explicit width/height to reserve layout, and a --surface-sunken background so the container is visible before the image lands. No fade-in on logos in a table — 400 elements fading in is exactly the kind of decorative motion the frequency law forbids.
Avatars
People, not brands. Circular, --radius-full.
16 (inline mention) · 24 (table row) · 32 (list) · 40 (header) · 64 (profile)
- Initials fallback with a deterministic
--viz-*background, Inter 500,--text-inverse. - Avatar stacks (approvers, participants) overlap by 25% of diameter with a 2px
--surfacering, max 4 shown, then+Nin a neutral chip. The ring is what makes the stack legible; without it the overlaps mush. - Never a colored ring for status. A presence dot, bottom-right, 25% of diameter, 2px surface ring.
Photography
None. Lopifi ships zero photographic assets. Stock photos of people shaking hands over laptops are the fastest way to make a financial product look untrustworthy. If a consumer needs photography, it goes in their marketing site, not in these components.
Visual hierarchy — the general rules
Beyond imagery: how any Lopifi surface ranks its content. Six mechanisms, in order of strength:
| Rank | Mechanism | Notes |
|---|---|---|
| 1 | Size | The strongest and cheapest. A 40px figure beats everything on the card. |
| 2 | Position | Top-left in LTR is read first. The most important number goes there or is the visual anchor. |
| 3 | Weight | 500 vs 400. One step is enough; two steps is shouting. |
| 4 | Color/contrast | --text-primary vs --text-secondary vs --text-tertiary. Three ranks, no more. |
| 5 | Space | Isolation creates importance. A figure with 24px of clear space outranks one with 8px, at the same size. |
| 6 | Color (chromatic) | The accent. Weakest in the list because we spend it once per view. |
The hierarchy laws
- Every surface has exactly one primary element. If you can't name it in one word, the surface is unranked and it will feel flat regardless of styling.
- Contrast the primary against its neighbors, not against an abstract scale. A 20px figure is primary in a dense card and tertiary on a dashboard hero.
- De-emphasize rather than emphasize. The instinct is to make the important thing bigger; the better move is usually to make everything else
--text-secondary. Quieter surfaces have clearer hierarchy. - Labels lose to values. Always. A label is
label/--text-secondary; its value isbody/--text-primaryor a figure token. Any surface where a label out-shouts its value is inverted — and uppercase table headers are the usual culprit. - Three ranks per surface. Primary, secondary, tertiary. A fourth is a sub-surface.
- Alignment is hierarchy. A left edge shared by six elements creates a group with no styling at all. Most "this looks messy" problems are alignment problems wearing a color costume.
- Icons never outrank their labels. One step down in color, matched in size band.
- The number wins. Whenever a chrome decision and a figure's legibility conflict, the figure wins. That's the whole product.