Skip to content

Latest commit

 

History

History
431 lines (327 loc) · 41.7 KB

File metadata and controls

431 lines (327 loc) · 41.7 KB

PyBLE App — "Signal" Design System (token reference)

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 Connection seam, ARB strings) remain owned by TDD.md / specs.md.

Token home: app/lib/theme/ (barrel package: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 from lib/app via AppLocalizations, so the locale-parity gate (which scans lib/app) always covers it. Every new .dart file under lib/theme/ opens with the two SPDX header lines.

0. Principles

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

  1. One seed, two brightnesses (+ high contrast). ColorScheme.fromSeed for light and dark; themeMode: ThemeMode.system. High-contrast schemes are the same seed at contrastLevel: 1.0, wired via highContrastTheme/highContrastDarkTheme (§9).
  2. 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 — never Colors.* literals.
  3. Offline-first. Default sans (Roboto) for UI; platform monospace for code. No google_fonts, no network/runtime fonts, no new dependency.
  4. 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).
  5. One home. All tokens live in app/lib/theme/; every surface themes from Theme.of(context) + the SignalStateColors / SignalCodeColors extensions. No per-surface brand/state colors (NFR-MAINT-2, NFR-COMPAT-1).

1. Color

1.1 Seed & scheme generation

  • Brand seed — pyble-blue: #2D5BFF (Color(0xFF2D5BFF)), token kPybleBlue.
  • Both schemes: ColorScheme.fromSeed(seedColor: kPybleBlue, brightness: Brightness.light | .dark, contrastLevel: 0.0), default tonalSpot variant (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 SignalStateColors extension (§1.3), and the single stderr color in SignalCodeColors (§1.4).

1.2 Semantic ColorScheme role usage

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.

1.3 Connection/run STATE colors — SignalStateColors (frozen)

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 (surfaceContainerHighest in the toolbar — not base surface).

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

1.4 Code-surface colors — SignalCodeColors (frozen)

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.


2. Spacing scale

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.

3. Radius scale

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.


4. Elevation

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.


5. Type scale

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 generic monospace. It deliberately does not name monospace first: on iOS/iPadOS the generic can resolve to the default proportional face (breaking indentation/column alignment on a first-class target, NFR-COMPAT-1), whereas Menlo always resolves there. On Android Menlo is absent, so resolution falls through to the real monospace.
  • Offline-first guardrail. The list contains only genuinely-resolvable families (Apple Menlo, Android monospace); RobotoMono/Consolas were removed (not bundled by Flutter, silent no-ops that invite a google_fonts "fix"). The list must not be completed with google_fonts or 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 in app/DEPENDENCIES.md — never google_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.


6. Motion

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.


7. Component specs

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

7.1 App bar / toolbar (top slot, FR-UI-3)

  • Background surfaceContainerHighest (level 4), full width; 1 px outlineVariant bottom 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 large textScaleFactor. (A hard SizedBox(height: 56) around titleMedium/labelMedium content clips at accessibility scales and trips the no-overflow assertion.)
  • Leading: app wordmark (AppLocalizations.appTitle, titleMedium, onSurface, scaler clamped ≤ 1.3) at SignalSpacing.lg inset — brand proper noun, verbatim.
  • Then the connection-status pill (§7.6), SignalSpacing.lg gap.
  • Spacer, then the action group (§7.7) right-aligned, SignalSpacing.sm trailing.
  • 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 labelsbefore 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.

7.2 Navigation rail (landscape, ≥ 900 dp and width > height)

  • Background surfaceContainerHighest (level 4); labelType: all (icon + text always visible — the four labels stay findable, per the widget-test contract).
  • Selection indicator: primaryContainer fill, SignalRadius.stadium; selected icon/label onPrimaryContainer, unselected onSurfaceVariant (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 outlineVariant trailing hairline separating rail from the first pane.

7.3 Navigation bar (stacked, < 900 dp or portrait)

  • M3 NavigationBar at the bottom; background surfaceContainerHighest; safe-area aware.
  • Selection indicator: primaryContainer pill (SignalRadius.stadium) behind the selected icon; selected icon onPrimaryContainer; unselected onSurfaceVariant. labelBehavior: alwaysShow (contract: four visible labels).
  • Portrait keeps the editor primary (FR-UI-2); the bar swaps the active surface via selectedSurfaceProvider.

7.4 Titled pane wrapper (landscape three-pane)

  • A column: title header + hairline + Expanded body.
  • Header: labelLarge in onSurfaceVariant, 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 on surfaceContainerLow (level 1). Panes separated by 1 px VerticalDivider (outlineVariant).
  • Corner treatment: as a card (portrait/phone) → SignalRadius.md; in the flush landscape split → edge-to-edge (dividers, no radius).

7.5 Console strip (bottom slot, landscape, FR-UI-1)

  • Full-width strip, 1 px outlineVariant top hairline. Two tiers: the title bar on surfaceContainer (level 2); the output body on surfaceContainerLowest (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 with textScaleFactor (resizable later by lib/console). The old 96 dp left ≈ 2 lines — a label bar, not a live output area.
  • Title consoleTitle (labelLarge, onSurfaceVariant) top-left at SignalSpacing.lg/SignalSpacing.sm inset.
  • Body reserved for lib/console; content uses SignalType.code with SignalCodeColors stream 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 ConsoleEvent arrives. 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.

7.6 Connection-status pill (FR-UI-6, FR-CONN-5)

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 host surfaceContainerHighest. 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 and disconnected reads as a prompt, not disabled chrome.

  • Per-state glyph map (folded from review — shape carries state independent of color, motion, and text):

    State Glyph
    disconnected Icons.bluetooth_disabled
    connecting Icons.bluetooth_searching
    ready Icons.check_circle (link established, idle)
    running Icons.play_circle (live)

    The glyph is the state color at full strength (≥ 3:1 graphic).

  • Contents (row, SignalSpacing.sm gap, EdgeInsets.symmetric(horizontal: SignalSpacing.md, vertical: SignalSpacing.xs)): the state glyph + the localized state label in colorScheme.onSurface (labelMedium, scaler ≤ 1.3). The label uses onSurface (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/running glyphs pulse (SignalMotion.pulse, opacity 0.55↔1.0) under the reduced-motion gate (§6). Color transitions animate over SignalMotion.standard.

  • Screen reader (folded from review): wrap the pill in a Semantics node with label: 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.

7.7 Toolbar actions (slots; FR-UI-3)

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/tooltip toolbarConnect, icon Icons.bluetooth; connected (ready/running) → label/tooltip toolbarDisconnect, icon Icons.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 in ready; while running it is de-emphasized/disabled (muted or a spinner). Stop is the sole attention accent (error/onError) only while running (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.

7.8 Buttons

  • PrimaryFilledButton (primary/onPrimary), SignalRadius.sm, ≥ 48 dp height, labelLarge.
  • Secondary / tonalFilledButton.tonal (secondaryContainer/onSecondaryContainer).
  • Low-emphasisOutlinedButton (outline border) or TextButton (primary label).
  • DestructiveFilledButton/TextButton in error/onError.
  • IconIconButton, ≥ 48 dp target, ARB tooltip. Disabled = M3 default (38 % onSurface).

7.9 Surface empty-state component (SurfacePlaceholder successor)

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.lg rounded square holding the surface icon (~40–48 dp). Its tint is a SurfaceTone parameter, not fixed (folded from review):

    SurfaceTone Fill / icon For
    action primaryContainer / onPrimaryContainer Connect (the primary entry point).
    informational tertiaryContainer / onTertiaryContainer Pin reference (reads as reference, not action — CON-7).
    neutral surfaceContainerHigh / onSurfaceVariant Editor, Console, Files (quiet, per §8 tone).

    (The old draft hard-coded primaryContainer for 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.md below the badge.

  • Detail: bodyMedium, onSurfaceVariant, centered, SignalSpacing.sm below title (ARB-sourced; may embed a verbatim identifier via SignalType.code, FR-I18N-4).

  • Optional action slot (SignalSpacing.xl below): a primary/tonal button for the surface's main CTA — wired by the feature owner.

7.10 File-list row (Files pane content, FR-FILES, TDD §4.5)

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.md vertical 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.lg inset, per the map:

    Entry Icon Tint
    folder Icons.folder / Icons.folder_open onSurfaceVariant
    .py Icons.description (opt. primary tint — the primary source type) onSurfaceVariant
    data / other eligible Icons.insert_drive_file onSurfaceVariant

    (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 via SignalType.code (FR-I18N-4).

  • Trailing affordance slot.

  • Selected / hover fill: surfaceContainerHigh (level 3) at SignalRadius.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_outline in onSurfaceVariant, row text dimmed to onSurfaceVariant (vs onSurface for editable rows), and edit/delete/rename affordances hidden or disabled (38 % onSurface). This is a UI hint only — the agent still enforces EACCES, surfaced localized.

7.11 Transfer progress / status (FR-FILES-4, NFR-PERF-3, TDD §8.4)

Per-file transfer visual language. Reuses existing tokens — adds none.

  • In progress: a determinate LinearProgressIndicator on the row driven by TransferProgress.sent/total, color colorScheme.primary.
  • Success: shown only after whole-file CRC match at PUT_END/GET_END (never on partial/unverified) — a success affordance in the ready green from SignalStateColors (reuse).
  • Failure: CRC/size mismatch or ENOSPCcolorScheme.error.
  • Busy (single active writer, EBUSY): a queued/busy indicator in the connecting amber from SignalStateColors.

7.12 Text-workbench secondary pane and focused Blocks workspace (FR-UI-1)

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 add rightPanePlots in 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 W be focused content width after the rail and outer/divider chrome. For a selectable generated-Python inspector width P ∈ [360 dp, 420 dp] and divider/gutter G, it appears only if the remaining Blockly width B = W − P − G satisfies both B ≥ 720 dp and B / W ≥ 0.60. Otherwise the inspector is absent and Blockly receives all of W; 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_outlined icon and SurfaceTone.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 uses SignalType.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.

7.13 Files pane chrome (FR-FILES, TDD §4.5 / §8.4)

Frozen patterns; copy authored in the Files feature story.

  • Path / breadcrumb header: current segment as labelLarge with an up/back affordance in the §7.4 trailing slot; fs_root and path segments render verbatim via SignalType.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 uses colorScheme.error (§7.8); selected rows use the §7.10 surfaceContainerHigh selection fill.
  • Listing-truncation banner (more == 1, TDD §8.4): an inline informational banner (not error) — background surfaceContainerHigh, Icons.info_outline + bodySmall in onSurfaceVariant, SignalRadius.sm — visually distinct from the colorScheme.error failure banner.

8. Per-surface empty-state visual treatment

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.


9. Accessibility & parity checklist (gate-facing)

  • Contrast (NFR-A11Y-3): all ColorScheme on-pairs are M3-guaranteed. The SignalStateColors are used as graphics (glyph/border/tint) verified ≥ 3:1, with the pill label in onSurface ≥ 4.5:1; SignalCodeColors.stderr is verified ≥ 4.5:1 on the console well; both brightnesses. A machine-checkable contrast test under app/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 the pumpAndSettle suite green.
  • High contrast (now implemented, not a phantom gate): MaterialApp wires highContrastTheme/highContrastDarkTheme = PybleTheme.highContrastLight/.highContrastDark (seed at contrastLevel: 1.0 + the stronger SignalStateColors variants + the always-on pill border). The pill reads MediaQuery.highContrastOf implicitly 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 onlyflutter_test's box font makes them deterministic but they do not prove glyph/metric parity; real iOS↔Android mono/sans rendering (incl. Menlo vs Android monospace) is covered by the HIL/manual visual check, plus a code-glyph golden (indentation + a def/: 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); no google_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 neutral Connection seam types (ConnState, ConsoleStream) from lib/pble (CON-8, FR-UI-7).