docs(agents): document surfaces, typography and the lib/ui layer

This commit is contained in:
chen08209
2026-10-16 22:42:17 +08:00
parent f43755c146
commit 766085995b
8 changed files with 810 additions and 121 deletions
+456 -74
View File
@@ -10,8 +10,15 @@ Android lib mode:
classes gobind generates in `com.follow.clash.core.mobile`. The `:core` Gradle module builds it and takes in its
classes and libraries (see Android Core Build).
- The Android `:core` module owns the binding: `Core.kt` is the only caller of the generated `Mobile` class. Flutter
crosses the `${packageName}/service` MethodChannel through `lib/plugins/service.dart` and Android's `ServicePlugin`
rather than calling the Core directly.
crosses MethodChannels through `lib/plugins/service.dart` and Android's `ServicePlugin` rather than calling the Core
directly: `${packageName}/service` for lifecycle commands and Core events, `${packageName}/core` for Core method
calls.
- Flutter runs the UI isolate on Android's main thread (3.47 merges the two and ignores the opt-out in release), so
main-thread work anywhere in the process, the Core's included, costs frames. Only sending to Dart needs that thread;
a `MethodChannel.Result` may be answered from any thread. `${packageName}/core` is handled on a serial background
task queue and answered from the Core's goroutine, an event batch is encoded on the Core's thread and only sent on
the main one, `ForceGC` (called from `onLowMemory`/`onTrimMemory`) collects on its own goroutine, and
`NetworkObserveModule` refreshes DNS on its own looper.
- `lib/core/lib.dart` (`CoreLib`) implements the shared Core interface, gates method calls on its connection completer,
initializes and synchronizes Android shared state, and closes the native service path exactly once.
- Because Core is in the application process on Android, application RSS already includes Core memory.
@@ -87,16 +94,28 @@ into `sniff`. A `rule-set:` key of a custom profile's DNS policy counts as a use
section reaches the core, because setting the system clock is a host change; `loadConfig` in the Go wrapper also drops
it on Android, where the seccomp policy kills the process for `settimeofday`.
A custom profile's TUN page is the exception to covering the raw struct. The network settings own `tun` through
`PatchClashConfig.tun`, spread last over the profile's section and patched again on every `updateConfig`, so
`TunOverrideKey` offers only a few keys outside it that work in FlClash's TUN: `disable-icmp-forwarding`, and
`exclude-interface`, which only Linux routing reads. `test/models/tun_override_test.dart` fails if a key comes to
overlap `Tun`. Android starts its VPN from `core/tun` without the section, so TUN is listed on desktop only.
Being a part of the section, the page has no quick edit; see Quick Edit.
TUN is the exception to covering the raw struct. The network settings own only `Tun.coreJson` (`enable`, `device`,
`stack`, and `auto-route` always on), spread last over the profile's section and patched again on every
`updateConfig`, so `TunOverrideKey` offers only keys outside it that work in FlClash's TUN. Its keys exist app-wide
(`PatchClashConfig.tunOverride` with `tunOverrideKeys`, listed in the network page's TUN section as `OverrideEntry`
rows and added from its last row, as a custom group's options are) for every profile, and per custom profile, whose own keys `ProfileOverrides.overAppTun` lays over the app-wide ones. MTU,
congestion control, strict route and the route lists are such keys on desktop, so a profile's own values apply unless
picked; Android's TUN rows keep them in `Tun`, which its VPN service starts from, and a config saved before the move
carries values changed from their defaults into picked keys. `bypassPrivateRoute` stays a network setting: the task
appends the private ranges to whatever `route-exclude-address` results. `dns-hijack` is a key too: the task writes
`any:53` beneath the overrides, so a profile's own value never applies, a picked key replaces it, and the key starts at
that value. A key sing-tun reads on some platforms only is listed only there
(`_isRead` in `lib/views/config/tun.dart`): strict route on Linux and Windows, GSO and the interface, user ID and port
filters on Linux. `udp-timeout` reaches only the gvisor and system stacks and congestion control only mips, which their
descriptions say. Route address sets are left out: sing-tun applies them only through `auto-redirect`.
`test/models/tun_override_test.dart` fails if a key comes to overlap `Tun.coreJson`. Android starts its VPN from
`core/tun` without the section, so TUN is listed on desktop only. Being a part of the section, the page has no quick
edit; see Quick Edit.
A custom profile's overview keeps tiles for rules, providers and DNS, which every custom profile runs on, and puts the
dialer proxies, NTP, sniffer and TUN behind a single "more" tile, which opens a page listing them with their states.
Another rarely changed section joins that list rather than taking a tile of its own.
A custom profile's overview keeps rows for rules, providers and DNS, which every custom profile runs on, and puts the
dialer proxies, NTP, sniffer and TUN behind a single "more" row, which opens a page listing them with their states.
Both lists are the same grouped rows, glyph, title and state, ending in a chevron. Another rarely changed section joins
the "more" list rather than taking a row of its own.
Every page is an `OverrideView` over an `OverrideSpec` from `lib/views/config/override.dart`. The spec supplies the
section's keys, labels and fields; its `OverrideTarget` is where the model and keys live, `PatchOverrideTarget` for the
@@ -151,7 +170,8 @@ or a remote party reaching the privileged Core's control or filesystem APIs is a
A process running as the same account is not stopped. It can ask the Helper to start the Core
against a socket or pipe it owns, or run a setuid Core against a port it listens on, and so become the host that drives
a privileged Core: `initClash` chooses the home directory every path check anchors on, and `getConfig` reads any path.
a privileged Core: `initClash` chooses the home directory every path check anchors on, and `getConfig`,
`getProfileBase`, `getProfileOutbounds` and a `setupConfig` or `buildProfile` base read any path.
The Core connects to whatever address it is given and cannot tell that host from FlClash. An account check cannot
distinguish it from a same-account process that can already read FlClash's memory and files. This remains a user-to-root
or user-to-SYSTEM privilege escalation, not a complete security fix. An open external
@@ -319,6 +339,20 @@ directions. The envelope is the only JSON serialization layer: keep arguments, r
values rather than embedding pre-encoded JSON strings. Plain domain strings, such as country codes or provider contents,
remain strings.
A response over 1 MiB goes to a file under `<home>/responses/`, and the envelope carries only its `file`
(`core/spill.go`). Sent inline, Android copies it into the Java heap three times (the gomobile `byte[]`, the codec and a
direct buffer), and desktop IPC drops a frame above `maxIPCFrameSize`. `lib/core/spill.dart` reads a spilled file only
from that directory, decodes it in an isolate and deletes it; `initClash` clears what an earlier run left.
A profile file's proxies, groups and rules never cross the bridge. `getProfileBase` returns the config without
`proxies`, `proxy-groups`, `sub-rules` and `rules`, with their counts, the profile's MATCH rules and a sha256 digest.
`SetupAction.getProfile` patches it as before and sends the result as `SetupParams.base.overlay`. `core/profile_base.go`
puts the profile's bulk sections into the overlay, appends the profile's rules after the overlay's, writes `config.yaml`
for the Android service and the controller's reload, and parses it. `config.yaml` is encoded a slice at a time, because on a
profile of 100k proxies and 2M rules one `Encode` call doubles the peak heap. The preview reads the YAML `buildProfile`
merges into the spill directory, and the extend page only the names
and types `getProfileOutbounds` lists; a script extension still needs the whole config from `getConfig`.
Go event delivery is intentionally non-blocking:
- Loaded-provider, geo-update, and route-changed events use a state queue that never evicts; the rules for it are under
@@ -346,9 +380,15 @@ Dialer-loop events come from the fork's `component/proxydialer/patch.go`. mihomo
back to its own proxy until the stack overflows, so a dial that asks the same `dialer-proxy` for an address it is
already dialing through it fails with `ErrDialerLoop`. `core/hooks.go` reports each looping name at most once every
three seconds as a priority event, which the app shows as an error notice whether or not logs are captured. The custom
profile pages flag only the loops FlClash can follow on its own, through a group's own list or an include-all that no
filter narrows; what filters and providers pick, including provider proxies that carry their own `dialer-proxy`, is
left to this guard.
profile pages flag only the loops FlClash can follow on its own, through a group's own list or an `include-all-proxies`
that no filter, exclude filter or exclude type narrows; what filters and providers pick, including provider proxies
that carry their own `dialer-proxy`, is left to this guard.
`ProxyGroup.fromJson` reads `include-all` as both `include-all-proxies` and `include-all-providers`, as the core does, so
import and quick edit keep it. They take in only what the config declares, which is what some group of the profile
names: its local proxies and the file or url profiles in a `use`. Setting one declares nothing more, so the app's other
proxies and subscriptions stay out, and the core picks from the declared ones by the group's filters. Groups saved by
v0.9.0 kept `include-all-proxies` in their stored `definition`, and it takes effect again.
Desktop RPC accepts both a single event object and batched event lists. Android and desktop listener dispatch isolate
listener exceptions so one faulty observer does not prevent the remaining events/listeners from running.
@@ -376,8 +416,8 @@ holds the group; `delayTestPageGroup` marks it, ignores a second request for the
so an RPC failure cannot leave the button spinning and remounting a header does not lose the state.
The dashboard's probe cards show a failure as still checking for five seconds after they see `proxied` turn true,
through `ProbeStartHold` in `lib/views/dashboard/probe_start_hold.dart`: `ServiceStatusCard` holds its check and the
exit IP beside it, and `NetworkDetection` holds the exit IP, whose tap to retry stays inert meanwhile.
through `ProbeStartHold` in `lib/views/dashboard/probe_start_hold.dart`: `ServiceStatusCard` holds the check on each
tile, and `CurrentOutboundCard` holds the exit IP, whose refresh button stays inert meanwhile.
`SetupAction.setRunning` flips the run time, and with it `proxied`, before the Core has brought its listeners up, so the
probe launched on the spot often fails, and the config re-apply that follows every start probes again moments later;
without the hold a card flashes a failure and then takes it back. The hold is local display state like the connecting
@@ -423,9 +463,34 @@ a new IP and an unchanged IP reuses the result. A lookup races its sources the w
first one that determines a type. A success is kept for the session with `ref.keepAlive()`; a failure is not cached,
and Riverpod's automatic retry is off because a retry after an HTTP 429 spends the same per-outbound-IP quota again.
## UI Layer
`lib/ui/` holds the components and design tokens that know nothing of the app: shapes, surfaces, motion, colors, the
scaffold, sheets, popups, lists and the glyphs under `lib/ui/icons/`. `lib/widgets/` holds the components built from
them that do: settings rows, subscription and profile pieces, anything that reads a provider or a model.
`widgets/widgets.dart` re-exports `ui/ui.dart`, so app code imports either barrel.
`test/lint/ui_layering_test.dart` walks the imports of every file under `lib/ui`. Nothing they reach may leave
`lib/ui`, except `lib/common` files whose own imports stay out of the app, never the `common.dart` barrel, and none
may import Riverpod. A component that needs app state reads what the app provides above its navigator:
- `ViewScope` (`lib/ui/view_scope.dart`): the window size, whether the layout is mobile, and the window controls
over a page. `AppViewScope` in `lib/manager/theme_manager.dart` fills it from `viewSizeProvider`,
`isMobileViewProvider` and `versionProvider`. It is an `InheritedModel`, so a reader of one value does not rebuild
when another changes; `context.isMobileView` reads it without listening.
- `MeasureScope` (`lib/ui/measure.dart`): the `Measure` that `ThemeManager` also assigns to `globalState.measure`.
- The `MediaQuery` text scaler that `ThemeManager` sets: the app text scale behind `.ap(context)`, `.mAp(context)` and
the `...Of(context)` paddings in `lib/ui/metrics.dart`. Reading it there makes a widget sized by them rebuild when the
scale changes; a global copy or a top-level final would keep the scale it was first read at.
- `UiLocalizations` (`lib/ui/l10n.dart`): the strings the components show themselves. `AppUiLocalizations.delegate`
in `lib/common/app_localizations.dart` serves them from the ARB strings; a new one needs a getter on both.
A test that builds its own `MaterialApp` registers `AppUiLocalizations.delegate` and returns `wrapAppScopes` from
`test/helpers/test_app.dart` in its builder, as `TestApp` does.
## Icons
`lib/icons/` holds every icon the app shows. Nothing in `lib/` uses the Material icon font, and
`lib/ui/icons/` holds every icon the app shows. Nothing in `lib/` uses the Material icon font, and
`test/lint/material_icons_test.dart` fails on any `Icons.*` or `AnimatedIcons.*`, so every icon shares one stroke and
can animate its shape.
A `Glyph` is a list of shapes on a 24-unit grid, stroked at `Glyph.strokeWidth` with round caps and joins and
@@ -451,8 +516,8 @@ some other state, like the sidebar toggle that tracks the pane's width, is a bui
through its checks; it is named for what it means in the app, one glyph per meaning, with SF Symbols and Fluent as the
reference for the metaphor. Tests find one with `find.byGlyph` from `test/helpers/glyph_finders.dart`.
Icons are outlined by default and filled inside a filled button: `IconButton.filled` and `.filledTonal`, `FilledButton`
in its tonal and icon forms, and floating action buttons draw their glyph with `fill: 1`, because an outline on a tinted
Icons are outlined by default and filled inside a filled button: `IconButton.filled` and `.filledTonal`, and
`FilledButton` in its tonal and icon forms draw their glyph with `fill: 1`, because an outline on a tinted
container reads weaker than the container it sits in. A glyph with no body only thickens its lines and still takes
`fill: 1`, so the rule holds if the glyph later gains one. `test/lint/filled_button_glyphs_test.dart` checks every
`GlyphIcon` written inside such a button; an icon that reaches the button from elsewhere, as through a helper that picks
@@ -464,7 +529,7 @@ Framework widgets that draw their own icon take it from the theme: the `ActionIc
supplies the back and close buttons, with `AppGlyphs.backFor` picking the platform's back glyph, and a `DropdownMenu`
is given its chevrons. A framework widget added later that paints a Material icon needs the same treatment.
Empty states draw no image assets either. `NullStatus` in `lib/widgets/null_status.dart` shows the glyph for its
Empty states draw no image assets either. `NullStatus` in `lib/ui/null_status.dart` shows the glyph for its
meaning filled on a Material 3 Expressive shape from `material_new_shapes`, with a few accent marks, and enters with
one of three choreographies picked at random: the shape spins in, morphs out of a circle, or springs in while the
accents orbit into place. The glyph takes `onPrimaryContainer`, not `primary`: the `content` scheme variant can leave
@@ -482,18 +547,227 @@ button while searching) when there is one, then foldable actions, primary action
the icon actions ahead of it;
everything else goes into one overflow menu, primary action first, and a folded action that is loading shows as a
disabled item. The bar reads as one button group of two beside a single button: the overflow menu button when
something folds, otherwise the last action, so the proxies list shows a search and delay test group beside the menu. `primaryAction` counts only where the bar holds it, in a bottom sheet and on a phone's home
page, whose foot the navigation dock takes; elsewhere it is the page's floating action button. No page adds a
floating action button of its own: a secondary action such as selecting every app goes into `menuItems`, and saving
an edit page is a check among its `iconActions`. Actions on a multi-selection, such as delete and select all, go into
something folds, otherwise the last action, so the proxies list shows a search and delay test group beside the menu.
The bar holds `primaryAction` everywhere, a phone's home page above the fixed navigation bar included, and nothing in
the app floats an action button: a secondary action such as selecting every app goes into `menuItems`. Saving or
confirming, in a page, a side sheet or a form, is a check in `confirmationAction`, never among `iconActions`: it ends
the bar and never folds, and only a form sheet moves it to its foot (Forms). Bar buttons are glyphs with tooltips,
select all included; the one text button is the custom profile's Import, a filled button whose `CommonPopupBox` opens
the import menu. Actions on a multi-selection, such as delete and select all, go into
`selectionActions` instead: the bar keeps them in sight as one button group that takes a single slot beside the search
or clear button, so they never fold.
or clear button, so they never fold. A sheet's close button leads its bar when the bar has actions, except beside a
primary action: then the two end the bar as one button group, as the proxies sheet's delay test and close do.
Actions on what the page edits as a whole, such as renaming the custom profile or viewing its final config, go into
`titleMenuItems` rather than the bar: the title shows a chevron and opens them, as an iOS document's title menu does.
The menu opens from the title's start edge (`PopupPlacement.overAnchorStart`), since the bar's own menus, which open
from their end edge, would cover the back button there.
A bar under a sheet drops its actions and keeps its title and back or close button. `CommonScaffold` fades the actions
out with the entrance of a sheet laid over its page, or over the sheet it is a page of, so none on the layer below
reads as an action of the sheet on top; `sheetCoverOf` in `lib/widgets/sheet_navigator.dart` reads that entrance, which a sheet route hands
reads as an action of the sheet on top; `sheetCoverOf` in `lib/ui/sheet_navigator.dart` reads that entrance, which a sheet route hands
down through `SheetCoverRouteMixin`, as `SnapSheetRoute` and `ModalSideSheetRoute` do.
## Liquid Glass
`ThemeProps.liquidGlass` picks a `LiquidGlassMode`, `none` by default, and reaches widgets as `LiquidGlassScope`, on for
`standard` and `ultra` with that `LiquidGlassQuality`, from `ThemeManager`. With it on, `CommonScaffold` passes `glass`
to the bar's `TonalButtonTheme`,
which puts a `LiquidGlass` pane behind each button through `ButtonStyle.backgroundBuilder` and one behind a
`TonalButtonGroup`; the navigation dock's bar and lifted lens become panes as well, and so do a sheet's docked search,
the cards of a `CommonPopupMenu`, and a `CommonDialog`. On the dashboard only the start button is a pane: its connected
buttons and switches keep their fills, since each pane adds its area to a capture retaken whenever the dashboard
redraws, which it does often. Each still swells on its own under `ElasticPress`, and a `DashboardConnectedGroup` paints
the button pressed last over the neighbours it swells past. `lib/ui/liquid_glass.dart` holds the rest:
- A `LiquidGlassHost` over the app (`lib/ui/liquid_glass_capture.dart`, after g1455's atlas proxy) draws its panes
without reading the backdrop. As the frame is composited, before its children, it redraws onto one small image what
each pane under it would read: the layers painted before the pane, within its reach, shrunk as far as a sigma of the
pane's frost still spans two of the image's pixels, twice where g1455 records its regular finish, and never past
eight screen pixels to one, the coarsest g1455 measured, with that frost blurred in by the engine. A pane without frost, the dock's lens, keeps the screen's resolution, as g1455 keeps its clear finish.
A pane with a `haze` takes a second slot in the same image, shrunk and blurred for its far wider `hazeBlur`, and
mixes the two.
Each pane paints the shader over its part of that image as an ordinary paint, one read a sample where its frost is
even. No pane ends the frame's render pass or copies the surface, and the glass never trails the content by a frame.
A pane outside any host draws only its tint and rim.
- `ultra` draws all of the glass below. `standard` leaves out what costs most for least: a haze becomes the whole
frost, so the dock takes one shrunken slot in place of a sharp one at the screen's resolution, and its text no
longer shows faintly through; a lens's rim parts no colors; the lifted lens's copy goes unwarped, tinted with the
light its pane would add; and the dock casts no shadow, which is under 3/255. The host applies the style changes to
every pane it draws, the dock the rest. A config saved with `standard` before `ultra` existed moves to `ultra` once
(`Migration.upgradeVersion` 2), since that was its look. On the SwiftShader emulator, as raster time a frame over
glass off, median of four alternating runs, the glass before the tiers, `ultra` and `standard` added 16.4, 13.5 and
9.5 ms scrolling, 52, 40 and 17 dragging the lens, 34, 26 and 18 scrolling under an open menu, 22, 13 and 11
opening it and 17, 9 and 5 closing it; scrolling took 18.8 render passes a frame in either tier against 23.6, and
the lens 22 in `ultra` and 18 in `standard` against 30.
- A pane over one painted before it in the same root reads a second image, a level up, with the first drawn in as
glass. Over means within 2.5 sigmas of its frost and as far as it bends, where a pane takes under 1% of what it
reads, rather than the 3 sigmas and 2 dp its slot holds: the bar's buttons stand 8 dp apart, inside the slot of the
overflow button, which went up a level and cost a second image every frame content moved under the bar. A pane's
root is the host or its nearest ancestor that draws offscreen: as a backdrop filter would there, it
sees only that ancestor's content, without the ancestor's own effect, and replaces what it covers. A
`LiquidGlassFade` is no root: as Core Animation fades a layer, its panes still read what lies behind it and fade with
the rest. A level keeps its image while what it drew, the same pictures under the same transforms, clips and
effects, stays the same, so a still screen redraws its glass without a capture and content scrolling under glass
captures once a frame. Within an image each band of slots that blur alike keeps its pixels the same way, copied
from the last image rather than drawn and blurred again, and a slot without frost is a band of its own: dragging
the lens redraws only its copy's slot, not the dock's haze or the buttons' frost. Not on Skia: an image there keeps
what it was drawn from as long as it lives, so each capture copied from the last would hold every one before it,
and memory grows by a capture a frame. The dark line along the rim darkens the blurred capture there rather than the
sharp backdrop, a pixel or two wide.
- Measured on an Android emulator rendering in software (SwiftShader, 1080×2400 at density 420), as raster-thread time a
frame counting the captures that FrameTiming's raster span leaves out, median of three runs, each capture against
none in a run of its own: scrolling Tools takes 43 ms with none, 6 more with a sigma over one pixel of the capture and
11 over three; scrolling the dashboard 64, 9 and 19; switching to Proxies 34, 15 and 25; opening a menu 42, 9 and 13;
closing it 41, 1 and 3; dragging the dock's lens 39, 38 and 42. One pixel adds about half what three do while
content scrolls, and about as much while a page switches, where the capture's size is not what costs, or while the
lens moves, which both capture at full resolution. Three was measured before a menu's capture was held to eight
screen pixels to one, and two, which glass uses, has not been timed. Ultra, which read the backdrop under each pane,
added 27 to 61 ms to the same frames and was dropped. A software renderer weighs passes and texture reads unlike a
phone's GPU, so the figures rank the captures rather than price them; no phone has measured them. A still screen
draws no frames.
- Rendered under `flutter test --enable-impeller` at density 3, against a capture with a sigma over three pixels, one
pixel blurs text under the dark dock into blocks that set 16% of its pixels apart side by side (CIEDE2000 above 2),
and two sets 0.5% apart. Reading a coarse capture through a bicubic filter barely helps: the blocks are in the
capture.
- Where a pane's frost is uneven, as along a rim that softens, the shader reads what the capture does not hold as the
engine's Gaussian, in device pixels, on a Vogel disc of 16 taps up to a sigma of 2 pixels, 32 up to 3.5 and 64
past it, so no taps lie further apart than 64 leave them at the widest; rendered at density 2.625 the lens moves by
at most 12/255 on a few pixels from 64 taps throughout.
- Glass builds no pipelines ahead of use. A warm-up that rendered panes offscreen once the first frame landed was
dropped: after an install, with no pipeline cache on disk, it compiled on the raster thread just as the app was first
used, to spare the first menu and lens only a few pipelines.
- A pane is a rounded rectangle, each corner with its own radius, unless a `clipper` gives it a path, as the start
button's cookie does. The shader reads that path as its reach from the pane's center in 64 directions, so it must
meet every ray from the center once.
- Without Impeller's shader filters (Skia on the Intel macOS build, `flutter test`) a pane frosts from its capture:
blur, saturation, brightness, tint, and a rim, with no refraction; the tint is not thickened there, as it is where a
pane has no capture, since the capture holds the blur. `flutter test --enable-impeller` runs the shader, so `toImage`
shows the real glass.
- A popup grows out of its anchor on a platter (`CommonPopupRoute`). On glass the platter is a `LiquidGlassPane`
easing from the button's `regular` glass to the thicker `menu` glass, and the menu's cards stand `bare` while it
shows, opening and returning, so one pane bends the backdrop at a time. The platter takes the `menu` glass's frost
from the start and, as the cards do, paints with a `hold` over all it may sweep: the host captures that once and
keeps it while the pane moves inside, its frost neither grows nor falls by a tenth, as a folding sub menu's glass
does, and the page holds still, so a menu opening captures three times and closing twice rather than every frame.
On the SwiftShader emulator that cut a menu's frames from 17 render passes to 12.4, against 11.1 without glass, but
saved time only within noise: what glass still adds there, about half again a frame, is the shader over the
growing platter. A held pane casts its shadow after itself, over its capture rather than in it, since a shadow moving with it would
renew the capture. The anchor's snapshot is taken under `LiquidGlass.withoutPanes`: offscreen a pane has no
backdrop and would come out a flat tint. A menu opened at a point fades out under the layer a `LiquidGlassFade`
pushes, so its glass keeps reading the page as it goes.
- An alert on glass takes the `alert` glass, the `menu` glass frosted wider, under its `AlertDialog`'s surface, which
it finds as the dialog's `Material` lays it out, since a dialog takes only a color. Its route fades it under a
`LiquidGlassFade`, so the glass keeps its full blur and fades with the text, as UIKit fades an alert and its glass on
one layer. Its plain buttons and fields fill with `glassFillOf`, black or in dark mode white at 13%: iOS 27 shifts
them from the glass under them by about 30/255, darker or lighter, a blend Flutter has no mode for, which 13% meets
over the glass an alert shows on a page. Its route dims by `alertScrim`, iOS's fifth in light mode and near half in
dark mode, not Material's third, so the glass reads the backdrop iOS's reads; under Material's a light alert came
out grey. Measured against UIKit over the same page, the title sits 21 dp down, a message 6 dp under it and a field
16, the buttons 20 under either; plain button labels take the medium weight, and a light message takes
`onSurfaceVariant` at 85%, as light as iOS's vibrant secondary label. UIKit sets a title with no message centred in
the body weight, so an alert with fields (`hasFields`) does too, with room for a trailing control's width kept on
both sides to hold the title on the alert's centre. A destructive action takes `ColorScheme.destructive`, iOS's
systemRed: Material's `error` read dull in light mode and pink at tone 80 on dark glass.
- A press anywhere on an alert swells the whole alert, glass and content, as UIKit swells it, measured at 4 pt on its
320 width, and a drag past its edges stretches it about 3 pt; the button under the finger does not swell, it fades
its fill to 40%, or a dark default button's to 50%, while a dark plain button keeps its fill. `ElasticPress` takes
the swell and the pull in dp and finds the surface through `panelOf`, since an `AlertDialog` spans the screen
around it.
- The `menu` and `alert` glass follow iOS 27's menus and alerts as the simulator showed them over solid, striped and
split grounds, keeping the scheme's tint: in light mode the glass passes the backdrop at 45%, doubled in saturation
and a tenth brighter, within 2/255 of iOS; in dark mode iOS keeps each colour's hue and chroma while it compresses
white to mid grey and lifts black to 32/255, which the shader matches over solid grounds, within 4/255 and 11 at
worst, by thickening the scheme's highest container over a light backdrop. The dark fit weighs black and dark grey
most, the grounds a dark page puts behind it. That `contrast` reads the whole pane's backdrop, not each pixel, so
over half black, half white the white half shows lighter than iOS's. It reads five points, each as four taps a frost
sigma apart so that a glyph passing under one does not flicker the tint, but once under a haze or a frost of 8 dp
or more, which blur a glyph past flickering. iOS frosts an alert at a sigma of about 13 dp
and a menu at 10 to 13, with no sharp share like the dock's.
- The `menu` glass leaves its rim to a `LiquidGlassRim`, which alerts, menu cards and the platter as it lands draw
around their panes: the line on the canvas outside the pane, and the highlight in the pane's shader, band by band
and turn by turn as the fallback draws it in nine even-odd rings, within 12/255 of those. It is shaded unlike the
bars' rim: as iOS 27 draws it around both, measured in the simulator, the dark
line lies just outside the glass, darkest along the sides, and the highlight is light added to the glass, brightest
at the middle of the top and bottom, about half as bright where a corner begins, a fifth where it ends and gone
down the sides, fading over about 5 dp inward; a menu's line is lighter than an alert's. A glass menu and a glass alert cast iOS 27's shadow as
measured over white, a deep one under a wide one, half again as dark in dark mode, where a solid menu keeps its
softer one.
- The optics bend with a circular bevel whose width and depth shrink to fit a small pane, so the rim shows the content
squeezed and mirrored. The materials are iOS 27's as codenameone/CodenameOne#5906 fitted them (`pill` for the dock,
`regular` for buttons): the backdrop passes through as `offset + scale * saturated backdrop`, the tint at
`1 - scale` carrying the offset; a dark line takes the outermost pixels because UIKit draws it just outside the
bounds, on the dock as faint all around as iOS draws it along the top and bottom, since the darker sides iOS draws
read as brackets on a pale page; a highlight lights the top and bottom, fading over 1.3 dp and, as iOS 27's tab bar
fades it around its rounded ends, with the square of the normal's upward part; the lifted lens keeps a narrower band.
Buttons (`regular`, `tinted`) take the rim of iOS 27's glass and bar buttons, measured in the simulator: the line
darkens toward the sides with the cube of the normal's sideways part, from nothing along a dark button's top and
bottom, and the highlight fades over 1.2 dp with a sixth of it in a tail to 4.5 dp, dims round the ends far faster
than the tab bar's, and is partly added as light, so it lifts a pale button as iOS does.
The line and the highlight keep to the superellipse the pane is clipped to, which the shader traces after the
engine's `round_superellipse_param.cc`: the circles the optics bend by lie up to half a dp outside it where a long
side runs into its end, and a line on them breaks there. A test holds the traced curve to the engine's path.
In dark mode the tint is
the scheme's `surfaceContainerHighest` at iOS's alpha: iOS's mid grey lifts a dark page into a grey haze. The tint
thickens over content much darker than it, or with a negative `contrast` much lighter.
- The `pill`'s frost, line and shadow follow iOS 27's tab bar at rest, measured in the simulator over solid fills and a
black and white split: 28% of the backdrop shows sharp and 72% through a blur of 15.6 dp, so a stripe under the bar
spreads over its neighbours; iOS mixes the two brighter toward white, as if it blurred in linear light, which the
engine's blur cannot. Dark mode draws no line, as iOS draws none along a dark bar's top and bottom. Glass bars cast
1.3% black blurred 3.8 dp and dropped 6.5 dp, shading only below and by 3/255 at most, in both modes; the solid
dock keeps its wider shadow. The sharp part needs no blur pass of its own, so on the SwiftShader emulator the haze
added half a render pass a frame scrolling Tools, about four on the dashboard, where it no longer shares the start
button's blur, and no time beyond the noise of three alternating runs. Its colors are deliberately not iOS's: iOS's
bar takes no `contrast`, its selection platter is a color matrix of the bar and its icons are vibrant, where the
dock keeps the theme's.
- The dock and its lens follow UIKit's tab bar as codenameone/CodenameOne#5906 measured it frame by frame: 86 dp a
destination, a resting lens 8 dp wider, a lift of 16 dp that magnifies what lies inside the lens by 16%, and springs
taken from the capture; the lens's stretch and squash are a fit to its deformation tables. The lift stays up until it
has risen fully and the lens has arrived, so a tap pops it all the way, and `ElasticPress` likewise finishes a tap's
swell on the bar's press spring before it bounces back. The destinations stand on the lifted lens, as iOS draws
them, so it bends only the bar; what lies inside it is copied, tinted and magnified, into an offscreen layer where
a second clear pane warps it toward the rim on its own, as liquid_prism read UIKit's displacement inputs on iOS 27.
The lens follows what iOS 27 shows of it over ramp and line backdrops in the simulator: it reads outward along its
normal all around, by nothing at the rim and 4.3 dp a 5.5 dp bevel in, easing off toward its middle, so a band
rings it with what lies right under its rim, drawn out; red and blue part by up to 6.5 dp along its top and bottom
and about 3.9 dp around its ends, across the band's outer 7 dp. In dark mode both panes brighten what they show by
a third, as iOS does, and the lens casts a soft shadow 8 dp down. A press lights the bar through `LiquidGlass.glow`:
a wash over the whole pane, brightest at the finger, that spreads out as it fades. A `pressed` pane, as a glass
button's or a `TonalButtonGroup`'s is, hears the pointer on what it wraps and lights from the finger the same way.
## Popup Motion
`CommonPopupRoute` follows WWDC25's account of iOS 26 menus: one that grows out of a control keeps the two continuous,
so closing returns it into the control on the same platter, its content blurring out first. Apple documents only the
opening; one opened at a point (a context menu) has no control to return into and shrinks toward the point to
`_dismissScale` as it fades, as iOS context menus do.
A sub menu is a `PopupMenuCard` laid out once at full size. One spring (`_unfold` in `lib/ui/popup.dart`) moves
its surface and clip from the item that opened it to its bounds, recedes the parent, and reveals the items, so a fold
caught mid-unfold turns back with the speed it had. The parent's item hides under the sub menu's header, which draws
it again, and the sub menu is removed only once it stands on that item.
A `CommonPopupBox` opens on its button's pointer down, as an iOS menu that is a button's primary action does, and the
press carries on into the menu. `_PopupPressRecognizer` claims the pointer then, so the button's own tap and its
tooltip's long press stand down. In a scroll view, as the settings rows are, it first waits `kPressTimeout`, as iOS
scroll views hold back a touch, so a drag from the button scrolls and a quicker tap opens the popup on its release. The
pointer's moves and release keep reaching the button, so the box hands them on (`_CarriedPress`) and lets go of the
`ElasticPress` around it. The menu follows them only once they leave the slop, since it covers the button and a press
let go where it went down would pick what now lies under it, and drives its own `ElasticPress` with them through
`ElasticPress.maybeOf`. The box tells a disabled button by the `onTap` of its first `InkResponse`, and holds the popup's
`anchorShift`, since it opens before the button's `onPressed` would run.
The menu stands in an `ElasticPress` of its own with `pullPastEdges`: it swells under a press and stretches only toward
a finger that strays past it, so sliding over the items or scrolling leaves it in place. A press on a row belongs to the
menu rather than the row's `InkWell`, which keeps only hover, focus, and keys: `_RowPressRecognizer` highlights the row
under the finger as it slides, hit testing where the menu paints (`ElasticPress.paintBoundsOf`), with a selection click
on each change, and picks the one it is let go on, or none off the rows. It claims the pointer only on release, so a
menu long enough to scroll takes a drag first and drops the press. A press that rests for `_dwellDuration` on an item
with a sub menu opens it, and one that rests on an open sub menu's header folds it back, each with a click as the
highlight moves; the finger then stands on the header or on the item it folded into, and letting go there changes
nothing. A tap that opens or folds a sub menu clicks no more than a tap on any item does, as iOS menus give selection
feedback only to a sliding press.
## Row Actions And Sort Mode
A list row or card carries no trailing more button. Its secondary actions go into a `ContextMenuRegion`, which opens
@@ -536,9 +810,9 @@ few keys of a `tun` section the network settings own, so its spec does not mix i
## Where A Route Opens
On desktop each navigation destination has its own `Navigator` (`_NavigationPage` in `lib/pages/home.dart`); on mobile
there is none, and everything goes on the root navigator, `rootNavigatorKey` in `lib/common/navigator.dart`. A route
takes the navigator of the context it opens from: `BaseNavigator.push`, `showExtend`, `showSheet` and `showSnapSheet`
all do, so a page or sheet opened from a destination stays beside the navigation rail, leaves the rail usable, and
there is none, and everything goes on the root navigator, `rootNavigatorKey` in `lib/ui/navigator.dart`. A route
takes the navigator of the context it opens from: `BaseNavigator.push`, `showExtend`, `showSheet`, `showFormSheet` and
`showSnapSheet` all do, so a page or sheet opened from a destination stays beside the navigation rail, leaves the rail usable, and
stays in that destination's stack when the user switches away and back. `sheetNavigatorOf` lifts a sheet out of a
paged sheet's own `SheetPagesNavigator`, never past the destination.
@@ -570,14 +844,50 @@ rebuilds one row instead of the whole section. Titles and subtitles are `ConfigL
Build settings screens from these directly, or from a file-local helper that binds one provider once — see `_networkToggle`
in `lib/views/config/network.dart` and `_appSettingToggle` in `lib/views/config/general.dart`. Declare a named
`ConsumerWidget` only when a row is genuinely reused across screens, as `lib/views/config/network.dart` rows are by
`lib/views/dashboard/widgets/quick_options.dart`. Rows with bespoke behaviour — a custom dialog, a derived value, or a
`ConsumerWidget` only when a row is genuinely reused across screens. Rows with bespoke behaviour — a custom dialog, a derived value, or a
second provider write — stay hand-written rather than growing extra parameters on the shared items.
In an inset grouped list (`generateSectionV3`, `generateAnimatedSection`), a row's subtitle shows its value or state,
In an inset grouped list (`generateSection`, `generateAnimatedSection`), a row's subtitle shows its value or state,
never what the setting does. As iOS settings do, the explanation goes in the group's `footer`, drawn by `ListFooter`,
one sentence per setting that names it, and a section header carries only its title. Flat `generateSection` lists keep
their descriptions on the row.
one sentence per setting that names it. A section header carries its title and at most a few actions on the group, as
`IconButtonData`: `ListHeader` draws them as 36 dp tonal buttons (`TonalButtonSize.section`), the first two grouped,
centered on the title. Every header stands as tall as one with a button, 6 dp clear of the group below, so headers
match whether or not they carry actions. A group with a selection swaps them
for delete and select all, never a text button. `generateSection` is the only section builder, and a sheet lays its groups inside the 16 dp inset the pages use.
A row that opens a page, `ListItem.open` or `ListItem.next`, ends in `AppGlyphs.chevronForward` unless it passes its own
`trailing`, as iOS settings rows do. A row that opens a dialog in place, `ListItem.input`, has no chevron. A setting
that takes one of a few values is an iOS pop-up row, `ListItem.options` or `ConfigOptionsItem`: it ends in its value and
`AppGlyphs.picker`, and a tap anywhere on it opens a `CommonPopupMenu` out of that value with the current option
checked. Its items are text alone, as an iOS picker's are; glyphs belong to menus of actions. One value is never picked
from a radio dialog or a row of selectable chips; a button that picks one, as the theme's scheme variant does, opens the
same menu through a `CommonPopupBox`. A pick from a menu that returns into its control runs only once the popup has
landed, so a setting that rebuilds the app, such as the language, never does so under the returning platter; the
control shows its value with `PopupValueText`, which blurs across to the new value while the control eases to its
width. A menu opened at a point, as a context menu or the editor's is, runs its pick at once. A menu capped by
`maxVisibleItems`, such as the proxy group tabs' more menu with every group in it, builds only the rows in view and
takes its width from its labels measured as `EmojiText` lays them out.
## Forms
A form that edits more than one value opens with `showFormSheet` (`lib/ui/sheet.dart`): a bottom sheet in the mobile
layout, and elsewhere a card `FormSheetRoute` centers over the navigator, as iPad and macOS present a form sheet, moving
as an alert does. Both give the page `SheetType.bottomSheet` or `SheetType.formSheet`, compact sheets that share
`CommonScaffold`'s sheet bar, so one page serves both: `FormPage` for a plain form, or the page a `NestedPagedSheet`
pushes for the custom profile editors. Its values sit in `FormRow`s inside `generateSection` groups, each field a
`FormRowTextField`, bare and set against the end in both, so a focused field's only outline is its row's focus ring.
`RenderEditable` ends a field's text short of its end by the caret's margin, so the field ends its hint as short and
reaches as far into the row's padding: the caret follows the hint or the text, and the text ends where another row's
value does. The
save is the scaffold's `confirmationAction`: last in a bottom sheet's bar, and in a form sheet the filled default beside
Cancel, which takes the close's place, in a row of alert buttons at the first page's foot, as SwiftUI places a
confirmation action on macOS; other actions, such as quick edit, stay in the bar. A row cannot hold an error line, so a
save the form refuses marks the row `invalid` and says why in the `ErrorBanner` above the groups, which follows each
edit from then on. A prompt for one value, such as a URL import, a rename or a password, stays an alert (`CommonDialog`,
`InputDialog`), as on every Apple platform; a key with its value, as `MapEditView` adds, is two values and a form.
An alert raised while focus is in a form sheet opens on the sheet's
navigator, centered on the card rather than on the window: `Dialogs` follows the focused route when it is an
`AlertHostRoute`, which the form sheet and every alert are.
## Keyboard Insets
@@ -587,48 +897,115 @@ which runs every one of those rebuilds inside the layout phase; in a profile tra
`BUILD`.
- A page the user is not typing in holds its bottom inset through `KeyboardInsetHold`
(`lib/widgets/keyboard_inset_hold.dart`). Pages under another route and inactive tabs are still laid out, offstage or
(`lib/ui/keyboard_inset_hold.dart`). Pages under another route and inactive tabs are still laid out, offstage or
not, and without it every page the app keeps relaid out, and rebuilt under its `LayoutBuilder`s, with each frame of a
keyboard raised by a dialog or sheet on top. The home route, each home tab, `CommonRoute`, `CommonDesktopRoute` and
the open-container route wrap their page in one; a new page route type does the same.
- A widget high in the tree reads the aspect it needs (`MediaQuery.paddingOf`, `viewInsetsOf`, ...), never
`MediaQuery.of` or `MediaQuery.removePadding(context: ...)` on its own context, which subscribes it to every inset
change. Move such a read into a small widget of its own below it, as `_AppMediaQuery` in `ThemeManager`,
`_BodyPadding` in `lib/pages/home.dart` and `_SheetKeyboard` in `SnapSheet` do.
- A bottom sheet makes room for the keyboard itself: `_SheetKeyboard` lays the content out above it and removes the
bottom inset from the content's `MediaQuery`, so nothing inside a sheet, `CommonScaffold` included, adds keyboard
space. A nested sheet must not carry the keyboard through its pages: `NavigatorResizable` learns a page's new size a
frame late, and the sheet would trail the keyboard by a frame.
`_BodyPadding` in `lib/pages/home.dart`, `_KeyboardViewport` in `lib/ui/snap_sheet.dart` and the `SafeArea` around
the docked search field in `CommonScaffold` do.
- A bottom sheet makes room for the keyboard itself: the keyboard raises the sheet's foot (Bottom Sheet Motion), and
`_WithoutKeyboard` removes the bottom inset from the content's `MediaQuery`, so nothing inside a sheet,
`CommonScaffold` included, adds keyboard space. A nested sheet must not carry the keyboard through its pages:
`NavigatorResizable` learns a page's new size a frame late, and the sheet would trail the keyboard by a frame.
- The keyboard moves a bottom sheet as it is painted. `_RenderSheetViewport` lays the surface out as tall as the view
whatever the keyboard, and a fit sheet keeps the limit its short content was laid out within while the keyboard
changes the room above it, until the content reaches either (`releaseLimit`). A keyboard frame then lays out and
paints nothing inside the sheet unless what it shows changes.
- A field without a label says `floatingLabelBehavior: FloatingLabelBehavior.never`, as `FormRowTextField` and
`SearchField` do. `InputDecorator` animates the floating label of an empty field on focus even when there is none,
`InputDecoration.collapsed` included, and each frame of it lays the decorator out and, inside a sheet, the whole
form, through the first frames of the keyboard.
## Rebuild Scopes
A `LayoutBuilder` runs the rebuilds of everything under it inside its own layout, so a widget rebuilding there marks
the builder for layout, and that climbs to the nearest relayout boundary. Flutter marks every render object it lays
out for paint, so the nearest repaint boundary above that path repaints too. Under the material `Scaffold`'s body
`LayoutBuilder` (it has one with `extendBodyBehindAppBar`) that was the page and its background, and under the home
shell's `LayoutBuilder` the whole home route: a spinner turning in a page, or the dock's lens moving, relaid out and
repainted the page on every frame, and the glass over it captured every band again, the bar's included, though
nothing under the bar had changed.
So the body and the dock each take their rebuilds in a scope of their own: a `LayoutBuilder` under a repaint boundary
that is also a relayout boundary. `CommonScaffold`'s `_BodyScope` sizes itself to the most the scaffold allows, which
the scaffold never reads back, and lays the body out under it as before; `NavigationDock` sizes itself as it always
was, full width and its bar's height, and lays its boundary out tight. A rebuild inside either relays out and
repaints only that layer.
## Bottom Sheet Motion
`SnapSheetRoute` follows `UISheetPresentationController` as measured on the iOS 27 simulator (a UIKit probe app driven
by the simulator's touch injection, logging the presented view's presentation layer every frame).
- One spring moves a sheet: UIKit's mass 3, stiffness 1000, which runs critically damped (ω ≈ 18.26 rad/s, done in
about 0.5 s). Presenting, closing, a detent change and a spring back all trace it within a point. A release thrown
toward its target keeps its velocity and lands with UIKit's slight overshoot, about damping ratio 0.8 at full
strength; a release away from its target loses its velocity and lands dead.
- A release lands on the detent, or the dismissal, nearest to where `UIScrollView.DecelerationRate.fast` projects it.
With no velocity a sheet closes once it is let go past half of its shortest detent; a flick of 1000 pt/s 85 pt
down springs back on iOS, one of 1500 pt/s closes.
- The sheet follows the finger one to one below its shortest detent and stops at its tallest, with no stretch.
- On the content, a drag up grows the sheet first and scrolls the content with what is left, in one gesture. A drag
down moves the sheet only when it began with the content at its top; one that scrolls the content back to its top
stops there, as iOS bounces it rather than handing it to the sheet.
- The keyboard raises the sheet's foot: a detent keeps what it shows above the keyboard, as a custom detent does on
iOS, up to a peek below the top. iOS lifts a medium sheet to its large detent instead, which a phone's keyboard,
about as tall as the gap between them, makes nearly the same. Since the sheet's height is a function of the inset,
it moves with the keyboard on every frame and lands back where it was.
- Where the keyboard starts or stops carrying the sheet, that function rounds its corner (`_rounded`), so the sheet's
speed changes smoothly. With a sharp corner a sheet stood still while the keyboard slid over the blank below short
content and then jumped, and on the way down halted in mid-flight; the tallest detent hit the peek at the keyboard's
full speed. Short content now eases into the rise over half its blank, which can leave the sheet up to an eighth of
the blank higher than its content needs, and the tallest detent slows into the peek over as much keyboard again as
it takes to get there, up to `_peakEase`.
`_SheetModel` keeps the sheet as an activity, resting at a detent, held at a height or settling toward a detent as an
offset from it, and layout resolves the detents again every frame from the view, the keyboard and the content. A
resting or settling sheet therefore follows a page transition's size or the keyboard without being told, where a
stored height would have to be patched after each change. Below the shortest detent a drag moves the route's own
animation, so the barrier, a sheet stepped back behind this one, and the page bar under it follow the finger, and the
close continues from the release on the presentation spring (`createSimulation`).
## Page Arrival
A pushed page is built, laid out and painted through every frame of its transition, so whatever it starts on arrival
(loading and decoding images, building the rows its data brings in) lands inside the animation. Impeller has no
raster cache, and every offscreen pass is paid again each frame on a phone GPU.
A pushed page is built, laid out and painted through every frame of its transition. Impeller has no raster cache, and
every offscreen pass is paid again each frame on a phone GPU. Even so, a page loads what it shows as it mounts: content
held back until the route settles slides in as an empty page and pops in once the motion has stopped, which reads worse
than the frames a load costs. What a transition must not carry is work that repeats on every refresh or every frame.
- Work a widget would start as it mounts waits for the route to settle. `RouteSettledMixin`
(`lib/widgets/route_motion_hold.dart`) calls `didSettleRoute` once the route that brought the page in has arrived,
and at once on a page that already sits still; `whenRouteSettled` (`lib/common/navigator.dart`) does the same for a
one-off future, and both wait out the frame a `HeroController` builds a pushed route offstage with its animation
pinned to completed. `CommonTargetIcon` and `PackageIcon` show what is already in memory at once and load the rest
after that. An icon still loading leaves a blank slot of its size and shows its fallback only once the load ends
without an image, so a grid of placeholders is never painted through the transition only to be replaced.
- A scroll view of image tiles takes `arrivalScrollCacheExtent(routeSettled)`: the rows past the viewport would
otherwise be built in the same frame as the ones on screen, which for a screen of icon cards is most of the push.
The icon grids (`IconGridScrollView`) and the access control list do this, and so does `ListEditorPage`, whose
rows are heavy enough that only its list rebuilds as the route settles, reusing the rows already built.
- A page whose content waits on what it loads on arrival (lists read from the database, file sizes, the installed fonts)
shows its `NullStatus` illustration, with no label, inside a `NullStatusSwitcher` until all of it is in, then switches
to the finished content in one step, as the custom profile, resources and font family pages do. Never a skeleton
screen of placeholder rows: it has to guess how many rows are coming, fills in piece by piece, and rebuilds every row
it stood in for. `SkeletonText` is for a single value inside a card that is already on screen, such as the dashboard's
service status. `holdsArrival` keeps the status a switcher opens on for at least 400 ms, about a route transition,
so a load that finishes just after arrival does not flash it; the connections, resources and font family pages set
it.
- Loads start as the widget mounts, never behind `whenRouteSettled`. `CommonTargetIcon` and `PackageIcon` show what is
already in memory at once and load the rest right away; the editor reads its file and mounts CodeForge as soon as the
content is in. An icon still loading leaves a blank slot of its size and shows its fallback only once the load ends
without an image, so a grid of placeholders is never painted only to be replaced. `whenRouteSettled`
(`lib/ui/navigator.dart`) is left for sequencing navigation, such as opening a page once the sheet above it is gone.
- A live feed holds its refreshes while its page moves and replays the latest once it is still, so a list does not
re-rank, reorder or grow under a transition. `RouteMotionHoldMixin` (`lib/ui/route_motion_hold.dart`) holds them
while `isRouteMoving`: the page's route or a sheet carrying it is pushed, popped or dragged, a page is pushed over it,
or a sheet covers it (`sheetCoverOf`). The connections, requests, logs and DNS pages and the dashboard's live
connections table use it. A feed's first content is no refresh and shows at once, as `ConnectionsView` applies its
first poll, and a held poll keeps the time it was read at, which `TrackerSpeedRanker` derives speeds from. Per-frame
work spread over frames pauses the same way: the font family page switches one name to its own family per frame and
stops while `isRouteMoving`.
- A scroll view of image tiles takes `arrivalScrollCacheExtent(routeSettled)` from `RouteSettledMixin`: the rows past
the viewport would otherwise be built in the same frame as the ones on screen, which for a screen of icon cards is
most of the push. The icon grids (`IconGridScrollView`) and the access control list do this, and so does
`ListEditorPage`, whose rows are heavy enough that only its list rebuilds as the route settles, reusing the rows
already built.
- A page whose content waits on what it loads (lists read from the database, file sizes, the installed fonts) shows
nothing until all of it is in, then the finished content in one step: `NullStatusSwitcher.isLoading`. A load that can
run long also gives the switcher a `loadingStatus`, its `NullStatus` illustration with no label, which takes the
place of a first load still running after about a route transition (400 ms); one that ends sooner goes straight to
its content, so the illustration never flashes. The custom profile, resources and font family pages set one. Never a
skeleton screen of placeholder rows: it has to guess how many rows are coming, fills in piece by piece, and rebuilds
every row it stood in for. `SkeletonText` is for a single value inside a card that is already on screen, such as the
dashboard's service status.
- `GlyphPainter` paints a translucent glyph through an offscreen layer. A placeholder repeated in every tile blends its
color over the surface instead of carrying an alpha.
- `PagedSheetRoute` and `CommonRoute` share
`SharedXPageTransition` (`lib/widgets/page_transition.dart`) and its `pageSlide`, a quarter of the page's width as
`SharedXPageTransition` (`lib/ui/page_transition.dart`) and its `pageSlide`, a quarter of the page's width as
Android 14 slides pages, so a page moves alike in a sheet and on its own. The transition fades a page by painting the surface over it
and needs no opacity layer: the page on top shows only on its side of the point where the two fades cross, opaque
over the other, so neither shows through the other. A page below that does not fade with it, such as the home
@@ -690,15 +1067,17 @@ itself live in one. Code without a `Ref` reaches providers through
path that never validates never resolves the controller.
The UI layer must not reach a process-wide singleton directly.
`test/lint/ui_layer_singleton_test.dart` scans `lib/views`, `lib/widgets`,
`test/lint/ui_layer_singleton_test.dart` scans `lib/ui`, `lib/views`, `lib/widgets`,
`lib/pages` and `lib/features` for `globalState.container` and the bare
`coreController` global and fails the run on either. Widgets that need Core hold
`CoreController get _core => ref.read(coreHandlerProvider)`.
`globalState.measure` and `globalState.theme` stay global on purpose. Both are
context-derived caches assigned by `ThemeManager`, and tests already scope them by
assigning in the app builder (see `test/helpers/test_app.dart`); moving them into
providers would touch every layout call site without changing behaviour.
assigning in the app builder (see `wrapAppScopes` in `test/helpers/test_app.dart`);
moving them into providers would touch every layout call site without changing
behaviour. `lib/ui` cannot read `globalState`, so it takes the same `Measure` from
`MeasureScope`; see UI Layer.
`globalState` in `lib/state.dart` is a singleton holding ambient app state — the
package info, the measure and theme, the container, and the start/stop flags —
@@ -707,7 +1086,7 @@ plus `safeRun`/`loadingRun`. Startup orchestration is **not** on it: `init` and
window, the autostart entry, the tray and the permission prompts. Providers are
generated into `lib/providers/generated/`.
The root navigator key lives in `lib/common/navigator.dart` as `rootNavigatorKey`;
The root navigator key lives in `lib/ui/navigator.dart` as `rootNavigatorKey`;
`globalState.navigatorKey` is a getter onto it. `lib/common/dialog.dart` reaches
the key directly and does not import `lib/state.dart`.
@@ -736,8 +1115,8 @@ The shapes that keep the closure small:
- `KeyboardModifier.toHotKeyModifier()` lives in
`lib/manager/hotkey_manager.dart`, its only consumer, not in `lib/enum/enum.dart`.
- Startup orchestration lives in `lib/bootstrap.dart`, not on `GlobalState`.
`common/num.dart`, `common/print.dart` and `common/request.dart` import
`state.dart` for `theme`, `container` and `packageInfo`/`ua`, so anything
`common/print.dart` and `common/request.dart` import `state.dart` for
`container` and `packageInfo`/`ua`, so anything
`GlobalState` reaches lands in the barrel's closure; the ambient state it
holds reaches nothing platform-specific.
- `SystemAction` talks to `WindowPort` and `TrayPort` from
@@ -755,9 +1134,8 @@ The shapes that keep the closure small:
`providers/state/navigation.dart` reads through and `bootstrap.init` binds;
unbound it yields no items, so a test that renders navigation assigns
`navigationPort` itself, as `test/pages/home_test.dart` does. `DashboardWidget`
is plain data, since it is persisted in the app settings; the widget mapping
lives in `lib/views/dashboard/widget_registry.dart`, the reverse lookup relying
on the branches returning canonical consts.
is plain data, since it is persisted in the app settings; the card mapping
lives in `lib/views/dashboard/widget_registry.dart`.
`test/lint/platform_layering_test.dart` also pins that the barrel's closure
contains no `lib/views` file. Data the provider layer needs from the UI layer
@@ -795,7 +1173,8 @@ seen. There are three levels of gating:
`SetupAction` follows it. The macOS menu bar title is the exception: it shows the traffic while the window is
hidden. `RouteTracker` and `RoutedProbe` follow it too, as described under Route Consistency.
- `ActivePollingMixin` is stricter for per-widget polls such as connections and memory. It polls only while the app is
`resumed` and its home page is the current one.
`resumed`, its home page is the current one, and no opaque route covers its page: the overlay turns tickers off on a
covered page once the route over it has finished moving in, and the mixin follows `TickerMode`.
- `_NavigationPage` puts every page except the current one under a disabled `TickerMode`. Home pages are kept alive in
the `PageView`, and Flutter does not mute tickers on a kept-alive page off screen. A spinner left on one would
otherwise keep scheduling frames.
@@ -1167,6 +1546,9 @@ NDK version stands in for the hook's cache.
concern into `lib/code_forge/controller/` extensions. `CodeForge` builds `_CodeFieldRenderer`, whose painting, layout,
folding, caret, gutter and pointer code sit in `lib/code_forge/code_area/` part files.
- `loadText` builds a document of 128K characters or more on a Rust worker thread (`RopeBridge.createInBackground`),
so a large file does not stall the frame it arrives in; `EditorView` loads through it, and a later `text =` or
`loadText` supersedes one still building.
- Rust counts offsets in Unicode scalars and Flutter in UTF-16 code units. Convert at the boundary with
`text_offsets.dart`; a mixed-up offset only shows up with astral characters such as emoji, so tests use them.
- The renderer learns what changed from `takeChange()`, whose `lines` (first line, lines removed, lines inserted) let it
+37 -5
View File
@@ -51,7 +51,7 @@ hooks:
```
CI flips `setup` to `false` with `yq` in its throwaway checkout before
`dart run tool/changelog.dart` and `flutter test`; no test loads the Go core. `rust_api`
`dart run tool/changelog.dart` and `dart run tool/test.dart`; no test loads the Go core. `rust_api`
stays on there because the editor tests in `test/plugins/code_forge/` and the socket
test in `test/core/desktop/ipc_native_test.dart` load the Rust library the hook
builds; the release-notes job, which runs no tests, flips both.
@@ -154,12 +154,29 @@ flutter test test/setup_test.dart
flutter test plugins/proxy/test/proxy_test.dart
```
For the whole suite, or a directory of more than a few files, use `tool/test.dart`, which is also what CI runs:
```bash
dart run tool/test.dart # all of test/
dart run tool/test.dart test/widgets test/views # some paths
dart run tool/test.dart -- --reporter expanded --coverage
```
Plain `flutter test` compiles each file into its own program and starts an engine for it, one file after another, and
that dominates a full run: 329 files took 128 s on a 12-core Mac with at most four running at once. `tool/test.dart`
imports the files into 32 generated entrypoints under `.dart_tool/test_bundles/` and runs those with one worker per
core: about 60 s for the same run, and 67 s instead of 180–213 s with `--coverage`. Files in one entrypoint share an
isolate, so a test can see state a file before it left behind; the script runs every file that failed there again on
its own, lets that run decide, and names the files that only failed together. See the Shared Test Isolate section of
`rules.md` before fixing one.
`plugins/setup/setup_hooks/` is a pure Dart package and is tested with `dart test` from its own directory, which is
also what CI runs; `tool/check_plugins.sh` does not descend into it. It must stay out of `flutter test`, because a test
run of `plugins/setup` would execute the build hook itself.
Native plugin code under `plugins/*/{linux,macos,windows}` is compiled by the CI `desktop` job, which runs
`flutter build <platform> --debug` on every push; `tool/check_plugins.sh` only analyzes and tests the Dart side.
`flutter build <platform> --debug` on every branch push, and by the release matrix on a tag; `tool/check_plugins.sh`
only analyzes and tests the Dart side.
Root `flutter test` only discovers the root package's `test/` directory by default. Include bundled plugin Dart tests by passing paths explicitly, or run `flutter test` from that plugin package directory, or run `bash tool/check_plugins.sh` to analyze and test every plugin package the way CI does. Native plugin tests under platform folders are not run by `flutter test`.
@@ -338,9 +355,11 @@ directories: `plugins` (local Flutter packages and the setup build tool), `go`
(the Core wrapper, plus a vet of the Android files), `android` (JVM unit tests
for `:common`, `:service` and `:app`, with the Flutter compile tasks excluded
and the gomobile AAR built for one ABI), `rust` (both crates), and a
Windows runner for the helper's `windows-service` feature. A `desktop` job also
compiles a debug build per desktop platform. Release builds run only in the
public repository and start once every job except `desktop` passes.
Windows runner for the helper's `windows-service` feature. On branch pushes a
`desktop` job also compiles a debug build per desktop platform; a tag skips it,
since its release matrix compiles the same native code. Release builds run only
in the public repository and start alongside the checks; `upload` publishes
only once every build and every check job passes.
`bash tool/check_plugins.sh` is that plugin gate, and CI runs the same script.
It discovers every `plugins/*/pubspec.yaml`, analyzes each package, and runs
@@ -348,6 +367,19 @@ It discovers every `plugins/*/pubspec.yaml`, analyzes each package, and runs
no workflow edit; enumerating packages by hand in the workflow is what
previously left `plugins/tray` unanalyzed and untested.
### CI Caches
A run restores only cache entries saved on its own branch or tag, or on the default branch (`main`), and the
repository holds 10 GB before evicting the least recently used entries. Every release is a new tag that no later run
reads from, so `BRANCH_CACHE` turns caching off in tag runs: one release used to write about 9 GB of such entries and
evict everything `dev` had saved. Release builds therefore start cold; branch runs, chiefly `dev`, keep the caches.
- setup-go keys its entry on the `cache-dependency-path` files alone and saves only on a miss, so jobs listing the
same files share one entry holding the build cache of whichever finished first. `desktop` and `android` each add
the file that drives their Core build to get an entry of their own.
- setup-gradle writes only on the default branch unless told otherwise, so `android` lets `dev` write as well. Its
entries are keyed by job, which is one more reason the release build gains nothing from caching.
## Worktree Tooling
```bash
+10
View File
@@ -91,3 +91,13 @@ listed in the SDK's `CHANGELOG.md`.
- **Remove when:** the Linux engine reports minimized windows on Wayland, which needs GTK 4 or xdg-shell's
`suspended` state. Check in a GNOME Wayland session without the workaround: minimizing from the caption button
must log `AppLifecycleState.hidden`.
## Liquid glass saves around the transform of a blurring layer
- **Bug:** not filed upstream. On 3.47 Impeller scales a `saveLayer`'s image filter by a transform set inside the
layer that is still in effect when the layer is restored. A host's capture shrinks each pane's slot inside the
layer that blurs it, so its blur vanished at a quarter scale and spread half again as far at 1.5.
- **Workaround:** `_LiquidGlassHostLayer._snapshot` sets each slot's transform inside a `save` of its own, and each
band's offset, restored before the layer is.
- **Remove when:** without that `save`, the frosted-pane case of `test/widgets/liquid_glass_host_test.dart` passes
under `flutter test --enable-impeller`.
+59 -8
View File
@@ -56,12 +56,31 @@ with an enclosing clip or shape where it shows. Framework widgets that paint the
decoration to override, such as the `Switch` track, are left as Material draws them. Tooltips take theirs from
`withAppShapes`, which sets `tooltipTheme.decoration`.
### Typography
Text sizes follow Apple's iOS Dynamic Type scale at its default size, on every platform. `AppTextStyle` in
`lib/ui/typography.dart` holds its eleven styles, `largeTitle` to `caption2`, with Apple's leading and weights, plus two
steps the scale lacks: `display` for a dashboard figure and `pageTitle` for an app bar. Take one with
`AppTextStyle.body.of(context)`, pass `emphasized: true` for the scale's emphasized weight (bold for the titles,
semibold for the rest), and change color or family with `copyWith`; a weight is either regular or emphasized. Text
sized to the shape it marks, such as a letter scaled to its icon, comes from `AppTextStyle.custom`. The dashboard's
roles (`DashboardText` in `lib/views/dashboard/style.dart`) are built on the same types. `ThemeData.withAppTypography`
maps Material's `TextTheme` slots onto the scale so Material components follow it; app code never reads those slots.
App bar titles sit at the leading edge in `pageTitle` on a root page and on any desktop page, at the leading edge in
`headline` on a pushed page in the mobile view, and in the bar's centre in `headline` on a bottom sheet. A title widget,
such as the editor's name field, stays at the leading edge. Past a back button a title keeps the bar's action inset
clear of the button on either side.
`test/lint/typography_test.dart` fails on `textTheme` anywhere in `lib/` but `typography.dart`, and on `fontSize:` or
`fontWeight:` outside `lib/ui/`, except in the code editor, whose text follows the editor font-size setting.
### Length Units
Flutter lengths are logical pixels, the unit Android calls dp. Comments, test names, docs and reports give them in dp,
as in `48 dp`, never px: a reader takes px for physical pixels and misjudges the size by the device pixel ratio. Write
"physical pixel" in full where the code really works in them, as the hairline snapping in
`lib/widgets/list_row_separator.dart` and the Win32 frame metrics in `plugins/window` do; raster image sizes in
`lib/ui/list_row_separator.dart` and the Win32 frame metrics in `plugins/window` do; raster image sizes in
`tool/` and CSS under `site/` keep px. `test/lint/length_unit_test.dart` fails on a number followed by px in `lib/`
and `test/`.
@@ -102,7 +121,7 @@ judgment, and it lives in the rules below and in review. Whether comments are to
that contains them. This does not need approval.
- These are not comments and are exempt from every rule here, including the density count: analyzer and linter
directives (`// ignore:`, `// ignore_for_file:`, `// coverage:ignore`), license and copyright headers,
code-generation markers, and comments inside vendored upstream code such as `lib/widgets/open_container.dart`.
code-generation markers, and comments inside vendored upstream code such as `lib/ui/open_container.dart`.
### Density
@@ -154,7 +173,8 @@ leaving a repo-wide policy as a comment reaches only the reader of that one file
A launch mode that runs the Core as any other user has to widen `admit` in `ipc/access.rs`, and Windows
keeps its identity check on the Dart side, which compares the named-pipe peer PID with the Core it launched.
- Keep the shared `CoreMethodCall`/`CoreMethodResponse` JSON envelope structurally identical across Dart, Go, the
gomobile binding, and desktop IPC. Do not double-encode `arguments`, `result`, or event batches.
gomobile binding, and desktop IPC. Do not double-encode `arguments`, `result`, or event batches. A response whose
`file` is set was spilled to disk; a transport resolves it with `resolveSpilledResponse` before reading `result`.
- `core/message.go` carries three event queues, and the split is load-bearing: state (loaded, geo-update,
route-changed), priority (every other event), and bulk (log, request, DNS). Priority and bulk evict their own oldest
entry under backpressure, except DNS, which enters the bulk queue through `enqueueState` so a lookup burst cannot push
@@ -371,7 +391,31 @@ land.
Prefer `coreHandlerProvider.overrideWithValue(CoreController.scoped(fake))` over `CoreController.test(fake)` in new and
touched tests. `CoreController.test` claims the process-wide singleton, which makes a global read and a provider read
resolve to the same fake, so it cannot fail on a call site that still reaches for the global. A test that does claim
the singleton calls `CoreController.resetInstance()` in `tearDown`.
the singleton calls `CoreController.resetInstance()` in `tearDown`. It also only reaches the global `coreController`
when nothing has read it yet: that is a top-level `final`, bound to whichever instance existed at the first read, which
in a shared isolate is another file's.
### Shared Test Isolate
`tool/test.dart` imports many test files into one entrypoint, so process-wide state outlives the file that set it, and
every file's `main()` runs before any test of the entrypoint does. Keep `main()` to declarations and
`TestWidgetsFlutterBinding.ensureInitialized()`: a mock installed there is replaced by the next file's before a test
sees it. Set up what the tests need in `setUp` or `setUpAll` instead, from the helpers that rebind the singletons:
- `appPath` resolves its directories once per process. Point them at a test's directory with `await useAppPaths(dir)`
from `test/helpers/app_paths.dart`, not with a `PathProviderPlatform` fake, which only works while nothing has asked
`appPath` yet.
- `preferences` opens its store once per process. Use `await mockPreferences(values)` from
`test/helpers/preferences.dart` rather than `SharedPreferences.setMockInitialValues` alone, which leaves `preferences`
on the store it already opened.
Both cache the resolved value, not the future that produced it. A future completed in one test's fake-async zone runs
its listeners in that zone, so a later test that awaits it never resumes. Await the helpers outside `testWidgets`
bodies for the same reason.
A test that only passes in a fresh isolate is leaning on a global it did not set up: `database_test.dart` once caught
its write failures only because `currentAppLocalizations` threw before anything had loaded them. The script reruns such
a file alone so it cannot fail the run, and lists it; fix it where it reads the global.
Construct the Android lib handler with `CoreLib.scoped(fakeService)`. The `service` global is gated on `Platform.isAndroid`
and is therefore null on every test host, so a `CoreLib()` built from it silently takes the null-service fallback on every
@@ -455,9 +499,9 @@ A public top-level declaration that nothing outside its own file references is d
riverpod notifier is reached through its generated provider) and barrels as neither. Files publishing only extensions or
typedefs are skipped: those are reached through the types they attach to, never by name.
`CommonScaffold` adds the height of its docked search and floating action button to `BottomInsetScope` around its
body, so a page that reads `BottomInsetScope.of(context)` with the context it builds the scaffold from gets the inset
without them, and the end of its content stays under the search field or the button. Read it below the scaffold,
`CommonScaffold` adds the height of its docked search to `BottomInsetScope` around its body, so a page that reads
`BottomInsetScope.of(context)` with the context it builds the scaffold from gets the inset without it, and the end of
its content stays under the search field. Read it below the scaffold,
inside `body: Builder(builder: (context) => ...)`. `test/lint/scaffold_bottom_inset_test.dart` enforces this by
scanning `lib/`.
@@ -564,6 +608,12 @@ data move, or a test for one, check that the state it reads shipped: `git tag --
introduced it, or `git show <tag>:<path>` for the file as the release had it. A state that only ever existed between
two releases gets no migration; a development install holding it may lose that setting or fall back to its default.
A release that resets or rewrites settings, instead of reading their old shape, adds a step to `_upgrades` in
`lib/common/migration.dart` under the next `upgradeVersion`. A step takes and returns the `Config`, runs once on an
install that already had settings, and never on a new one. Leave `currentVersion` for changes to the stored format:
backups carry it, and an older app refuses a backup stamped newer than its own, so bumping it for a reset would strand
backups between devices on different releases.
The database takes one migration step per release. The newest step in `lib/database/database.dart` has not shipped
while `schemaVersion` at the last release tag is below its number; a schema change then goes into that step, raising
`schemaVersion` and the step's condition together so a development install runs the step again, instead of adding a
@@ -578,6 +628,7 @@ Do not manually edit generated files under:
- `lib/models/generated/`
- `lib/providers/generated/`
- `lib/database/generated/`
- `lib/ui/generated/`
- `lib/l10n/intl/`
After schema, model, or provider changes, run build generation and include focused tests when behavior changes.
@@ -591,7 +642,7 @@ l10n paths for that reason. It is harmless because the file only needs `Locale`,
human writes takes Material from `material_ui`; `cupertino_ui` is banned outright and
survives only as a transitive dependency of `material_ui`.
Strings live in `arb/intl_{en,zh_CN,ja,ru}.arb` — flat JSON, no `@` metadata. Add a key to all four, then regenerate with
Strings live in `arb/intl_{en,zh_CN,ja,ru,fr,es,ko}.arb` — flat JSON, no `@` metadata. Add a key to all seven, then regenerate with
`dart run intl_utils:generate`, which rewrites `lib/l10n/`. A key present in only some locales silently falls back to
English at runtime, so add the translation rather than leaving it out.
+18 -12
View File
@@ -23,9 +23,9 @@ The update path carries these fields:
- From `PatchClashConfig`: `mode`, `log-level`, `mixed-port`, `allow-lan`, `ipv6`, `find-process-mode`,
`tcp-concurrent`, `unified-delay`, `external-controller`, `secret`, `geo-auto-update`, `geo-update-interval`,
`geox-url`, and every `tun` field.
- From other settings: `NetworkProps.authentication` and `bypassPrivateRoute`, which `Tun.getRealTun` folds into the
TUN exclusions, and `AppSettingProps.checkCertificate`, sent as `skip-cert-verify`.
`geox-url`, and the `tun` keys of `Tun.coreJson`: `enable`, `device`, `stack`, and `auto-route`, always on.
- From other settings: `NetworkProps.authentication`, and `AppSettingProps.checkCertificate`, sent as
`skip-cert-verify`.
On desktop, `updateListeners` rebuilds the TUN from the patched `tun`. Enabling TUN may need authorization first. When
`requestAdmin` gets it newly granted, `updateConfig` restarts the Core instead of patching it. On Android,
@@ -45,15 +45,17 @@ Prefer update whenever Go has a setter for the setting, because an apply reloads
## Apply
`CoreManager` answers each change of `setupPatchProvider` with `applyProfileDebounce(silence: true)`. That call
rebuilds the profile YAML and hands it to Go's `applyConfig` through `setupConfig`. The provider holds two values:
rebuilds the profile YAML and hands it to Go's `applyConfig` through `setupConfig`. The provider holds three values:
- `PatchClashConfig.setupOnly`: the config with every field that update carries reset to its default.
- `NetworkProps.appendSystemDns`.
- `NetworkProps.bypassPrivateRoute` on desktop, where the task adds the private ranges to the TUN's final
`route-exclude-address`.
A change to anything else in `PatchClashConfig` therefore reaches it. Today that is the DNS override (`dns`,
`dnsOverrideKeys`), `hosts`, the extra inbound ports (`port`, `socks-port`, `redir-port`, `tproxy-port`), the dialer
options (`keep-alive-*`, `disable-keep-alive`, `interface-name`, its mode, `routing-mark`), `geodata-loader` and
`global-ua`. A new `PatchClashConfig` field written by `_makeRealProfileTask` lands here without further work. A value
A change to anything else in `PatchClashConfig` therefore reaches it. Today that is the DNS and TUN overrides (`dns`,
`dnsOverrideKeys`, `tunOverride`, `tunOverrideKeys`), `hosts`, the extra inbound ports (`port`, `socks-port`,
`redir-port`, `tproxy-port`), the dialer options (`keep-alive-*`, `disable-keep-alive`, `interface-name`, its mode,
`routing-mark`), `geodata-loader` and `global-ua`. A new `PatchClashConfig` field written by `_makeRealProfileTask` lands here without further work. A value
the profile build reads from another settings object has to be added to `setupPatchProvider`.
An apply without `force` sends nothing when the md5 of the generated YAML has not changed. A value that only
@@ -64,9 +66,10 @@ Profile content does not go through `setupPatchProvider`. `setupStateProvider` r
database once instead of watching them. Content is applied explicitly by whatever changes it:
- Profile editors call `autoApplyProfile` when they close.
- Subscription updates, `setProfileAndAutoApply`, scripts and app-level providers call `applyProfileDebounce`.
- Subscription updates, scripts and app-level providers call `applyProfileDebounce`.
- A profile switch runs `fullSetup`.
- A start applies again with `force`.
- A start applies again with `force` unless the last apply saw the same inputs (`_inputsApplied`), the `testUrl` and
`skipCertVerify` that only `SetupParams` carries included.
## Restart
@@ -116,8 +119,11 @@ options, because the service does not report the options it was started with.
Each path watches its own provider, so a single setting can take more than one of them:
- `mixedPort`: update moves the Core's listener; under the VPN system proxy, the restart notice follows.
- TUN fields and `bypassPrivateRoute`: update rebuilds the desktop TUN. On Android the update reaches no TUN, and the
restart notice carries the change.
- TUN switch and stack: update rebuilds the desktop TUN. On Android the update reaches no TUN, and the restart notice
carries the stack.
- MTU, congestion control and routes: Android's rows take the restart path; on desktop they are TUN overrides, applied
with the profile.
- `bypassPrivateRoute`: apply on desktop, restart on Android.
- Local authentication: update applies it to the inbounds. It also switches the VPN system proxy, which needs a
restart.
+40 -21
View File
@@ -19,27 +19,41 @@ dialogs, and interaction behavior.
- `.agents/rules.md` is binding here: the lint set (`child:` last, `const`, final locals), the superellipse corner API,
and the widget-test facts under Testing Rules (`pumpAndSettle` never returns on `EditorView`, `dispose()` must not
await, auto-dispose providers need a container-level hold, platform decisions are passed in as parameters).
- A component that reads no provider, model or app string goes in `lib/ui/`; one built for an app feature goes in
`lib/widgets/`. The UI Layer section of `.agents/architecture.md` says how a `lib/ui` component gets app state.
- Settings screens are built from the `ConfigToggleItem` family in `lib/widgets/config_item.dart`; the Settings Rows
section of `.agents/architecture.md` says when a file-local helper or a named widget is warranted.
- Every icon lives in `lib/icons/`, never the Material `Icons` font: reuse or add a glyph in `AppGlyphs` and show it
- Every icon lives in `lib/ui/icons/`, never the Material `Icons` font: reuse or add a glyph in `AppGlyphs` and show it
with `GlyphIcon`, or with `AnimatedGlyph` where it fills on selection. Glyphs are outlined, except inside a filled
button, where they take `fill: 1`. The Icons section of `.agents/architecture.md` covers shape roles, the stroke, the
fill animation, and when to fill.
- Text sizes and weights come from `AppTextStyle` in `lib/ui/typography.dart`, Apple's iOS Dynamic Type scale, never a
Material `TextTheme` slot, a bare `fontSize`, or a `fontWeight`; the Typography section of `.agents/rules.md` has the
rule.
- Modals dim what they cover with the Material 3 scrim (`ColorScheme.modalScrim`, `scrim` at 32%) and never blur it.
Every modal brings its own scrim, including one opened from inside another, so do not make a barrier transparent to
keep scrims from stacking.
- A sheet's size belongs to its route, never to its content or call site: a bottom sheet rests at
`SheetProps.detents`, and a side sheet is as tall as the window and as wide as `ModalSideSheetRoute` makes it from
the window width (`lib/widgets/side_sheet.dart`, pinned by `test/widgets/side_sheet_width_test.dart`). Do not wrap a
sheet's content in a fixed width or height to resize one sheet.
`SheetProps.detents`, a side sheet is as tall as the window and as wide as `ModalSideSheetRoute` makes it from
the window width (`lib/ui/side_sheet.dart`, pinned by `test/widgets/side_sheet_width_test.dart`), and a form sheet
is a card `FormSheetRoute` caps in width and height (`lib/ui/form_sheet.dart`). Do not wrap a sheet's content in a
fixed width or height to resize one sheet.
- A form with more than one value opens with `showFormSheet` and lays its values out in `FormRow`s, never as labeled
fields in a `CommonDialog`; a prompt for one value stays an alert. Forms in `.agents/architecture.md` has the rule.
- A modal scrolls only once the window and keyboard leave it no room, and then through one scroll view: `CommonDialog`
caps its body at nothing but the space it is given, and a multi-line field in a dialog or sheet grows with its text
(`maxLines: null`) rather than scrolling inside a line count. With the keyboard up, a bottom sheet keeps what it
shows above the keyboard, rising past its tallest detent as far as a peek below the top (`_top` in
`lib/ui/snap_sheet.dart`).
- The keyboard animates `MediaQuery.viewInsets` every frame. Read MediaQuery by aspect (`paddingOf`, `viewInsetsOf`)
rather than `MediaQuery.of`, and wrap the page of a new route type in `KeyboardInsetHold`; Keyboard Insets in
`.agents/architecture.md` has the reasons.
- A page full of images loads them once its route has settled and builds no rows past the viewport until then:
`RouteSettledMixin` and `arrivalScrollCacheExtent`, described under Page Arrival in `.agents/architecture.md`.
- A page that waits on its data as it arrives shows its illustration alone (`NullStatus` without a label, in a
`NullStatusSwitcher`) until all of it is ready, never a skeleton screen; Page Arrival in `.agents/architecture.md`
has the rule.
- A page loads what it shows as it mounts, never once its route has settled; what waits out a transition is work
that repeats, such as a live feed's refreshes (`RouteMotionHoldMixin`), and the rows past the viewport
(`arrivalScrollCacheExtent`). Page Arrival in `.agents/architecture.md` has the rule.
- A page that waits on its data shows nothing until all of it is ready (`NullStatusSwitcher.isLoading`), and a load
that can run long adds its illustration as `loadingStatus`, never a skeleton screen; Page Arrival in
`.agents/architecture.md` has the rule.
- App bar buttons reach `CommonScaffold` as data (`primaryAction`, `iconActions`, `menuItems`) so it can fold them into
its overflow menu; App Bar Actions in `.agents/architecture.md` has the rule.
- A row's secondary actions open from a context menu, never a trailing more button; a list whose rows have one
@@ -57,7 +71,7 @@ dialogs, and interaction behavior.
## Corner Radii
All corner radii come from `lib/common/shape.dart`: a token, a derived value, or a named off-scale value passed to its
All corner radii come from `lib/ui/shape.dart`: a token, a derived value, or a named off-scale value passed to its
builders. Never write a bare radius literal at a call site.
The scale is picked by the component's **shortest side**. A radius that reads as a soft card at 64 dp tall
@@ -68,10 +82,10 @@ reads as a pill at 24 and as a square at 400, so one radius everywhere is the wr
| `none` | 0 | - | square edges, and the flat side of a grouped run |
| `xs` | 4 | inset blocks | tiles clipped inside an already rounded surface |
| `sm` | 8 | up to 48 | chips, thumbs, swatches, small square tiles |
| `md` | 16 | up to 64 | interactive chrome: inputs, menus, buttons, popups, FAB |
| `md` | 16 | up to 64 | interactive chrome: inputs, menus, buttons, popups |
| `lg` | 20 | 64 to 180 | mid-size cards: dashboard tiles, proxy node cards |
| `xl` | 24 | full-bleed | anything spanning the whole width: list rows, grouped runs, group headers |
| `xxl` | 28 | over 200 | sheets, dialogs, full-screen containers |
| `xxl` | 28 | over 200 | sheets, full-screen containers |
| `full` | 1000 | - | pills and circles: buttons, tracks, indicators, progress, avatars |
- `AppCorner` holds the scale as `double`; `AppRadius` mirrors it as `BorderRadius` with `all`, `top`, and `vertical`
@@ -80,19 +94,21 @@ reads as a pill at 24 and as a square at 400, so one radius everywhere is the wr
section of `.agents/rules.md` explains why and lists the superellipse API per layer.
- `AppCorner.fit(shortestSide)` snaps to the largest token at or under one third of the shortest side. Use it whenever
the size comes from a `LayoutBuilder` or scales with text.
- `ThemeData.withAppShapes` in `lib/common/shape.dart` already applies the scale to card, dialog, bottom sheet, snack
- `ThemeData.withAppShapes` in `lib/ui/shape.dart` already applies the scale to card, dialog, bottom sheet, snack
bar, chip, menu, input, FAB, navigation indicator, progress, and the filled, text, outlined, elevated, and icon
button themes. Do not restate those shapes at call sites, and
leave `InputDecoration.border` unset so inputs inherit `AppShape.input`.
- Colors of surfaces follow `.agents/surfaces.md`: fills by `SurfaceLevel`, no structural borders, and
`primaryContainer` only on a selected item.
`xl` and `lg` exist because one token cannot serve both size classes. A full-bleed surface is wide and short, so its
corner must be larger than its height alone suggests or it stops reading as a card; everything full-bleed shares `xl`
(`CommonSelectedListItem`, the profiles card, the outer corners of a grouped run in `DecorationListItem`, the proxy
group header in `lib/views/proxies/list.dart`) and does not use `fit()`. This is the one deliberate overshoot of the
group card in `lib/views/proxies/list.dart`) and does not use `fit()`. This is the one deliberate overshoot of the
one-third rule; the value stays under half a standard row so nothing clamps and every full-bleed surface renders the
same radius. `lg` is for mid-size cards that are not full-bleed: a proxy node card at `ProxyCardType.min` is only about
64 tall, which caps its radius at 21, so `xl` would break on a setting the user can change at any time while `lg`
clears all three heights and the smallest dashboard tile.
same radius. `lg` is for mid-size cards that are not full-bleed: a proxy node card in the tab view at
`ProxyCardType.compact` is only about 64 tall, which caps its radius at 21, so `xl` would break on a setting the user
can change at any time while `lg` clears both heights and the smallest dashboard tile.
Outlined inputs use `AppInputBorder`, not `ShapedInputBorder` from `package:material_ui`. The package border subtracts
the floating label's notch as a flat band along the top edge, which takes the corner curve with it at any radius from 8
@@ -100,12 +116,15 @@ up. `AppInputBorder` clips the notch region and paints the full superellipse thr
`OutlineInputBorder` does by shortening the arc. It needs no `contentPadding` compensation.
Nested radii are derived, never tokens: concentric corners need `outer = inner + inset`, so name the inset and compute
the outer value, as `_cardRadius` in `lib/widgets/popup.dart`, `thumbRadius + _kThumbInset` in `lib/widgets/tab.dart`, and the
selection ring in `lib/widgets/palette.dart` do. Adding a token for one of these is how a scale grows without bound.
the outer value, as `_cardRadius` in `lib/ui/popup.dart` does. Adding a token for one of these is how a scale grows without
bound. A card that also stands on its own elsewhere keeps its token when nested: the proxy node cards inside a proxies
list group stay `lg` like those in the tab view, since deriving them from the group card's `xl` left them at 14, smaller
than every other card in the app.
A component the scale does not fit keeps its own value instead of growing the scale: name it once in the file and pass
it to the `all`/`top`/`vertical` builders, as `_itemShape` in `lib/widgets/sidebar.dart` does with 10, between the
chip-sized `sm` and the near-pill `md` on a 40 tall row. Reach for this only once the table and `fit()` are ruled out.
it to the `all`/`top`/`vertical` builders, as `_itemShape` in `lib/ui/sidebar.dart` does with 10, between the
chip-sized `sm` and the near-pill `md` on a 40 tall row, and `_dialogCorner` in `lib/ui/shape.dart` does with 34, the
corner of an iOS alert. Reach for this only once the table and `fit()` are ruled out.
## Verification
+187
View File
@@ -0,0 +1,187 @@
# 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.
+3 -1
View File
@@ -17,6 +17,8 @@ Read these only when the task touches their area:
local plugins.
- [.agents/setting-changes.md](.agents/setting-changes.md): whether a changed setting is patched live, re-applies the
profile, or asks for an Android VPN restart.
- [.agents/surfaces.md](.agents/surfaces.md): fill levels, selection and status colors, and the few borders that
remain, for any change to how a surface is colored.
- [.agents/agent-config.md](.agents/agent-config.md): how to choose between `AGENTS.md`, `.agents`, skills, Codex config,
command rules, and hooks.
- [.agents/worktrees.md](.agents/worktrees.md): worktree hygiene across Claude Code, Codex, and Gemini.
@@ -55,7 +57,7 @@ Read these only when the task touches their area:
requests.
- Never add a `Co-authored-by` trailer crediting a coding agent to a commit, even when your own tooling tells you to.
The `commit-msg` hook rejects it; see [.agents/rules.md](.agents/rules.md) for the rest of the commit rules.
- Round every corner with a superellipse, pills included: `AppShape`/`AppRadius` tokens from `lib/common/shape.dart`,
- Round every corner with a superellipse, pills included: `AppShape`/`AppRadius` tokens from `lib/ui/shape.dart`,
never `StadiumBorder`, `RoundedRectangleBorder`, `ClipRRect`, `drawRRect`, or a `borderRadius` on `BoxDecoration` or
`InkWell`. `test/lint/superellipse_corners_test.dart` enforces it; see the Corner Radius section of
[.agents/rules.md](.agents/rules.md).