Palette: seven roles (accent, good, caution added; highlight renamed alarm); no hard-coded colours left
/ crosscheck (push) Successful in 0s
CI / Analyze & Test (push) Successful in 13s
CI / Debug APK (push) Successful in 22s

background, foreground, subdued, accent (emphasis without danger), and the state
gradient good → caution → alarm; surface and onFill derived. smart-serow's
four-key themes still load (highlight = alarm; the new roles fall back).
Material's scheme follows: buttons in accent instead of the alarm colour.
Link state, mock banner, signal tiles, raw log, checklist button and marks,
camera page all take palette roles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz
This commit is contained in:
2026-10-14 13:55:52 +09:00
parent 54ea278ca2
commit 3aeb09d61f
11 changed files with 165 additions and 72 deletions
+6 -3
View File
@@ -17,7 +17,7 @@ Laid out like smart-serow's HUD (`pi/ui/lib/`), which several pieces come from.
| Widget | File | Shows | From |
|---|---|---|---|
| `StatBox`, `StatBoxMain` | `widgets/stat_box.dart` | A value, its unit and label, sized from its box's height; highlight on warning, subdued when dim | smart-serow |
| `StatBox`, `StatBoxMain` | `widgets/stat_box.dart` | A value, its unit and label, sized from its box's height; alarm on warning, subdued when dim | smart-serow |
| `Telltale` | `widgets/telltale.dart` | An on/off lamp: a label or an icon, lit or dark, "—" when missing | Yukart |
| `WhiskeyMark` | `widgets/whiskey_mark.dart` | Attitude: a horizon tilting with roll, ladder moving with pitch | smart-serow |
| `AccelGraph` | `widgets/accel_graph.dart` | G-meter: a dot for lateral/longitudinal g, a trace and a peak marker over a window | smart-serow |
@@ -27,8 +27,11 @@ Laid out like smart-serow's HUD (`pi/ui/lib/`), which several pieces come from.
- **The boxed pattern.** A widget takes its share of a row and sizes its type from its own height, so a layout
is a grid of boxes and a value gaining a digit never moves anything. Labels shrink to fit, never wrap.
- **Colours by meaning.** `AppTheme.of(context)` gives the palette's `background`, `foreground`, `highlight` and
`subdued`; no widget names a colour. Themes are smart-serow's JSON (`assets/themes/`), `rei` by default.
- **Colours by meaning.** `AppTheme.of(context)` gives seven roles: `background`, `foreground`,
`subdued` (muted, missing, stale), `accent` (emphasis without danger: buttons, links, the current item),
and the state gradient `good` → `caution` → `alarm`; plus the derived `surface` (raised bars) and
`onFill(colour)` (text on a fill). No widget names a colour. Themes are JSON in `assets/themes/`, `rei` by
default; smart-serow's four-key files still load (their `highlight` is `alarm`).
- **Cosmetic fonts are opt-in.** A widget that wants the HUD face names `fontFamily: hudFont` in its own
`TextStyle`; everything else keeps the basic font, which covers CJK where DIN 1451 does not.
- **Missing is a state** (app guideline 2): a widget given no value draws "—" or subdued, never an error.
+4 -2
View File
@@ -10,6 +10,8 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'theme/palette.dart';
const camviewPort = 7710;
class CamView extends ChangeNotifier {
@@ -165,7 +167,7 @@ class _CamViewPageState extends State<CamViewPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
backgroundColor: AppTheme.of(context).background,
appBar: AppBar(title: const Text('Camera (test)')),
body: SafeArea(
child: ListenableBuilder(
@@ -182,7 +184,7 @@ class _CamViewPageState extends State<CamViewPage> {
cam.available == false
? 'Camera unavailable on the Orin'
: cam.status,
style: const TextStyle(color: Colors.white70),
style: TextStyle(color: AppTheme.of(context).subdued),
),
)
: Image.memory(
+12 -8
View File
@@ -18,6 +18,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'boundary.dart';
import 'theme/palette.dart';
const checklistAssets = 'assets/contracts/checklists/';
@@ -309,14 +310,17 @@ class _ChecklistPageState extends State<ChecklistPage> {
Widget _panel(BuildContext context) {
final s = run.current;
final theme = Theme.of(context);
final p = AppTheme.of(context);
final a = run.currentAction;
// The big button by what pressing it does: start (good), stop a running span
// (caution: something is in progress), a point mark (accent), skip on (subdued).
final (Color color, IconData icon) = run.stepDone
? (Colors.blueGrey, Icons.skip_next)
? (p.subdued, Icons.skip_next)
: a!.point
? (Colors.blue, Icons.flag)
? (p.accent, Icons.flag)
: run.inSpan
? (Colors.red.shade700, Icons.stop)
: (Colors.green.shade700, Icons.play_arrow);
? (p.caution, Icons.stop)
: (p.good, Icons.play_arrow);
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
@@ -339,7 +343,7 @@ class _ChecklistPageState extends State<ChecklistPage> {
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: color,
foregroundColor: Colors.white,
foregroundColor: p.onFill(color),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
onPressed: run.finished ? null : () => _send(run.press()),
@@ -394,7 +398,7 @@ class _ChecklistPageState extends State<ChecklistPage> {
style: TextStyle(
fontSize: 32,
fontFeatures: const [FontFeature.tabularFigures()],
color: reached ? Colors.lightGreenAccent : Colors.white,
color: reached ? AppTheme.of(context).good : AppTheme.of(context).foreground,
),
);
}
@@ -409,9 +413,9 @@ class _ChecklistPageState extends State<ChecklistPage> {
final pending = b.pending.length;
final ack = b.acks.where((x) => x.id == last.id).firstOrNull;
final (String status, Color? color) = ack != null
? (ack.ok ? ('✓', null) : ('✗ ${ack.detail}', Colors.redAccent))
? (ack.ok ? ('✓', AppTheme.of(context).good) : ('✗ ${ack.detail}', AppTheme.of(context).alarm))
: b.pending.any((p) => p.id == last.id)
? ('… unacked', Colors.orange)
? ('… unacked', AppTheme.of(context).caution)
: ('?', null);
return Text(
'"${last.text}" $status${pending > 1 ? ' · $pending waiting' : ''}',
+4 -4
View File
@@ -49,13 +49,13 @@ class DashPage extends StatelessWidget {
children: [
if (b.mock)
Container(
color: p.highlight,
color: p.caution,
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
'MOCK DATA — NOT FROM THE CAR',
textAlign: TextAlign.center,
style: TextStyle(
color: p.background,
color: p.onFill(p.caution),
fontWeight: FontWeight.w900,
letterSpacing: 2,
),
@@ -186,8 +186,8 @@ class _DashBar extends StatelessWidget {
final p = AppTheme.of(context);
final (text, color) = switch (boundary.state) {
LinkState.up => ('LINK', p.subdued),
LinkState.connecting => ('CONNECTING', p.highlight),
LinkState.down => ('NO LINK', p.highlight),
LinkState.connecting => ('CONNECTING', p.caution),
LinkState.down => ('NO LINK', p.alarm),
};
return SizedBox(
height: 40,
+30 -17
View File
@@ -169,14 +169,15 @@ class MockBanner extends StatelessWidget {
@override
Widget build(BuildContext context) {
final p = AppTheme.of(context);
return Container(
color: Colors.amber.shade700,
color: p.caution,
padding: const EdgeInsets.symmetric(vertical: 6),
child: const Text(
child: Text(
'MOCK DATA — NOT FROM THE CAR',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.black,
color: p.onFill(p.caution),
fontWeight: FontWeight.w900,
fontSize: 20,
letterSpacing: 2,
@@ -200,10 +201,11 @@ class LinkBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final b = boundary;
final p = AppTheme.of(context);
final (color, label) = switch (b.state) {
LinkState.up => (Colors.green, 'CONNECTED'),
LinkState.connecting => (Colors.orange, 'CONNECTING'),
LinkState.down => (Colors.red, 'DOWN'),
LinkState.up => (p.good, 'CONNECTED'),
LinkState.connecting => (p.caution, 'CONNECTING'),
LinkState.down => (p.alarm, 'DOWN'),
};
// Fixed-width numbers, padded with figure spaces (U+2007, digit-wide): the bar must
// not reflow every time a value gains a digit.
@@ -230,7 +232,10 @@ class LinkBar extends StatelessWidget {
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Chip(
label: Text(label),
label: Text(
label,
style: TextStyle(color: color, fontWeight: FontWeight.w600),
),
backgroundColor: color.withValues(alpha: 0.3),
),
Text(b.url, style: const TextStyle(fontFamily: 'monospace')),
@@ -317,7 +322,10 @@ Future<void> orinPowerOptions(BuildContext context, Boundary b) async {
label: const Text('Reboot'),
),
FilledButton.icon(
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
style: FilledButton.styleFrom(
backgroundColor: AppTheme.of(context).alarm,
foregroundColor: AppTheme.of(context).onFill(AppTheme.of(context).alarm),
),
onPressed: () => Navigator.of(context).pop('poweroff'),
icon: const Icon(Icons.power_settings_new),
label: const Text('Power off'),
@@ -403,26 +411,27 @@ class SignalTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final p = AppTheme.of(context);
if (missing) {
return ListTile(
title: Text(
name,
style: const TextStyle(fontFamily: 'monospace', color: Colors.grey),
style: TextStyle(fontFamily: 'monospace', color: p.subdued),
),
subtitle: const Text(
subtitle: Text(
'MISSING · not in the latest state',
style: TextStyle(color: Colors.redAccent),
style: TextStyle(color: p.alarm),
),
trailing: Text.rich(
TextSpan(
children: [
const TextSpan(
TextSpan(
text: '?',
style: TextStyle(fontSize: 34, color: Colors.grey),
style: TextStyle(fontSize: 34, color: p.subdued),
),
TextSpan(
text: ' ${envelope['unit'] ?? ''}',
style: const TextStyle(fontSize: 16, color: Colors.grey),
style: TextStyle(fontSize: 16, color: p.subdued),
),
],
),
@@ -439,12 +448,13 @@ class SignalTile extends StatelessWidget {
fontSize: 34,
fontWeight: FontWeight.w600,
fontFeatures: const [FontFeature.tabularFigures()],
color: stale || v == null ? Colors.grey : null,
color: stale || v == null ? p.subdued : p.foreground,
fontStyle: stale ? FontStyle.italic : null,
);
return ListTile(
title: Text(name, style: const TextStyle(fontFamily: 'monospace')),
subtitle: Text(
style: stale ? TextStyle(color: p.caution) : null,
[
if (stale) quality.toUpperCase(),
if (v == null) 'no value',
@@ -479,11 +489,14 @@ class RawLog extends StatelessWidget {
if (b.pending.isNotEmpty || b.acks.isNotEmpty) ...[
Text('Commands', style: Theme.of(context).textTheme.titleSmall),
for (final p in b.pending)
Text('… ${p.cmd} ${p.args['text'] ?? ''} (unacked)'),
Text(
'… ${p.cmd} ${p.args['text'] ?? ''} (unacked)',
style: TextStyle(color: AppTheme.of(context).caution),
),
for (final a in b.acks)
Text(
'${a.ok ? '✓' : '✗'} ${a.detail}',
style: TextStyle(color: a.ok ? null : Colors.redAccent),
style: TextStyle(color: a.ok ? AppTheme.of(context).good : AppTheme.of(context).alarm),
),
const Divider(),
],
+81 -23
View File
@@ -1,14 +1,22 @@
// The palette system, from smart-serow's HUD: four semantic colours, each with a dark
// (low ambient light) and a bright (high ambient light) variant, read from a theme
// JSON in assets/themes/. Same file format as smart-serow's extra/themes/, so themes
// move between the two as they are. Widgets ask for meanings, never for colours:
// The palette system, from smart-serow's HUD, widened to seven roles: each a meaning,
// each with a dark (low ambient light) and a bright (high ambient light) variant, read
// from a theme JSON in assets/themes/. Widgets ask for meanings, never for colours:
//
// final p = AppTheme.of(context);
// color: p.foreground
//
// The roles: background, foreground, subdued (muted), accent (emphasis without danger:
// buttons, links, the current item), and the good → caution → alarm gradient for state.
// Two more are derived, never authored: surface (raised bars and panels) and onFill
// (text on a filled colour).
//
// smart-serow's four-key files load unchanged: their `highlight` is `alarm`, and the
// three newer roles fall back (accent and good to foreground, caution to subdued).
//
// Pick the theme at build time: `--dart-define=YUKART_THEME=default` (default: rei, smart-serow's).
import 'dart:convert';
import 'dart:math' show max, min;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -24,45 +32,87 @@ class Palette {
const Palette({
required this.background,
required this.foreground,
required this.highlight,
required this.subdued,
required this.accent,
required this.good,
required this.caution,
required this.alarm,
});
/// Behind everything.
final Color background;
/// Values: what is read at a glance.
/// Values and primary text: what is read at a glance.
final Color foreground;
/// Warnings, and the one thing that must be seen.
final Color highlight;
/// Labels, units, grids, and anything missing or stale.
/// Labels, units, grids, secondary text, and anything missing or stale.
final Color subdued;
/// Emphasis without danger: buttons, links, the current item.
final Color accent;
/// Connected, done, healthy.
final Color good;
/// In progress, unconfirmed, mock data.
final Color caution;
/// Down, refused, destructive, over a limit: the one thing that must be seen.
final Color alarm;
/// Raised bars and panels: background with a little foreground mixed in.
Color get surface => Color.lerp(background, foreground, 0.08)!;
/// Text drawn on a filled [fill]: background or foreground, whichever contrasts more.
Color onFill(Color fill) {
double contrast(Color a, Color b) {
final (x, y) = (a.computeLuminance(), b.computeLuminance());
return (max(x, y) + 0.05) / (min(x, y) + 0.05);
}
return contrast(fill, background) >= contrast(fill, foreground)
? background
: foreground;
}
static Palette _fromJson(Map<String, dynamic> j) {
Color c(String k) =>
Color(0xFF000000 | int.parse((j[k] as String).substring(1), radix: 16));
Color? c(String k) => j[k] is String
? Color(
0xFF000000 | int.parse((j[k] as String).substring(1), radix: 16),
)
: null;
final foreground = c('foreground')!;
final subdued = c('subdued')!;
return Palette(
background: c('background'),
foreground: c('foreground'),
highlight: c('highlight'),
subdued: c('subdued'),
background: c('background')!,
foreground: foreground,
subdued: subdued,
accent: c('accent') ?? foreground,
good: c('good') ?? foreground,
caution: c('caution') ?? subdued,
// smart-serow calls it highlight.
alarm: (c('alarm') ?? c('highlight'))!,
);
}
/// smart-serow's hard-coded last resort, used when no theme file loads.
/// The last resort, used when no theme file loads (smart-serow's four, plus three).
static const fallbackDark = Palette(
background: Color(0xFF000000),
foreground: Color(0xFFFFFFFF),
highlight: Color(0xFFFF5555),
subdued: Color(0xFF808080),
accent: Color(0xFF5EC8F2),
good: Color(0xFF6CCB5F),
caution: Color(0xFFFFC107),
alarm: Color(0xFFFF5555),
);
static const fallbackBright = Palette(
background: Color(0xFFF0F0F0),
foreground: Color(0xFF1A1A1A),
highlight: Color(0xFFCC0000),
subdued: Color(0xFF606060),
accent: Color(0xFF0277BD),
good: Color(0xFF2E7D32),
caution: Color(0xFF8D6E00),
alarm: Color(0xFFCC0000),
);
}
@@ -106,7 +156,8 @@ class Themes extends ChangeNotifier {
return Themes('fallback', Palette.fallbackDark, Palette.fallbackBright);
}
/// Material's theme for the rest of the app, from the same four colours.
/// Material's theme for the rest of the app, from the same roles: buttons and
/// selection in accent, errors in alarm, raised surfaces in surface.
ThemeData material() {
final p = current;
final brightness = _isDark ? Brightness.dark : Brightness.light;
@@ -116,13 +167,20 @@ class Themes extends ChangeNotifier {
scaffoldBackgroundColor: p.background,
colorScheme:
ColorScheme.fromSeed(
seedColor: p.highlight,
seedColor: p.accent,
brightness: brightness,
).copyWith(
surface: p.background,
onSurface: p.foreground,
primary: p.highlight,
error: p.highlight,
surfaceContainerHigh: p.surface,
surfaceContainerHighest: p.surface,
primary: p.accent,
onPrimary: p.onFill(p.accent),
primaryContainer: Color.lerp(p.background, p.accent, 0.25),
onPrimaryContainer: p.foreground,
secondary: p.accent,
error: p.alarm,
onError: p.onFill(p.alarm),
outline: p.subdued,
onSurfaceVariant: p.subdued,
),
+2 -1
View File
@@ -50,7 +50,8 @@ class GpsCompass extends StatelessWidget {
final theme = AppTheme.of(context);
// No signal = subdued color, valid = foreground
final iconColour = _hasSignal ? theme.foreground : theme.highlight;
// Heading: foreground; acquiring: caution; no receiver at all: alarm.
final iconColour = _hasSignal ? theme.foreground : (_isAcquiring ? theme.caution : theme.alarm);
// Convert to radians, 0 = no rotation when no signal
final angle = _hasSignal ? (heading! * math.pi / 180.0) : 0.0;
+4 -4
View File
@@ -2,7 +2,7 @@
// every readout is a box that takes its share of a row (`flex`) and sizes its type
// from its own height, so a layout is a grid of boxes, and a value changing digits
// never moves anything. Value in foreground with tabular figures, unit and label in
// subdued; a warning turns the value to highlight.
// subdued; a warning turns the value to alarm.
//
// Added for Yukart: [dim]. A missing or stale value is a normal state in a car
// (app guideline 2), shown in subdued rather than hidden.
@@ -28,7 +28,7 @@ class StatBox extends StatelessWidget {
final String label;
final int flex;
/// If it returns true, the value shows in highlight.
/// If it returns true, the value shows in alarm.
final bool Function()? isWarning;
/// Missing or stale: the value shows in subdued.
@@ -41,7 +41,7 @@ class StatBox extends StatelessWidget {
final valueColor = dim
? theme.subdued
: warning
? theme.highlight
? theme.alarm
: theme.foreground;
return Expanded(
@@ -168,7 +168,7 @@ class StatBoxMain extends StatelessWidget {
color: dim
? theme.subdued
: warning
? theme.highlight
? theme.alarm
: theme.foreground,
height: 1,
),
+2 -2
View File
@@ -7,7 +7,7 @@ import '../reading.dart';
import '../theme/palette.dart';
/// An on/off lamp in the boxed pattern: the label is the lamp. Lit in foreground
/// (highlight when [warn]), dark in subdued; missing shows as a dash.
/// (alarm when [warn]), dark in subdued; missing shows as a dash.
class Telltale extends StatelessWidget {
const Telltale({
super.key,
@@ -32,7 +32,7 @@ class Telltale extends StatelessWidget {
final missing = reading.value == null;
final lit = reading.on && !reading.dim;
final color = lit
? (warn ? p.highlight : p.foreground)
? (warn ? p.alarm : p.foreground)
: p.subdued.withValues(alpha: missing ? 0.35 : 0.6);
return Expanded(
flex: flex,