Skip to content

Chorki+ design guideline

This is the brand system the whole app is moving towards. It is not a rewrite plan: screens adopt it one at a time, and a ratchet in the quality gate makes sure the app only ever gets closer to it.

docs/design/
README.md this guideline: principles, rules, components, adoption
tokens.md reference: every token, its value and its contrast
glass.md materials: the Liquid Glass kit and how it is built
lib/presentation/theme/
app_tokens.dart AppColors · AppSpace · AppRadius · AppMotion · AppSize
app_text.dart AppText, the type scale
glass_tokens.dart GlassTokens, the numbers behind glass
lib/presentation/widgets/glass/
GlassSurface · GlassPill · GlassChip · GlassCapsule · GlassEdgeFade

Where the app is today. The nav bar uses the glass kit. The Search page and its filter sheet are the first screens built fully on the tokens: 115 raw values went to 0. Everything else is still on the old appTheme / CustomTextStyles values, and tool/design_ratchet.dart --report lists what to migrate next.


Every rule below comes from one of these six. When a case isn’t covered, decide it with them.

# Principle In practice
1 Content is the colour. Posters and artwork carry the colour. Chrome is black, grey and glass, so the artwork never competes with the interface.
2 One accent, used for actions. Rose means “you can do something here”: play, apply, clear, retry. It never marks a state. A selected chip is white, not rose.
3 Glass only for what floats. Nav bar, search field, sheets, anything over moving content. Cards and lists are solid. Glass everywhere is noise, and it costs frames.
4 Readable first. Every text colour passes WCAG AA (4.5:1) on every surface it can sit on. A test checks this, so a token change can’t quietly break it.
5 Motion explains. Things move to show where they came from or went: a capsule glides to the new tab, a header tints as content slides under it. Nothing loops or bounces for decoration.
6 Within thumb reach. The main input on a screen sits in the bottom third. The Search field floats above the nav bar, not at the top of the screen.

primitives roles components screens
(theme_helper) (app_tokens, app_text) (widgets/glass, …)
┌──────────────┐ ┌────────────────────┐ ┌───────────────┐ ┌──────────┐
│ appTheme │ ───► │ AppColors.accent │──►│ GlassChip │──►│ Search │
│ .primary │ │ AppColors.surface │ │ GlassSurface │ │ Home … │
│ .rose400 │ │ AppText.body │ │ SearchCapsule │ │ │
│ .gray100 … │ │ AppSpace.lg │ │ … │ │ │
└──────────────┘ └────────────────────┘ └───────────────┘ └──────────┘
"what colour" "what it is FOR" "what it looks like"
  • Screens use roles, never primitives. Write AppColors.textTertiary, not appTheme.gray100. A role can be re-pointed later, for example to a lighter grey for legibility, and every screen follows. A primitive says nothing about intent.

  • Roles live in theme/. That is the only folder where a hex literal or an appTheme.* call is allowed. The ratchet ignores it.

  • Tokens are static and dark-only, the same style as GlassTokens. The app has no light theme. If one ever arrives, the role names stay and only their values change.

  • Sizes are design points at a kDesignWidth-wide phone (370), scaled by one factor. Every size is multiplied by s = screen width ÷ kDesignWidth (lib/core/utils/design_scale.dart) on phones, and by 1 on tablets (shortest side 600 or more). Tokens scale themselves (AppSpace.lg, AppSize.bottomNav, AppText.body); a raw length carries the unit: SizedBox(height: 24.s), EdgeInsets.all(12.s). Because it is one factor, ratios hold: a 2:3 poster is 2:3 on every phone.

    360 phone ×0.90 iPhone SE ×0.94 iPhone 15 ×0.98
    400 phone ×1.00 Pro Max ×1.08 tablet ×1.00
    `kDesignWidth` is a width in dp, like Android's *smallest width* setting:
    a larger value draws everything smaller.

    The app used to scale with sizer’s .h (width), .v (height) and .adaptSize (the smaller); mixing them put parts out of ratio (posters were height.v by width.h). Those units are gone and the scaled_unit ratchet metric keeps them at 0.

  • Why numbers, not the whole canvas. Scaling Flutter’s canvas (a custom binding, or Transform.scale) looks the same but breaks native views: the player, webviews and IMA ads are sized by the OS density, so they would draw at the wrong size and take taps in the wrong place.

  • Ratios and counts stay unscaled. Only lengths get .s: not x / 2 for a radius, an aspectRatio, a flex, a VisualDensity step, an opacity or a duration. Flutter’s own constants are unscaled too, so scale them where they meet ours (toolbarHeight: kToolbarHeight.s), or a SliverAppBar asserts collapsedHeight >= toolbarHeight on a small phone.

  • Screen-proportional values are explicit. When something really is a share of the screen, say so: MediaQuery.sizeOf(context).width, or SizeUtils.width where there is no context. Sheets cap at AppSize.sheetMaxWidth and dialogs centre at AppSize.dialogMaxWidth via SizeUtils.insetFor(...), so a tablet gets a sheet, not a stretched phone.

  • Layout flexes. Let the screen size decide how many and how wide:

    • widths: Expanded, Flexible, FractionallySizedBox;
    • grid columns: LayoutBuilder or SizeUtils.deviceType;
    • proportional media: AspectRatio (posters, the hero);
    • system bars: MediaQuery safe areas.
  • Text scales with s like every size, then with the OS setting, clamped to [1.0, 1.1] in MyApp.


The app is built on the Tailwind zinc greys (#18181B, #27272A, #3F3F46, #A1A1AA, #D4D4D8) and the rose family for the accent.

background #000000 ███ the page
surface #18181B ███ cards, solid sheets
surfaceRaised #27272A ███ fields, secondary buttons, placeholders
border #3F3F46 ─── hairlines on solid surfaces
textPrimary #FFFFFF Aa titles, body, selected labels
textSecondary #D4D4D8 Aa resting labels, supporting copy
textTertiary #A1A1AA Aa hints, counts, meta, placeholders
textDisabled #7E7E83 Aa disabled only (exempt from contrast rules)
accent #E11D48 ███ primary action FILLS (with onAccent text)
accentText #FB7185 Aa rose TEXT and icons: Clear, Reset, Retry
selection #FFFFFF ███ selected state (with onSelection #000)
danger #EF4444 ███ destructive fills, bold labels only
premium #F97316 ███ commerce badges only

The full table with every contrast figure is in tokens.md.

The accent #E11D48 is dark: as text on black it reaches only 4.47:1, just under AA. So it has two roles:

┌──────────────────────┐
│▓▓▓ Show 24 titles ▓▓▓│ accent FILL + onAccent text (4.7:1) ✓
└──────────────────────┘
Clear All accentText #FB7185 (7.8:1 on black) ✓
Clear All accent #E11D48 as text (4.47:1) ✗

And rose is for actions only:

( Genres ▾ ) (▓ Drama +1 ▾ ▓) ( Cast ▾ ) Clear All
grey pill └ selected: rose pill └ action: accentText

Pills select in rose. The Icons-screen filters, search facets and the filter sheet use one flat pill, the Icons-screen look the client asked for: pillIdle (#2D2D32) at rest, pillSelected (rose) with onAccent text when chosen, no outline (September 2026). The home nav chips are the exception: translucent white (9%) with a 14% white hairline at rest and a white selection pill with onSelection text (restored 2026-09-28). Other selection, such as a checkbox or the bottom nav’s tab, stays white (selection).

These still exist as primitives, and the ratchet counts every use until it is migrated:

Legacy Uses today Becomes
primaryButton #FD5930, secondaryButton #D81A4A CTAs accent
pink300, pink500, rose500, raw #FF474A links, “clear” text accentText
gray600 #5C676D (bodySmallHint) hints textTertiary, since gray600 is only 3.6:1 on black and fails AA
white300, gray200, gray400 body text textPrimary / textSecondary
popUp, grayButton, gray700 fills surfaceRaised
blueGray, formFilled, gray500 panels surface

One family (Inter) and eight roles. Every role is white by default. Change the tone with .copyWith(color: AppColors.textSecondary), not with a new style.

Role Size / weight For
AppText.display 24 / 700 Hero titles on content pages
AppText.titleLarge 20 / 600 Screen titles
AppText.title 18 / 600 Sheet and dialog titles
AppText.section 16 / 500 Section headers (“Trending Now”), large buttons
AppText.body 14 / 400 Default text, list rows, fields
AppText.label 13 / 500 Chips, tabs, small buttons
AppText.caption 12 / 400 Meta lines under posters
AppText.micro 11 / 500 Badges, overlines. Nothing is smaller.

Rules:

  • 11 is the floor. The old 10-point styles (textTn, textSmall) become micro.
  • Weight shows emphasis, not size. A selected label goes from 500 to 600 at the same size, so the layout doesn’t jump.
  • The text scale is clamped to [1.0, 1.1] in MyApp. Layouts must survive 1.1: test at textScale: 1.1 (see test/helpers/pump_app.dart).
  • The old CustomTextStyles stay until each screen migrates. tokens.md maps every one of them to its role.

xxs 2 ▏ xs 4 ▎ sm 8 ▌ md 12 ▊ lg 16 █ xl 24 █▌ xxl 32 ██
  • Two page gutters. Content-browsing screens (home and category feeds, content details, search, view-more, genre, artist, library lists) use AppSpace.contentGutter: 12 on phones, 16 on tablets. Forms, auth, settings, subscription, bottom sheets and app bars keep AppSpace.gutter (lg, 16), because text-heavy screens read better with more margin.
  • Every header and text block on a content screen starts at contentGutter, so it lines up with the first card of the rail below it.
  • Inside a component use xs to md. Between components use md to xl. Around empty states use xxl.
  • If the value you want is not on the scale, the design is off the grid. Round to the nearest step instead of adding a number.
Token Value For
AppRadius.xs 4 Badges, tiny tags, shimmer blocks
AppRadius.sm 8 Posters, cards, ink highlights on list rows
AppRadius.md 12 Buttons, fields, dialogs
AppRadius.lg 16 Large cards, panels
AppRadius.xl 20 Top corners of bottom sheets
AppRadius.pill stadium Chips, capsules, the search field, the nav bar
  • AppSize.minTouch (44) is the smallest tappable area. A control may draw smaller, as a 28-tall pill does, but its hit area must be 44. GlassChip, the home nav chips and HeaderPill do this for you.
Token Value For
AppSize.controlCompact 28 Pills (home nav, Icons filters, search facets), header pills (Subscribe, Awards)
AppSize.control 44 Buttons
AppSize.controlLarge 48 The search field, primary calls to action
AppSize.iconSm / icon / iconLg 16 / 20 / 24 Icons inside a pill / in a row / in a bar
AppSize.headerBar 44 The home header row (logo, cast, subscribe)
AppSize.bottomNav 48 The floating nav pill

Controls that sit side by side share a height: Subscribe sits next to the cast icon and above the nav pills, and all of them are 28 drawn, 44 to touch.

  • Leave at least xs between tappable things, so a slightly-off tap still lands on one of them.

┌──── floating chrome (GLASS) ───────────────────────┐
layer 3 │ nav bar · search capsule · sheets over artwork │ GlassSurface
└──────────────────────────────────────────────────────┘
┌──── controls ON glass (glass, no blur) ─────────────┐
layer 2 │ chips · tabs · fields inside a glass sheet │ GlassPill / GlassChip
└──────────────────────────────────────────────────────┘
┌──── solid surfaces ─────────────────────────────────┐
layer 1 │ cards · list rows · dialogs over flat UI │ surface / surfaceRaised
└──────────────────────────────────────────────────────┘
layer 0 background #000
Situation Use Why
Stays put while content scrolls under it GlassSurface This is what glass is for
Sheet or dialog over posters or video GlassSurface with AppRadius.xl top The artwork shows through blurred, which keeps context
A chip, tab or field inside glass or on an edge fade GlassPill / GlassChip Glass look without a second blur
A card or row in a scrolling list solid surface A blur per card would re-blur every frame
Anything over a flat black area solid Glass has nothing to blur; it would just be a grey pill

Two rules from glass.md matter most:

  • At most a few blurs on screen. Search has one (the capsule) and the nav bar has one. The filter sheet adds one.
  • Never put an Opacity or FadeTransition above a BackdropFilter.

Edge fades, not bars. Content scrolls under floating chrome and darkens there through GlassEdgeFade, instead of stopping at a solid bar. A top fade (top: true) is clear while nothing is under it and fades in with the scroll offset. Behind the header itself it holds at 0.9 (solidExtent), so chips stay legible over bright titles. The header band absorbs taps meant for the content hidden under it. A pinned header on a glass sheet stays clear at rest and takes on GlassTokens.scrollEdgeTint once something scrolls under it, as iOS does.


Token Value For
AppMotion.quick 140 ms Press feedback, small state changes (a tick appearing, a header tint)
AppMotion.standard 250 ms Things entering, leaving or resizing (tab content, a sheet expanding)
AppMotion.curve easeOutCubic Fast start, soft landing, for everything above
GlassTokens.spring stiffness 380, ζ 0.72 Things that travel (selection capsules); it retargets mid-flight
  • Movement tied to the finger is never animated. The nav bar hide and the search capsule follow the scroll offset directly. Only the release snaps.
  • Reduce motion: when MediaQuery.disableAnimations is on, jump to the end state. The nav bar shows how.
  • Nothing loops. Loading uses the existing shimmer, which is the one exception.

Kind Look Tokens
Primary (one per view) Filled rose accent fill, onAccent text in AppText.section w600, AppRadius.md, 48 tall
Secondary Filled grey surfaceRaised fill, textPrimary, AppRadius.pill or md
Tertiary / text Rose text accentText, AppText.body w600
Destructive Filled red danger fill, onDanger text, bold label
Disabled Grey surfaceRaised fill, textDisabled text
resting ( Genres ▾ ) tint + rim, textSecondary 500
selected (▓ Drama ▾ ▓) + capsule, textPrimary 600
action ( Clear All ) tint + rim, accentText
└──── 36 drawn, 44 hit ────┘
  • It is one merged semantics node: button, label and selected state, so a screen reader hears “Drama, selected, button”.
  • Pass leading: for a tick and trailing: for a chevron, count badge or ✕.
  • Floating (the search field): a GlassSurface stadium, SearchCapsule.height (48, never under 44), a transparent TextField, AppText.body, placeholder in textTertiary, and a 44 × 44 clear button.
  • Inside glass (the sheet’s search): a GlassPill, 44 tall, no border.
  • On solid surfaces: surfaceRaised fill, AppRadius.md, and a focused border in accent.
  • Over artwork: GlassSurface(shadow: false, borderRadius: top AppRadius.xl). The grabber is 36 × 4 in textDisabled, the title is AppText.title, and the footer has a GlassTokens.hairline top border.
  • Over flat UI: solid surface with the same radius.
  • AppRadius.sm corners, solid, never glass.
  • Rail gap AppSpace.railGap (6 on phones, 8 on tablets), grid gap AppSpace.gridGap (8 on phones, 12 on tablets), gutter AppSpace.contentGutter. PosterRail and PosterGrid default to these.
  • Home feed rails are packed denser than the rest of the app: the home and category tabs wrap their feed in PosterDensity(PosterTuning.density), and PosterTuning.portraitRailVisible in poster_metrics.dart sets how many portrait cards a home rail shows. Other shapes follow by the same factor. Grids and rails outside the feed keep the designed count.
  • A poster never gets a fixed width or height. Grids use PosterGrid and rails use PosterRail (widgets/content_widget/poster/). Both measure the width they are given and size every card from PosterGridLayout / PosterRailLayout, so tiles fill a row edge to edge on any phone, tablet, orientation or split screen. Grids gain columns as the width grows; rails keep a peeking last card. The numbers are in tokens.md, and test/contract/poster_sizing_lint_test.dart fails the gate on a literal poster height.
╭─────╮
│ ⌕ │ 72 circle, surfaceRaised, icon in textTertiary
╰─────╯
No results for "domm" AppText.section
Try a different title or name AppText.body, textTertiary
Clear filter accentText (only if it applies)
( 🔥 Browse trending ) secondary button, pill
  • Always offer a way out. Never a dead end: retry, clear, or browse.
  • Loading keeps what is already there. Old results stay on screen with a 2 pt accent progress line, rather than being replaced by a shimmer. Use a shimmer only when there is nothing to show yet.
  • Centre states in the visible band, between the header and the floating dock, not in the whole screen.

The Search page is the reference layout for a screen with floating chrome:

┌───────────────────────────────┐ ─┐
│ status bar │ │ header extent =
│ (Genres ▾)(Cast ▾)(Dir ▾) │ │ safe top + xs + 48 + 2
│ ─ progress line ─ │ ─┘
│▓▓▓ GlassEdgeFade(top) ▓▓▓▓▓▓▓│ solid 0.9 behind the header, then fades;
│ │ opacity follows the scroll offset
│ [poster][poster][poster] │ ◄─ content is full-bleed. Its padding
│ [poster][poster][poster] │ clears the header at the top and the
│▓▓▓ GlassEdgeFade ▓▓▓▓▓▓▓▓▓▓▓▓│ dock at the bottom.
│ ╭───────────────────────────╮ │ ─┐
│ │ ⌕ Search movies or TV │ │ │ accessory: bottom =
│ ╰───────────────────────────╯ │ │ max(keyboard + sm,
│ ╭──── glass nav bar ────╮ │ │ accessoryBottomOffset(p))
└───────────────────────────────┘ ─┘
  • Accessories ride the nav bar. Read the bar’s hide progress with FloatingNavScope.maybeProgressOf(context) and position with FloatingNavBarMetrics.accessoryBottomOffset. The accessory drops into the bar’s place as the bar hides, and never overlaps it (a test pins this).
  • The keyboard lifts the accessory. Set resizeToAvoidBottomInset: false and use MediaQuery.viewInsetsOf(context).bottom yourself. A resizing Scaffold hides the keyboard height from its body.
  • A tab with its own bottom chrome opts out of the shell’s edge fade (main_scaffold.dart) and draws its own fade behind that chrome. Otherwise the shell’s fade, which paints above the tab, darkens it.

Run through this for every screen that adopts the system:

  • All text uses a text role, so AA contrast is guaranteed by app_tokens_test.dart.
  • Every tappable area is at least 44 × 44.
  • Custom controls expose button, selected/checked and a label in one node. Use MergeSemantics, or GlassChip, which does it.
  • Icon-only buttons have a tooltip or semantics label.
  • The screen works at text scale 1.1 and on a 320-wide phone (TestScreens.smallPhone).
  • Reduce motion jumps instead of animating.
  • State is not colour-only: selection has a weight change, a tick, or the capsule.

  1. Replace appTheme.* with AppColors.* roles, and CustomTextStyles.* or TextStyle( with AppText.*.
  2. Replace numbers in EdgeInsets and radii with AppSpace / AppRadius.
  3. Decide solid or glass per surface using the table in §6.
  4. Walk the accessibility checklist.
  5. Test the behaviour, not the pixels: “the active chip is a selected GlassPill”, not “the fill is 0x24FFFFFF”.
  6. tool/quality.sh --update to lock in the lower counts.

tool/design_ratchet.dart counts what still bypasses the tokens in lib/presentation (outside theme/ and widgets/glass/):

Metric Counts Baseline (Sep 2026)
raw_color Color(0x…), Color.fromARGB/fromRGBO( 30
material_color Colors.white etc. (not transparent) 106
primitive_color appTheme.* 419
raw_text_style TextStyle( 46
literal_radius (Border)Radius.circular(<number>) 197
literal_spacing EdgeInsets…(<non-zero number>) 339

Any count that rises fails tool/quality.sh (exit 17). Counts are totals, so moving code around never trips it. See the next targets with:

Terminal window
fvm dart run tool/design_ratchet.dart --report

Screens where the most users look the longest come first:

  1. Home feed chrome and nav_chip_row.dart (a one-of-N selector: a GlassCapsule glide).
  2. Content page (display title, primary Play button, season picker as a capsule selector).
  3. Shared components: button_components.dart, popup_component.dart, shimmer_component.dart. These are the top three in --report, and migrating them moves every screen at once.
  4. Player chrome and cast sheets: glass over video, where it pays off most.
  5. Profile, settings and auth.
Area Tokens Glass
Nav bar partial ✓
Search page + filter sheet ✓ ✓
Everything else — —