UI Work
When To Use
Use this for user-facing Flutter UI changes in lib/, including widgets, screens, navigation surfaces, settings rows, dialogs, and interaction behavior.
Workflow
Locate existing nearby widgets and reuse their patterns before adding new abstractions.
Follow current Material You and Surfboard-like visual conventions.
Use existing providers, notifiers, and helpers where possible.
Keep child: last in widget constructors.
Prefer const constructors and final locals.
Localize user-facing text through ARB; use localization when text changes are non-trivial.
Add focused widget tests when behavior changes, especially for rendering states, taps, scrolling, and empty/error states.
For asynchronous controls, define separately:
- authoritative provider/domain state;
- display-only state such as a minimum progress duration;
- tap policy while work or display holds are active;
- failure/disposal cleanup, normally in
finally for animations and timers.
Run targeted verification:
flutter analyze
flutter test test/widgets/
Corner Radii
All corner radii come from lib/common/shape.dart. Never write a radius literal in a widget.
The scale is picked by the component's shortest side, not by what looks good in isolation. A radius that reads as
a soft card at 64 logical pixels tall reads as a pill at 24 and as a square at 400, so a single radius everywhere is
the wrong kind of consistency.
| Token |
Value |
Shortest side |
Use |
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 |
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 |
full |
1000 |
- |
pills and circles: tracks, indicators, progress, avatars |
AppCorner.fit(shortestSide) applies that table at runtime and is the right call whenever the size comes from a
LayoutBuilder or scales with text size. It snaps to the largest token that stays at or under one third of the
shortest side, which is the rule the table encodes.
AppCorner holds the scale as double, for radius: on CommonCard and for arithmetic.
AppRadius mirrors it as BorderRadius, plus all, top, and vertical builders.
AppShape mirrors it as RoundedSuperellipseBorder, plus full (stadium), circle, input, and the
all/top/vertical/of builders.
ThemeData.withAppShapes in lib/application.dart applies the scale to card, dialog, bottom sheet, snack bar,
chip, menu, input, FAB, navigation indicator, and progress themes. Do not restate those shapes at call sites; in
particular, leave InputDecoration.border unset so inputs inherit AppShape.input.
The three tiers between md and xxl each answer to a different size class, and the split is what keeps one radius
from being wrong for two of them.
xl is for a surface that spans the full width. A wide, short strip needs a larger corner than its height alone
suggests, or it stops reading as a card. Everything full-bleed shares this token and must stay on it:
CommonSelectedListItem, the profiles card, the outer corners of a grouped run in DecorationListItem
(generateSectionV3), and the proxy group header in lib/views/proxies/list.dart. The value is deliberately under
half the height of a standard row, so nothing gets clamped and every one of them renders at the identical radius no
matter which is taller. This is the one place the one-third rule is knowingly overshot; the width is what carries
it, and fit() is not used here.
lg is for the mid-size card that is not full-bleed: dashboard tiles and proxy node cards. It exists because xl
does not fit all of them. A proxy node card's height follows the user's ProxyCardType setting, and at min it 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 every one of the three heights. The same value has room to spare on the smallest dashboard tile,
about 177x80, so both surfaces can hold one token instead of branching per size.
Outlined inputs use AppInputBorder, not ShapedInputBorder from package:material_ui. That one subtracts the
floating label's notch from the outline as a two-pixel band along the top edge, which assumes the label sits over a
flat run of border. Any radius from 8 up puts the corner curve under the notch instead, the subtraction takes the
corner with it, and the top edge is left drawn a pixel low. AppInputBorder clips the notch region away and paints
the full superellipse through it, so the corner arc is truncated exactly where the notch begins. That is what
Flutter's own OutlineInputBorder does by shortening the corner arc's sweep, which is why the framework border
survives large radii and the package one does not. The border needs no contentPadding compensation; leave Material's
defaults alone.
Nested radii are derived, never tokens. Concentric corners need outer = inner + inset, so name the inset and
compute the outer value: _cardRadius in lib/widgets/popup.dart, _kCornerRadius in lib/widgets/tab.dart, and
the selection ring in lib/widgets/palette.dart all do this. Adding an intermediate token to spell one of these out
is what makes a scale grow without bound.
Pitfalls
- Do not introduce a new visual system for one screen.
- Do not manually edit generated localization or provider files.
- Avoid broad layout rewrites unless the requested change requires them.
- Do not mutate provider/domain state merely to smooth a transition. Keep presentation holds local and let real errors
bypass them immediately.
- Do not leave loading animations active when callbacks throw. Test the exception path, not only the successful tap.
Current Interaction Examples
CoreStatusButton watches coreStatusProvider but keeps its 600-millisecond connecting hold locally. Taps are ignored
during the hold or genuine connecting state; disconnected cancels the hold immediately.
- Proxy delay testing writes
0 while pending, the measured delay on success, and -1 on failure. DelayTestButton resets
its animation in finally.
1---2name: ui-work3description: Use when changing FlClash Flutter UI, widgets, screens, Material You styling, navigation surfaces, async feedback, or user-facing interactions.4---56# UI Work78## When To Use910Use this for user-facing Flutter UI changes in `lib/`, including widgets, screens, navigation surfaces, settings rows, dialogs, and interaction behavior.1112## Workflow13141. Locate existing nearby widgets and reuse their patterns before adding new abstractions.152. Follow current Material You and Surfboard-like visual conventions.163. Use existing providers, notifiers, and helpers where possible.174. Keep `child:` last in widget constructors.185. Prefer `const` constructors and final locals.196. Localize user-facing text through ARB; use `localization` when text changes are non-trivial.207. Add focused widget tests when behavior changes, especially for rendering states, taps, scrolling, and empty/error states.218. For asynchronous controls, define separately:22 - authoritative provider/domain state;23 - display-only state such as a minimum progress duration;24 - tap policy while work or display holds are active;25 - failure/disposal cleanup, normally in `finally` for animations and timers.269. Run targeted verification:2728 ```bash29 flutter analyze30 flutter test test/widgets/31 ```3233## Corner Radii3435All corner radii come from `lib/common/shape.dart`. Never write a radius literal in a widget.3637The scale is picked by the component's **shortest side**, not by what looks good in isolation. A radius that reads as38a soft card at 64 logical pixels tall reads as a pill at 24 and as a square at 400, so a single radius everywhere is39the wrong kind of consistency.4041| Token | Value | Shortest side | Use |42| --- | --- | --- | --- |43| `none` | 0 | - | square edges, and the flat side of a grouped run |44| `xs` | 4 | inset blocks | tiles clipped inside an already rounded surface |45| `sm` | 8 | up to 48 | chips, thumbs, swatches, small square tiles |46| `md` | 16 | up to 64 | interactive chrome: inputs, menus, buttons, popups, FAB |47| `lg` | 20 | 64 to 180 | mid-size cards: dashboard tiles, proxy node cards |48| `xl` | 24 | full-bleed | anything spanning the whole width: list rows, grouped runs, group headers |49| `xxl` | 28 | over 200 | sheets, dialogs, full-screen containers |50| `full` | 1000 | - | pills and circles: tracks, indicators, progress, avatars |5152`AppCorner.fit(shortestSide)` applies that table at runtime and is the right call whenever the size comes from a53`LayoutBuilder` or scales with text size. It snaps to the largest token that stays at or under one third of the54shortest side, which is the rule the table encodes.5556- `AppCorner` holds the scale as `double`, for `radius:` on `CommonCard` and for arithmetic.57- `AppRadius` mirrors it as `BorderRadius`, plus `all`, `top`, and `vertical` builders.58- `AppShape` mirrors it as `RoundedSuperellipseBorder`, plus `full` (stadium), `circle`, `input`, and the59 `all`/`top`/`vertical`/`of` builders.60- `ThemeData.withAppShapes` in `lib/application.dart` applies the scale to card, dialog, bottom sheet, snack bar,61 chip, menu, input, FAB, navigation indicator, and progress themes. Do not restate those shapes at call sites; in62 particular, leave `InputDecoration.border` unset so inputs inherit `AppShape.input`.6364The three tiers between `md` and `xxl` each answer to a different size class, and the split is what keeps one radius65from being wrong for two of them.6667`xl` is for a surface that spans the full width. A wide, short strip needs a larger corner than its height alone68suggests, or it stops reading as a card. Everything full-bleed shares this token and must stay on it:69`CommonSelectedListItem`, the profiles card, the outer corners of a grouped run in `DecorationListItem`70(`generateSectionV3`), and the proxy group header in `lib/views/proxies/list.dart`. The value is deliberately under71half the height of a standard row, so nothing gets clamped and every one of them renders at the identical radius no72matter which is taller. This is the one place the one-third rule is knowingly overshot; the width is what carries73it, and `fit()` is not used here.7475`lg` is for the mid-size card that is not full-bleed: dashboard tiles and proxy node cards. It exists because `xl`76does not fit all of them. A proxy node card's height follows the user's `ProxyCardType` setting, and at `min` it is77only about 64 tall, which caps its radius at 21 — so `xl` would break on a setting the user can change at any time,78while `lg` clears every one of the three heights. The same value has room to spare on the smallest dashboard tile,79about 177x80, so both surfaces can hold one token instead of branching per size.8081Outlined inputs use `AppInputBorder`, not `ShapedInputBorder` from `package:material_ui`. That one subtracts the82floating label's notch from the outline as a two-pixel band along the top edge, which assumes the label sits over a83flat run of border. Any radius from 8 up puts the corner curve under the notch instead, the subtraction takes the84corner with it, and the top edge is left drawn a pixel low. `AppInputBorder` clips the notch region away and paints85the full superellipse through it, so the corner arc is truncated exactly where the notch begins. That is what86Flutter's own `OutlineInputBorder` does by shortening the corner arc's sweep, which is why the framework border87survives large radii and the package one does not. The border needs no `contentPadding` compensation; leave Material's88defaults alone.8990Nested radii are derived, never tokens. Concentric corners need `outer = inner + inset`, so name the inset and91compute the outer value: `_cardRadius` in `lib/widgets/popup.dart`, `_kCornerRadius` in `lib/widgets/tab.dart`, and92the selection ring in `lib/widgets/palette.dart` all do this. Adding an intermediate token to spell one of these out93is what makes a scale grow without bound.9495## Pitfalls9697- Do not introduce a new visual system for one screen.98- Do not manually edit generated localization or provider files.99- Avoid broad layout rewrites unless the requested change requires them.100- Do not mutate provider/domain state merely to smooth a transition. Keep presentation holds local and let real errors101 bypass them immediately.102- Do not leave loading animations active when callbacks throw. Test the exception path, not only the successful tap.103104## Current Interaction Examples105106- `CoreStatusButton` watches `coreStatusProvider` but keeps its 600-millisecond connecting hold locally. Taps are ignored107 during the hold or genuine connecting state; disconnected cancels the hold immediately.108- Proxy delay testing writes `0` while pending, the measured delay on success, and `-1` on failure. `DelayTestButton` resets109 its animation in `finally`.