Report-only findings for a future cleanup pass; no code changed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
15 KiB
Design audit — wake-alarm
Generated against safe-design-rules (anthonyhobday.com/sideprojects/saferules). Report only — nothing in this repo was changed by the audit itself.
This repo has two distinct UI surfaces, audited separately below:
- Flutter app —
phone_app/lib/(theme inmain.dart, screens inscreens/) - Python/Tkinter alarm dismiss-challenge UI —
wake_alarm/_alarm.py
Passing/violation/N-A counts per surface are given at the top of each section so all 28 rules are accounted for even though only violations get expanded.
Flutter app (phone_app/)
Theme entry point: phone_app/lib/main.dart:17-21 —
ThemeData.dark(useMaterial3: true).copyWith(colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange, brightness: Brightness.dark)).
Material 3's fromSeed auto-generates near-black surfaces, tinted neutrals,
and contrast-checked roles, so rules 1/2/3/9/10/18 pass "for free" via the
theme — the actual findings are places where code reaches for a literal
Colors.* constant instead of a role from that scheme, or a shadow/padding
value the theme doesn't control.
Verdict roster: Pass — 1,2,3,8,10,17,18,23,26. Violation — 4,9(partial, see notes),11,20,26(Card),28. N/A — 5,6,7,12,13,14,15,16,19,21,22,24,25,27. (9 and 26 appear in both lists — see the per-rule notes.)
Violations
- Rule 4 (everything deliberate) —
phone_app/lib/screens/home_screen.dart:198-199andsettings_screen.dart:126-128,161,245use rawColors.redAccent/Colors.greenAccent/Colors.redfor status/error text instead ofTheme.of(context).colorScheme.error(or atertiary/success role);home_screen.dart:207uses rawColors.orangeandsettings_screen.dart:138uses rawColors.greyfor the same reason. None of these are derived from the seededColorScheme, so they won't stay in sync if the seed color ever changes, and they bypass whatever contrast guaranteefromSeedgives the rest of the app. → Replace withcolorScheme.error/ a defined success color /colorScheme.onSurfaceVariant. - Rule 9 (distinct brightness / no competing colors) —
home_screen.dart:198-199vssettings_screen.dart:126-128,161:Colors.redAccent/Colors.greenAccentare reused ad hoc as the only "status" signal color pair across two separate screens with no shared definition — not a brightness clash per se, but an undeliberate ad-hoc pairing (same root cause as rule 4). - Rule 11 (mathematically related measurements) — the app mostly uses an
8px scale (
home_screen.dart:149,154,165,170,191,203= 32/16/8/32/24/16;settings_screen.dart:93,98,121,131= 16/16/16/32) but breaks it twice:settings_screen.dart:111(padding: EdgeInsets.symmetric(vertical: 14)) andsettings_screen.dart:163(SizedBox(width: 12)) — 14 and 12 aren't on the 8px grid the rest of the file uses. → change to 16/8 or otherwise round to the established scale. - Rule 20 (body text ≥16px) —
settings_screen.dart:138:TextStyle(fontSize: 12, color: Colors.grey)for the PC-config help text is below the 16px minimum.home_screen.dart:153(fontSize: 16) and otherfontSize: 18usages are fine. - Rule 26 (no shadows in dark interfaces) —
settings_screen.dart:154(return Card(...)) is an unmodified Material 3Card, which renders with default elevation (a soft shadow/tonal overlay) inside aThemeData.dark()app (main.dart:17). Per the rule, dark UIs should avoid shadows entirely. → Either setelevation: 0and rely on a filled/tonal surface color for the card, or accept the default and drop this from future review — flagging now since it's the only place shadows appear at all (see rule 27 below). - Rule 28 (lower icon contrast next to text) —
settings_screen.dart:159-162(Icon(connected ? Icons.check_circle : Icons.cancel, color: connected ? Colors.greenAccent : Colors.redAccent)) sits directly next toText(...)at full theme-text contrast — the icon isn't dimmed relative to the adjacent label. Same issue for the settings-gearIconButtonathome_screen.dart:133-134(defaultIcons.settingscolor, undimmed, next to the "Wake Alarm Sync" AppBar title).
Not applicable
- Rule 5 (optical alignment) — only stock Material icons/text used, no custom shapes needing eyeballed alignment.
- Rule 6 (letter-spacing/line-height by size) — no explicit
letterSpacing/heightoverrides anywhere inphone_app/lib/(grep confirmed); relies on Material defaults, nothing to audit. - Rule 7 (border contrast) — no
Border/BorderRadius/border:usage found anywhere underphone_app/lib/(grep confirmed); no borders exist. - Rule 12 (order by visual weight) — single linear
Columnper screen, no competing-weight layout to order. - Rule 13 (12-column grid) — single-column mobile screens, no grid layout.
- Rule 14 (space between high-contrast points) — can't be assessed from
source; spacing is via
SizedBox/Paddingbounding boxes, not measured edge-to-edge. - Rule 15 (closer = lighter) — the one elevated element (
Card,settings_screen.dart:154) follows M3's automatic surface-tint elevation overlay, which already lightens on elevation — no manual z-stack to check. - Rule 16 (shadow blur = 2x distance) — shadow is Material's default (see
rule 26), not a custom
BoxShadow, so there's no explicit offset/blur pair to check a ratio on. - Rule 19 (outer padding ≥ inner) —
settings_screen.dart:93outer body padding (16) vssettings_screen.dart:156_StatusCard's own inner padding (16): equal, satisfies "≥".home_screen.dart:149outer padding (32) vs the button's inner vertical padding (16,home_screen.dart:186): outer > inner. Both pass, no violation to report. - Rule 21 (line length ~70 chars) — only short status strings/labels; the one
paragraph (
settings_screen.dart:132-137) is already manually broken into short lines, no long unbroken runs to measure. - Rule 22 (button padding horizontal = 2x vertical) —
home_screen.dart:185-188andsettings_screen.dart:110-113only overrideverticalpadding (16 and 14) and leave horizontal at theElevatedButtontheme default — the actual rendered ratio can't be read from source alone. - Rule 24 (nest corners) — no custom
BorderRadiusanywhere (grep confirmed), only default Material shapes. - Rule 25 (adjacent hard divides) — only one
Dividerin the whole app (settings_screen.dart:131), not adjacent to another border/transition. - Rule 27 (don't mix depth techniques) — only one depth technique appears at
all (the
Card's default elevation, rule 26) — nothing to mix.
Notes
- No dedicated
theme.dart— theColorScheme/ThemeDatais inline inmain.dart:17-21. That's fine at this size (2 screens), but every rawColors.*literal found above is a spot that would have been caught by a lint/review pass if a shared palette module existed to import from instead.
Python/Tkinter alarm UI (wake_alarm/_alarm.py)
No shared style/constants module exists for color or font — confirmed via
find (no theme/style/colors module under wake_alarm/) and via
_constants.py, which defines timing/day constants but nothing visual.
Every tk.Label/tk.Frame/tk.Entry call in _alarm.py repeats its own
hex-color and font=(...) tuple literal inline (confirmed: _alarm.py:166, 172-174,181-183,190-192,200-206,209-211,219-224,228-232,326-331,409). This
structural absence is itself the rule 4 finding — it also means the same
rule (e.g. rule 2, rule 10) is violated independently at every call site
rather than in one place, so a future fix should introduce a small
_style.py/constants block rather than patch each tk.Label(...) call.
Verdict roster: Pass — 3,8,17,20,21,23,26,27. Violation — 1(partial), 2,4,9(mixed reuse—see below),10,11,12. N/A — 5,6,7,13,14,15,16,18,19,22,24,25,28.
Violations
- Rule 1 (near-black/near-white, not pure) —
_alarm.py:166(bg="#1a1a1a") correctly uses near-black, not pure black — that part passes. But_alarm.py:191-192(info_label,fg="white") uses pure white text, which the rule explicitly calls out to avoid. → use an off-white like#e8e8e8/#f0f0eeinstead of the literal"white". - Rule 2 (saturate your neutrals) — the only "neutral" grey values used
are perfectly desaturated (zero-chroma) greys:
_alarm.py:232(timer_label,fg="#aaaaaa") and_alarm.py:305(hidden-code flash state,fg="#555555"). Neither has any color tint, so they read as generic gray rather than part of the warm/cool palette the rest of the screen uses (see rule 10). → tint both toward the same hue family as the accent colors (e.g. a warm dark gray like#4a3f3f/#8a7d7d). - Rule 4 (everything deliberate / centralize styling) — see the surface
intro above; every color/font is a repeated inline literal across
_alarm.py:166-234and_alarm.py:326-331, with no single source of truth. → extract a small_COLORS/_FONTSmapping (mirroring the existing_constants.pypattern for non-visual constants) and reference it from every widget call. - Rule 9 (distinct brightness / deliberate reuse) —
title_label(_alarm.py:173,fg="#ff4444") andstatus_label(_alarm.py:223,fg="#ff4444") reuse the exact same color for two different semantic roles ("WAKE UP!" heading vs. a transient error/status message) with no evidence it was a deliberate choice rather than copy-paste — flagging alongside rule 4 since fixing the missing style module would force this decision to be made explicitly. - Rule 10 (warm OR cool, not both) — the screen simultaneously uses warm
reds/ambers (
_alarm.py:173#ff4444,:182#ffaa00,:409#ff0000flash) and a fully saturated cool green (_alarm.py:204,263,274, 347#00ff00) on the same screen at the same time. → pick one temperature family for the "safe/positive" accent (e.g. a warm amber-green or switch the alert red to a cooler red) so the palette reads as one coherent set rather than a stoplight. - Rule 11 (mathematically related measurements) —
padyvalues across the widget stack are20, 5, 10, 30, 10, 10, 5(_alarm.py:176, 185, 194, 207, 215, 226, 235) — mostly multiples of 5/10, butpady=5at lines 185 and 235 breaks the 10/20/30 pattern used by the surrounding widgets. → round the twopady=5calls to10for a consistent scale. - Rule 12 (order by visual weight) — the largest, boldest element on
screen is
code_labelat up to 72pt bold (_alarm.py:198-206,code_font_size = 48 if ... else 72), sitting in the visual middle of the stack, while the actual title (_alarm.py:172, 48pt bold) anchors the top edge and the lightest elements (timer_label, 14pt,_alarm.py:231) sit at the bottom edge. The rule wants the heaviest element toward an outer edge, not buried in the middle. → this is likely intentional here (the code you must type back is the whole point of the screen), so treat as a judgment-call violation to confirm rather than an unambiguous bug.
Not applicable
- Rule 5 (optical alignment) — only text
Labels stacked via.pack(), no asymmetric shapes/icons to align by eye. - Rule 6 (letter-spacing/line-height by size) — Tkinter's
font=(family, size, weight)tuple has no letter-spacing/line-height control at all; not expressible in this toolkit. - Rule 7 (border contrast) — no
relief/highlightthickness/borderwidthset on any widget (grep confirmed); nothing has a visible border. - Rule 13 (12-column grid) — single fullscreen widget centered via
.place(relx=0.5, rely=0.5)(_alarm.py:167), no grid layout in use. - Rule 14 (space between high-contrast points) —
pack(pady=...)controls bounding-box spacing, not measured edge-to-edge visual contrast; not determinable from source. - Rule 15 (closer = lighter) — Tkinter has no z-stack/elevation concept; this is a flat 2D layout on one background plane.
- Rule 16 (shadow blur = 2x distance) — no shadow effects implemented anywhere in the file.
- Rule 18 (container brightness limits) — only one background plane exists
(
#1a1a1a, toggled with a full-screen alert flash to#ff0000at_alarm.py:409— not a stacked container), so there's no second surface brightness to compare it against. - Rule 19 (outer padding ≥ inner) —
container(_alarm.py:166) has no configured padding of its own; only individual children setpady. No outer/inner pair exists to compare. - Rule 22 (button padding) — no
tk.Buttonwidget exists anywhere in the file (grep confirmed) — dismissal is viatk.Entry+<Return>bind (_alarm.py:209-217), not a button. - Rule 24 (nest corners) — Tkinter widgets are square by default with no corner-radius concept; not implemented here.
- Rule 25 (adjacent hard divides) — no border/divider lines used anywhere.
- Rule 28 (icon contrast next to text) — no icons used anywhere in this UI; text-only.
Passes (brief)
- Rule 3 (high contrast for important elements) —
_alarm.py:172-174"WAKE UP!" title in#ff4444on#1a1a1abackground is clearly the highest-contrast element on screen. - Rule 8 (align with something) —
container.place(relx=0.5, rely=0.5, anchor="center")(_alarm.py:167) plus uniform.pack()centering means every widget shares the same center-alignment axis. - Rule 17 (simple on complex) — flat solid
#1a1a1abackground behind flat text (_alarm.py:166) is plain-on-plain throughout; no visual noise. - Rule 20 (body text ≥16px) — smallest font used is 14pt
(
_alarm.py:231,timer_label) ≈ 18-19px at typical DPI, above the 16px floor; all other labels are 18pt+ (_alarm.py:190,222) or larger. - Rule 21 (line length ~70 chars) — only short single-line status strings
are used (longest is ~55 characters,
_alarm.py:372); no wrapped paragraph text to overflow the readable range. - Rule 23 (two typefaces max) — exactly two families used throughout,
"Arial"and"Courier"(_alarm.py:172,181,190,203,211,222,231,329) — at the limit, not over it. - Rule 26 (no shadows in dark interfaces) — trivially satisfied: no shadow effects are implemented anywhere in this file.
- Rule 27 (don't mix depth techniques) — trivially satisfied: zero depth techniques are used, so none can conflict.
Notes
- The most impactful single fix for this surface is structural, not any one
color/font value: introduce one
_style.py(or a_COLORS/_FONTSdict in_constants.py) and reference it from everytk.Label/tk.Framecall in_alarm.py. That alone would make rules 2, 9, 10, and 11 fixable in one place instead of ~15 separate call sites.