diff --git a/.agents/architecture.md b/.agents/architecture.md index a9b1fd09..12b88255 100644 --- a/.agents/architecture.md +++ b/.agents/architecture.md @@ -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 `/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 diff --git a/.agents/commands.md b/.agents/commands.md index a8acc221..e98b328e 100644 --- a/.agents/commands.md +++ b/.agents/commands.md @@ -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 --debug` on every push; `tool/check_plugins.sh` only analyzes and tests the Dart side. +`flutter build --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 diff --git a/.agents/flutter-upgrade.md b/.agents/flutter-upgrade.md index 39a5f654..a2febb29 100644 --- a/.agents/flutter-upgrade.md +++ b/.agents/flutter-upgrade.md @@ -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`. diff --git a/.agents/rules.md b/.agents/rules.md index f880fdb6..6ad16833 100644 --- a/.agents/rules.md +++ b/.agents/rules.md @@ -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 :` 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. diff --git a/.agents/setting-changes.md b/.agents/setting-changes.md index 0988ba57..6dc2ec6e 100644 --- a/.agents/setting-changes.md +++ b/.agents/setting-changes.md @@ -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. diff --git a/.agents/skills/ui-work/SKILL.md b/.agents/skills/ui-work/SKILL.md index 9fe451be..9a1ca5fa 100644 --- a/.agents/skills/ui-work/SKILL.md +++ b/.agents/skills/ui-work/SKILL.md @@ -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 diff --git a/.agents/surfaces.md b/.agents/surfaces.md new file mode 100644 index 00000000..40b56306 --- /dev/null +++ b/.agents/surfaces.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 598a2d45..040a0787 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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).