Phase 22.1 — Packing the Blueprint
05 JUL AT 08:37 PM

Phase 22.1 — Packing the Blueprint

0 LOVES 0 VIEWS
Port the g-* token foundation.

UTS Phase 22.1 — Flutter token foundation (port the g-* system)

The load-bearing sub-phase — Flutter's "web Phase 1." Every later sub-phase (22.2–22.6) consumes these tokens; if the foundation is wrong, every surface reworks. Gated on nothing. Additive — the new g-* token surface lands alongside the existing DesignTokens.guide* consts, which stay until each consuming widget migrates (deleted at 22.6). So flutter analyze stays green and zero widgets change in 22.1.

Provenance (why the mapping is authoritative, not invented): the existing Flutter guide tokens were themselves ported from the pre-UTS web — design_tokens.dart:522 / :814 literally read "from kon_guide.css". So this sub-phase re-applies the web's own kon_guide.css → kon_guide_v2.css migration (Phases 1–11, already shipped + live in prod) to the Flutter mirror. The old Flutter const = the old web rule; the new g-* const = the new web rule; the delta is the one the web already proved.

Method (no guessing — per the program rule): the web g-* system is my own shipped work; this sub-phase ports it, it does not re-derive it. Every value below is read from a source, with file:line: - Valuesstatic/css/kon_guide_v2.css (the live, sole guide stylesheet — kon_guide.css is retired). - Mapping target + app conventionsflutter/gokonversed/lib/config/design_tokens.dart (old consts), theme.dart, site_config.dart, features/posts/ui/organisms/post_header_zone.dart (the on-brand pattern). - The spec design-system-spec.md §1–§4 is the portable summary of the CSS; where they differ, the CSS wins.

Program: Under the Tuscan Sun → Phase 22. Owner: Flutter (with Travis).

Goal

Port the web's :root g-* design-system tokens (kon_guide_v2.css:15-40) into a new g* token surface in lib/config/design_tokens.dart, wiring the three brand-derived tokens through the conventions the production app already ships (no ThemeExtension, no new architecture). This is the tokens half of web Phase 1 (color semantics §2, two-tier typography §3, spacing/shape §4); the per-component exact values (§5A–F) are applied per surface in 22.2–22.6, read from kon_guide_v2.css at that point.

Scope

In: - The 17 :root g-* tokens (kon_guide_v2.css:15-40) → new DesignTokens members. - The brand-derived wiring: --g-brand (colorScheme.primary) and --g-brand-ink (lerp) — the 2 that can't be plain consts. (--g-on-brand is static Colors.white in prod — never overridden on the web; see the ledger.) - The two-tier typography weights (§3) and the shape tokens (radius/thumb/disc + the gallery tile size). - Delete the 1 dead white-label-leak const (guideLightboxCloseHoverColor, grep-verified 0 readers). (The 2nd leak, guideMaterialQuantityColor, has a live reader → its repoint to gInk defers to 22.3; see below.)

Out (→ later sub-phases / unchanged): - Per-component pixel values — material row padding, step title size, etc. (§5A–F) → applied in 22.2–22.6 from kon_guide_v2.css when each widget is restyled. - No widget edits in 22.1 (additive); no font work (BasicGothicPro already bundled, see Typography); no SiteConfig field add (D22.5: band = site brand + white, no per-post field needed); no ThemeExtension (D22.4). - Deleting the old DesignTokens.guide* consts — that's the 22.6 cleanup (the "kon_guide.css retirement" analog).

The token port — kon_guide_v2.css:15-40 → Flutter (the spine)

Add a // --- UTS g-* design system (port of kon_guide_v2.css :root) --- block to DesignTokens. Fixed values = static const; the 3 brand-derived = static helpers / the post pattern (below).

web token (kon_guide_v2.css) value Flutter member
--g-brand :17 var(--kon-brand-color, #cf0015) (not a const) Theme.of(context).colorScheme.primary — the per-site brand (= SiteConfig.primaryColorValue 0xFFCF0015). Read at the widget, as the app already does (43× primaryColor / 42× colorScheme.primary).
--g-on-brand :24 #fffnever overridden in prod (the light-brand override is documented in the CSS comment :18-23 but not wired: base.gohtml:27 injects only --kon-brand-color) (not a const) Colors.white on every brand fill (band/disc/submit). Per-post header_text_color is the web's intended-but-unwired light-brand override → mirror only if/when the web wires it (D22.5). See Dynamic vs static.
--g-brand-ink :25 color-mix(in srgb, brand 55%, --g-ink) (helper) DesignTokens.gBrandInk(brand) = Color.lerp(brand, gInk, 0.45)!color-mix(brand 55%, ink) ≡ 0.45 toward ink (§7 sanctions Color.lerp as the color-mix equivalent).
--g-ink :26 #222 static const Color gInk = Color(0xFF222222);
--g-body :27 #3a3a3a gBody = Color(0xFF3A3A3A)
--g-muted :28 #6b7280 gMuted = Color(0xFF6B7280)
--g-faint :29 #9aa0a6 gFaint = Color(0xFF9AA0A6)
--g-line :30 #eee gLine = Color(0xFFEEEEEE)
--g-line2 :31 #e2e2e2 gLine2 = Color(0xFFE2E2E2)
--g-tint :32 #f7f7f8 gTint = Color(0xFFF7F7F8)
--g-ok :33 #198754 gOk = Color(0xFF198754)
--g-bad :34 #dc3545 gBad = Color(0xFFDC3545)
--g-warn-bg :35 #fff8e1 gWarnBg = Color(0xFFFFF8E1) (tip)
--g-warn-bd :35 #f0c000 gWarnBd = Color(0xFFF0C000)
--g-warn-ink :35 #7a5b00 gWarnInk = Color(0xFF7A5B00)
--g-font :37 'BasicGothicWebPro',… SiteConfig.fontFamily ('BasicGothicPro' — already the app default; no token needed)
--g-radius :39 6px gRadius = 6.0
--g-thumb :39 48px gThumb = 48.0
--g-disc :39 30px gDisc = 30.0
--g-shadow :40 0 10px 40px rgba(0,0,0,.18) gShadow = BoxShadow(offset: Offset(0,10), blurRadius: 40, color: Color(0x2E000000))overlay (lightbox) ONLY

One gallery-tile size lives in §5A not :root: .g-tile / .g-strip img = 64px (kon_guide_v2.css §5A — the strip tile, distinct from the 48px row thumb). Add gTile = 64.0 here since the gallery (22.2) reuses it.

Brand-derived wiring (D22.4 / D22.5 realized) — the only non-const tokens

The web defines these once (var(--g-brand-ink), --g-on-brand, var(--g-brand)) and references them everywhere. The faithful Flutter port keeps "define once, reference everywhere" using the app's existing conventions — not a ThemeExtension:

  1. --g-brand → read Theme.of(context).colorScheme.primary at each widget (the app's pervasive idiom). No token.
  2. --g-on-brandColors.white on every brand fill (band, disc, submit). Verified against source: the web injects only --kon-brand-color per site (base.gohtml:27); --g-on-brand is never overridden, so it resolves to its :root default #fff for every site in prod. The masthead band is therefore: dart final brand = Theme.of(context).colorScheme.primary; // == --g-brand (the per-site brand) final bandBg = brand; // .g-band background:var(--g-brand) — the SITE brand, NOT a per-post color final onBrand = Colors.white; // .g-band color:var(--g-on-brand) — #fff default, never overridden Not the per-post header_background_color/header_text_color: the guide band uses the SITE brand + white, distinct from the post-detail header (which post_header_zone colors per-post). The CSS comment (:18-23) documents a future light-brand override mapping --g-on-brandheader_text_color; it is not wired on the web, so for parity Flutter uses white and mirrors that override only if/when the web does. (The post-header ?? Colors.white pattern stays the reference for how the app expresses on-brand-with-white-fallback — disc text / submit label already do this, e.g. guideLightboxStepNumberCompletedColor = #FFFFFF.)
  3. --g-brand-inkColor.lerp(brand, DesignTokens.gInk, 0.45) — the exact color-mix(brand 55%, ink) equivalent (§7). Provide a 1-line helper so the derivation is identical everywhere (mirrors the single var(--g-brand-ink) definition): dart static Color gBrandInk(Color brand) => Color.lerp(brand, gInk, 0.45)!;
  4. Brand tints (e.g. selected-option bg color-mix(in srgb, brand 6%, #fff), §5D) → the exact equivalent is Color.lerp(Colors.white, brand, pct). A 1-line helper mirrors the web's color-mix: dart static Color gBrandTint(Color brand, double pct) => Color.lerp(Colors.white, brand, pct)!; (Decision 2026-06-28 — operator: "follow the kontent web way." So Color.lerp — the exact, opaque port of color-mixnot the app's looser brandColor.withValues(alpha: pct) idiom (which only matches over a white backdrop). This takes the app from 0 → 2 Color.lerp uses, both confined to gBrandInk/gBrandTint as the literal port of the web's two color-mix tokens, §7-sanctioned. A deliberate source-faithful port, not a broad new mechanism — the anti-rogue tripwire still holds.)

gBrandInk / gBrandTint are static methods on DesignTokens — a minimal extension of the existing all-const class, not a new pattern; they are the literal port of the web's two color-mix tokens.

Dynamic vs static — the brand-dependent ledger (verified 2026-06-28)

"Verify what needs to be dynamic will be dynamic." The web is unambiguous: base.gohtml:25-28 injects exactly one per-site value — --kon-brand-color (from SiteBranding.HexColor, fallback #cf0015). Every brand- dependent token in the guide derives from that single value; nothing else is overridden per site. So the Flutter split is mechanical:

g-* token(s) web source per-site? Flutter
--g-brand (18 uses) var(--kon-brand-color) :17 DYNAMIC colorScheme.primary (= SiteConfig.primaryColorValue)
--g-brand-ink (16 uses) color-mix(brand 55%, ink) :25 DYNAMIC (off brand) gBrandInk(colorScheme.primary)
brand tints — badge 12% / opt-selected 6%×2 / media-ic 10% color-mix(brand N%, #fff) §5A/D DYNAMIC (off brand) gBrandTint(colorScheme.primary, pct)
--g-on-brand (8 uses: band/disc/submit text) #fff, never overridden (base.gohtml injects no override; comment :18-23 = intended-but-unwired light-brand mapping) static #fff in prod Colors.white (mirror header_text_color override only if/when the web wires it)
neutrals ink/body/muted/faint/line/line2/tint, semantics ok/bad, warn trio, --g-bad reset-modal color-mixes, font, radius/thumb/disc, shadow fixed :root / §5C STATIC (identical every site) plain static const

The one correction this verification forced: the guide band is the per-site brand (--g-brand) + white (--g-on-brand), not the post-detail header's per-post header_background_color/header_text_color. Those are a different surface (the post header zone). My earlier D22.5 conflated them; the source (.g-band{ background:var(--g-brand); color:var(--g-on-brand) }) settles it.

Why this is safe for white-label: the only knob a rebrand turns is colorScheme.primary; brand fills, brand- ink text, and brand tints all re-derive from it, and on-brand text is white (legible on both prod brands, which are dark). The latent light-brand gap is identical to the web's — so Flutter is at parity, not ahead or behind.

Web runtime vs Flutter build-time — same white-label, different mechanism (don't conflate "dynamic"). On the web the brand is resolved per request at runtime — one multi-tenant server, base.gohtml:27 injects --kon-brand-color from the DB (SiteBranding.HexColor) per site; the same binary renders every brand live. On Flutter the brand is resolved at build time: SiteConfig.primaryColorValue is a static const (site_config.dart:46) that the rebranding script sets per site (site_config.dart:1-2: "the single source of truth for all white-label replaceable values… the rebranding script targets this file to produce per-site app builds") → theme.dart:9 feeds it to ColorScheme.fromSeed(primary:). Each branded app is a standalone build with its brand baked in — no runtime per-site switch. This does NOT relax the token rule: the g- brand tokens must still read the value from the theme (colorScheme.primary / the helpers), never hardcode the literal — the rebrand only edits site_config.dart, so a token that bakes Color(0xFFCF0015) is unreachable by it (that is precisely the 2 white-label leaks this sub-phase deletes). So "DYNAMIC" in the table above means theme-derived so the single rebranding target controls it* — which then resolves to a per-build constant in any one app. The brand-derived tokens stay non-const (theme reads + gBrandInk/gBrandTint) for exactly this reason.

The 2 white-label leaks (1 deleted now, 1 deferred to 22.3 — grep-verified readers)

The web routes everything through --g-brand; two existing Flutter consts hardcode a literal, violating the white-label rule. A reader grep decides each one's timing (the load-bearing safety check, run 2026-06-28): - design_tokens.dart:824 guideLightboxCloseHoverColor = Color(0xFFCF0015) (RI brand red) → the web close-hover recolors the icon to --g-brand (kon_guide_v2.css:366 .o-guide-lightbox-close:hover{ color:var(--g-brand); transform:scale(1.06) }icon color, not background). Grep-verified DEAD — 0 readers in lib/delete the const NOW (pure config cleanup, nothing rendered changes); 22.4 wires the real close-hover to colorScheme.primary when it builds the masthead. - design_tokens.dart:606 guideMaterialQuantityColor = Color(0xFF1565C0) (blue) → the web qty is --g-ink (kon_guide_v2.css:86 .g-mat-qty{ color:var(--g-ink); font-weight:600 }) → maps to gInk. Grep-verified it has 1 LIVE reader — material_row.dart:79 (the quantity Text's color:, lines 74-81). So do NOT delete it in 22.1 — that would turn flutter analyze red, and 22.1 is config-only. Defer to 22.3: with the material_row restyle, repoint :79 color: DesignTokens.guideMaterialQuantityColorcolor: DesignTokens.gInk (both static const Color → the const TextStyle stays const) and remove the const then. This leak is the one currently rendering wrong (blue, not ink) — a visible fix that belongs with the material restyle + its smoke, not buried in a no-visual-change token phase.

Typography & shape (§3 / §4)

  • Font: BasicGothicPro is already bundled at weights 200 / 400 / 500 / 700 (pubspec.yaml:190-210) and is the app default (site_config.dart:49). No font work. The web's --g-font (BasicGothicWebPro) is the same typeface, webfont packaging.
  • ⚠️ Known minor delta (record, do not act in 22.1): the spec §3 / kon_guide_v2.css use w600 (step title, quantity); the bundled OTF set has no 600 (200/400/500/700). The existing app already requests FontWeight.w600 (e.g. guideStepTitleWeight), so Flutter already resolves it to the nearest weight — this is current shipped behavior, parity-neutral vs. itself. Adding a true 600 OTF is a font-asset decision out of scope; flag if the heavier rendering reads wrong at 22.3/22.5 smoke.
  • Two-tier weights (§3): display/shell title w200 (large lightbox/hero titles only); section heading w500; body/step text w400; step title / quantity w600; label/eyebrow/group w700. These are FontWeight constants applied per component in 22.2+ (the role→size table is §5A–F, applied then).
  • Shape (§4): gRadius=6, gThumb=48, gDisc=30, gTile=64 (above). Row vertical padding (.55rem material / .85rem step) and other spacing are per-component (§5A/§5B) → 22.3.

Files & wiring

  • Edit: lib/config/design_tokens.dartadd the g-* token block (consts + the 2 helpers) in a new section; delete only the dead guideLightboxCloseHoverColor (:824, 0 readers). Leave guideMaterialQuantityColor (:606, has a live reader → 22.3) and all other guide*/meGuides* consts untouched (additive; removed at 22.3 / 22.6).
  • theme.dart — no change required (brand already flows via colorScheme.fromSeed(seedColor: brand); the helpers take brand as a param, read at the widget). (On-brand is Colors.white everywhere, matching the web.)
  • site_config.dart — no change (D22.5: band = site brand + white; no per-post header field involved).
  • pubspec.yaml — no change (font bundled).
  • No widget edits, no generated-code regen (these are plain consts/methods, not freezed).

Steps

  1. Re-read kon_guide_v2.css:15-40 against this table (line numbers drift — confirm before typing values).
  2. Add the g-* const block + gBrandInk / gBrandTint to DesignTokens.
  3. Delete only the dead guideLightboxCloseHoverColor (:824) — re-confirm grep -rn 'guideLightboxCloseHoverColor' lib returns empty first. Leave guideMaterialQuantityColor (:606) — it has a live reader (material_row.dart:79), removed at 22.3.
  4. flutter analyzemust stay green (config-only: the one deleted const has no readers; the new members add nothing breaking).
  5. Contrast gate (below).

Acceptance / contrast gate (the 22.1 sign-off — mirrors web Phase 1)

  • The new g-* tokens exist; the 17 root values match kon_guide_v2.css:15-40 exactly; the brand-derived tokens resolve via the documented conventions; the dead leak const (:824) is gone (the live material-qty leak repoints at 22.3). flutter analyze clean.
  • Dual-brand contrast check (the web Phase-1 guardrail): render a throwaway swatch screen of the key g- pairs — brand fill + white on-brand text (band/disc/submit), gBrandInk as text on white (links/+−/badges), gOk/gBad fills — under a dark brand (0xFF1C1D20) and a light brand (0xFFE7C84A) (the exact values the web verified, spec §6). Confirm: gBrandInk stays legible on white for the pale brand (the key guardrail — color-mix 55% toward ink is what makes this hold); the fixed semantics (ok/bad/tip) are always legible. Expected (web parity, not a bug): white on-brand text on the pale brand fill is low-contrast — this is the web's identical latent light-brand gap (--g-on-brand un-wired). Do not* "fix" it ahead of the web; record it. Both prod brands are dark, so on-brand white is correct in production. (Swatch = scratch, not committed.)
  • Operator sign-off on the swatch under both brands gates 22.2.

Smoke

None on-device for 22.1 (no widget changes). The contrast swatch is the visual check. On-device smoke begins at 22.3 when the first restyled widgets land.

Depends-on

Nothing. (The font + brand plumbing already exist; this is purely additive token definitions.)

Decisions

  • D22.4 (mechanism) — ✅ resolved: follow the app convention (const + colorScheme.primary + the 2 Color.lerp helpers for the web's 2 color-mix tokens). No ThemeExtension.
  • D22.5 (on-brand source) — ✅ resolved, corrected by the dynamic/static verification 2026-06-28: the guide band is --g-brand (site brand = colorScheme.primary) fill + --g-on-brand = Colors.white text. Verified the web never overrides --g-on-brand (base.gohtml:27 injects only --kon-brand-color). The per-post header_text_color/header_background_color belong to the post-detail header, not the guide band; the header_text_color→on-brand mapping is the web's unwired light-brand override — not implemented for parity. The post_header_zone ?? Colors.white idiom remains the reference pattern for on-brand-with-white-fallback.
  • No open decisions.

Flutter CLAUDE.md compliance (self-check — all verified in the shipped 22.1 commit ec30c714)

  • [x] White-label: no hardcoded brand — --g-brand/brand-ink derive from colorScheme.primary; on-brand = Colors.white (web parity); the dead leak const deleted, the live one (material-qty) scheduled for 22.3. Semantic ok/bad/tip are fixed (correct — not brand).
  • [x] Design tokens, not magic numbers: values land as named DesignTokens members, not inline literals.
  • [x] Snake_case JSON — N/A (no model/serialization change).
  • [x] No new architecture — no ThemeExtension, no shared util file; helpers are static methods on the existing DesignTokens class (mirrors the web's single var(--g-*) definitions).
  • [x] Additive — old consts untouched; flutter analyze green; no widget churn.
← Back to Under the Tuscan Sun