feat(theme): resolve the shard's palette into the Material scheme (M12 phase 1) #34

Merged
whitlocktech merged 1 commits from feat/m12-phase-1-colors into edge 2026-08-08 09:57:23 +00:00
Member

What & why

M12 phase 1 — colors. Design of record: docs/android/THEMING_AND_NAV.md §5.1. Docs pair: docs#113.

Phase 0 landed the store and deliberately read nothing out of it. This is the first phase that shows: the fifteen themable tokens of GET /public/settings' theme map are parsed into a ShardPalette and applied field by field over the shipped palette. Ten of them have a Material role and go through darkColorScheme; the other five reach screens through a new LocalShardPalette.

The invariant that makes this cheap is now a test rather than a claim: the app's M5 palette is the website's runic-gateway preset, so an instance with no theme_visual row does not resolve to something close to today's scheme — it resolves to the same one, role for role.

No backend work; all of it is live on website/main.

The proof (AC-1) needed two things the plan didn't anticipate

  • ColorScheme has no equals. material3 1.3.0 declares none — checked against the artifact rather than assumed. So "the full ColorScheme equality, not a spot check" is a field-by-field compare by reflection over every Color-valued getter (36 roles), not a hand-written list of the roles the mapping happens to set. A role Material adds, or one the mapping forgets, cannot escape it; a guard on the count fails if the reflection ever stops seeing them.
  • The expected value is a verbatim copy of the pre-M12 ShardColorScheme, held in the test rather than referenced — the same device that locked htmlShell's output on the website side. The proof is against what the app used to do, not against what the new code does today.

One visible consequence, and it is the intended fix

§5.1 called RunicGatewayTheme(accent) "wrong twice over" — it put --accent on primary, which the contract assigns to --accent-bright, and ignored the other fourteen tokens. Correcting it moves something for one class of instance, so stating it plainly:

A shard with a BRAND_ACCENT_COLOR and no theme_visual row previously carried that color on primary/secondary/tertiary, so its filled CTA buttons were accented. brand.accent now seeds --accent only — secondary/tertiary — and primary returns to --accent-bright. Links and highlights keep the brand color; filled buttons go back to the light CTA fill the M5 design specifies.

An instance that wants accented buttons sets the accent from Admin → Appearance, which is what the token map is for. The env path itself keeps working, which §5.1 requires: the server resolves brand.accent as theme['--accent'] || env, so the token and the brand can never disagree, and the token wins where a client is holding a stale brand anyway.

Smaller calls, all recorded in the doc

  • ShardPillFg is derived, not a sixteenth token. §5.1 lists it as themable but the table has fifteen rows and none is it — its value is ShardCta's, both --accent-bright. It follows cta under the rule §5.1 states for ShardOnCta, so the neutral pill's text tracks the CTA fill instead of freezing at today's literal.
  • The resolution is pure. ShardPalette.resolve(theme, brandAccent) takes a map and shardColorScheme(palette) takes a palette; neither knows about SiteAppearance, so AC-1 and AC-2 are plain JVM assertions with no Compose test rule. RunicGatewayTheme is the one place the two meet.
  • toneColors became @Composable to read the palette. Private, called only from StatusPill — the alternative would have leaked a palette parameter into a public signature.

The migration check §5.1 asked for

ThemeComponents.kt was the only file reaching past MaterialTheme.colorScheme for a themable color, and leaving one behind is a card that stays blue on a Fantasy shard without failing to compile. After the migration:

$ grep -rn "ui\.theme\.Shard" app/src/main --include=*.kt | grep -v "/ui/theme/"
ThemeComponents.kt:  ShardDanger, ShardDangerBg, ShardSuccess, ShardSuccessBg,
                     ShardSuccessDot, ShardWarning, ShardWarningBg
ShardComponents.kt:  ShardSuccess, ShardSuccessDot

Nine imports, all of them semantic constants that are never themed — exactly what §5.1 predicted would be left.

How it was tested

  • ./gradlew testDebugUnitTest lintDebug assembleDebug373 unit tests green (360 before), lint and assemble clean.
  • New: ShardPaletteTest (9 — the shipped no-op, all fifteen tokens landing in the right field, AC-2's one-good-four-bad map, unknown tokens ignored, the brand-accent fallback and the token beating it, a malformed brand accent, and both derived colors) and ShardColorSchemeTest (4 — AC-1 from both ends, the role-set guard, and a themed shard moving its roles and only its roles).
  • Not exercised on device. The AVD walk against a Fantasy-themed instance is AC-5, in phase 8.

Checklist

  • I have read CONTRIBUTING.md.
  • The change builds and existing tests/checks pass locally.
  • I have added or updated tests/docs where it makes sense.
  • My commits are reasonably scoped with clear messages.

AI-assisted contributions (required)

  • AI tools were used. Tool(s): Claude Code (Opus 5). I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a Co-Authored-By trailer.

License

  • I agree that my contribution is licensed under this project's license (GNU GPL v3.0 or later), and I have the right to contribute it.
## What & why **M12 phase 1 — colors.** Design of record: [`docs/android/THEMING_AND_NAV.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/android/THEMING_AND_NAV.md) §5.1. Docs pair: **docs#113**. Phase 0 landed the store and deliberately read nothing out of it. This is the first phase that shows: the fifteen themable tokens of `GET /public/settings`' `theme` map are parsed into a `ShardPalette` and applied **field by field** over the shipped palette. Ten of them have a Material role and go through `darkColorScheme`; the other five reach screens through a new `LocalShardPalette`. The invariant that makes this cheap is now a test rather than a claim: the app's M5 palette **is** the website's `runic-gateway` preset, so an instance with no `theme_visual` row does not resolve to something close to today's scheme — it resolves to the same one, role for role. No backend work; all of it is live on `website/main`. ### The proof (AC-1) needed two things the plan didn't anticipate - **`ColorScheme` has no `equals`.** material3 1.3.0 declares none — checked against the artifact rather than assumed. So "the full `ColorScheme` equality, not a spot check" is a **field-by-field compare by reflection** over every `Color`-valued getter (36 roles), not a hand-written list of the roles the mapping happens to set. A role Material adds, or one the mapping forgets, cannot escape it; a guard on the count fails if the reflection ever stops seeing them. - **The expected value is a verbatim copy of the pre-M12 `ShardColorScheme`**, held in the test rather than referenced — the same device that locked `htmlShell`'s output on the website side. The proof is against what the app used to do, not against what the new code does today. ### One visible consequence, and it is the intended fix §5.1 called `RunicGatewayTheme(accent)` "wrong twice over" — it put `--accent` on `primary`, which the contract assigns to `--accent-bright`, and ignored the other fourteen tokens. Correcting it moves something for one class of instance, so stating it plainly: > A shard with a `BRAND_ACCENT_COLOR` and **no** `theme_visual` row previously carried that color on `primary`/`secondary`/`tertiary`, so its **filled CTA buttons were accented**. `brand.accent` now seeds `--accent` only — `secondary`/`tertiary` — and `primary` returns to `--accent-bright`. Links and highlights keep the brand color; filled buttons go back to the light CTA fill the M5 design specifies. An instance that wants accented buttons sets the accent from Admin → Appearance, which is what the token map is for. The env path itself keeps working, which §5.1 requires: the server resolves `brand.accent` as `theme['--accent'] || env`, so the token and the brand can never disagree, and the token wins where a client is holding a stale brand anyway. ### Smaller calls, all recorded in the doc - **`ShardPillFg` is derived, not a sixteenth token.** §5.1 lists it as themable but the table has fifteen rows and none is it — its value *is* `ShardCta`'s, both `--accent-bright`. It follows `cta` under the rule §5.1 states for `ShardOnCta`, so the neutral pill's text tracks the CTA fill instead of freezing at today's literal. - **The resolution is pure.** `ShardPalette.resolve(theme, brandAccent)` takes a map and `shardColorScheme(palette)` takes a palette; neither knows about `SiteAppearance`, so AC-1 and AC-2 are plain JVM assertions with no Compose test rule. `RunicGatewayTheme` is the one place the two meet. - **`toneColors` became `@Composable`** to read the palette. Private, called only from `StatusPill` — the alternative would have leaked a palette parameter into a public signature. ### The migration check §5.1 asked for `ThemeComponents.kt` was the only file reaching past `MaterialTheme.colorScheme` for a themable color, and leaving one behind is a card that stays blue on a Fantasy shard without failing to compile. After the migration: ``` $ grep -rn "ui\.theme\.Shard" app/src/main --include=*.kt | grep -v "/ui/theme/" ThemeComponents.kt: ShardDanger, ShardDangerBg, ShardSuccess, ShardSuccessBg, ShardSuccessDot, ShardWarning, ShardWarningBg ShardComponents.kt: ShardSuccess, ShardSuccessDot ``` Nine imports, all of them semantic constants that are never themed — exactly what §5.1 predicted would be left. ## How it was tested - `./gradlew testDebugUnitTest lintDebug assembleDebug` — **373 unit tests green** (360 before), lint and assemble clean. - New: `ShardPaletteTest` (9 — the shipped no-op, all fifteen tokens landing in the right field, AC-2's one-good-four-bad map, unknown tokens ignored, the brand-accent fallback and the token beating it, a malformed brand accent, and both derived colors) and `ShardColorSchemeTest` (4 — AC-1 from both ends, the role-set guard, and a themed shard moving its roles and only its roles). - **Not** exercised on device. The AVD walk against a Fantasy-themed instance is AC-5, in phase 8. ## Checklist - [x] I have read [CONTRIBUTING.md](CONTRIBUTING.md). - [x] The change builds and existing tests/checks pass locally. - [x] I have added or updated tests/docs where it makes sense. - [x] My commits are reasonably scoped with clear messages. ## AI-assisted contributions (required) - [x] AI tools were used. Tool(s): `Claude Code (Opus 5)`. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a `Co-Authored-By` trailer. ## License - [x] I agree that my contribution is licensed under this project's license (**GNU GPL v3.0 or later**), and I have the right to contribute it.
wtclaude added 1 commit 2026-08-08 09:54:54 +00:00
The fifteen themable tokens of GET /public/settings' theme map are parsed into
a ShardPalette and applied field by field over the shipped M5 palette, which is
the runic-gateway preset value for value — so an instance with no theme_visual
row resolves back to a color scheme identical to the one the app shipped, not
an approximation of it (THEMING_AND_NAV.md §2, §5.1).

Ten tokens have a Material role and go through darkColorScheme; the other five
reach screens through LocalShardPalette. ShardOnCta and ShardPillFg are derived
rather than themed — they track --bg-deep and --accent-bright, following the
server's rule that a value expressed in terms of another token is never frozen
as a literal.

RunicGatewayTheme(accent) becomes RunicGatewayTheme(appearance). The old
signature put --accent on primary, which the contract assigns to
--accent-bright; brand.accent now seeds --accent alone, and the server already
resolves it as theme['--accent'] || env so the two can never disagree.

ThemeComponents.kt was the only file reaching past MaterialTheme.colorScheme
for a themable color; its seven now come from the palette and its seven
semantic constants stay imported.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit 1530c83fbc into edge 2026-08-08 09:57:23 +00:00
whitlocktech deleted branch feat/m12-phase-1-colors 2026-08-08 09:57:24 +00:00
Sign in to join this conversation.
No description provided.