Files
FlClash/.agents/surfaces.md
T

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. CommonCard and the other shared surfaces read context.raisedSurfaceLevel and wrap their child in SurfaceScope, so the same row is white on a page and gray inside a card. A branch on isInBottomSheet or on where a widget is placed to choose its fill is the bug this replaces.
  • Sheets are page level, 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.elevated lifts every surface role by the step from page to card, 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. ElevatedColorScheme carries the result and the sheet routes apply it through ElevatedSurfaceTheme; 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: card in light, block in 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, as ShapeDecoration does: 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 light surface and surfaceBright at 1.5, and outlineVariant at 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. tinted is 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.
  • primaryContainer comes from the primary palette. It is primary's hue at tone 90 in light and 30 in dark, with onPrimaryContainer at 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.
  • vivid and tinted keep the seed as primary, 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. soft and monochrome keep 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 SelectionCheck at its edge; a card that stands on its own, a profile or a proxy, and a tile take the filled SelectionBadge, 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.isSelected only 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, and primaryContainer read 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's fixed): 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): solid primary with onPrimary, as the dashboard outbound mode does. Only one segment per group.
  • Error on a row or card: errorContainer with onErrorContainer, no border.
  • Status values (delay, availability, success, warning): the color goes on text, a glyph, or a small chip, never on a whole card. success and warning come from fixed green and orange palettes that ignore the seed, like Material's error; 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 takes successContainer/warningContainer (90/30) with the on role (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, not error. A menu's danger item still takes error.
  • 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 lines onSurfaceVariant. outline is 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, and tertiaryContainer are 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 FormRowTextField at the end of a grouped FormRow whose 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, through FilledInputTheme. 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.withAppShapes gives every input AppShape.input, so call sites leave border unset. 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 in onSurfaceVariant, 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: surfaceContainerHighest in light and surfaceContainerLow in dark, four tones from the page either way, with a divider along the edge that meets the content. At page level the sidebar merged with the content. In dark the chrome stays below card, 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.dart instead 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, and ThemeData.withAppSurfaces, applied next to withAppShapes wherever the app builds its theme.
  • lib/ui/color.dart: seededColorScheme, the status palettes, and delayColor.
  • lib/views/dashboard/style.dart: DashboardColors maps page, card, tone, and tone2 onto 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.