Files
FlClash/.agents/surfaces.md
T

188 lines
16 KiB
Markdown
Raw Normal View History

# 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.