Chorki+ design guideline
Chorki+ design guideline
Section titled “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 glasslib/presentation/widgets/glass/ GlassSurface · GlassPill · GlassChip · GlassCapsule · GlassEdgeFadeWhere 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.
1. Principles
Section titled “1. Principles”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. |
2. How the system is layered
Section titled “2. How the system is layered” 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, notappTheme.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 anappTheme.*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 bys = 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.98400 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 wereheight.vbywidth.h). Those units are gone and thescaled_unitratchet 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: notx / 2for a radius, anaspectRatio, aflex, aVisualDensitystep, an opacity or a duration. Flutter’s own constants are unscaled too, so scale them where they meet ours (toolbarHeight: kToolbarHeight.s), or aSliverAppBarassertscollapsedHeight >= toolbarHeighton a small phone. -
Screen-proportional values are explicit. When something really is a share of the screen, say so:
MediaQuery.sizeOf(context).width, orSizeUtils.widthwhere there is no context. Sheets cap atAppSize.sheetMaxWidthand dialogs centre atAppSize.dialogMaxWidthviaSizeUtils.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:
LayoutBuilderorSizeUtils.deviceType; - proportional media:
AspectRatio(posters, the hero); - system bars:
MediaQuerysafe areas.
- widths:
-
Text scales with
slike every size, then with the OS setting, clamped to [1.0, 1.1] inMyApp.
3. Colour
Section titled “3. Colour”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 onlyThe full table with every contrast figure is in tokens.md.
The accent rule
Section titled “The accent rule”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: accentTextPills 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).
Legacy colours
Section titled “Legacy colours”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 |
4. Type
Section titled “4. Type”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) becomemicro. - 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 attextScale: 1.1(seetest/helpers/pump_app.dart). - The old
CustomTextStylesstay until each screen migrates. tokens.md maps every one of them to its role.
5. Space and shape
Section titled “5. Space and shape”Spacing: a 4-point grid
Section titled “Spacing: a 4-point grid” 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 keepAppSpace.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
xstomd. Between components usemdtoxl. Around empty states usexxl. - 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.
Radius
Section titled “Radius”| 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 andHeaderPilldo this for you.
Component sizes
Section titled “Component sizes”| 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
xsbetween tappable things, so a slightly-off tap still lands on one of them.
6. Surfaces: solid or glass
Section titled “6. Surfaces: solid or glass” ┌──── 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
OpacityorFadeTransitionabove aBackdropFilter.
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.
7. Motion
Section titled “7. Motion”| 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.disableAnimationsis on, jump to the end state. The nav bar shows how. - Nothing loops. Loading uses the existing shimmer, which is the one exception.
8. Components
Section titled “8. Components”Buttons
Section titled “Buttons”| 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 |
Chips and tabs: GlassChip
Section titled “Chips and tabs: GlassChip” 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 andtrailing:for a chevron, count badge or ✕.
Fields
Section titled “Fields”- Floating (the search field): a
GlassSurfacestadium,SearchCapsule.height(48, never under 44), a transparentTextField,AppText.body, placeholder intextTertiary, and a 44 × 44 clear button. - Inside glass (the sheet’s search): a
GlassPill, 44 tall, no border. - On solid surfaces:
surfaceRaisedfill,AppRadius.md, and a focused border inaccent.
Sheets
Section titled “Sheets”- Over artwork:
GlassSurface(shadow: false, borderRadius: top AppRadius.xl). The grabber is 36 × 4 intextDisabled, the title isAppText.title, and the footer has aGlassTokens.hairlinetop border. - Over flat UI: solid
surfacewith the same radius.
Posters and cards
Section titled “Posters and cards”AppRadius.smcorners, solid, never glass.- Rail gap
AppSpace.railGap(6 on phones, 8 on tablets), grid gapAppSpace.gridGap(8 on phones, 12 on tablets), gutterAppSpace.contentGutter.PosterRailandPosterGriddefault 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), andPosterTuning.portraitRailVisibleinposter_metrics.dartsets 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
PosterGridand rails usePosterRail(widgets/content_widget/poster/). Both measure the width they are given and size every card fromPosterGridLayout/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, andtest/contract/poster_sizing_lint_test.dartfails the gate on a literal poster height.
Empty, error and loading states
Section titled “Empty, error and loading states” ╭─────╮ │ ⌕ │ 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
accentprogress 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.
9. Layout: floating chrome
Section titled “9. Layout: floating chrome”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 withFloatingNavBarMetrics.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: falseand useMediaQuery.viewInsetsOf(context).bottomyourself. 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.
10. Accessibility checklist
Section titled “10. Accessibility checklist”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/checkedand a label in one node. UseMergeSemantics, orGlassChip, 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.
11. Adopting it
Section titled “11. Adopting it”Per screen
Section titled “Per screen”- Replace
appTheme.*withAppColors.*roles, andCustomTextStyles.*orTextStyle(withAppText.*. - Replace numbers in
EdgeInsetsand radii withAppSpace/AppRadius. - Decide solid or glass per surface using the table in §6.
- Walk the accessibility checklist.
- Test the behaviour, not the pixels: “the active chip is a selected
GlassPill”, not “the fill is 0x24FFFFFF”. tool/quality.sh --updateto lock in the lower counts.
The ratchet
Section titled “The ratchet”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:
fvm dart run tool/design_ratchet.dart --reportSuggested order
Section titled “Suggested order”Screens where the most users look the longest come first:
- Home feed chrome and
nav_chip_row.dart(a one-of-N selector: aGlassCapsuleglide). - Content page (
displaytitle, primary Play button, season picker as a capsule selector). - 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. - Player chrome and cast sheets: glass over video, where it pays off most.
- Profile, settings and auth.
Status
Section titled “Status”| Area | Tokens | Glass |
|---|---|---|
| Nav bar | partial | ✓ |
| Search page + filter sheet | ✓ | ✓ |
| Everything else | — | — |