16 KiB
Surfaces
FlClash draws structure with fills and keeps color for state. A surface stands out from what it sits on by a step in lightness, never by a border. Color appears where something is selected, on, failing, or the one action a screen is for. The dashboard set this direction; every other screen follows it, and Material components keep their behavior but give up their tonal container fills.
Levels
SurfaceLevel in lib/ui/surface.dart names four depths. Each surface takes the level one deeper than the one
it sits on, which SurfaceScope passes down the tree.
| Level | Light | Dark | Holds |
|---|---|---|---|
page |
surfaceContainer |
surface |
scaffold body, sheets |
card |
surfaceBright |
surfaceContainer |
cards, grouped runs of rows, light-mode floats |
block |
surfaceContainer |
surfaceContainerHigh |
unselected segments, tiles and wells in a card |
inset |
surfaceContainerHighest |
surfaceContainerHighest |
small marks inside a block: glyph wells, badges, tracks |
Light mode alternates gray and paper: a near-white card on a gray page, a gray block in the card. The card is tone 98,
not pure white, and the page tone 94: pure white beside a tinted gray made the white glare and the gray look soiled. Dark mode only
lightens, because a darker step inside a lighter surface reads as a hole rather than a layer. inset is the floor;
nothing nests below it, so a design that needs a fifth level needs fewer containers instead.
- A widget never picks its level at the call site.
CommonCardand the other shared surfaces readcontext.raisedSurfaceLeveland wrap their child inSurfaceScope, so the same row is white on a page and gray inside a card. A branch onisInBottomSheetor on where a widget is placed to choose its fill is the bug this replaces. - Sheets are
pagelevel, like a page, so a section looks the same in a sheet and in a page. In dark mode a sheet presents a level up, as iOS presents a sheet on its elevated backgrounds:ColorScheme.elevatedlifts every surface role by the step frompagetocard, so the sheet's page takes the tone of the cards it covers and every level inside it follows. Under pure black the sheet takes the plain dark scheme instead, which is the same step up and keeps the seed's tint that a lift from black would lose.ElevatedColorSchemecarries the result and the sheet routes apply it throughElevatedSurfaceTheme; a sheet opened from a sheet keeps the same lift. A scrim barely darkens a dark page, so a dark sheet at the page's own tone read as more of the page rather than a layer over it, a covered sheet's peek vanished, and on black the edge and corners went with it. Light mode keeps its levels, because the scrim already sets a light sheet apart. - What floats over content, dialogs, menus, popups, the mobile dock, toasts, takes
SurfaceLevel.floatingOf:cardin light,blockin dark. A shadow does not show on a dark page, so a float has to come out lighter than the cards it covers, and its content scopes to that level so a block inside still steps away from it. - Shadows mark what floats, plus the dashboard cards. Cards in a scrolling list have none; a grid of proxies with a
blurred shadow each costs raster time for no extra layering. A render object that paints a shadow itself draws it
with
drawRSuperellipse, asShapeDecorationdoes: Impeller blurs a superellipse in one analytic pass, but a path through an offscreen Gaussian blur on every frame, which made the shadows the largest raster cost of an opening menu.
The Seeded Scheme
seededColorScheme in lib/ui/color.dart builds every app scheme: Material's scheme for the seed and the
ColorStyle picked in the theme settings, with three groups of roles replaced. Everything else stays Material,
including toPureBlack applied after it.
The four styles each change something a reader sees at once. Material's nine variants mostly moved secondary and tertiary, which the app barely shows, so most of them looked alike once surfaces stayed gray.
| Style | Material variant | Primary | Surfaces |
|---|---|---|---|
vivid |
content | the seed | near gray |
tinted |
content | the seed | the seed's hue, up to chroma 10 |
soft |
tonal spot | Material's, muted | near gray |
monochrome |
monochrome | black or white | gray |
- Surfaces stay near gray. Each
surface*role keeps Material's tone but its chroma is capped at 3, the lightsurfaceandsurfaceBrightat 1.5, andoutlineVariantat 4. Material tints neutrals by the seed (content divides the seed's chroma by 8), so a yellow, green, or rose seed tinted the whole page.tintedis that wash chosen on purpose and made even: every surface takes the seed's hue at a sixth of its chroma, capped at 10, so a gray seed stays gray and any vivid one tints about as much. primaryContainercomes from the primary palette. It is primary's hue at tone 90 in light and 30 in dark, withonPrimaryContainerat 30 and 90, and the pair's chroma is capped at 48. Content and fidelity otherwise put it at the seed's own tone: a yellow seed filled it with pure yellow, a red seed with something that read as an error, and dark mode with a light block under light text. The cap matters for light, chromatic hues: yellow at tone 90 is still close to full strength.vividandtintedkeep the seed asprimary, as an Apple accent color does: the color picked in the theme settings is the color of every check, switch, and filled button. Light holds the seed's tone between 35 and 60, so white text on it keeps about 3:1 and a near-black seed still reads as a color; dark lifts it 3 tones, as iOS lifts its system colors, within 50 to 75, and puts dark text on it above 60. Material sets primary at tone 40 and 80 for any seed, which turned iOS blue into navy in light and a pastel in dark.softandmonochromekeep Material's primary, since moving away from the seed is what they are for.
State
Color marks the one item that differs, never the structure around it.
- Selected, one of several rows, cards, or tiles: the item keeps its level's fill and a primary mark says which,
as iOS checks the option picked from a list. A row in a grouped run takes
SelectionCheckat its edge; a card that stands on its own, a profile or a proxy, and a tile take the filledSelectionBadge, which holds its own beside a 40 dp icon well where the bare check read as small and thin (lib/ui/selection_mark.dart).CommonCard.isSelectedonly reports the state to accessibility. Fills were tried and rejected: a wash of primary landed on a gray that did not read as a choice, andprimaryContainerread as a warning under rose seeds in light and as a heavy slab in dark, muddied the translucent marks on it, and cut a grouped run in two. - Multi-select in edit mode: the circle checkbox is the mark; the row stays at its level.
- Proxy groups have two states in URLTest and Fallback: the check badge marks the proxy traffic goes through, in
every group type, and a pin badge (
SelectionBadge.preferred) marks the preferred proxy (mihomo'sfixed): filled like the check while the group uses it, tonal while the group has fallen back to another proxy, which carries the check. - Chosen segment in a connected button group, a mode switch, or a run of compact choice chips
(
CommonCard.fillsSelection): solidprimarywithonPrimary, as the dashboard outbound mode does. Only one segment per group. - Error on a row or card:
errorContainerwithonErrorContainer, no border. - Status values (delay, availability, success, warning): the color goes on text, a glyph, or a small chip, never
on a whole card.
successandwarningcome from fixed green and orange palettes that ignore the seed, like Material'serror; harmonizing pulled orange toward red under rose seeds until warning and error matched. Text takes tone 40 in light and 80 in dark, a chip takessuccessContainer/warningContainer(90/30) with theonrole (30/90). A delay under 600 ms is success, above it warning, a failure error:ColorScheme.delayColor. - Destructive action in an alert:
ColorScheme.destructive, iOS 27's systemRed in both modes, noterror. A menu'sdangeritem still takeserror. - Data series on one chart: the first is primary, the second primary at 45% over the card
(
DashboardColors.series2), so they differ by lightness under any seed. Tertiary is not a series color. - Text: titles and values
onSurface, labels and secondary linesonSurfaceVariant.outlineis not a text color, and a value is never tinted primary. - Marks that only identify, a country badge, a group or service icon, a source avatar, sit on a neutral well with
onSurfaceVariant. primaryContainer,secondaryContainer, andtertiaryContainerare neither surface nor selection fills. Keep them out of cards, rows, marks, and navigation indicators.
Borders That Stay
- Focus: a 2 dp primary stroke while a surface has keyboard or remote focus. TV navigation depends on it, and a
fill change alone is too faint to follow from a couch. A card, a grouped row above all, rings only focus a key moved
there (
FocusMovedByKey), or any focus while a remote drives the app: a tap or a click puts focus where the reader is already looking, a field's caret already marks it, and the ring then read as a selection box around the row. - Inputs: a form's field is a bare
FormRowTextFieldat the end of a groupedFormRowwhose title labels it, as iOS and macOS set one, with no stroke or fill of its own. A field among filled controls takes their fill, one level above its surface, throughFilledInputTheme. An alert's fields, because they sit over its pill buttons, as UIAlertController sets them: as wide as the buttons, in their fill, with their text in line with the title and the label as the placeholder (CommonDialog.hasFields); a field under a filled block of text in an alert takes the block's width and corner instead. The custom color's hex field, because its card holds only fills, the color previews, where an outline was the one stroked thing. Search fields are filled pills. A sheet's docked search, because the list scrolls under it: it floats like the dock, with the floating fill and the dock's shadow, or the dock's glass. The editor's find bar, because its fields sit in a row of filled pill buttons: they take the buttons' fill, one level above the bar. All of these stroke only while focused. Any other field, one standing alone on a page, is outlined, with the floating label notched into the stroke:ThemeData.withAppShapesgives every inputAppShape.input, so call sites leaveborderunset. A lone field is never filled: here a fill means structure, so one read as one more card or row, and in light mode the hover overlay brought it down to the page's own tone. No field counts its characters, as none does on iOS or macOS; a length limit holds through its formatter. Material strokes a filled field's whole outline inonSurfaceVariant, which ringed the pill brighter than anything else in a dark sheet. - Window chrome: the desktop sidebar's inner edge and the title bar's bottom edge draw a divider where they meet the content. The chrome fill alone left the edge to a four-tone step, which read as a smudge rather than a boundary.
Nothing else draws an outline. Rows in a grouped run are separated by the hairline from lib/ui/list_row_separator.dart, which divides
one surface and does not outline it. Record lists, requests, connections, logs, and DNS queries, are not grouped: they
run to thousands of tall rows, and one card that long reads as a slab rather than a group. A stacked row of three or
four lines is already its own block, so each is a card of its own, RecordListItem: the raised fill at xl, 16 dp
from the edges and 8 dp from the next, as the profile cards sit. Every card takes the same fill, so the warning and
error tints on a label still stand out, where a fill on every other row competed with them; rows on the page split by
a full-width divider were the one structural line left on screens otherwise drawn with fills. Laid out as a table,
where a row is one line read across many columns, they drop the gap and give every other row the raised fill, inset
and rounded like the column header, so the eye keeps its row; the dashboard's table does the same.
Every divider takes its color from ThemeData.withAppSurfaces: onSurface at 14%, never outlineVariant. A
translucent line keeps the same contrast on the page, a card, or the chrome, in both modes; outlineVariant stood out
twice as much on the dark page as on the light one and carried the seed's tint.
Navigation
- The desktop sidebar and title bar are window chrome,
ColorScheme.chromeSurface:surfaceContainerHighestin light andsurfaceContainerLowin dark, four tones from the page either way, with a divider along the edge that meets the content. Atpagelevel the sidebar merged with the content. In dark the chrome stays belowcard, so cards still stand out from it. The pane the sidebar opens over the content takes the same fill and edge, plus a shadow. The selected destination keeps its neutral fill and primary bar, which already marks it with color. - The mobile dock and the docked navigation bar are floats; their indicator is a neutral platter one level above them, and the selected destination's content takes primary, as iOS's tab bar marks its selection.
- With Liquid Glass on (Theme settings), the dock, the buttons of a page's bar, a sheet's docked search, popup
menus, and alerts are glass from
lib/ui/liquid_glass.dartinstead of a fill, and the dock's platter takes the secondary hue and chroma at tone 90, or at tone 4 in dark, which sinks it below the bar as iOS 27 does. - The proxies page's group tabs are compact choice chips, the current group in the chosen segment's solid
primary. Once they overflow, a tonal button at their end opens every group as a popup menu, the current one checked and scrolled into view. A profile can carry dozens of groups, so the menu stops at seven rows and half of the next.
Where It Lives
lib/ui/surface.dart:SurfaceLevel,SurfaceScope,context.surfaceLevel,context.raisedSurfaceLevel,ColorScheme.chromeSurface, andThemeData.withAppSurfaces, applied next towithAppShapeswherever the app builds its theme.lib/ui/color.dart:seededColorScheme, the status palettes, anddelayColor.lib/views/dashboard/style.dart:DashboardColorsmapspage,card,tone, andtone2onto the levels and adds the dashboard's switch, series, and status colors.
Checking A Change
Render the touched screens in light and dark, on a phone width and a desktop width, before and after. A throwaway
widget test does it: pump HomePage or the view inside a RepaintBoundary, load Roboto with FontLoader so text
gets real glyphs, and write RenderRepaintBoundary.toImage to a PNG outside the repository. Look for a surface that
matches the one it sits on, which means it skipped SurfaceScope, and for a tonal container fill that survived on a
card or row.