Status: Frozen (2026-07-02; re-frozen 2026-07-28 for the focused Blocks landscape workspace and beginner-example chooser; target-identifier wording amended 2026-07-29) with ADR-0008, ADR-0014, and ADR-0016. This is the implementable token contract for the app's visual layer. It realizes TDD.md §13.2 visually and satisfies specs.md FR-UI-6 (clear connection/run state, large touch targets) and NFR-A11Y-3 (contrast, large-font/high-contrast). The more-specific spec wins on its topic; this doc is authoritative for color/shape/spacing/type/motion tokens and per-component visual specs. Behavior contracts (layout breakpoints, the
Connectionseam, ARB strings) remain owned by TDD.md / specs.md.Token home:
app/lib/theme/(barrelpackage:pyble/theme/theme.dart). Files:signal_scales.dart(SignalSpacing/SignalRadius/SignalElevation/SignalMotion),signal_type.dart(SignalType),signal_colors.dart(kPybleBlue,SignalStateColors,SignalCodeColors),pyble_theme.dart(PybleTheme.light/.dark/.highContrastLight/.highContrastDark).lib/theme/holds tokens/ThemeData/ThemeExtensions ONLY — zero display copy. All user-facing text is passed in fromlib/appviaAppLocalizations, so the locale-parity gate (which scanslib/app) always covers it. Every new.dartfile underlib/theme/opens with the two SPDX header lines.
Signal = adaptive Material 3, Expressive-leaning, built from one brand seed. Expressiveness comes from shape, elevation, and explicit state color — never from cranking chroma or from unreleased "Expressive" widget APIs (only stable Flutter 3.44 theming APIs: ColorScheme.fromSeed, per-component *ThemeData, ThemeExtension).
- One seed, two brightnesses (+ high contrast).
ColorScheme.fromSeedfor light and dark;themeMode: ThemeMode.system. High-contrast schemes are the same seed atcontrastLevel: 1.0, wired viahighContrastTheme/highContrastDarkTheme(§9). - State is a first-class color. Connection/run state (disconnected/connecting/ready/running) and the console streams (stdout/stderr/system) are frozen
ThemeExtensions, contrast-checked — neverColors.*literals. - Offline-first. Default sans (Roboto) for UI; platform
monospacefor code. Nogoogle_fonts, no network/runtime fonts, no new dependency. - Large targets, clear state (FR-UI-6): interactive controls ≥ 48×48 dp hit area; connection/run state is always legible by shape (glyph) + text, with color as reinforcement — never color alone, and never text alone (§7.6).
- One home. All tokens live in
app/lib/theme/; every surface themes fromTheme.of(context)+ theSignalStateColors/SignalCodeColorsextensions. No per-surface brand/state colors (NFR-MAINT-2, NFR-COMPAT-1).
- Brand seed — pyble-blue:
#2D5BFF(Color(0xFF2D5BFF)), tokenkPybleBlue. - Both schemes:
ColorScheme.fromSeed(seedColor: kPybleBlue, brightness: Brightness.light | .dark, contrastLevel: 0.0), defaulttonalSpotvariant (predictable neutrals + WCAG-safe on-pairs; do not switch to a higher-chroma variant — contrast stability over saturation). - High-contrast schemes reuse the same call with
contrastLevel: 1.0(PybleTheme.highContrastLight/.highContrastDark). - The generated tonal palette is authoritative for the ColorScheme roles below — do not hand-override individual roles. The only hand-authored colors are the seed, the
SignalStateColorsextension (§1.3), and the singlestderrcolor inSignalCodeColors(§1.4).
Roles are generated by fromSeed; this table fixes how each is used so surfaces stay consistent. (Exact hexes are deterministic from the seed; they are M3 tonal-palette outputs, not free choices.)
| Role | Used for |
|---|---|
primary / onPrimary |
Primary FilledButton fill + label; selected nav indicator accent; focused/active affordances; the brand-blue identity. |
primaryContainer / onPrimaryContainer |
Selected navigation indicator container (rail + bar); tonal emphasis chips; the Connect empty-state badge (the primary entry point). |
secondary / secondaryContainer |
Secondary/tonal buttons; less-prominent selected states; segmented controls (the right-pane switcher, §7.12). |
tertiary / tertiaryContainer |
Sparingly — the pin-reference informational badge (reads as reference, not action, §8), accent highlights that must read distinct from primary/state colors. |
error / onError / errorContainer / onErrorContainer |
Destructive actions; ENOSPC/transfer-failure banners; the Stop action while running (§7.7). Error as a connection state is deferred (§1.3). |
surface / onSurface |
Default page/background and default text; the connection-status pill label (§7.6). |
surfaceContainerLowest / Low / (base) / High / Highest |
Deliberate elevation tiers (§4); assigned per component, not by shadow. surfaceContainerLowest is the code well (editor field + console body). |
surfaceContainerHighest |
Highest resting tonal surface — toolbar background, nav rail/bar background; the pill's host surface (its tint composites over this). |
onSurfaceVariant |
Secondary text, pane titles, inactive nav labels/icons, captions; SignalCodeColors.system/.gutter. |
outline / outlineVariant |
outlineVariant for the 1 px pane/console/toolbar hairlines (softer than outline); input borders. |
inverseSurface / onInverseSurface / inversePrimary |
SnackBars / tooltips / transient inverse surfaces. |
surfaceTint |
Left at primary but suppressed to transparent on flat components (app bar, cards) — Signal expresses elevation by tonal tier, not tint-on-scroll. Kept for M3 overlays. |
scrim |
Modal/bottom-sheet scrim. |
Categorical plot series palette (delegated): an ordered ≥ 6-entry series palette for fl_chart (A-32) is owned by app-viz-engineer, added to lib/theme/ when plots land, under these frozen constraints: derived from tonal families; verified distinct in both brightnesses and for common color-vision deficiencies; and fenced off from the four SignalStateColors hues (running-blue / ready-green are load-bearing state signals). Legend chips reuse the same ordered palette. Not defined here to avoid a half-specified palette.
Hand-authored beyond the seed. Maps 1:1 to ConnState { disconnected, connecting, ready, running } (app/lib/app/providers.dart). Exposed as a ThemeExtension; read via SignalStateColors.of(context) and .colorFor(state).
| State | Light | Dark | High-contrast light | High-contrast dark | Meaning / intent |
|---|---|---|---|---|---|
disconnected |
#565E6C |
#A8B0BF |
#3C434F |
#C8D0DE |
Off / no board. Reads as an actionable prompt (border + bluetooth-off glyph), not disabled chrome. |
connecting |
#8A5A00 |
#FFB868 |
#6B4500 |
#FFD0A0 |
Transitional — scan/connect/HELLO. Pulses (§6). |
ready |
#146C2E |
#7ADB92 |
#0B5020 |
#A6EEB6 |
Connected, idle, ready to run. |
running |
#1B4FD6 |
#A9C0FF |
#123FB0 |
#CBD9FF |
A program is executing on the board (live). Pulses (§6). |
How the state color is used — corrected from the first draft. The state color is a graphic, not the pill's label text. Compositing the same hue as both the label text and a tint fill under it drops contrast below AA (the first draft's 5.9–6.7:1 figures were measured against pure white, not the real tinted pill; the true text-on-tint ratios are ≈ 3.5–4.3:1 — a fail). Signal therefore renders:
- Label text in
colorScheme.onSurface→ ≈ 14:1, comfortably ≥ 4.5:1 regardless of state hue (this is the AA-safe channel). - State color as graphic in three places, each ≥ 3:1 as a non-text graphic against its background: the per-state glyph (§7.6), the always-on 1 px pill border, and the tint fill = state color composited at 16 % (light) / 24 % (dark) over the pill's actual host surface (
surfaceContainerHighestin the toolbar — not basesurface).
Distinguishability for color-vision-deficient users comes primarily from the distinct per-state glyph + the always-present text label (§7.6), with hue as reinforcement — so state survives greyscale, reduced-motion (no pulse), and label truncation.
Exact WCAG ratios are not asserted in prose (prose rots). They are frozen as a machine-checkable contrast test under app/test/theme/ (author: app-test-author): for each state × brightness, glyph/border/tint-edge ≥ 3:1 against both the composited pill interior and the host surface; label (onSurface) ≥ 4.5:1; high-contrast variants ≥ their normal counterparts.
error is out of scope this milestone. FR-UI-6 lists an error state, but the frozen ConnState enum carries four members (no error). No error pill can be driven or tested today, so it is not a claimed obligation. Adding it requires the seam (lib/pble, app-protocol-engineer) to add the member first; only then does the design claim error coverage. Destructive/error actions still use colorScheme.error (§7.7, §7.11).
The console streams (FR-CONSOLE-2) and the editor's structural chrome. A ThemeExtension sibling to SignalStateColors; read via SignalCodeColors.of(context) and .streamColor(stream). Most values derive from the (contrast-aware) scheme so they track brightness and contrastLevel; only stderr is hand-authored (M3 guarantees no on-pair for error text over an arbitrary surface).
| Token | Light | Dark | Role |
|---|---|---|---|
stdout |
onSurface |
onSurface |
Program standard output — ordinary code text. |
stderr |
#B3261E |
#FFB4AB |
Program standard error / tracebacks. Verified ≥ 4.5:1 on the console well (surfaceContainerLowest) in both brightnesses (≈ 5.9:1 light / ≈ 11:1 dark). |
system |
onSurfaceVariant |
onSurfaceVariant |
Client-synthesized notices (reconnected, etc.) — quiet, distinct from stdout/stderr. |
gutter |
onSurfaceVariant |
onSurfaceVariant |
Editor line-number / gutter text. |
activeLine |
primary @ 8 % |
primary @ 8 % |
Current-line highlight fill (translucent). |
selection |
primary @ 24 % |
primary @ 24 % |
Text-selection fill (translucent, verified against onSurface code text). |
Code well is one tonal tier. Both the editor field and the console output body sit on surfaceContainerLowest (a recessed well) — identical code-role text on identical backgrounds, and the console reads as an output sink rather than a raised card. The console title bar uses the higher surfaceContainer tier (§7.5). Syntax-highlight palettes remain deferred to lib/editor (they derive from the tonal palette); this doc fixes the streams + chrome so lib/console/lib/editor bind to tokens, not literals.
Traceback banners (FR-ERR) render stderr text on the console well, or — when a full banner is wanted — onErrorContainer text over an errorContainer fill (a guaranteed M3 on-pair). Never raw colorScheme.error as text on an arbitrary surface.
4-based; SignalSpacing.* (dp doubles). Use these only — no arbitrary paddings.
| Token | dp | Typical use |
|---|---|---|
SignalSpacing.xs |
4 | Icon↔label gap, chip inner, tight stacks. |
SignalSpacing.sm |
8 | Dense control padding, dot↔label in the status pill. |
SignalSpacing.md |
12 | Pane title padding, list-row vertical inset (not total height), default gap. |
SignalSpacing.lg |
16 | Standard content inset (toolbar sides, pane body, console). |
SignalSpacing.xl |
24 | Empty-state padding, section separation. |
SignalSpacing.xxl |
32 | Empty-state internal breathing room, large gaps. |
SignalSpacing.xxxl |
48 | Major vertical rhythm on spacious empty states. |
Expressive-leaning, 12–28 px band; SignalRadius.*. Applied via BorderRadius.circular / SignalRadius.stadium.
| Token | px | Applied to |
|---|---|---|
SignalRadius.sm |
12 | Buttons, text fields, small chips, list-tile hover/selection. |
SignalRadius.md |
16 | Cards, the titled pane wrapper container, dialogs' inner blocks. |
SignalRadius.lg |
20 | Large cards, the empty-state illustration badge, dialogs. |
SignalRadius.xl |
28 | Bottom sheets, prominent containers, connect-flow board cards. |
SignalRadius.stadium |
stadium | Navigation selection indicator, the connection-status pill, the right-pane segmented switcher, FAB-like actions. |
Small circular graphics (status dot, if used) are true circles (shape: BoxShape.circle), not radii.
Expressive-leaning = deliberate tonal elevation via M3 surface containers, not heavy drop shadows. SignalElevation.tier(scheme, level) returns the resting surface for a level; real elevation dp is used only where M3 expects a shadow.
| Level | tier() returns |
Shadow dp | Components |
|---|---|---|---|
0 (level0) |
surface |
0 | Page background; pane bodies. |
1 (level1) |
surfaceContainerLow |
0 | Titled pane wrapper background; resting cards. |
2 (level2) |
surfaceContainer |
0 | Console strip title bar. |
3 (level3) |
surfaceContainerHigh |
0 | Selected/hover states, raised card headers, list selection fill. |
4 (level4) |
surfaceContainerHighest |
0 | Top toolbar / app bar; navigation rail/bar; pill host surface. |
| well | surfaceContainerLowest |
0 | Editor field + console output body (recessed code well, §1.4). |
overlay (overlay) |
dialog/menu surface + tint | 3–6 | Menus, dialogs, bottom sheets, drag feedback (M3 default shadows). |
Separators between tonal tiers use a 1 px outlineVariant hairline, not a shadow.
Material 3 type scale on the default sans (Roboto / platform default — no network font). Role→usage map; sizes are the M3 defaults unless noted.
| Role | Used for |
|---|---|
displaySmall / headlineMedium |
Reserved for large marketing/empty-hero moments (sparingly). |
titleLarge |
Empty-state title; dialog titles. |
titleMedium |
App wordmark in the toolbar; card titles; multi-select selected-count. |
titleSmall |
(Available for sub-section titles.) |
labelLarge |
Button labels; pane titles (onSurfaceVariant); toolbar action labels; console title. |
labelMedium |
Navigation labels (rail + bar); connection-status pill text. |
labelSmall |
Badges, dense captions. |
bodyLarge |
Primary reading text in dialogs/sheets. |
bodyMedium |
Default body text; file-list row name; empty-state detail. |
bodySmall |
Secondary captions, helper/hint text (onSurfaceVariant); file-row metadata. |
code role (SignalType.code, offline mono). TextStyle(fontFamilyFallback: ['Menlo', 'monospace'], fontSize: 14, height: 1.45); apply the color via SignalType.codeOn(scheme) (onSurface) or a SignalCodeColors stream color.
- Apple-platform correctness (folded from review). The family list leads with
Menlo(the guaranteed-installed mono on iOS/iPadOS), then the Android genericmonospace. It deliberately does not namemonospacefirst: on iOS/iPadOS the generic can resolve to the default proportional face (breaking indentation/column alignment on a first-class target, NFR-COMPAT-1), whereasMenloalways resolves there. On AndroidMenlois absent, so resolution falls through to the realmonospace. - Offline-first guardrail. The list contains only genuinely-resolvable families (Apple
Menlo, Androidmonospace);RobotoMono/Consolaswere removed (not bundled by Flutter, silent no-ops that invite agoogle_fonts"fix"). The list must not be completed withgoogle_fontsor any runtime/network font. If a guaranteed iPad mono glyph ever proves necessary, bundle a mono font as a local asset (offline) and record its license inapp/DEPENDENCIES.md— nevergoogle_fonts.
Used by editor buffers, console output, and any verbatim technical identifier
(opaque target values including esp32/esp32-s3/esp32-c3, filesystem
paths, PBLE/1 status names — never localized, FR-I18N-4). The pin-reference pane
renders target identifiers in this role and falls back to generic board-
documentation guidance for an unknown value.
Respect text scaling: never fix heights that clip at large textScaleFactor; the shell's no-overflow contract holds to textScaleFactor 2.0 (§9). Purely-cosmetic chrome text (the toolbar wordmark and the pill label) may clamp its scaler to ≤ 1.3 so the top chrome stays bounded while body/content text scales fully.
M3-aligned durations; SignalMotion.*. Easing: standardCurve = Curves.easeInOutCubic; emphasizedCurve = Curves.easeInOutCubicEmphasized.
| Token | ms | Use |
|---|---|---|
fast |
120 | Hover/press state, ripple, small color fades. |
standard |
240 | Surface swap on nav change, pill color transition. |
emphasized |
400 | Layout/pane transitions, bottom-sheet in/out. |
pulse |
1200 | The connecting/running indicators breathe (opacity 0.55↔1.0). |
Reduced-motion is mandatory and load-bearing for the test suite (folded from review). The connecting/running pulse MUST be driven by an AnimationController that runs repeat(reverse: true) only while animations are enabled, and is held on a single static frame (controller stopped) when MediaQuery.of(context).disableAnimations is true. A perpetual repeating animation makes WidgetTester.pumpAndSettle() time out and throw — which would break the frozen widget suite (app/test/widget/app_shell_test.dart calls emit(...) then pumpAndSettle()). Because the harness pumps with disableAnimations: true by default, the pill settles; any test that must exercise a live pulse uses pump(Duration), not pumpAndSettle. State stays fully legible without the pulse (glyph + label + color). disconnected/ready are always static.
Every component binds to the tokens above and to Theme.of(context) / the color extensions only. Interactive controls keep a ≥ 48 dp hit area (FR-UI-6).
- Background
surfaceContainerHighest(level 4), full width; 1 pxoutlineVariantbottom hairline. - Height is a minimum, not a fixed box (folded from review). Use
ConstrainedBox(minHeight: 56)/ intrinsic height so the row grows with text rather than clipping at largetextScaleFactor. (A hardSizedBox(height: 56)aroundtitleMedium/labelMediumcontent clips at accessibility scales and trips the no-overflow assertion.) - Leading: app wordmark (
AppLocalizations.appTitle,titleMedium,onSurface, scaler clamped ≤ 1.3) atSignalSpacing.lginset — brand proper noun, verbatim. - Then the connection-status pill (§7.6),
SignalSpacing.lggap. Spacer, then the action group (§7.7) right-aligned,SignalSpacing.smtrailing.- Shrink order (FR-UI-6 — protect the state signal): when width is tight, drop lower-value chrome first — the app wordmark, then non-essential action labels — before the pill's state word ever ellipsizes. The pill's minimum content (glyph + full state word) is never sacrificed to fit the wordmark or actions.
- The shell owns the SLOT only; action content/wiring (Connect/Disconnect, Run, Stop, Soft-reboot) belongs to app-connect-engineer / app-editor-console-engineer (§7.7). Until wired, actions render disabled (
onPressed: null, M3 38 %onSurface), tooltips from ARB.
- Background
surfaceContainerHighest(level 4);labelType: all(icon + text always visible — the four labels stay findable, per the widget-test contract). - Selection indicator:
primaryContainerfill,SignalRadius.stadium; selected icon/labelonPrimaryContainer, unselectedonSurfaceVariant(M3 defaults). - Connected-shell destinations are Editor / Console / Files / Blocks, in that order; Connect remains the full-screen connection gate rather than a rail destination. Each destination is a ≥ 48 dp target. Blocks is first-class: selecting it replaces the three text-workbench panes with the focused workspace specified in §7.12 while retaining the rail and top app chrome.
- 1 px
outlineVarianttrailing hairline separating rail from the first pane.
- M3
NavigationBarat the bottom; backgroundsurfaceContainerHighest; safe-area aware. - Selection indicator:
primaryContainerpill (SignalRadius.stadium) behind the selected icon; selected icononPrimaryContainer; unselectedonSurfaceVariant.labelBehavior: alwaysShow(contract: four visible labels). - Portrait keeps the editor primary (FR-UI-2); the bar swaps the active surface via
selectedSurfaceProvider.
- A column: title header + hairline +
Expandedbody. - Header:
labelLargeinonSurfaceVariant,EdgeInsets.symmetric(horizontal: SignalSpacing.md, vertical: SignalSpacing.md), single-line ellipsis; optional trailing affordance slot (right-aligned) — the entry point for the right-pane switcher (§7.12), a path/breadcrumb header (§7.13), or a multi-select contextual bar (§7.13). - Body background
surface(level 0); the pane container may sit onsurfaceContainerLow(level 1). Panes separated by 1 pxVerticalDivider(outlineVariant). - Corner treatment: as a card (portrait/phone) →
SignalRadius.md; in the flush landscape split → edge-to-edge (dividers, no radius).
- Full-width strip, 1 px
outlineVarianttop hairline. Two tiers: the title bar onsurfaceContainer(level 2); the output body onsurfaceContainerLowest(the code well, matching the editor, §1.4). - Resting height is a formula, not a fixed 96 dp (folded from review). A 4–6-line MicroPython traceback must fit at rest, so the default resting height =
title + N × code-line-height + insets, N ≥ 6 (≈ 176–190 dp), and it is a minimum that grows withtextScaleFactor(resizable later bylib/console). The old 96 dp left ≈ 2 lines — a label bar, not a live output area. - Title
consoleTitle(labelLarge,onSurfaceVariant) top-left atSignalSpacing.lg/SignalSpacing.sminset. - Body reserved for
lib/console; content usesSignalType.codewithSignalCodeColorsstream colors. The shell renders only the titled, empty slot (its empty state, §8). - Focused Blocks override (ADR-0014). The same console buffer renders below
Blocks, but defaults to a collapsed 48 dp title/toggle row so it does not
permanently take height from Blockly. It expands to the normal resting-height
formula when the user activates the row, when the sole Blocks Run begins, or
when a new
ConsoleEventarrives. The user may collapse it again. Expansion participates in layout below the workspace; it MUST NOT overlay the Blockly canvas, and collapsing/expanding MUST NOT recreate the WebView or clear its workspace.
The primary state signal. Binds to connStateProvider (read-only, ValueListenableBuilder), SignalStateColors, and the per-state glyph map. Carries a stable ValueKey('connStatusPill') so widget + golden tests target it independent of locale and color.
-
Shape:
SignalRadius.stadium. Fill = state color tint @ 16 % (light) / 24 % (dark) over the hostsurfaceContainerHighest. A 1 px full-strength state-color border is always on (not high-contrast-only) so the pill reads as a contained, tappable-looking chip in both brightnesses anddisconnectedreads as a prompt, not disabled chrome. -
Per-state glyph map (folded from review — shape carries state independent of color, motion, and text):
State Glyph disconnectedIcons.bluetooth_disabledconnectingIcons.bluetooth_searchingreadyIcons.check_circle(link established, idle)runningIcons.play_circle(live)The glyph is the state color at full strength (≥ 3:1 graphic).
-
Contents (row,
SignalSpacing.smgap,EdgeInsets.symmetric(horizontal: SignalSpacing.md, vertical: SignalSpacing.xs)): the state glyph + the localized state label incolorScheme.onSurface(labelMedium, scaler ≤ 1.3). The label usesonSurface(not the state color) so text is AA-safe at any hue (§1.3). The state word never ellipsizes to nothing — the pill's minimum content is glyph + full word (§7.1 shrink order). -
Motion:
connecting/runningglyphs pulse (SignalMotion.pulse, opacity 0.55↔1.0) under the reduced-motion gate (§6). Color transitions animate overSignalMotion.standard. -
Screen reader (folded from review): wrap the pill in a
Semanticsnode withlabel: l10n.connStatusSemanticLabel(stateWord)→ "Connection status: {state}", so the state is announced with context rather than a bare "Ready". -
Hit area ≥ 48 dp tall (transparent padding) so it can later become a tappable connection summary.
Right-aligned action group: Connect/Disconnect (toggle), Run, Stop, Soft-reboot. Icon buttons, ≥ 48 dp targets, tooltips from ARB.
- Connect/Disconnect is a state-driven toggle (folded from review — no connect dead-end):
disconnected→ label/tooltiptoolbarConnect, iconIcons.bluetooth; connected (ready/running) → label/tooltiptoolbarDisconnect, iconIcons.bluetooth_disabled. The same slot enters and exits a link, so a user on a flaky/wrong board always has a toolbar exit. This action shares its connect copy/icon with the Connect empty-state CTA (§8) — one canonical connect path. - State-dependent emphasis (folded from review — no blue-on-blue): Run is the emphasized action (tonal
primaryContainer/onPrimaryContainer) only inready; whilerunningit is de-emphasized/disabled (muted or a spinner). Stop is the sole attention accent (error/onError) only whilerunning(optionally pulsing) — so the escape action never competes with brand-blue exactly when a runaway program must be stopped. - Enablement (later wiring): Run ←
ready; Stop ←running; Connect ←disconnected; Disconnect ← connected; Soft-reboot ← connected. Until owners wire them, all render disabled. The shell owns the slot + disabled visual only.
- Primary →
FilledButton(primary/onPrimary),SignalRadius.sm, ≥ 48 dp height,labelLarge. - Secondary / tonal →
FilledButton.tonal(secondaryContainer/onSecondaryContainer). - Low-emphasis →
OutlinedButton(outlineborder) orTextButton(primarylabel). - Destructive →
FilledButton/TextButtoninerror/onError. - Icon →
IconButton, ≥ 48 dp target, ARB tooltip. Disabled = M3 default (38 %onSurface).
The shared "not-yet-populated / nothing here" component every surface uses. Lives in lib/app (a string-bearing widget — kept out of lib/theme); all copy passed in via AppLocalizations.
-
Centered column,
MainAxisSize.min,EdgeInsets.all(SignalSpacing.xl), overflow-safe (scrollable if constrained — never overflows at any breakpoint or text scale). -
Illustration badge: a
SignalRadius.lgrounded square holding the surface icon (~40–48 dp). Its tint is aSurfaceToneparameter, not fixed (folded from review):SurfaceToneFill / icon For actionprimaryContainer/onPrimaryContainerConnect (the primary entry point). informationaltertiaryContainer/onTertiaryContainerPin reference (reads as reference, not action — CON-7). neutralsurfaceContainerHigh/onSurfaceVariantEditor, Console, Files (quiet, per §8 tone). (The old draft hard-coded
primaryContainerfor every surface, which made a read-only pin-reference badge wear the brand/action color and the "calm" editor look loud — both contradicted their stated tone.) -
Title:
titleLarge,onSurface,SignalSpacing.mdbelow the badge. -
Detail:
bodyMedium,onSurfaceVariant, centered,SignalSpacing.smbelow title (ARB-sourced; may embed a verbatim identifier viaSignalType.code, FR-I18N-4). -
Optional action slot (
SignalSpacing.xlbelow): a primary/tonal button for the surface's main CTA — wired by the feature owner.
Frozen so lib/files builds a consistent, parity-safe list (NFR-MAINT-2). Copy for row affordances is authored in the Files feature story's ARB commit (not this redesign).
-
Density: single-line comfortable row = 48 dp min height; two-line (with metadata) = 56 dp. The
SignalSpacing.mdvertical is an inset, not the total height — every tappable row meets the ≥ 48 dp target (FR-UI-6). -
Leading file-type icon (24 dp) at
SignalSpacing.lginset, per the map:Entry Icon Tint folder Icons.folder/Icons.folder_openonSurfaceVariant.pyIcons.description(opt.primarytint — the primary source type)onSurfaceVariantdata / other eligible Icons.insert_drive_fileonSurfaceVariant(The
.py/data-only filter, CON-3, means only these three appear.) -
Name:
bodyMedium,onSurface, single-line ellipsis. Optional secondary line (size/type):bodySmall,onSurfaceVariant. Sizes/paths render verbatim viaSignalType.code(FR-I18N-4). -
Trailing affordance slot.
-
Selected / hover fill:
surfaceContainerHigh(level 3) atSignalRadius.sm. -
Read-only / control-plane row (SEC-7 / FR-FILES-6): the agent control-plane and forbidden paths are marked non-editable — a leading/trailing
Icons.lock_outlineinonSurfaceVariant, row text dimmed toonSurfaceVariant(vsonSurfacefor editable rows), and edit/delete/rename affordances hidden or disabled (38 %onSurface). This is a UI hint only — the agent still enforcesEACCES, surfaced localized.
Per-file transfer visual language. Reuses existing tokens — adds none.
- In progress: a determinate
LinearProgressIndicatoron the row driven byTransferProgress.sent/total, colorcolorScheme.primary. - Success: shown only after whole-file CRC match at
PUT_END/GET_END(never on partial/unverified) — a success affordance in thereadygreen fromSignalStateColors(reuse). - Failure: CRC/size mismatch or
ENOSPC→colorScheme.error. - Busy (single active writer,
EBUSY): a queued/busy indicator in theconnectingamber fromSignalStateColors.
Responsive placement — FROZEN ([docs] 2026-07-28,
ADR-0014).
The earlier right-pane placement in ADR-0013 is superseded. The two landscape
hosts are visually distinct:
- Text workbench. Files | Editor/Console | Pin Reference may remain the
default. The right pane defaults to
pinReferenceTitle; A-32 may addrightPanePlotsin the §7.4 trailing slot only when a live plot surface exists. There is no Blocks segment and no dead control. - Focused Blocks. Selecting the Blocks NavigationRail destination replaces all three titled panes below the retained application toolbar. The rail stays visible and selected. The top toolbar keeps connection state, Disconnect, Stop, and Soft-reboot but hides its editor-targeted Run. A single Flutter-owned Blocks action strip, outside the WebView, contains Examples, Preview, Open in editor, Save, and the only Blocks Run. Every action is a ≥ 48 dp target and every label/tooltip is ARB-sourced. At constrained widths Examples moves first into a labelled, keyboard- and screen-reader-reachable overflow; further non-Run actions may join it when needed. Horizontal scrolling, clipping, shrinking targets, or compressing the canvas is not the overflow behavior.
- Canvas first. Below the action/status bands, Blockly fills the available
host. Let
Wbe focused content width after the rail and outer/divider chrome. For a selectable generated-Python inspector widthP ∈ [360 dp, 420 dp]and divider/gutterG, it appears only if the remaining Blockly widthB = W − P − Gsatisfies bothB ≥ 720 dpandB / W ≥ 0.60. Otherwise the inspector is absent and Blockly receives all ofW; horizontal squeezing or an undersized inspector is not an alternative. - Notices never cover the canvas. Loading and action progress use a compact Flutter-owned status band between the action strip and canvas. Generator failures use an inline notice in the same host band, leaving the WebView editable below it while source actions stay disabled. Terminal host failures replace the canvas region with an assertive live-region recovery panel; they never float over an interactive WebView. Error title/detail is announced once; Retry is reachable; and Start fresh appears only with retained JSON and requires confirmation.
- Console and rotation. The focused workspace uses the collapsed/on-demand console in §7.5. Its expansion relayouts rather than overlays Blockly. Rotating to the stacked layout keeps Blocks selected and retains the serialized workspace; a recreated platform view restores it before actions enable. Portrait continues to use the full-width Blocks destination from §7.3 and omits the generated inspector when the width rule is not met.
- Beginner-example chooser (ADR-0016). A structurally empty workspace shows
one prominent Examples call to action using the
school_outlinedicon andSurfaceTone.action; it leaves the toolbox reachable and disappears as soon as any block, variable, or procedure exists. The action-strip and toolbox entries remain available. All three open the same adaptive chooser controller: below 600 dp it uses a scroll-controlled modal bottom sheet, while at 600 dp and wider it uses a dialog. Its catalog list, summary/concept chips, wiring note, explicit GPIO fields, and selectable generated-source well scroll as one body; the footer keeps Preview plus either Create copy or Replace workspace reachable. Duplicate values across separate GPIO roles show localized field errors without claiming board-pin validity. At large text the footer may stack and the body scrolls—it never clips or resizes the Blockly canvas behind the modal. Destructive Replace uses the standard confirmation pattern in §7.8; Preview and Create copy are not styled as destructive. Source usesSignalType.code. A loading indicator may begin after Create/Replace, but loaded-success feedback appears only after the active host acknowledges the restored candidate snapshot. - Clean-room boundary. These are fresh PyBLE proportions and generic high-level layout behaviors. Visual reference review does not authorize copying any third-party product source, assets, DOM/CSS, styling implementation, identifiers, or block catalog.
Frozen patterns; copy authored in the Files feature story.
- Path / breadcrumb header: current segment as
labelLargewith an up/back affordance in the §7.4 trailing slot;fs_rootand path segments render verbatim viaSignalType.code(FR-I18N-4), never localized. - Multi-select contextual bar: on multi-select, the §7.4 pane header is replaced by a bar showing the selected count (
titleMedium) + bulk actions; destructive bulk delete usescolorScheme.error(§7.8); selected rows use the §7.10surfaceContainerHighselection fill. - Listing-truncation banner (
more == 1, TDD §8.4): an inline informational banner (not error) — backgroundsurfaceContainerHigh,Icons.info_outline+bodySmallinonSurfaceVariant,SignalRadius.sm— visually distinct from thecolorScheme.errorfailure banner.
Each surface's empty state uses §7.9 with a surface-specific icon, SurfaceTone, and copy. All copy is ARB-sourced; the keys below are frozen in app_en.arb this redesign (downstream engineers do not re-author them).
| Surface | Icon | SurfaceTone |
Copy (ARB keys) | Intent |
|---|---|---|---|---|
| Connect | bluetooth |
action |
connectEmptyTitle / connectEmptyDetail / connectEmptyCta |
The most inviting empty state. Prominent CTA opens the BLE picker; detail notes the scan is filtered to PyBLE boards (BLE-only, no raw list). |
| Editor | code |
neutral |
editorEmptyTitle / editorEmptyDetail / editorEmptyCta (+ editorEmptyCtaOpen) |
Quiet, not loud — editor stays the primary surface (FR-UI-2). |
| Console | terminal |
neutral |
consoleEmptyTitle / consoleEmptyDetail |
Observational — no CTA. |
| Files — disconnected | bluetooth_disabled / folder_off |
neutral |
filesDisconnectedTitle / filesDisconnectedDetail |
Nothing to browse while disconnected — no create CTA. |
| Files — connected & empty | folder_outlined |
neutral |
filesEmptyTitle / filesEmptyDetail / filesEmptyCta |
Create/import; CTA enabled only at ConnState.ready. |
| Pin reference | developer_board |
informational |
pinReferenceDetailGeneric (pre-connect) / pinReferenceDetailForChip ({chip} verbatim, FR-I18N-4) |
Read-only. Never a CTA, never editable, never a stored/enforced profile (FR-UI-4, CON-7). |
| Blocks — empty workspace | school_outlined |
action |
blocksExamplesEmptyTitle / blocksExamplesEmptyDetail / blocksExamplesOpen |
Opens the same offline example chooser as the persistent action-strip/toolbox entries; never loads or runs content by itself. Disappears only when the workspace is structurally non-empty. |
Files has two empty states (folded from review): disconnected (no on-device filesystem to browse) vs connected-but-empty are fundamentally different; one CTA cannot serve both. CTA enablement is keyed to ConnState.ready.
Populated pin-reference is display-only (CON-7, folded from review): the chip-keyed pin table has no switches, toggles, inputs, or editable fields; chips are non-interactive; identifiers render in the code role verbatim. It must never read as a config editor / enforced profile.
Connect first-run must not ship polished-but-inert (folded from review — DoR ruling, app-architect): the styled Connect empty state and its connectEmptyCta, and the toolbar Connect action, must land functional in the same increment (route: app-connect-engineer). Do not ship a finished-looking Connect screen whose CTA and toolbar Connect are both dead — a done-looking dead-end reads as more broken. One canonical connect copy/icon (bluetooth + the connect action) is reused by both entry points.
- Contrast (NFR-A11Y-3): all ColorScheme on-pairs are M3-guaranteed. The
SignalStateColorsare used as graphics (glyph/border/tint) verified ≥ 3:1, with the pill label inonSurface≥ 4.5:1;SignalCodeColors.stderris verified ≥ 4.5:1 on the console well; both brightnesses. A machine-checkable contrast test underapp/test/theme/(author: app-test-author) is the regression guard — prose ratios are not. Status is never color-only (glyph + text) and never text-only (glyph + color). - Color-vision deficiency: state is distinguished by distinct glyph + text label, not hue — robust under greyscale, reduced-motion, and truncation.
- Targets (FR-UI-6): interactive controls ≥ 48×48 dp (file rows included, §7.10).
- Large font: no clipping to
textScaleFactor 2.0— toolbar/console are min-height (grow, not clip), empty states scroll, cosmetic chrome text clamps ≤ 1.3. This is the explicit, testable bound (replacing the un-anchored "max textScaleFactor" claim). - Reduced motion: the pulse holds a static frame under
MediaQuery.disableAnimations(§6) — mandatory, and what keeps thepumpAndSettlesuite green. - High contrast (now implemented, not a phantom gate):
MaterialAppwireshighContrastTheme/highContrastDarkTheme=PybleTheme.highContrastLight/.highContrastDark(seed atcontrastLevel: 1.0+ the strongerSignalStateColorsvariants + the always-on pill border). The pill readsMediaQuery.highContrastOfimplicitly via the active theme. - Parity (NFR-COMPAT-1) — dark is gated at M1 (folded from review): the golden matrix is light AND dark across all 5 surfaces (not light-only); one dark golden per form factor minimum. Goldens assert layout parity only —
flutter_test's box font makes them deterministic but they do not prove glyph/metric parity; real iOS↔Android mono/sans rendering (incl.Menlovs Androidmonospace) is covered by the HIL/manual visual check, plus a code-glyph golden (indentation + adef/:line) on both platforms if glyph parity must be gated. High-contrast goldens may be added; not required at M1. - Offline-first: default sans + platform monospace only (
Menlo/monospace); nogoogle_fonts, no runtime font fetch, no new dependency. - No-leak / seam: the theme layer references only Pyble-owned names and binds to nothing under
lib/ble; it may depend on the neutralConnectionseam types (ConnState,ConsoleStream) fromlib/pble(CON-8, FR-UI-7).