Summary#
An app looks generic when every decision was left at its default: the system font, a blue button, grey cards in a grid, everything the same size, nothing moving. It looks designed when a few decisions were made on purpose and then held everywhere. This page is those decisions, in the order they pay off, each written in the language's own vocabulary — so the result is still an ordinary Osy# app, themed and checked at compile time.
The order matters more than any single rule: direction → theme → type → colour → imagery → composition → motion → states → look at it. Motion is sixth, not first. Motion on a page with no direction is decoration on a template.
Description#
1. Find the direction in the brand's world, not in your defaults#
A direction is the set of decisions you check every screen against — "does this card belong here?" — and it has to come from this brand, its customers and its category. It must not come from what a design "usually" looks like, because what it usually looks like is exactly the problem.
⛔ The three default looks — if your design lands on any of them, start again:
| default | what it looks like | why it happens |
|---|---|---|
| The AI default | warm cream or beige "paper", a serif display face, a terracotta / rust / clay accent, tiny letter-spaced uppercase labels, hairline rules | it is what "tasteful" averages to — which is why a hundred unrelated brands built this way are indistinguishable |
| The template default | white or light grey, the system font, an indigo or blue accent, rounded grey cards in an even grid | nothing was decided at all |
| The premium-dark default | a near-black or warm-charcoal page, bone-white text, one deep accent (wine, gold, forest) | it is what "premium" averages to — and with the two light defaults ruled out it looks like the safe distinctive choice, which is exactly why it is not one |
A business app starts from a look. For a back office, a desk, a dashboard or any tool built on the UI kit, run
osy kit looks first: five finished designs — Ledger, Relay, Civic, Studio, Signal — each a whole system of colour,
type, density and chrome. Try the closest on your own pages (osy compile --theme Looks.Ledger), derive from it
(theme Brand : Looks.Ledger { … }) and make it the brand's own with the method below — which then decides what you
override, rather than inventing a whole system from nothing (Looks for business apps — start from a finished design and make it yours).
…and from an ARRANGEMENT, chosen before the look. How the app is USED decides its shape: read records as documents
(the document desk), watch and act at full density (the workbench), answer questions once (the flow), work through
people and records in passing (the record desk), or know something at a glance (the command centre). Each look names
the one it pairs with — osy kit looks prints it on the line "arranged as" — but that is a default, not a binding:
take the arrangement that fits the work and the look that fits the feel (Arrangements — five ways to build a whole app, and which look wears each).
The method, before any code:
- Write the brief in one line: who buys, what they pay, what the brand must feel like to them.
- Write down your first instinct before you look at anything — the palette, the display face and the layout you
would reach for right now — in
DESIGN.md, under "first instinct". It is not a direction: it is the average of every site like this one, which is what this whole page exists to avoid. The direction you pick must differ from it in at least two of the three, andDESIGN.mdsays which two. - Look at the best of the category — found by searching, and actually read. Run a web search for the category
(and its best-known names in the brand's own country or scene), open at least two of the most admired sites it
finds, and record each in
DESIGN.mdas its URL and one concrete thing you saw there — "the menu is set in a wide grotesque at 11px, all caps", "every product photographed on the same pale-green seamless", "no cards: one product per screen". ⛔ A URL typed from memory is a guess, not a reference, and a note about a page you did not open steers the design as confidently as a real one, and wrongly. If a page does not load, say so and open another.osy design look <url>renders a page wide and on a phone intodesign/refs/and prints the faces it really loads (Looking at a reference site before you design) — open both pictures; a page's text says nothing about its type or layout. A performance cycling brand, a heritage outfitter, a Scandinavian homeware shop and a streetwear label all sell objects online and look nothing alike; that difference is the point. - Write three contrasting directions for this brand, one sentence each, naming type, colour, photography and motion. Make them genuinely different — a dark one, a stark one, a colourful one — not three shades of one idea.
- Pick the most distinctive one that still fits the brief, and write down why it is none of the defaults. ⚠ Take the REGISTER from the category's best references and the client's own, not from what "premium" feels like. If the sites you looked at — and any the client pointed you to — are bright, light and colourful, going dark is a claim you have to argue from them, not a safe choice; "dark reads as considered" is the premium-dark default talking. When a direction was rejected as "too loud" or "might look cheap", check whether the references agree before trusting that instinct: the boldest option that still fits the brief is usually the right one.
- Write the system down before any screen — in a
DESIGN.mdbeside the app: the type scale (the exact sizes, nothing between them), the one or two background colours, the accent and what it is for, ONE corner-radius rule ("everything square", or "buttons pill, cards 12px" — and then everywhere), the spacing steps, the motion language, and a rough ASCII wireframe of each screen. Then answer one question in writing: what would the generic version of this screen be, and where exactly does mine differ? If the answer is "the colours", the direction is not strong enough yet. - Decide where the boldness goes. A memorable page is bold in ONE place — an enormous headline, a full-bleed photograph, one unexpected colour — and calm everywhere else. Bold everywhere reads as noise, and bold nowhere as a template.
- Write the concept before the first line of code ([[ui-design#concept]]): who it is for, the idea in one
sentence, the grid with its numbers, the type pairing with its reason, the budgets (the type scale, the colours
beyond the neutrals, the animations, what is forbidden) and the ONE signature element nobody else in the category
has.
osy design checkreads it, and holds the budgets against the app's own theme and animations.
| a direction is… | the range to choose from — every row has many answers, and none is a default |
|---|---|
| a type pairing, chosen on purpose | a rounded geometric sans for both; a high-contrast didone over a grotesque; a slab over a humanist sans; a monospace for display; a hand-drawn or stencil face for one word only |
| a colour system with one job per colour | two saturated colours that clash on purpose; a pale tint field with ink-dark text; a full spectrum used as a code (one colour per category); a single bright ground colour with white |
| a photographic style | studio product shots on seamless colour; documentary, in use, outdoors; flat-lay from above; close crops of hands and textures; no photographs at all, only illustration |
| a layout character | a dense, precise grid; asymmetric editorial spreads; one thing per screen, scrolled through; a long single column like a printed menu |
| a motion character | none but hover; quick and snappy; slow reveals |
⚠ These rows are dimensions, not a menu. Taking the first or the "safest" answer from each row assembles a template of its own — the same black page, condensed headline and one bright signal colour that every unguided sports or nightlife site already wears. Choose from your references and your brief, and check the result against your written first instinct (step 2).
Pick ONE direction. Mixing two is how most apps end up looking like none.
The concept: decisions and budgets, before any code
A direction is a feeling; a concept is what holds it. Rebuilding five well-designed sites in Osy# showed that each one's look came from decisions taken before any screen was drawn: an idea, a grid, a pairing of faces, and one loud element nobody else in its category had: a type foundry's one yellow, a perfumer's wordmark filling its column, an oat brand's hard pen shadow. A page with those decided can be held to them screen by screen. A page without them drifts back to the average, because every undecided choice is taken by default.
The budgets are the other half: how many type sizes, how many colours beyond the neutrals, how many animations, and what the design refuses to do. Generated pages are generous in all four. They have a size for every heading, a colour for every section and an entrance on every card. A budget written down before the first screen is a decision you can hold the code to.
Write them into DESIGN.md, under a ## Concept heading, one labelled line each:
## Concept
- Audience: families planning a clear evening on the hill, reading on a phone before they set off.
- Idea: the night sky's timetable — what you can see from the hill tonight, set like a printed almanac.
- Grid: one 64ch column of text beside a 4-column almanac table; 32px gutters; nothing wider than 1200px.
- Type: Newsreader + IBM Plex Mono — because an almanac is a book of times and positions, and the mono sets them in columns.
- Scale: 13 / 16 / 21 / 34 / 88px
- Palette: 1 colour beyond the neutrals — a sodium orange, only on tonight's best object and on what to press.
- Motion: one — the sky chart turns once to tonight's hour as the page opens.
- Forbidden: no cards; no centred hero; no second accent; no photograph of a telescope.
- Signature: the page opens on tonight's sky as a chart, redrawn at each visit for the date and the hour.| line | what it must hold | warned when |
|---|---|---|
| Audience | who comes, to do what, on what device | it is "everyone" or "users", or a label of a few words |
| Idea | the ONE direction, in one sentence a stranger could hold every screen against | it is two sentences, or a label of a few words |
| Grid | the system with its numbers: columns, a measure, a ratio | it has no number. A grid without numbers is a mood |
| Type | the display face, the text face, and because | there is no reason, the reason would fit any brand ("clean", "modern", "elegant"), or the faces are not the ones the theme sets |
| Scale | the exact type sizes, or a ratio | it names fewer than three sizes, or the theme sets a FontSize token that is not on it |
| Palette | how many colours beyond the neutrals, and each one's job | it has no number, or the theme's Colors hold more hue families than the budget (neutrals and status colours are not counted) |
| Motion | how many animations, and where ("none but hover" is a budget) | it has no number, or the app declares more animation blocks than the budget |
| Forbidden | at least two moves this design will not make | it names fewer than two |
| Signature | the ONE element nobody else in the category has, and where it sits | it is one every generated page already has: a gradient hero, frosted cards, a bento grid, a logo strip, big statistics, cards that lift, an oversized footer wordmark… |
The three budgets with numbers are checked against the app's own source, not a kit's. A theme derived from a look (Looks for business apps — start from a finished design and make it yours) is counted by what it overrides, so the colours the look brings do not count against the budget.
The signature is on the first screen. A visitor decides in the first screen whether the page was made for this place, and a signature two screens down cannot help that decision. Measured in a blind review of four sites built from one brief: the two whose concepts put the signature (a drawn timetable) in the second section were called generated every time, with the reason "nothing on the first screen belongs to this place".
The signature is the hard one, and the check's list of stock signatures is not taste: each is something the anti-reference gallery ([[ui-design#critique]]) shows on page after page. A signature belongs to the brand: the thing it makes, sells, measures or does, turned into the page's one memorable move. Something you could put on any site in the category is not one.
$ osy design check
concept — DESIGN.md
✓ Audience families planning a clear evening on the hill, reading on a phone before they set off.
✓ Idea the night sky's timetable — what you can see from the hill tonight, set like a printed almanac.
✓ Grid one 64ch column of text beside a 4-column almanac table; 32px gutters; nothing wider than 1200px.
⚠ Type the concept names faces the theme does not set (the theme sets Inter) — one of the two has drifted
⚠ Scale the theme sets sizes that are not on the scale (FontSize.Lead = 18px) — move them onto it, or add them to the scale and say why
⚠ Palette the palette budget is 1, and the theme sets 2 hues beyond the neutrals (Primary #D9640B; Highlight #2F6BFF) — cut to the budget, or raise it and write each extra colour's job
✓ Motion one — the sky chart turns once to tonight's hour as the page opens.
✓ Forbidden no cards; no centred hero; no second accent; no photograph of a telescope.
✓ Signature the page opens on tonight's sky as a chart, redrawn at each visit for the date and the hour.With no concept, osy design check prints the nine lines to fill in.
2. The theme IS the design system#
Every decision above becomes a token in one theme block, and nothing in a component spells a raw value again.
That is what makes the direction hold: a card cannot drift off-brand, because it can only name what the theme
declares. Name tokens by ROLE (Display, Primary, Floating), never by what they happen to look like today.
theme Studio {
Colors {
Bg = Modes.Of(light: "#EEF3EC", dark: "#132019"); // the page — the kit reads it
OnBg = Modes.Of(light: "#16352A", dark: "#E4EEE6"); // text on the page
Surface = Modes.Of(light: "#FFFFFF", dark: "#1C2D24"); // cards and panels
OnSurface = Modes.Of(light: "#16352A", dark: "#E4EEE6");
Muted = Modes.Of(light: "#DDE8DA", dark: "#25382E"); // a quiet BACKGROUND tint — not a text colour
TextMuted = Modes.Of(light: "#4E6B5E", dark: "#A6BDAF"); // secondary text
Border = Modes.Of(light: "#C3D4C5", dark: "#33493D"); // soft lines, one step off the ground
Primary = Palette.From("#F2603D"); // THE accent — every kit button reads it
OnPrimary = "#FFFFFF";
}
Font {
Display = "\"Nunito\", \"Quicksand\", system-ui, sans-serif"; // new — the kit has none
Sans = "\"Nunito Sans\", system-ui, sans-serif"; // the kit's text face, replaced
}
FontSize { Hero = "72px"; Title = "30px"; Lead = "19px"; Body = "16px"; Label = "13px"; }
Radius { Card = "20px"; Pill = "999px"; }
Shadow {
Raised = "0 1px 2px rgb(22 53 42 / 0.08)";
Floating = "0 20px 44px -22px rgb(22 53 42 / 0.30)";
}
Motion { Lift = "transform 220ms ease, box-shadow 220ms ease"; }
}Override the kit's own token names, and add names only for what the kit does not have. The colour roles the kit reads, which are the ones worth overriding first:
| token | role | the kit paints with it |
|---|---|---|
Bg / OnBg | the page, and text on it | page background, body text |
Surface / OnSurface | a raised panel, and text on it | cards, dialogs, menus |
Muted | a quiet background tint | hovered rows, chips, skeletons, input fills |
TextMuted / TextSecondary | secondary and tertiary text | captions, hints, metadata |
Border | hairlines | card edges, dividers, field outlines |
Primary / OnPrimary | the accent, and text on it | primary buttons, links, focus |
⚠ Muted is a background, TextMuted is text — the pair is easy to swap, and a text colour declared as Muted
repaints every hovered row and chip in the kit with it. The UI kit already reads
Bg, Surface, Primary, Sans, Body, Pill, Raised… — declaring one of those in your theme is an override
(same name, same group), so every kit Button, Field and Card takes the new direction with nothing else written.
A token's leaf name is one namespace across all groups, so Card can be a radius or a colour, never both.
Modes.Of gives each colour its dark twin, so the direction survives dark mode instead of being inverted by the
browser. Palette.From turns one accent into a ramp, so a hover (Colors.Primary.Hover) is a step of the same colour
rather than a guess.
3. Type does most of the work#
The single largest difference between a template and a design is typography: a real display face for headings, a readable face for text, and a scale with real contrast between its steps (a 56px hero over a 15px body reads as intentional; 20px over 16px reads as nothing happened).
- Ship the fonts. Naming a font in the theme only asks for it —
osy font addships the file so it renders on a machine that has never seen it (web fonts — shipping a typeface with your app). To find a face by its character and ship it in one step,osy font find "rounded geometric"thenosy font add --fontsource <id>(Finding a typeface by its character, and shipping it). A face that is not shipped silently falls back to the next name in the stack, and the design falls back with it. - Two faces at most: one for display, one for text.
- Tight headings, loose text: negative letter-spacing on the big sizes, normal line-height on body text.
- Hierarchy from contrast, not from decoration. Size, weight and the display face do the work. A small, letter-spaced uppercase "kicker" above every heading is one of the default look's tells — use it only when the direction genuinely calls for it, never as a reflex.
[Composable] component Hero(string title, string lead) {
render {
Stack(gap: 4, maxW: "760px") {
Text(title, fontFamily: Font.Display, fontSize: FontSize.Hero, fontWeight: FontWeight.Bold, lineHeight: "1.02",
letterSpacing: "-0.02em", color: Colors.OnBg);
Text(lead, fontSize: FontSize.Lead, lineHeight: "1.55", color: Colors.TextMuted);
}
}
}4. Colour: one job each, and the accent is rare#
Give every colour a job — background, surface, text, secondary text, lines, one accent — and keep the accent for
what the reader should act on or notice: the buy button, a sale price, a count on the cart. An accent used everywhere
is a background. Depth comes from the surface stepping against the page (Surface on Bg) and from shadow, not from
more colours. A gradient is legal — BgImage takes one — and is strongest as a large, quiet field behind a hero, not
on buttons.
5. Real imagery — photographs, not stand-ins#
Nothing makes an app look unfinished faster than grey boxes, gradients or diagrams standing where photographs should be. A shop, a landing page, a portfolio, a menu — anything people look at — needs real pictures, and the app should ship them rather than point at someone else's server:
- Source them with a licence you can use. Unsplash (
images.unsplash.com) and Pexels allow free use, commercial included, with no attribution required (credit is appreciated); Wikimedia Commons is excellent for real objects and places, but each file carries its OWN licence — read it, and credit where it asks. Keep aCREDITS.mdbeside the pictures either way. - Size them for the web — around 1600px on the long edge for a hero, 800–1000px for a card,
.jpgor.webp. - Put them in
model/textures/with names that say what they are (harbour_at_dusk.jpg), and show them withImage(texture: Textures.HarbourAtDusk, alt: "…")([[ui-image#texture]]). A catalogue row picks its picture as data with aTexturesfield. - Art-direct them like one shoot: the same aspect ratio for every card (
aspectRatio,objectFit: ObjectFit.Cover), the same light and tone, the same kind of background. Twelve great photos that do not match look worse than twelve ordinary ones that do. - Write real
alttext — what the picture shows, not "image". - Hold each picture to the category's best — and search for it per ROLE. One search per picture role (the hero, a product, a person at work, a detail), each with its own words, and look past the first results: the top hit for a common query is the picture every other site already used. Download around ten candidates small for each role and keep the ones that would not look out of place on the most admired site in this brand's world. A merely acceptable photograph in the hero is the single most visible compromise a page can make. ⚠ Do not take the palette from the hero photograph. A palette picked off one picture is the picture's, not the brand's, and it changes the day the photo does — choose colour from the direction, then pick photos that sit in it.
- An honest gap beats a fake. If the right picture cannot be found, leave a clearly labelled placeholder and say so — never a grey box pretending, a stock photo of the wrong thing, or a "screenshot" built out of boxes.
An illustration style can be a direction of its own (the "bold and graphic" row above) — but then it is a decision made on purpose and drawn to one standard, never a placeholder for a photograph nobody fetched.
6. Composition: space, scale and one surprise#
- More space than feels necessary. Crowding is the fastest way to look generic. Double the padding you first wrote on anything that is meant to be looked at.
- Vary the scale. One big thing per screen — a hero image, a featured item twice the size of the rest — gives the eye somewhere to start.
- Align to something. A max width for reading text (60–75 characters), a consistent card ratio (
AspectRatio), a grid that the whole page shares (Cols). - One surprise: an overlap, an asymmetric split, a rotated sticker, an oversized number. One. Two is a style, three is noise.
- The first screen is one moment. Headline in at most two lines, a lead of at most ~20 words, one primary action and at most one secondary — and all of it visible without scrolling. Anything else (trust logos, feature bullets, a pricing teaser) is the NEXT section. If the headline wraps to four lines, the size is wrong, not the copy.
- Every section a different shape. Once a layout family is used — three cards in a row, image beside text, a full-width quote — do not use it again on the same page, and never alternate image-left/image-right more than twice running. A page of eight sections needs at least four shapes.
- Cards only when the lift means something. Group with space or one rule where you can; a grid of identical boxes is the template. A grid has exactly as many cells as there is content — never a blank tile to fill a row.
- One shape language. Pick the radius rule in
DESIGN.mdand hold it: round buttons on a page of square cards, or the reverse, reads as two designs. - Navigation on one line on a wide screen, and no taller than ~72px.
Custom composition: the image IS the section, not a box
⛔ The single move that makes a page read as generic is a uniform grid of equal picture-cards — a hero photo, then rows of same-size boxes each with a photo and a caption. It is competent and it is forgettable, because every brand that reaches for it lands in the same place. A page that looks custom uses the photography as the GROUND of a section and lets type and layout interact with it. These four moves are what that takes, and the platform expresses every one with ordinary style props ([[ui-styling#placement]]) — no new control:
| the move | how | props |
|---|---|---|
| full-bleed photo as the ground | the image fills the section at H: "80vh", objectFit: ObjectFit.Cover — nothing is boxed | Image sized to the section |
| oversized type crossing the photo's edge | the headline is Position.Absolute, pinned near the bottom with a NEGATIVE bottom, so it straddles the boundary — half on the image, half below | Position.Absolute, bottom: "-40px", z |
| a small label in the negative space of big type | a stat or kicker placed Position.Absolute inside the headline's own Position.Relative box, tucked into a gap | Position.Relative parent, Position.Absolute child |
| an asymmetric bento | one big cell beside small ones — cols: "2fr 1fr 1fr", one image cell rowSpan: 2 — cells of DIFFERENT weight, not a uniform grid | Display.Grid, cols, rowSpan |
[Composable] component Showcase() {
render {
Stack(gap: 0, bg: Colors.Bg, color: Colors.OnBg) {
// The photo is the ground; the headline crosses its bottom edge.
Box(position: Position.Relative, w: "100%", h: "78vh", overflow: Overflow.Visible) {
Image(src: "/hero.jpg", alt: "The studio at work", w: "100%", h: "100%", objectFit: ObjectFit.Cover);
Text("PRESSED BY HAND", position: Position.Absolute, bottom: "-32px", left: "24px", z: ZIndex.Sticky,
fontFamily: Font.Display, fontSize: "128px", fontWeight: FontWeight.Bold, lineHeight: "0.85", color: "#FFFFFF",
mixBlendMode: MixBlendMode.Difference); // white on the photo, dark on the page below — one headline
}
// A statement with a stat tucked into its own negative space.
Box(position: Position.Relative, px: 6, pt: 20, pb: 10) {
Text("Real ink, real paper.", fontFamily: Font.Display, fontSize: FontSize.Hero, lineHeight: "0.95");
Text("est. 1998", position: Position.Absolute, top: "24px", right: "40px", fontSize: FontSize.Label, color: Colors.TextMuted);
}
// One big cell beside small ones — weight varies, so it is not a template grid. Exactly five cells for a
// 3-column grid whose big cell spans two rows: 3 + 2, no blank tile. One column on a phone.
Box(display: Display.Grid, cols: Layout.AtLeast(Tablet) ? "2fr 1fr 1fr" : "1fr", gap: 3, px: 6, pb: 20) {
Box(rowSpan: 2, h: "420px") { Image(src: "/press.jpg", alt: "A print in progress", w: "100%", h: "100%", objectFit: ObjectFit.Cover); }
Box(bg: Colors.OnBg, color: Colors.Bg, p: 4) { Text("Letterpress"); }
Box(bg: Colors.OnBg, color: Colors.Bg, p: 4) { Text("Risograph"); }
Box(bg: Colors.OnBg, color: Colors.Bg, p: 4) { Text("Foil"); }
Box(bg: Colors.OnBg, color: Colors.Bg, p: 4) { Text("Binding"); }
}
}
}
}⚠ In a real app the photo is a shipped Image(texture: …) ([[ui-image#texture]]); src: is used here only so the
example compiles without shipping a file.
⚠ The half below the edge sits on the PAGE, not the photo — white type there is white on white and simply
vanishes (measured: it did, in this very example, until the line below). mixBlendMode: MixBlendMode.Difference
makes the headline invert against whatever is behind it: light over the photo, dark over the page, with no second
copy of the words. Or choose the colours so both halves read. Either way, check it in the screenshot
([[ui-design#look]]).
7. Motion: few places, each with a meaning#
Motion should answer a question the reader has — what just arrived? what can I press? where did that go? what is further down? — and nothing else. Four places cover almost every app (animation — keyframe motion, on entry, on exit, in a loop, or as it scrolls into view has each construct in full):
| the reader asks | the motion | the construct |
|---|---|---|
| what just arrived? | a short rise-and-fade, staggered across a list | animation + animationDelay |
| what can I press? | a lift on hover, reversing cleanly | Hover { … } + Transition |
| where did that go? | the removed thing slides or fades out | exitAnimation |
| what is further down? | sections that rise as they are scrolled to | animationTimeline: AnimationTimeline.View |
Keep durations short (150–450ms), ease out on the way in and in on the way out, and move things a small distance (8–24px). Someone whose system asks for less motion gets every one of these as a plain fade with nothing written at the use site — the platform does it ([[ui-animation#reduced-motion]]) — so there is no reason to hold back for them.
⚠ Fewer, better. A fade-up on EVERY section and a hover lift on EVERY card is itself one of the default look's tells: when everything moves the same way, nothing is being said. One orchestrated moment — the first screen arriving in sequence, or a product image that settles as the page opens — does more than motion sprinkled on every block. Choose where motion answers a question, and leave the rest still.
animation Rise {
Duration = "460ms";
Easing = EaseOut;
from { Opacity = 0; TranslateY = "14px"; }
to { Opacity = 1; TranslateY = "0"; }
}
animation SlideAway {
Duration = "240ms";
Easing = EaseIn;
from { Opacity = 1; }
to { Opacity = 0; TranslateX = "32px"; }
}
animation Surface {
Duration = "1s";
from { Opacity = 0; TranslateY = "28px"; }
to { Opacity = 1; TranslateY = "0"; }
}8. A card that invites a click#
The pattern behind every catalogue: an image-led card that rests quietly, lifts when the pointer finds it, and only
then shows its quick action — so a grid of forty is calm, and the one under the pointer is obviously alive. The
action's reveal is inside: it names its card's hover, so the card needs to know nothing about it.
[Composable] component PrintCard(string title, string price, string art, int index, Action onAdd) {
variants {
base {
Bg = Colors.Surface; Rounded = Radius.Card; Shadow = Shadow.Raised; Overflow = Overflow.Hidden;
Transition = Motion.Lift; Cursor = Cursor.Pointer;
Hover { TranslateY = "-6px"; Shadow = Shadow.Floating; }
}
}
render {
Stack(gap: 0, animation: Rise, animationDelay: (index * 60) + "ms") {
Box(aspectRatio: "4 / 5", bgImage: art, bgSize: BgSize.Cover, position: Position.Relative) {
QuickAdd(onAdd);
}
Stack(gap: 1, p: 4) {
Text(title, fontFamily: Font.Display, fontSize: FontSize.Lead, color: Colors.OnBg);
Text(price, fontSize: FontSize.Body, color: Colors.TextMuted);
}
}
}
}
[Composable] component QuickAdd(Action onAdd) {
variants {
base {
Position = Position.Absolute; Bottom = 3; Right = 3;
Opacity = 0; TranslateY = "6px"; Transition = Motion.Lift;
inside PrintCard.Hover { Opacity = 1; TranslateY = "0"; }
}
}
render {
Button("Add to cart", onPress: onAdd, tone: Tone.Primary, size: Size.Sm);
}
}A touch screen has no hover, so the quick action must not be the only way to add — the card's detail page carries the real one. Treat the hover reveal as a shortcut, never as the path.
9. A cart that slides in, and out#
The cart is where a shop's motion earns its keep: it arrives from the edge it lives at, and a line removed from it
leaves visibly, so the reader sees what changed. Both are one prop each — animation for the arrival, exitAnimation
for the departure — and the line that is leaving takes no second click while it goes.
class CartLine { public string Title; public string Price; }
component CartPanel() {
bool open = true;
List<CartLine> lines = [ new CartLine { Title = "Harbour at dusk", Price = "€38" },
new CartLine { Title = "Allotment, June", Price = "€42" } ];
action Close() { open = false; }
action RemoveFirst() { if (lines.Count > 0) { lines.RemoveAt(0); } }
render {
if (open) {
Stack(gap: 4, p: 6, w: "380px", bg: Colors.Surface, shadow: Shadow.Floating, animation: Rise, exitAnimation: SlideAway) {
Row(align: Align.Center) {
Text("Your cart", fontFamily: Font.Display, fontSize: FontSize.Title, color: Colors.OnBg);
Button("Close", onPress: Close);
}
foreach (var line in lines) {
Row(gap: 3, py: 2, borderBW: 1, border: Colors.Border, exitAnimation: SlideAway) {
Text(line.Title, color: Colors.OnBg);
Text(line.Price, color: Colors.TextMuted);
}
}
Button("Remove the first line", onPress: RemoveFirst);
}
}
}
}10. The states nobody designs, which is why they look broken#
A design is judged on its worst screen. Design these on purpose, in the same direction:
- Loading — a skeleton in the shape of what is coming (the kit's
Skeleton), never a lone spinner on a blank page. - Empty — an empty cart or an empty search is an invitation, with one clear next action.
- Unavailable — a sold-out item stays visible, dimmed (
Opacity), with its reason, rather than vanishing. - Refused — a failed action says what happened, in the app's own voice.
11. Sections that arrive as they are scrolled to#
A long page — a landing page, a collection, an "about the studio" — can read better when its story sections rise into place as the reader reaches them, instead of everything having finished animating before it was seen. Use it on the one long page where the story unfolds, not on every section of every page ([[ui-design#motion]]):
[Composable] component StorySection(string heading, string body) {
variants { base { Animation = Surface; AnimationTimeline = AnimationTimeline.View; } }
render {
Stack(gap: 3, py: 16, maxW: "640px") {
Text(heading, fontFamily: Font.Display, fontSize: FontSize.Title, color: Colors.OnBg);
Text(body, fontSize: FontSize.Lead, lineHeight: "1.6", color: Colors.TextMuted);
}
}
}12. The words are part of the design#
A beautiful page with generic copy still reads as generated. Write like the brand's best shop assistant talks:
- Concrete over clever. Say what the thing is and what it costs. Cute wordplay that does not quite parse, and mock-poetic labels ("Field notes", "On the bench", "Loose plates"), read as a machine trying to sound human — a plain sentence beats them every time.
- No filler words: elevate, seamless, unleash, curated, crafted, next-generation, journey.
- No filler sections. No "Our values", "Why choose us" or testimonials unless the brief gives you real ones. A section that feels empty is a composition problem — make what is there bigger — not a reason to invent content.
- One label per intent. If the action is "Book", it is "Book" in the nav, the hero and the footer — not "Book", "Reserve a time" and "Get started".
- Button labels fit on one line, one to three words.
- Numbers are real or absent. No invented statistics, ratings or "trusted by 10,000" lines.
- Names and places sound real for the brand's locale — never "Jane Doe" or "Acme".
- Reading width 60–75 characters, and a headline that reads naturally before it reads cleverly.
- Re-read every visible string before you finish — headings, buttons, captions, alt text, empty states — and rewrite any you would not say out loud to a customer.
13. The tells — each one alone says "generated"#
These are the specific moves an unguided design reaches for. Each is fine when the direction genuinely calls for it and you can say why; as a reflex, each one marks the page as a template. Check your screenshots against the list.
| tell | instead |
|---|---|
| a small, letter-spaced uppercase label ("eyebrow") above every section heading | at most one per three sections — usually none; the heading is enough |
numbered section labels (01 /, No. 02, Step 1) when nothing is a sequence | number only a real sequence; otherwise name the thing |
monospace metadata strips joined with middle dots (SIZE · COLOUR · YEAR) | one fact per line, or a proper table; one · per line at most |
an arrow appended to every link (View all →) | an arrow only on the one link that leads somewhere new |
| an em dash in interface copy | a full stop, a comma or a colon — shorter sentences |
| captions or tags laid over photographs | let the picture speak; a caption goes under it, if at all |
| decorative coloured dots before nav items, rows or badges | a dot only for a real live state |
| a rule under every row of a list or spec sheet | group the rows, with one divider per group |
| three equal cards in a row as the feature section | a featured item plus smaller ones, an asymmetric split, or a single strong image |
| a uniform grid of equal picture-cards (a photo + caption, repeated) | make the photo the GROUND of the section, not a box in it — see [[ui-design#composition-custom]] |
| a centred hero over a gradient or blurred blob | a real photograph, and the text aligned to the page's grid |
| the 50/50 split hero: a flat colour panel holding the headline, a photograph beside it | let the photograph own the screen and set the type across it, or put the signature where the panel would be |
| the hero photograph is the first result a search for the category returns | look further, and prefer pictures of the real kind of place in its particulars; two separate builds of one brief chose the same stock photograph |
| a big headline left, a small paragraph floating in the right column | headline, then body beneath it |
| a near-black or charcoal page with one accent, chosen because dark "feels premium" | the register of the category's own best references — often bright and light — see [[ui-design#direction]] |
| warm cream background, serif display, brass / clay / terracotta accent | the AI default in full — see [[ui-design#direction]] |
| a serif display face because the brief said "premium" or "creative" | a serif only for a brand that is genuinely editorial, heritage or luxury, with a reason you can write down |
| one word of a headline set in a different font or italic serif for "interest" | emphasis in the same family — bold or italic |
| fade-up on every section, hover lift on every card | one orchestrated moment ([[ui-design#motion]]) |
| scroll cues ("Scroll ↓"), city / time / weather strips, version stamps | delete them |
| a skeleton or spinner as the only loading design | a skeleton in the shape of what arrives ([[ui-design#states]]) |
14. Look at it — the only check that sees design#
Every test in the platform except one reads text. A page can pass all of them with its buy button off the card, two
headings the same size and a hover that never fires. osy test --pixels runs your UI tests in a real browser and
photographs each page into osy-shots/ — open them and look, at a phone width and a wide one, in light and dark.
Before calling it done:
- Can you name the direction in one sentence, and does every screen belong to it?
- Is there a clear biggest thing on each screen?
- Is the accent only on things to act on or notice?
- Is every picture a real, licence-clear photograph (or a deliberate illustration style), shipped with the app and matching the others?
- Do the fonts actually load (not the fallback)?
- Does each motion answer one of the four questions in [[ui-design#motion]], and nothing more?
- Are loading, empty, unavailable and refused designed?
- Does the first screen hold one moment, fully visible — and does every section have a shape of its own?
- Is every visible string something you would say to a customer ([[ui-design#words]]), and does any of [[ui-design#tells]] appear without a reason you could write down?
- Did you look at the screenshots?
15. Critique it against the agent-made pages#
The question a stranger asks of a page in its first second is not "is it good?" but "have I seen this before?", and for a page an agent made, the answer is usually yes: the same centred headline under a pill label, one italic word, a drawing where a photograph belongs, three cards, a dark band asking "Ready to begin?". Knowing the list of tells is not the same as seeing them on your own page, so look at it beside them:
osy test --pixels # photograph every page into osy-shots/
osy design critique # each wide shot beside your references and the agent-made pagesFor each screenshot osy design critique writes a contact sheet into design/critique/: your first screen, the two
references of yours it sits nearest, and the three nearest pages from the anti-reference gallery, each captioned
with what makes it read as generated. The gallery ships with the CLI. It holds landing pages models produced for a
plain ask ("make a landing page for a bookshop"), and our own sites and apps, which have the same problem.
osy design critique --gallery lists it.
It also measures. The first screen's tone, colour and layout (where the light and the dark sit) are compared with each set, and the verdict says which the page sits closer to:
$ osy design critique --shot granola-rebuild-1440.jpg
⚠ granola-rebuild-1440.jpg is closer to the agent-made pages (0.192) than to your references (0.213) — back to the concept.
nearest agent-made: generated-04-architecture-practice — the AI default: cream paper, a serif display, a clay accent · …
sheet → design/critique/granola-rebuild-1440-critique.pngThat verdict is a real one, on a rebuild of an oat brand's site made for this platform's own design audit: its graph-paper ground is cream, and cream paper is where the generated pages gather. The sheet shows it at once.
A page closer to the agent-made pages than to its own references goes back to the CONCEPT ([[ui-design#concept]])
— the idea, the grid, the pairing, the signature — not to its colours. Recolouring a page that is built like the
gallery moves the number and not the look. Then, with the sheet open, write in DESIGN.md each tell the nearest
agent-made pages carry and whether yours has it.
⚠ The number compares look, not quality. It cannot read type or copy. A page can sit nearer its references and
still carry every tell in [[ui-design#tells]]. Closer to the references is necessary, not sufficient, so open the
sheet either way. With no references in design/refs/ there is nothing to be closer to: render them first with
osy design look <url> (Looking at a reference site before you design).
And the gut check, looking at them as a stranger would: could any AI have made this, or only someone who knows this brand? Is there one thing a visitor would remember? If not, go back to the boldness you decided in step 6 of [[ui-design#direction]] and make it bolder — a distinctive page with a flaw beats a safe one nobody remembers.
See also#
- theme tokens — tokens, groups and dark mode, in full.
- color palettes — one seed colour, a whole ramp.
- web fonts — shipping a typeface with your app — shipping a typeface with the app.
- Showing a picture on a page —
Image, andtexture:for pictures the app ships. - style props — every style prop, hover and focus states,
inside. - animation — keyframe motion, on entry, on exit, in a loop, or as it scrolls into view — entrances, exits, loops, scroll reveals and reduced motion.
- App shells — the frame around every page, and which arrangement fits which kind of app.